Files
2026-05-27 23:47:53 +01:00

4.4 KiB

encryption/key-ring/key-ring.ts

(no submodules)

Purpose

KeyRing is the multi-user wrapped-key store at the heart of @suaveplan/drive-sharing. It owns the partition master key, holds per-user data keys (DEKs) wrapped under that master key, and exposes the lifecycle operations needed to add, retrieve, rotate, remove, export, and import wrapped entries. Every operation emits a lifecycle event so callers can persist to durable storage and audit changes; events deliberately carry only userId (no key material).

Features

  • AAD-bound wrap / unwrap via wrapKey / unwrapKey from encryption/envelope. The AAD is per-user (drive-sharing/v1/keyring/<userId>) so a wrapped key cannot be moved between users without producing an AEAD failure.
  • rotateKey rewraps a single user's entry; rotateMasterKey rewraps every entry atomically. Both zeroize old buffers via @suaveplan/crypto-utils's zeroize.
  • Versioned JSON-like export / import. The export carries keyring_format_version: 1; unknown versions throw SHARE_KEYRING_FORMAT_UNSUPPORTED.
  • Hard-cap on entries (KEYRING_MAX_ENTRIES = 100_000) prevents pathological bulk-add denial-of-service.
  • Event bus (key:added, key:removed, key:rotated) with on(event, listener) => unsubscribe.

Usage: Basic Example

import { KeyRing } from "@suaveplan/drive-sharing/encryption/key-ring";
import { userId } from "@suaveplan/drive-sharing/types/common";

const ring = new KeyRing();
const dek = new Uint8Array(32).fill(0xab);
await ring.addKey(userId("alice"), dek);
const recovered = await ring.getKey(userId("alice"));

Usage: Advanced Example

import { KeyRing } from "@suaveplan/drive-sharing/encryption/key-ring";
import { userId } from "@suaveplan/drive-sharing/types/common";

const masterKey = new Uint8Array(32).fill(0x01);
const ring = new KeyRing({ masterKey });

const off = ring.on("key:rotated", ({ userId }) => {
    console.log("rotated:", userId);
});

await ring.addKey(userId("alice"), new Uint8Array(32).fill(0x10));
await ring.rotateKey(userId("alice"), new Uint8Array(32).fill(0x20));

// Backup / restore round-trip.
const blob = ring.export();
const restored = new KeyRing({ masterKey });
await restored.import(blob);

off();

API Reference

class KeyRing

  • constructor(config?: KeyRingConfig) — masterKey defaults to a fresh 32-byte CSPRNG buffer.
  • hasKey(userId): boolean
  • listKeys(): UserId[]
  • addKey(userId, key): Promise<void> — wraps and stores. Emits key:added.
  • getKey(userId): Promise<Uint8Array | undefined>
  • removeKey(userId): Promise<void> — emits key:removed only when an entry is actually removed.
  • rotateKey(userId, newKey): Promise<UserWrappedKey> — replaces the entry, emits key:rotated, increments share.key.rotate_total.
  • rotateMasterKey(newMasterKey): Promise<void> — rewraps every entry atomically.
  • export(): { keyring_format_version: 1; entries: UserWrappedKey[] }
  • import(payload): Promise<void> — Zod-validated.
  • on(event, listener): () => void

interface UserWrappedKey

{ userId, wrappedKey }. The export shape stores arrays of these.

interface KeyRingConfig

{ masterKey?: Uint8Array }. Master key length must be exactly 32 bytes.

Events

  • key:added — { userId }
  • key:removed — { userId }
  • key:rotated — { userId }

Implementation Notes

  • The constructor copies the supplied masterKey so the caller's buffer can be zeroized independently. rotateMasterKey writes the new key into the same buffer slot via Uint8Array.set so external references to the slot are invalidated rather than left dangling.
  • export deliberately produces a plain object (not a JSON string) so the caller can pipe it through their preferred serializer (e.g. serialize for tagged JSON). Validation lives in KeyRingExportSchema.
  • The hard cap KEYRING_MAX_ENTRIES = 100_000 matches the OpenSpec scalability table. Above this, the spec calls for sharded ring storage which is out of scope for this package.
  • rotateKey zeroizes the caller-supplied newKey on success — this matches the spec's "no plaintext key in caller's heap after success" guarantee. If the caller wants to retain the bytes they should pass a copy.
  • The aadFor derivation uses ASCII text (drive-sharing/v1/keyring/<userId>) rather than raw bytes so the AAD is human-readable in audit logs that capture it.