Skip to main content

What it is

packages/crypto is where every signature Parmana produces or checks actually happens: signing an ExecutionTrustRecord, an ExecutionAuthorization, a Receipt, a PolicyChangeApprovalRecord, a caller-audit chain entry, and verifying every one of those independently. It provides a pluggable set of hash and signature algorithms, a deterministic serialization step every one of them depends on, and two key-custody models: local files on disk, and AWS KMS (“sign-without-release,” the private key never leaves KMS).

Why it was built

The trust model in Chapter 1 is only as strong as the signatures backing it. Every artifact in the chain of custody claims to be “signed by Parmana,” and that claim needs to be checkable by a third party who was never in the room, using nothing but the public key and the artifact itself. That requirement drives three design choices covered below: canonical serialization (so the same logical content always produces the same bytes to sign, regardless of how it was constructed in memory), a Signer abstraction separate from KeyProvider (so a key that can never be exported, like an AWS KMS key, is a first-class option, not a workaround), and hybrid signing (so a future break of one algorithm doesn’t retroactively invalidate everything signed under it, as long as a second algorithm was also applied).

How it works

CanonicalSerializer: the thing every signature is actually computed over

packages/crypto/src/CanonicalSerializer.ts is nineteen lines of normalize() plus a serialize() that runs JSON.stringify over the result. The normalization rule: objects have their keys recursively sorted lexicographically before serialization; arrays keep their original order (element order is meaningful, key order in an object is not); Date becomes its ISO string; everything else passes through unchanged. This single function is what makes two logically-identical objects with different key insertion order, or objects that came from different sources (a hand-typed JSON literal vs. a value round-tripped through a Postgres JSONB column, which does not guarantee to preserve key order) hash and sign identically. Every cryptographic operation in this codebase is required to go through it, ArtifactSigner.sign(), TrustRecordHasher.hash(), and every *Crypto class (PolicyChangeCrypto, RefusalCrypto, VerificationCrypto, AuditEventCrypto) all construct their own CanonicalSerializer instance rather than hashing raw JSON.stringify output directly. A real, recent incident makes the “array order still matters” half of that rule concrete: on 2026-09-16, a batch of policy approvals initially appeared to fail a content-hash check purely because a database round-trip had reordered object keys in the stored proposedContent, that turned out to be a red herring, since CanonicalSerializer normalizes object key order away. The real, separate finding underneath it was that the stored content was missing an entire field (unboundSignalReasons, and for two policies, boundSignals) relative to the current file, a genuine content difference no amount of key-order normalization could paper over. See docs/CLAIMS.md §2.26’s “Legacy-policy backfill” entry for the full account , worth reading as a caution against assuming a hash mismatch has an obvious cause before actually diffing the two objects field by field.

Hash and signature providers: pluggable, registered, resolved through CryptoBootstrap

packages/crypto/src/providers/hash/SHA256HashProvider.ts implements HashProvider: one method, hash(data: Uint8Array): Promise<string>, returning a hex digest via Node’s node:crypto createHash("sha256"). Two more hash algorithms (sha3-512, blake3) are recognized by Config.ts’s validation but have no registered provider class yet, selecting either at the config layer would fail when HashRegistry.get() tries to resolve them; sha256 is the only one with a real implementation. Signature providers implement SignatureProvider (sign/verify, plus a readonly algorithm field): Ed25519SignatureProvider.ts (46 lines, wraps node:crypto’s sign/verify with no options object, i.e. pure Ed25519, not Ed25519ph) and Dilithium3SignatureProvider.ts (53 lines, near-identical shape, using Node’s native "ml-dsa-65" key type, this is the ML-DSA-65/FIPS 204 standard, and ConfigValidation.ts accepts the string "ml-dsa-65" as a config-time alias that resolves to the same internal "dilithium3" identifier, so an operator can write either name in PRIMARY_SIGNATURE_PROVIDER). ML-DSA-65 signatures are randomized , signing the same message twice with the same key produces two different, both valid, signatures, noted directly in that provider’s own doc comment, worth knowing before assuming a signature mismatch across two runs means something is broken. MlDsaSupport.ts detects at runtime (cached after first call) whether the current Node/OpenSSL build actually supports generateKeyPairSync("ml-dsa-65"), this requires Node ≥24 with OpenSSL ≥3.5; tests that need it skip cleanly with ML_DSA_65_SKIP_REASON on older runtimes rather than failing. Two more algorithms (ecdsa-p256, sphincs-plus) are recognized by config validation with no registered provider at all. CryptoBootstrap.create() is the actual resolution point: it builds a CryptoProvider (hash + signature bundled) by registering the built-in providers into HashRegistry/ SignatureRegistry and selecting by whatever loadConfig().crypto.hashProvider/ primarySignatureProvider say. createHybrid() builds both primary and secondary providers at once, throwing if CRYPTO_MODE=hybrid but no SECONDARY_SIGNATURE_PROVIDER is set. ProviderFactory (providers/ProviderFactory.ts) is a three-line compatibility wrapper around CryptoBootstrap.create(), nothing more.

Signer vs. KeyProvider: two abstractions for a real constraint

KeyProvider (KeyProvider.ts) has a getPrivateKey(keyId): Promise<KeyObject> method, it assumes the caller can obtain the raw private key material. That assumption is structurally false for AWS KMS, an HSM, or Vault Transit, where the entire point is that the private key never leaves the custody boundary. Signer (Signer.ts) exists for exactly that case: it drops getPrivateKey and adds sign(keyId, data): Promise<string> instead, the backend signs on the caller’s behalf and only ever returns a signature, never key material. Read operations (getPublicKey, getMetadata, hasKey, listKeys) are identical on both interfaces, since verification never needed private key material regardless of custody model. Two implementations exist for each side today. FileKeyProvider (providers/key/FileKeyProvider.ts, 192 lines) reads <keyId>.private.pem/<keyId>.public.pem from PARMANA_KEY_DIR, this is the only real KeyProvider, and KeyBootstrap.create() (Chapter 2) throws for any other KEY_PROVIDER value rather than silently falling back to it. LocalFileSigner wraps that same file-reading logic behind the Signer interface (so local custody works through either abstraction), and KmsSigner (providers/signer/KmsSigner.ts, 217 lines) is the real AWS KMS implementation, resolved only through SignerBootstrap, never through KeyBootstrap.

KmsSigner: sign-without-release, against real AWS KMS

KmsSigner supports exactly one key spec/algorithm pair , ECC_NIST_EDWARDS25519/ED25519_SHA_512, matching this codebase’s ed25519 default, noted in-source as deliberate: AWS KMS added Ed25519 support in November 2025, so adopting it needed no signature-algorithm migration. sign()/getPublicKey()/getMetadata()/hasKey() each call a real KMSClient command (SignCommand, GetPublicKeyCommand, DescribeKeyCommand); listKeys() is deliberately unimplemented, since enumerating every key in an account/region is a broader operation this codebase’s routes don’t need. Credentials are never a static access key/secret pair read from this codebase’s own configuration. If AWS_ROLE_ARN is set, KmsSigner dynamically imports the optional @vercel/oidc-aws-credentials-provider peer dependency and exchanges Vercel’s per-invocation OIDC token for short-lived STS credentials; otherwise it falls back to the AWS SDK’s own default credential provider chain. resolveKmsKeyId() is a small, standalone, exported function (KmsSigner.ts:46-56) that matters more than its size suggests. Every real signing call site in this codebase passes a logical keyId, "default", or a tenant-scoped "tenant.acme", never a raw AWS identifier. AWS KMS’s KeyId parameter requires a real key ID (UUID), a full ARN, or an alias name/ARN (which must carry the alias/ prefix); a bare "default" matches none of those and AWS rejects it outright. resolveKmsKeyId() maps a bare logical keyId to alias/<keyId> (mirroring FileKeyProvider’s own <keyId>.private.pem filename convention), and passes an already-qualified alias, ARN, or raw UUID straight through unchanged. This was a real bug, found by code review before any production traffic hit it: the very first version of this class passed the logical keyId straight through, which meant KmsSigner.sign("default", data) would have called AWS with { KeyId: "default" } and failed immediately on every real signing attempt. Because of this function, alias/default has to actually exist in AWS, it is not a naming convenience, it is the literal resolution target for the logical id every call site already uses.

SignerKeyProviderAdapter: closing a real signing/verification divergence

providers/SignerKeyProviderAdapter.ts is the fix for the most serious of the real incidents this migration produced, found against live production traffic on 2026-09-16 (see docs/VERIFICATION-GAPS.md G-48/G-49 and the KMS migration troubleshooting guide’s item 7). The bug: EnvelopeVerifier.resolveKey() uses a KeyProvider to look up the public key for every authorization it verifies, including ones signed under the plain "default" keyId , not only tenant-scoped ones, contrary to an earlier assessment in this same codebase’s own comments that this path was “currently inert.” createExecutionGateway.ts unconditionally constructed a fresh new FileKeyProvider() for that lookup, regardless of KEY_PROVIDER. Once KEY_PROVIDER=aws-kms was set, every authorization was signed by the real KMS key (via SignerBootstrap) but verified against whatever stale local default.public.pem happened to still be materialized from a pre-migration PARMANA_KEY_MATERIAL_JSON entry. Signing and verification silently used two different keys, every real request’s signatureVerified check (and everything that cascades from it: businessTransactionHashMatches, nonceUnseen) failed. SignerKeyProviderAdapter closes this by wrapping the same Signer instance createGatewayPublicKey() already uses, so signing and per-authorization verification now resolve through one identical source, KMS-backed or file-backed, whichever KEY_PROVIDER actually says.

HybridSignatureProvider: two independent signatures, fail-closed on either

HybridSignatureProvider.ts is not a SignatureProvider implementer, that interface signs with exactly one key and produces exactly one signature. Hybrid mode needs two of each. sign() produces a fixed [primary, secondary] pair of SignatureEntry values; verify() requires exactly two entries, one matching each configured algorithm, both independently verified, a missing, extra, duplicated, or mismatched-algorithm entry is rejected outright, never a partial pass. The stated purpose (its own doc comment) is “harvest now, decrypt/forge later” defense for the classical-to-post-quantum transition: if a future quantum computer ever breaks the classical primary algorithm, the post-quantum secondary signature alone still holds, and vice versa if a weakness is ever found in the newer PQ scheme instead. HYBRID_SIGNATURE_REQUIRED=true (Chapter 2) makes VerificationCrypto reject any record whose signatures array is absent or partial, rather than silently falling back to the legacy single-signature check, off by default so turning CRYPTO_MODE=hybrid on never retroactively invalidates records signed before that flag was set.

A note on what .env.example gets wrong

.env.example’s comment on KEY_PROVIDER states that only local has an implementing class and that setting it to anything else “does nothing.” That was true when written and is no longer true: SignerBootstrap.ts implements aws-kms for real. Separately, a live .env in this repository was observed carrying KMS_REGION and KMS_KEY_ALIAS variables, neither name is read anywhere in packages/*/src (confirmed by a repo-wide grep returning zero matches). The variables KmsSigner/assertKmsSigningKeyReachable actually read are AWS_REGION and AWS_ROLE_ARN. If you are configuring KMS custody for a real deployment, set those two, not KMS_REGION/KMS_KEY_ALIAS, the latter pair currently does nothing at all.

How it enables things, with a concrete example

  • examples/tutorials/113-kms-key-id-resolution exercises resolveKmsKeyId() directly against five real input shapes (bare logical id, tenant-scoped id, already-qualified alias, full ARN, raw UUID), no AWS credentials needed since it’s a pure string-mapping function.
  • examples/tutorials/114-signing-verification-key-agreement exercises the SignerKeyProviderAdapter fix, proving signing and verification now resolve through the same key source.
  • examples/tutorials/47-canonical-json demonstrates CanonicalSerializer’s normalization behavior directly.
  • packages/crypto/tests/unit/kms-signer.test.ts and signer-key-provider-adapter.test.ts are the permanent, mocked-AWS-SDK regression coverage for both fixes above; the KMS migration’s own troubleshooting guide notes a real, one-time end-to-end test was also run against actual AWS KMS before this coverage was trusted, then deleted once confirmed.

How to validate this yourself

  • packages/crypto/src/CanonicalSerializer.ts, read the whole file, it’s short and the normalization rule is the single most load-bearing piece of logic in this chapter.
  • packages/crypto/src/CryptoBootstrap.ts, KeyBootstrap.ts, SignerBootstrap.ts, the three composition roots (Chapter 2 also covers these from the config-and-startup angle).
  • packages/crypto/src/Signer.ts, KeyProvider.ts, the two interfaces, both with doc comments explaining exactly why they’re separate.
  • packages/crypto/src/providers/signer/KmsSigner.ts, providers/SignerKeyProviderAdapter.ts , the real AWS KMS implementation and the incident it closes.
  • docs/operations/2026-09-15-kms-migration-troubleshooting-guide.md, the real incident timeline this chapter draws from; each entry names its symptom, root cause, and fix separately, and is worth reading end to end for the migration’s own stated lesson: “a migration that changes where a system’s trust boundary sits needs to be verified against the real target platform’s actual runtime behavior, not just a mocked test double of it.”
  • docs/VERIFICATION-GAPS.md G-48/G-49, the ledger entries for the SignerKeyProviderAdapter fix specifically.

Integration requirements

For local-file signing (the default): PARMANA_KEY_DIR pointing at a directory containing default.private.pem/default.public.pem (Ed25519 PEM pair), or PARMANA_KEY_MATERIAL_JSON set so the process materializes them itself at startup. For AWS KMS signing: KEY_PROVIDER=aws-kms, AWS_REGION set, an Ed25519 KMS key (ECC_NIST_EDWARDS25519 key spec) with an alias matching your logical keyId (alias/default for the default signing key), and either AWS_ROLE_ARN (for Vercel OIDC federation) or a real AWS credential chain reachable in the runtime environment. @vercel/oidc-aws-credentials-provider is an optional peer dependency, only needed if AWS_ROLE_ARN is set. Note the Vercel-specific cold-start constraint from the troubleshooting guide’s item 3: on Vercel specifically, KMS calls that need the OIDC token cannot run at module top level, they need a real in-flight HTTP request to read the token header from. A long-running process (server.ts) is not subject to this, since it calls assertKmsSigningKeyReachable() once at real process startup, not per-request. For hybrid mode: CRYPTO_MODE=hybrid, SECONDARY_SIGNATURE_PROVIDER set to a different algorithm than PRIMARY_SIGNATURE_PROVIDER, and a second key pair present at <PARMANA_KEY_DIR>/default-secondary.{private,public}.pem.