- Create decisions/ directory with 32 numbered ADRs (ADR-001 through ADR-032) extracted from inline DD/SD/ED/SE decision sections - Create open-questions.md with 16 OQs organized by theme, cross-referenced to ADRs, with status tracking (resolved/open) - Create README.md as architecture index with doc table, ADR table, and lifecycle status definitions (draft/reviewed/stable/deprecated) - Replace inline decision sections in all spec docs with ADR reference tables - Replace inline open questions with OQ references to centralized tracker - Update frontmatter: metagraph-module.md, overview.md, sqlite-host.md → reviewed; schema-evolution.md and encrypted-data.md remain draft - DD1-DD10 → ADR-009 through ADR-018 - D1-D8 → ADR-001 through ADR-008 - SD1-SD5 → ADR-019 through ADR-023 (SD5 folded into ADR-006/008) - ED1-ED5 → ADR-023 through ADR-027 - SE1-SE5 → ADR-028 through ADR-032
11 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-05-28 |
Encrypted Data
Design for storing encrypted data at rest within the metagraph model. Uses AES-256-GCM + PBKDF2 key derivation, providing a reusable node type, TypeBox schema, and crypto utility for any consumer that needs to store secrets.
Overview
Sensitive data — API keys, passwords, OAuth tokens, SSH keys — must be encrypted
at rest. In @alkdev/storage, the encryption pattern becomes a reusable utility
and an encrypted node type, so any graph can store secrets without special table
definitions.
Key principle: The storage package provides the encryption primitives and the schema shape, not key management. Consumers provide the encryption key. This keeps the package agnostic to deployment-specific secret management.
Provenance: The encryption pattern (AES-256-GCM + PBKDF2) was originally
implemented in the hub's client_secrets table and src/crypto/mod.ts.
@alkdev/storage extracts this pattern as a general-purpose utility, independent
of the hub's domain model.
The Problem
The hub has client_secrets as a standalone table with columns like:
| Column | Purpose |
|---|---|
clientId |
FK to the client this secret belongs to |
key |
Secret name (e.g., "api_key", "oauth_credentials") |
value |
The encrypted payload (EncryptedData JSON) |
keyVersion |
Which encryption key version was used |
expiresAt |
When the secret expires |
lastUsedAt |
Audit trail |
This is a domain-specific table. The encryption logic itself is generic — AES-256-GCM with PBKDF2 key derivation and key versioning. When we want encrypted secrets in a spoke (local SQLite) or in a different domain model, we shouldn't have to duplicate the table definition or the crypto code.
Design: Encrypted Data as a Node Type
Instead of a dedicated client_secrets table, encrypted data becomes a node
type in a graph:
import { Metagraph } from "@alkdev/storage";
import { Type } from "@alkdev/typebox";
import { EncryptedDataSchema } from "@alkdev/storage";
const SecretGraph = Type.Module({
Config: Type.Object({
type: Type.Literal("undirected"),
multi: Type.Literal(false),
allowSelfLoops: Type.Literal(false),
}),
SecretNode: Type.Composite([
Metagraph.Import("BaseNode"),
Type.Object({
key: Type.String({ minLength: 1, maxLength: 255 }),
encryptedData: EncryptedDataSchema,
expiresAt: Type.Optional(Type.String({ format: "date-time" })),
}),
]),
ClientNode: Type.Composite([
Metagraph.Import("BaseNode"),
Type.Object({
name: Type.String(),
type: Type.String(),
config: Type.Record(Type.String(), Type.Unknown()),
enabled: Type.Boolean({ default: true }),
}),
]),
HasSecretEdge: Type.Composite([
Metagraph.Import("BaseEdge"),
Type.Object({
type: Type.Literal("has_secret"),
secretKey: Type.String(),
}),
]),
HasSecretEdgeConstraints: Type.Object({
edgeType: Type.Literal("has_secret"),
allowedSourceTypes: Type.Array(Type.String()), // ["Client"]
allowedTargetTypes: Type.Array(Type.String()), // ["Secret"]
}),
});
This represents the same relationship as client_secrets.clientId — but as a
graph edge rather than a foreign key.
Why This Works
- No special tables needed — The existing
graph_types,node_types,edge_types,graphs,nodes,edgestables store everything. - Schema validation — The
EncryptedDataSchemaTypeBox schema validates the encryption envelope at write time. - Domain flexibility — An "ACL graph" might also have encrypted credential nodes. A "call graph" might store encrypted auth headers. Different graphs, same pattern.
- Query through edges — "Find all secrets for client X" becomes "find all
edges of type
has_secretfrom node X to secret nodes." - The crypto utility is shared —
@alkdev/storageexportsencrypt()anddecrypt()that any consumer uses.
What Lives Where
| Layer | Responsibility | Package |
|---|---|---|
@alkdev/storage graphs |
EncryptedDataSchema (TypeBox shape) |
@alkdev/storage |
@alkdev/storage crypto |
encrypt(), decrypt(), generateEncryptionKey() |
@alkdev/storage |
@alkdev/storage sqlite |
Node storage (attributes contain encrypted JSON) | @alkdev/storage/sqlite |
@alkdev/storage repo |
Validate schema, encrypt before insert (⚠️ not yet impl) | @alkdev/storage |
| Application | Key management (key ring, key rotation) | Consumer |
EncryptedData Schema
Ported from the hub's src/crypto/mod.ts interface, now expressed as a TypeBox
schema in @alkdev/storage:
import { Type } from "@alkdev/typebox";
export const EncryptedDataSchema = Type.Object({
keyVersion: Type.Integer({
minimum: 1,
description: "Encryption key version for rotation",
}),
salt: Type.String({ description: "Base64-encoded 16-byte PBKDF2 salt" }),
iv: Type.String({
description: "Base64-encoded 12-byte AES-GCM initialization vector",
}),
data: Type.String({ description: "Base64-encoded AES-256-GCM ciphertext" }),
});
This is the same structure as the hub's EncryptedData interface but as a
TypeBox schema, enabling runtime validation when inserting encrypted nodes.
Crypto Utility
The encryption module provides three functions, ported from the hub's
src/crypto/mod.ts:
encrypt(plaintext, password, keyVersion?): Promise<EncryptedData>
Encrypts a string using AES-256-GCM with PBKDF2 key derivation.
Process:
- Generate random 16-byte salt
- Generate random 12-byte IV
- Derive 256-bit key from password + salt via PBKDF2 (SHA-256, 100k iterations for v1)
- Encrypt plaintext with AES-256-GCM using the derived key and IV
- Return
{ keyVersion, salt: base64(salt), iv: base64(iv), data: base64(ciphertext) }
decrypt(encryptedData, password): Promise<string>
Decrypts an EncryptedData object.
Process:
- Decode base64 salt, IV, and ciphertext
- Derive key from password + salt + keyVersion via PBKDF2
- Decrypt with AES-256-GCM
- Return plaintext string
- Throw
"Decryption failed: Invalid data or key"on failure (no information leakage about which part failed)
generateEncryptionKey(): string
Generates a 32-byte random key encoded as base64. Used by operators to create encryption keys for the key ring.
Key ring format (application-level, not in this package): A comma-separated
list of v{N}:{base64key} pairs. The first key is the "current" key used for
new encryptions. All keys are available for decryption.
Key Versioning
PBKDF2 iteration count varies by key version:
- v1: 100,000 iterations
- Future versions: 200,000+ (adjust for hardware improvements)
This allows gradual security upgrades. Old data encrypted with v1 can still be decrypted. Re-encryption (rotate) reads with the old key and writes with the current key.
Web Crypto API
The implementation uses the standard Web Crypto API (crypto.subtle), available
in:
- Deno runtime (native)
- Node.js 19+ (native)
- Modern browsers (native)
- Cloudflare Workers (native)
No external crypto dependencies.
Design Decisions
All design decisions are documented as ADRs in decisions/.
| ADR | Decision | Summary |
|---|---|---|
| 023 | Per-attribute encryption, not per-node | Only sensitive payload encrypted; key/metadata remain queryable |
| 024 | Encrypted data as node type, not standalone table | No special tables; metagraph pattern with SecretNode and HasSecretEdge |
| 025 | Password-based encryption via PBKDF2 | Consistent with hub; ~100ms per operation; encryptRaw() added later if needed |
| 026 | Application-managed key ring | Storage provides encrypt/decrypt primitives, not key management |
| 027 | No key rotation utility in this package | Application orchestrates rotation; storage provides building blocks |
Integration with SQLite Host
Encrypted node attributes are stored as JSON text in the nodes.attributes
column, same as any other node attributes. The EncryptedDataSchema validates
the shape at the application level.
import { decrypt, encrypt } from "@alkdev/storage";
import { EncryptedDataSchema } from "@alkdev/storage";
const encryptionKey = "v1:YmFzZTY0a2V5"; // from application config
const plaintext = "sk-ant-api03-...";
const encryptedData = await encrypt(plaintext, encryptionKey, 1);
// Validate before storage
const attributes = {
key: "api_key",
encryptedData,
expiresAt: new Date().toISOString(),
created: new Date().toISOString(),
};
// Store as a node in a graph
// db.insert(nodes).values({ graphId, key: "anthropic-api-key", attributes });
// Retrieve and decrypt
// const node = await db.query.nodes.findFirst({ where: eq(nodes.key, "anthropic-api-key") });
// const decrypted = await decrypt(node.attributes.encryptedData, encryptionKey);
Export Plan
The crypto module will be exported from the main @alkdev/storage package (no
db deps):
src/graphs/
├── modules/metagraph.ts # Metagraph Module (BaseNode, BaseEdge, Config)
├── crypto.ts # new: encrypt(), decrypt(), generateEncryptionKey(), EncryptedDataSchema
└── mod.ts # re-exports all of the above
This keeps the encryption utility in the zero-dep export path (it only uses Web
Crypto API and @alkdev/typebox for the schema).
Open Questions
Open questions are tracked in open-questions.md. Key questions affecting encrypted data:
- OQ-07: Should we add
encryptRaw()for performance? (open, low priority) - OQ-08: Should the
keyattribute on secret nodes be encrypted? (resolved: plaintext for now) - OQ-09: Should secret nodes have
lastUsedAtandexpiresAtas first-class columns? (resolved: JSON attributes for spoke, standalone table for hub)
References
- Web Crypto API: https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto
- Hub crypto utility (provenance):
/workspace/@alkdev/hub/src/crypto/mod.ts - Hub
client_secretstable (provenance):/workspace/@alkdev/hub/docs/architecture/storage/services.md - Hub ADR-008 (provenance):
/workspace/@alkdev/hub/docs/decisions/ADR-008-secrets-encrypted-at-rest-with-key-versioning.md @alkdev/operationsAccessControl:/workspace/@alkdev/operations/docs/architecture/api-surface.md