Skip to content

feat(keys): X25519 recipient-addressed seal primitive (@nestm/crypto/keys) - #2

Merged
kauandotnet merged 1 commit into
mainfrom
feat/keys-x25519-seal
Aug 12, 2026
Merged

feat(keys): X25519 recipient-addressed seal primitive (@nestm/crypto/keys)#2
kauandotnet merged 1 commit into
mainfrom
feat/keys-x25519-seal

Conversation

@kauandotnet

Copy link
Copy Markdown
Contributor

What

Adds a new subpath entry point @nestm/crypto/keys:

  • X25519 keypair generation with raw↔DER conversion (generateX25519KeyPair, x25519PublicKeyFromRaw/FromRaw, x25519PublicKeyBytes/PrivateKeyBytes).
  • hkdfSha256 — an RFC 5869 HKDF-SHA256 extract-and-expand helper.
  • sealTo / openFrom / openKeyFrom / inspectSealed — a recipient-addressed seal: ephemeral-static X25519 → HKDF-SHA256 → AES-256-GCM. It wraps an existing 32-byte secret (or any Uint8Array ≤ 64 KiB) to a recipient's public key. This is the "wrap this key to a public key" primitive that DataKeyProvider cannot express (generateDataKey mints its own key).

Design

Wire format is version- and suite-tagged (0x01 ‖ 0x01 ‖ epk(32) ‖ ct ‖ tag(16); 50-byte fixed overhead). Key schedule:

okm = HKDF-SHA256(X25519(esk, rpk), salt=∅,
                  info = frame("nmc/seal/v1", suite, epk, rpk, callerInfo), 44)
key = okm[0..32]; nonce = okm[32..44]   // derived, never transmitted
ct‖tag = AES-256-GCM(key, nonce, plaintext, aad = callerAad)
  • The recipient public key is bound into the key schedule by the library, so a caller cannot forget it.
  • The nonce is derived, not on the wire — the AES key is unique per seal (fresh ephemeral), so nonce reuse is impossible by construction and there is no attacker-grindable field.
  • Callers may bind key-schedule info (domain separation) and AEAD aad (per-message binding); a mismatch fails as AUTHENTICATION_FAILED.
  • A low-order / all-zero ephemeral key is rejected by Node at derivation and surfaces as AUTHENTICATION_FAILED.
  • Failures reuse the existing CryptoError codes only — no new CryptoErrorCode.

Boundaries

  • Node built-ins only — no new runtime dependency (no HPKE library, no WASM).
  • Independent of DataKeyProvider and CipherEngine. @nestm/crypto/core stays free of NestJS, tenant, and cloud-SDK imports.
  • Subpath export only — not re-exported from the root entry, so the Nest entry's surface and bundle are unchanged.
  • An X25519 DataKeyProvider adapter is intentionally deferred (wrong shape for per-recipient wrapping); sealTo/openFrom is the primitive callers need.

Tests

tests/unit/keys.test.ts (16 tests): seal/open round-trips (bytes, raw keys, KeyObject); wrong-recipient / wrong-info / wrong-aad / tampered-ciphertext-tag-ephemeral → AUTHENTICATION_FAILED; truncated/oversized/unknown-version/unknown-suite framing errors; non-X25519 key rejection; fresh-ephemeral-per-seal; RFC 5869 SHA-256 HKDF KATs (cases 1 and 3). Format-frozen golden vectors live in the consuming app per this repo's CONTRIBUTING (no ciphertext fixtures upstream).

pnpm run verify passes: lint, format, typecheck, 143 unit tests, build, check-package, publint, attw (./keys resolves 🟢 node16-ESM / bundler), packed consumers.

Context

Upstream primitive for conceptadev/concepta-artifacts#220 (SEC-05: per-user/per-workspace content keys). SEC-05 uses sealTo to wrap a per-artifact content key to user/org principal public keys, and to a sandbox service key for the DB-less byte server.

A follow-up PR will fix the Azure Key Vault provider silently dropping wrappingContext (unrelated to this change; tracked separately to keep this PR focused).

Adds the @nestm/crypto/keys entry point: X25519 keypair generation with
raw<->DER conversion, an HKDF-SHA256 helper, and sealTo/openFrom/openKeyFrom
(ephemeral-static X25519 -> HKDF-SHA256 -> AES-256-GCM) for wrapping an existing
secret to a recipient public key.

The wire format is version- and suite-tagged; the recipient public key is bound
into the HKDF key schedule by the library; the nonce is derived and never
transmitted; callers may bind key-schedule info and AEAD aad. Independent of
DataKeyProvider/CipherEngine, so the framework-neutral core stays free of NestJS,
tenant, and cloud-SDK imports.

This is the upstream primitive that SEC-05 (conceptadev/concepta-artifacts#220)
consumes to seal per-artifact content keys to user/org principals.
@kauandotnet
kauandotnet merged commit a90d79a into main Aug 12, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant