Files
storage/docs/architecture/encrypted-data.md
glm-5.1 ed8710a7f5 Clean up architecture specs: remove stale references, align docs with code, improve readability
- Replace stale DD references (DD3, DD6, DD9, DD10) with proper ADR links
- Fix 'Open Question 1' → OQ-01/OQ-03 cross-references
- Rewrite metagraph-module.md 'Why TypeBox Modules' to describe capabilities
  directly instead of framing as SchemaBuilder replacement
- Remove 'Transition from SchemaBuilder' section, replace with Source Structure
- Clean up implementation path: strikethrough phases → status table
- Fix data model diagram: remove non-existent nodeTypeId, fix EdgeType label
- Align EdgeConstraints examples with actual code (add default values)
- Clarify validateNode/validateEdge error behavior in docs
- Align EncryptedDataSchema code example with actual implementation
- Fix overview.md: correct dependency table, update current state, fix TypeBox URL
- Fix forward-look.md garbled text about dbtype element migration
- Fix open-questions.md: correct OQ count (4→7 open), add summary table
- Update doc statuses: schema-evolution, encrypted-data, open-questions → reviewed
- Update AGENTS.md to reflect current implementation state
2026-05-30 09:12:24 +00:00

12 KiB

status, last_updated
status last_updated
reviewed 2026-05-30

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

  1. No special tables needed — The existing graph_types, node_types, edge_types, graphs, nodes, edges tables store everything.
  2. Schema validation — The EncryptedDataSchema TypeBox schema validates the encryption envelope at write time.
  3. Domain flexibility — An "ACL graph" might also have encrypted credential nodes. A "call graph" might store encrypted auth headers. Different graphs, same pattern.
  4. Query through edges — "Find all secrets for client X" becomes "find all edges of type has_secret from node X to secret nodes."
  5. The crypto utility is shared@alkdev/storage exports encrypt() and decrypt() 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 on insert (⚠️ CRUD layer not yet built) @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 }),
  salt: Type.String(),   // Base64-encoded 16-byte PBKDF2 salt
  iv: Type.String(),     // Base64-encoded 12-byte AES-GCM initialization vector
  data: Type.String(),   // Base64-encoded AES-256-GCM ciphertext
});

The fields contain: keyVersion — which encryption key version was used (enables key rotation), salt — base64-encoded 16-byte PBKDF2 salt, iv — base64-encoded 12-byte AES-GCM initialization vector, data — 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:

  1. Generate random 16-byte salt
  2. Generate random 12-byte IV
  3. Derive 256-bit key from password + salt via PBKDF2 (SHA-256, 100k iterations for v1)
  4. Encrypt plaintext with AES-256-GCM using the derived key and IV
  5. Return { keyVersion, salt: base64(salt), iv: base64(iv), data: base64(ciphertext) }

decrypt(encryptedData, password): Promise<string>

Decrypts an EncryptedData object.

Process:

  1. Decode base64 salt, IV, and ciphertext
  2. Derive key from password + salt + keyVersion via PBKDF2
  3. Decrypt with AES-256-GCM
  4. Return plaintext string
  5. 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 is exported from the main @alkdev/storage package (no db deps):

src/graphs/
├── modules/
│   ├── metagraph.ts      # Metagraph Module (Config, BaseNode, BaseEdge)
│   ├── call-graph.ts     # CallGraph reference Module
│   ├── secret-graph.ts   # SecretGraph reference Module (uses EncryptedDataSchema)
│   └── index.ts          # Barrel re-export
├── bridge.ts             # moduleToDbSchema, validateNode, validateEdge
├── crypto.ts             # encrypt(), decrypt(), generateEncryptionKey(), EncryptedDataSchema
└── mod.ts                # Re-exports all of the above

The encryption utility is in the zero-dep export path (it only uses Web Crypto API and @alkdev/typebox for the schema). SecretGraph in secret-graph.ts composes EncryptedDataSchema into a node type via Type.Composite.

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 key attribute on secret nodes be encrypted? (resolved: plaintext for now)
  • OQ-09: Should secret nodes have lastUsedAt and expiresAt as 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_secrets table (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/operations AccessControl: /workspace/@alkdev/operations/docs/architecture/api-surface.md