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

9.0 KiB

Capability Spec Delta: crypto-vault (extend-crypto-vault-orchestration)

Package: @suaveplan/crypto-vault Status: PROPOSED

ADDED Requirements

Requirement: createRootKeys produces a keychain + encrypted vault + signed public-key bytes

The package SHALL export async createRootKeys(password: string, options?: CreateRootKeysOptions): Promise<RootKeysResult> that generates a fresh root Keychain via @suaveplan/crypto generateKeychain, derives an Argon2id KEK from password (defaults from DEFAULT_ARGON2ID_PARAMS), AEAD-encrypts the canonical-encoded keychain bytes into vault bytes, extracts the public keychain via extractPublicKeys, and returns { keychain, vaultBytes, publicKeyBytes, publicKeys }. The publicKeyBytes SHALL be the output of encodePublicKeychain (which carries the master public key + per-slot algorithm tags). On success, openVault(vaultBytes, password) SHALL recover an equivalent keychain.

Scenario: result round-trips through openVault

  • WHEN createRootKeys("hunter2") returns { vaultBytes } and openVault(vaultBytes, "hunter2") is then awaited
  • THEN the returned keychain has the same master public key as result.keychain.master.public

Verified by: src/orchestration/orchestration.test.ts::"createRootKeys output round-trips through openVault"


Requirement: createServerKeys derives a versioned child keychain from root master

The package SHALL export async createServerKeys(rootKeys: RootKeysResult, serverPassword: string, options?: CreateServerKeysOptions): Promise<ServerKeysResult> that derives a server Keychain from rootKeys.keychain.master.private (deterministic HD-key derivation at the default path), encrypts under serverPassword, and returns { keychain, vaultBytes, publicKeyBytes, publicKeys, version }. Default version SHALL be 1. Re-running with the same (rootKeys, password, version) SHALL produce keychains with identical public keys (deterministic derivation).

Scenario: deterministic re-derivation

  • WHEN createServerKeys(rootKeys, "pw") is called twice with the same root + password
  • THEN both results have identical publicKeys (private material differs only by nonce in the encrypted vault layer)

Verified by: src/orchestration/orchestration.test.ts::"createServerKeys deterministically derives from root master"


Requirement: rollServerKeys bumps the version deterministically

The package SHALL export async rollServerKeys(rootKeys: RootKeysResult, currentVersion: number, serverPassword: string, options?: RollServerKeysOptions): Promise<ServerKeysResult> returning a ServerKeysResult at version = currentVersion + 1. currentVersion < 0 SHALL raise CryptoVaultError(CRYPTO_VAULT_INVALID_VERSION). The returned keychain SHALL be reproducible from (rootKeys, currentVersion, password).

Scenario: version increments

  • WHEN rollServerKeys(root, 3, "pw") is awaited
  • THEN result.version === 4 and the underlying keychain differs from one derived at version 3

Scenario: negative version rejected

  • WHEN rollServerKeys(root, -1, "pw") is awaited
  • THEN the promise rejects with CryptoVaultError(CRYPTO_VAULT_INVALID_VERSION)

Verified by: src/orchestration/orchestration.test.ts::"rollServerKeys increments version" AND src/orchestration/orchestration.test.ts::"rollServerKeys rejects negative currentVersion"


Requirement: openVault and packVault are inverses

The package SHALL export async openVault(vaultBytes: Uint8Array, password: string): Promise<Keychain> and async packVault(keychain: Keychain, password: string, options?: PackVaultOptions): Promise<Uint8Array>. openVault(packVault(k, p), p) SHALL produce a keychain with the same canonical-encoded public bytes as k. Wrong password or tampered bytes SHALL raise CryptoVaultError(CRYPTO_VAULT_DECRYPT_FAILED).

Scenario: round-trip

  • WHEN openVault(await packVault(k, "pw"), "pw") is awaited
  • THEN the result's encodePublicKeychain output equals k's

Scenario: wrong password rejected

  • WHEN openVault(await packVault(k, "right"), "wrong") is awaited
  • THEN the promise rejects with CryptoVaultError(CRYPTO_VAULT_DECRYPT_FAILED)

Scenario: tampered bytes rejected

  • WHEN any byte of a valid vault is flipped and openVault(tampered, password) is awaited
  • THEN the promise rejects with CryptoVaultError(CRYPTO_VAULT_DECRYPT_FAILED)

Verified by: src/orchestration/orchestration.test.ts::"openVault and packVault round-trip" AND src/orchestration/orchestration.test.ts::"openVault rejects wrong password" AND src/orchestration/orchestration.test.ts::"openVault rejects tampered bytes"


Requirement: viewPublicKeys decodes a signed public-key file with optional verification

The package SHALL export async viewPublicKeys(publicKeyBytes: Uint8Array, optionalSignKey?: PublicKey): Promise<PublicKeychain> that decodes via decodePublicKeychain. When optionalSignKey is provided, the embedded signature SHALL be verified against it; verification failure SHALL raise CryptoVaultError(CRYPTO_VAULT_PUBLIC_KEY_SIGNATURE_INVALID). Without optionalSignKey, decoding succeeds without verification.

Scenario: verification succeeds with matching key

  • WHEN viewPublicKeys(bytes, signerPublicKey) is awaited for bytes signed by signerPrivateKey
  • THEN the call resolves with the decoded PublicKeychain

Scenario: verification fails with wrong key

  • WHEN viewPublicKeys(bytes, wrongPublicKey) is awaited
  • THEN the promise rejects with CryptoVaultError(CRYPTO_VAULT_PUBLIC_KEY_SIGNATURE_INVALID)

Verified by: src/orchestration/orchestration.test.ts::"viewPublicKeys verifies signature when sign key supplied" AND src/orchestration/orchestration.test.ts::"viewPublicKeys rejects wrong sign key"


Requirement: Storage helpers wrap AbstractStorage idempotently

The package SHALL export async saveVaultToStorage(storage: AbstractStorage<Uint8Array>, key: string, vaultBytes: Uint8Array): Promise<void>, async savePublicKeysToStorage(storage: AbstractStorage<Uint8Array>, key: string, publicKeyBytes: Uint8Array): Promise<void>, and async getDataFromStorage(storage: AbstractStorage<Uint8Array>, key: string): Promise<Uint8Array>. getDataFromStorage SHALL raise CryptoVaultError(CRYPTO_VAULT_STORAGE_KEY_NOT_FOUND) when the key is absent (translating whatever the backend throws). Save helpers SHALL be idempotent — calling with the same key + bytes twice SHALL not raise.

Scenario: save then get returns the same bytes

  • WHEN saveVaultToStorage(memStorage, "k", bytes) is awaited then getDataFromStorage(memStorage, "k") is awaited
  • THEN the result equals bytes

Scenario: missing key raises typed error

  • WHEN getDataFromStorage(memStorage, "absent") is awaited
  • THEN the promise rejects with CryptoVaultError(CRYPTO_VAULT_STORAGE_KEY_NOT_FOUND)

Scenario: double-save is idempotent

  • WHEN saveVaultToStorage(memStorage, "k", bytes) is awaited twice
  • THEN both awaits resolve without error

Verified by: src/orchestration/orchestration.test.ts::"saveVaultToStorage + getDataFromStorage round-trip" AND src/orchestration/orchestration.test.ts::"getDataFromStorage raises CRYPTO_VAULT_STORAGE_KEY_NOT_FOUND on missing key" AND src/orchestration/orchestration.test.ts::"saveVaultToStorage is idempotent"


Requirement: New error codes registered without breaking existing ones

The package SHALL extend CRYPTO_VAULT_CODES with CRYPTO_VAULT_DECRYPT_FAILED, CRYPTO_VAULT_PUBLIC_KEY_SIGNATURE_INVALID, CRYPTO_VAULT_STORAGE_KEY_NOT_FOUND, CRYPTO_VAULT_INVALID_VERSION. Every existing code SHALL remain registered with identical value. Every new code SHALL be raised by at least one production call site in src/orchestration/.

Scenario: new codes thrown from production paths

  • WHEN the conformance test exercises each new code's production path
  • THEN every new CRYPTO_VAULT_* code is raised at least once

Verified by: src/orchestration/orchestration.spec-conformance.test.ts::"every new orchestration code is thrown from a production path"


Requirement: Subpath export, sibling docs, no breaking change to existing surface

The package SHALL declare a new subpath export ./orchestration/orchestration in package.json. The new src/orchestration/orchestration.ts SHALL have a sibling .md of ≥200 words. Existing exports SHALL be unchanged. Existing consumers (@suaveplan/vault, @suaveplan/crypto-vault's own tests) SHALL pass without modification.

Scenario: existing crypto-vault tests still pass

  • WHEN bunx turbo test --filter=@suaveplan/crypto-vault runs
  • THEN the run exits 0

Scenario: dependent sweep is clean

  • WHEN bunx turbo test --filter=...@suaveplan/crypto-vault runs
  • THEN the run exits 0

Verified by: tests/dep-graph.test.ts::"orchestration subpath export is reachable" AND tests/dep-graph.test.ts::"orchestration.ts has a sibling .md"