raz-encrypt 1.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +246 -10
  2. package/package.json +28 -7
package/README.md CHANGED
@@ -18,6 +18,28 @@ Nothing here requires the two parties' computers to ever talk to each other dire
18
18
  npm install raz-encrypt
19
19
  ```
20
20
 
21
+ ## Upgrading from 1.x to 2.0
22
+
23
+ 2.0 closes every High and Medium finding from a security audit of 1.1.0. Most of it is invisible, but these changes need action from existing users:
24
+
25
+ | Change | What you need to do |
26
+ | --- | --- |
27
+ | `trust.json` is now authenticated (HMAC) and an old one is **not read implicitly** | The first trust-list read throws `TrustStoreIntegrityError`. Check the file's entries are yours, then call `migrateLegacyTrustStore(identity)` once. |
28
+ | The audit log is now MAC-chained with a key derived from your identity | An old `log.jsonl` fails `verifyLogIntegrity`. Move it aside (keep it as an archive); a new log starts clean. Deleting the most recent entries is now detected too, using a new `log.head.json`. |
29
+ | `sign()` writes version-2 certificates (alias and `signedAt` are now signed) | Old certificates still verify. A v2 certificate does **not** verify on 1.x, so upgrade verifiers before signers. |
30
+ | The live-session handshake (`raz-encrypt/core`) signs role, peer identities and a domain tag, and its key labels moved to `v3` | A 2.0 peer cannot complete a handshake with a 1.x peer, and there is no fallback. Upgrade both ends together. (Only matters if you use the session handshake and ratchet.) |
31
+ | `encryptFor` boxes are now **version 2**: the key is bound to both parties and every public value, and the sender dual-signs the box. **1.x boxes are rejected.** | Decrypt anything you still need with 1.x first and re-encrypt it with 2.0. 2.0 and 1.x cannot read each other's boxes. Boxes now carry `id`, `signedAt` and `signature` and are about 9 KB larger. |
32
+ | `identity.enc.json` moves to a versioned, authenticated format | Nothing: it upgrades itself on the first successful `loadIdentity`. Once upgraded, 1.x can't read it, so back the file up first if you may roll back. |
33
+
34
+ Smaller behavior changes:
35
+
36
+ - `canonicalize`/`hashDocument` are strict RFC 8785: values JSON can't represent (`undefined`, `NaN`, `Infinity`, `BigInt`, `Map`, `Set`, `Date`, typed arrays, class instances, lone surrogates, cycles) now throw `TypeError` instead of silently colliding. Convert them first (omit the key, or `date.toISOString()`). Output for valid documents is unchanged.
37
+ - `verify()` returns `{ valid: false, reason: "malformed-certificate" }` for malformed certificates instead of throwing, and an invalid `expiresAt` is rejected by `sign()` and fails closed in `verify()`.
38
+ - New identities need a passphrase that is normalized (Unicode NFKC), at least 12 characters, not one repeated character, and not a very common one. Existing passphrases keep working.
39
+ - Storage directories created by the library are mode `0700`.
40
+
41
+ New in 2.0: key-confirmation helpers (`makeKeyConfirmation`/`verifyKeyConfirmation`), role- and peer-bound handshake signatures, `untrustFingerprint`, `zeroizeIdentity`, a ratchet-caller guide (ADR 0013), CI, npm provenance releases and a [security policy](https://github.com/razmans/raz-encrypt/blob/main/SECURITY.md).
42
+
21
43
  ## Quickstart
22
44
 
23
45
  ```ts
@@ -83,7 +105,7 @@ const opened = await decryptFrom(bob, aliceCard, box);
83
105
  **1. `sign`/`verify` and `encryptFor`/`decryptFrom` are two separate, independent tools — not a pipeline.**
84
106
 
85
107
  - `sign` + `verify` prove **authenticity**: "this exact document really came from Alice and wasn't altered." The document itself travels in the clear.
86
- - `encryptFor` + `decryptFrom` provide **confidentiality**: "nobody but Bob can read this." It says nothing about who sent it.
108
+ - `encryptFor` + `decryptFrom` provide **confidentiality**: "nobody but Bob can read this." Since 2.0 the box is also signed by the sender and bound to both parties, so Bob knows it came from Alice and was meant for him. That signature covers the box, not a separate document: use `sign`/`verify` when the document itself must be provable to a third party.
87
109
 
88
110
  You use whichever one (or both) fits what you're protecting against. You do **not** need to call `verify()` before you can call `decryptFrom()`, or vice versa — neither depends on the other. If you want both (a document that's both proven-authentic _and_ private), sign it, then encrypt the `{ document, certificate }` pair together — see the worked example below.
89
111
 
@@ -145,12 +167,79 @@ const combinedResult = await verify(
145
167
 
146
168
  **So, to directly answer "do I need to run verify first?"** — no. `verify()` is only relevant when Alice actually signed something; `decryptFrom()` is only relevant when Alice actually encrypted something. Run whichever one matches what you received, in either order, or both if she did both.
147
169
 
170
+ ## Example scenarios
171
+
172
+ A few concrete situations this library is built for, beyond the single-document walkthrough above.
173
+
174
+ **1. Signing a legal document that must expire.** A contract, a power of attorney, anything with a validity window:
175
+
176
+ ```ts
177
+ const certificate = await sign(alice, contractDocument, {
178
+ expiresAt: new Date("2027-01-01T00:00:00.000Z").toISOString(),
179
+ });
180
+ // ... time passes ...
181
+ const result = await verify(bob, contractDocument, certificate);
182
+ // => { valid: false, reason: "expired" } once 2027-01-01 has passed
183
+ ```
184
+
185
+ **2. Private, authenticated messaging between two known parties.** Encrypt and sign together so the recipient knows both "only I can read this" and "this provably came from Alice":
186
+
187
+ ```ts
188
+ const certificate = await sign(alice, { message: "Transfer approved" });
189
+ const box = await encryptFor(alice, bobCard, { document: { message: "Transfer approved" }, certificate });
190
+ const opened = await decryptFrom<{ document: unknown; certificate: Certificate }>(bob, aliceCard, box);
191
+ const result = await verify(bob, opened.document, opened.certificate);
192
+ ```
193
+
194
+ **3. A compliance-driven deployment that needs confidentiality at rest, not just tamper-evidence.** Call both migration functions once, right after `createIdentity` (or at any later point — see "Encrypting the trust store and audit log" below), before anything sensitive accumulates:
195
+
196
+ ```ts
197
+ const alice = await createIdentity({ storageDir, passphrase, alias: "alice" });
198
+ await migrateTrustStoreToEncrypted(alice);
199
+ await migrateAuditLogToEncrypted(alice);
200
+ // From here on, trust.json and every new log entry are encrypted, not just authenticated.
201
+ ```
202
+
203
+ **4. Auditing an identity's history after the fact (encrypted or not).** `readLog`/`verifyLogIntegrity` work identically either way — the caller never needs to know which format is on disk:
204
+
205
+ ```ts
206
+ const entries = await readLog(alice); // transparently decrypts if migrateAuditLogToEncrypted was called
207
+ const integrity = await verifyLogIntegrity(alice);
208
+ if (!integrity.intact) {
209
+ console.error("Audit log was tampered with or truncated:", integrity);
210
+ }
211
+ ```
212
+
213
+ **5. Revoking trust after a suspected compromise, then telling everyone.** Combine `untrustFingerprint` (stop trusting a specific card locally) with a portable revocation notice (tell every peer who ever trusted you, independent of this library's local state):
214
+
215
+ ```ts
216
+ await untrustFingerprint(bob, fingerprint(compromisedCard));
217
+ const notice = await createRevocationNotice(alice, { reason: "Suspected device compromise" });
218
+ // Send `notice` to everyone who has ever trusted alice's card — each of them runs:
219
+ await applyRevocationNotice(someoneElse, notice);
220
+ ```
221
+
222
+ **6. Rotating to a new key pair without losing existing peers' trust.** A dual-signed handoff, plus an out-of-band fingerprint check by each peer. Two signatures alone do not stop someone holding a stolen old key (they sign with their own "new" key too), so `applyRotation` requires you to confirm the new card's fingerprint with its real owner by phone or in person (ADR 0029):
223
+
224
+ ```ts
225
+ const newAlice = await createIdentity({ storageDir: "./alice-store-v2", passphrase, alias: "alice" });
226
+ const draft = await beginRotation(alice, exportPublicIdentity(newAlice));
227
+ const statement = await completeRotation(newAlice, draft);
228
+ // Send `statement` to every peer who trusts the old card, and tell them the new
229
+ // fingerprint over a separate channel. Each peer runs:
230
+ await applyRotation(bob, statement, { confirmNewFingerprint: fingerprintTheyConfirmedWithAlice });
231
+ ```
232
+
233
+ **7. An ephemeral, confirmed live session instead of one-shot documents/boxes** (chat, a real-time channel) — see "Live sessions" below for the full `ConfirmedSession` walkthrough.
234
+
148
235
  ## API
149
236
 
150
237
  | Function | What it does |
151
238
  | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
152
- | `createIdentity({ storageDir, passphrase, alias, overwrite? })` | Generates a new identity (see "Security posture") and persists it encrypted at `storageDir`. Requires a passphrase of at least `MIN_PASSPHRASE_LENGTH` (12) characters. |
239
+ | `createIdentity({ storageDir, passphrase, alias, overwrite?, checkPassphrase? })` | Generates a new identity (see "Security posture") and persists it encrypted at `storageDir`. Passphrase is normalized (Unicode NFKC), must be at least `MIN_PASSPHRASE_LENGTH` (12) characters, not one repeated character, and not a very common passphrase. Optional `checkPassphrase(normalized)` runs after those checks — see below. |
153
240
  | `loadIdentity({ storageDir, passphrase })` | Loads a previously-created identity back from disk. |
241
+ | `loadIdentityFromKeyStore({ alias, storageDir, keyStore })` | Builds an identity from a caller-supplied `KeyStore` (`{ load() }`) instead of `identity.enc.json`/a passphrase. Key sourcing only — see "Security posture" and ADR 0024. Checks lengths and that each public key matches its secret key; throws on a mismatch. |
242
+ | `zeroizeIdentity(identity)` | Best-effort wipe of the identity's secret key bytes in memory when you're done with it (the identity is unusable afterwards). |
154
243
  | `exportPublicIdentity(identity)` | Returns the shareable public card `{ alias, classicalPublicKey, pqcSignPublicKey, pqcKemPublicKey }`. |
155
244
  | `fingerprint(card)` | Short, human-comparable fingerprint of a public card (grouped hex). Compare this over a side channel before trusting a card you received over email/chat. |
156
245
  | `trustParty(identity, card)` | Records another party's public card as trusted, locally. Re-trusting a previously revoked card reinstates it. |
@@ -161,13 +250,91 @@ const combinedResult = await verify(
161
250
  | `sign(identity, document, options?)` | Canonicalizes and hashes `document`, signs it with both keys, returns a `Certificate`. Pass `{ expiresAt }` (ISO timestamp) to make it expire; omit it for a permanent proof (the default). |
162
251
  | `verify(identity, document, certificate)` | Checks the certificate's signer is trusted (and not revoked), the document hasn't changed, it hasn't expired, and both signatures check out. |
163
252
  | `encryptFor(identity, counterpartCard, data)` / `decryptFrom(identity, counterpartCard, box)` | Encrypt/decrypt data meant only for one specific counterpart. Both throw `UntrustedPartyError` unless that counterpart is already in your local trust list (`trustParty`). |
253
+ | `untrustFingerprint(identity, fingerprint)` | Revokes exactly one card by its `fingerprint(card)` string (case/spacing ignored); other cards sharing the alias stay trusted. Returns `false` if no such trusted card exists. |
254
+ | `migrateLegacyTrustStore(identity)` | One-time adoption of an unauthenticated `trust.json` from an earlier version. Review the file first; returns how many records were adopted. See "Upgrading the trust store" below. |
255
+ | `migrateTrustStoreToEncrypted(identity)` | One-time, explicit opt-in that encrypts `trust.json`'s entries (not just authenticates them). No-op if already encrypted. See "Encrypting the trust store and audit log" below. |
256
+ | `getTrustStoreAnchor(identity)` / `verifyTrustStoreAnchor(identity, anchor)` | `{ seq, mac }` of the trust store, to keep somewhere an attacker on this machine can't write (second device, remote). Verifying throws `TrustStoreIntegrityError` if the local store is older than the anchor. See ADR 0030. |
257
+ | `migrateAuditLogToEncrypted(identity)` | One-time, explicit opt-in that encrypts every audit-log entry's content (not just authenticates it); every `appendLogEntry` call afterwards encrypts only the new entry. No-op if already encrypted. |
258
+ | `exportSignedCard(identity, options?)` / `trustSignedCard(identity, card, certificate)` | A self-signed, optionally-expiring version of your card (`{ expiresAt? }`), and verifying/trusting one you received. Enforces a cryptoperiod a bare card has no way to carry. See ADR 0023. |
259
+ | `beginRotation(oldIdentity, newCard)` / `completeRotation(newIdentity, draft)` / `applyRotation(identity, statement, { confirmNewFingerprint })` | A dual-signed statement handing trust from an old card to a new one. Signatures alone do not protect against a stolen old key, so you must pass the new card's fingerprint as confirmed out of band; a mismatch returns `fingerprint-mismatch` and a missing value throws. No-ops for a card you never trusted. See ADR 0029. |
260
+ | `createRevocationNotice(identity, options?)` / `applyRevocationNotice(identity, notice)` | A self-signed, portable revocation notice any peer can verify independently — generate one right after `createIdentity` and store it separately for recovery if the identity later becomes inaccessible. No-ops for a card you never trusted. |
164
261
  | `readLog(identity)` | Reads the local, append-only audit log of everything this identity has signed/verified/encrypted/decrypted. |
165
- | `verifyLogIntegrity(identity)` | Walks the log's hash chain and reports `{ intact: true }` or `{ intact: false, brokenAtIndex }` — detects an edited or deleted entry. |
166
- | `canonicalize(value)` / `hashDocument(value)` | The deterministic JSON hashing used internally — exposed in case you want to compute a document hash yourself. |
262
+ | `verifyLogIntegrity(identity)` | Walks the log's keyed (HMAC) chain and reports `{ intact: true }` or `{ intact: false, brokenAtIndex, reason? }` — detects an edited or deleted entry, and removal of the newest entries (`reason: "truncated"`), including by someone with file access but not your keys. Cannot detect both files being rolled back together, or deleted outright; keep an off-box copy of `log.head.json` if that matters. |
263
+ | `canonicalize(value)` / `hashDocument(value)` | RFC 8785 (JCS) canonical JSON and its SHA-512 hash, used internally. Throws `TypeError` for anything JSON can't represent faithfully (`undefined`, `NaN`/`Infinity`, `BigInt`, `Map`/`Set`/`Date`/typed arrays/class instances, lone surrogates, cycles) instead of silently collapsing it. |
264
+
265
+ ### Checking a passphrase against a real breach corpus
266
+
267
+ `createIdentity`'s own blocklist is deliberately small — "a floor, not a strength meter," per its own comment. NIST SP 800-63B expects checking against a *large* corpus of known-compromised passphrases, which this library doesn't ship (and doesn't make network calls to check, either — see ADR 0021). Plug in a real checker yourself via `checkPassphrase`, which runs after the built-in checks and gets the already-normalized passphrase:
268
+
269
+ ```ts
270
+ import zxcvbn from "zxcvbn"; // or an offline top-N list, or your own HIBP k-anonymity call
271
+
272
+ await createIdentity({
273
+ storageDir,
274
+ passphrase,
275
+ alias: "alice",
276
+ checkPassphrase: (normalized) => {
277
+ if (zxcvbn(normalized).score < 3) {
278
+ throw new Error("Passphrase is too weak — choose something harder to guess.");
279
+ }
280
+ },
281
+ });
282
+ ```
283
+
284
+ Throw to reject the passphrase with your own message; return normally to accept it. An async function (returning a `Promise`) works too — `createIdentity` awaits it.
285
+
286
+ ### Live sessions (`raz-encrypt/core`) — key confirmation is a required step
287
+
288
+ `createResponderHandshake`, `establishSessionInitiator` and `establishSessionResponder` return a `confirmationKey` alongside the session keys. A completed handshake is **not** proof the peer derived the same secrets until you confirm it — and nothing stops you from skipping that step entirely if you build on the low-level pieces directly. `ConfirmedSession` closes that gap: it won't let you `seal` or `open` anything until confirmation has actually succeeded in both directions.
289
+
290
+ ```ts
291
+ const session = new ConfirmedSession("initiator", result, transcript); // or "responder"
292
+ // ...send session.ownConfirmationTag to the peer, receive `theirTag` back...
293
+ if (!session.confirmPeer(theirTag)) {
294
+ /* close the connection: the peer didn't prove it derived the same secrets */
295
+ }
296
+ const frame = session.seal(bytes, aad?); // throws UnconfirmedSessionError before confirmPeer succeeds
297
+ const plain = session.open(frame, aad?); // same — both directions are gated
298
+ session.destroy(); // wipe the copied keys when the session ends
299
+ ```
300
+
301
+ `transcript` is `{ initiatorCard, responderCard, responderMessage, initiatorMessage }`. `ConfirmedSession` performs no network I/O itself — you still transport `ownConfirmationTag` (and the handshake messages before it) however you already do; it only decides when `seal`/`open` are allowed to run. See ADR 0025.
302
+
303
+ **Advanced use: the low-level pieces, if you need to skip the gate.** `makeKeyConfirmation(confirmationKey, role, transcript)` / `verifyKeyConfirmation(confirmationKey, counterpartRole, transcript, tag)` are role-bound (your own tag can't be reflected back at you), compare in constant time, and `verifyKeyConfirmation` returns `false` instead of throwing for malformed input. `createFrameChannels(result.keys, role)` returns the underlying `{ sender, receiver }` pair directly — `sender.seal`/`receiver.open` own the AES-GCM counter so a (key, counter) pair is never reused (AES-GCM is catastrophically weak if one ever is): create one sender per key, never a second for the same key; the receiver only advances after a frame authenticates, so forged frames can't move it; counter problems throw `FrameCounterError`, bad ciphertext throws `FrameDecryptionError`. `ConfirmedSession` is built from exactly these two functions — reach for them directly only if you specifically need to manage confirmation yourself.
304
+
305
+ ### Upgrading the trust store (`trust.json`)
306
+
307
+ `trust.json` now carries an HMAC keyed from your identity's secret keys, so an edited, truncated or copied file is detected: everything that reads the trust list (`verify`, `encryptFor`, `decryptFrom`, `listTrustedParties`, ...) throws `TrustStoreIntegrityError` instead of using it.
308
+
309
+ A `trust.json` written by an earlier version has no MAC, so it is **not** read implicitly (an attacker could otherwise plant one). The first call throws an error pointing at `migrateLegacyTrustStore(identity)`. Check the file's entries are yours, then call it once; it keeps your records (and revocations) and rewrites the file authenticated. A fresh identity needs nothing.
310
+
311
+ ### Encrypting the trust store and audit log
312
+
313
+ By default, `trust.json` and `log.jsonl` are **authenticated but not encrypted** — an edit is detected, but anyone with filesystem read access (no passphrase, no knowledge of your identity's secrets) can still read who you trust and what you've done. If that confidentiality matters for your deployment (shared hosting, a backed-up disk image, a compliance requirement), opt in once per store:
314
+
315
+ ```ts
316
+ import { loadIdentity, migrateTrustStoreToEncrypted, migrateAuditLogToEncrypted } from "raz-encrypt";
317
+
318
+ const alice = await loadIdentity({ storageDir: "./alice-store", passphrase: "alice's secret passphrase" });
319
+
320
+ // One-time opt-in, per store. Safe to call on a brand-new identity (nothing
321
+ // to migrate yet) or an established one (every existing record/entry is
322
+ // re-encrypted in that one call).
323
+ await migrateTrustStoreToEncrypted(alice);
324
+ await migrateAuditLogToEncrypted(alice);
325
+
326
+ // Everything else is unchanged — trustParty, sign, verify, encryptFor,
327
+ // decryptFrom, readLog, verifyLogIntegrity all keep working exactly as
328
+ // before; trust.json and new log entries are now written encrypted.
329
+ ```
330
+
331
+ Both encryption keys are derived from your identity's own secret keys (the same HKDF approach already used for the existing integrity MACs) — never from your passphrase, since these calls never receive one. The existing tamper/deletion detection (the MAC chain and `log.head.json`'s head record) keeps working exactly as before; encryption only adds confidentiality on top of it, it doesn't replace it. Calling either migration function a second time is a safe no-op. See ADR 0027 and [docs/adr/0027-encrypted-trust-store-and-audit-log.md](docs/adr/0027-encrypted-trust-store-and-audit-log.md).
167
332
 
168
333
  ## Security posture
169
334
 
170
- **Is this "military grade"?** It uses the same algorithms — ECDSA/ECDH on curve P-384, AES-256 — that banks, governments, and CNSA-1.0-compliant systems use. The math is the real thing, not a toy. What it is **not** is FIPS 140-2/3 (CMVP) validated: that's a certification of one specific software build/deployment, done by an accredited lab, and it takes years — no realistic npm library ships with that certification. Not being certified doesn't mean it's unsafe: it's the difference between a home-cooked meal made with the exact same top-shelf ingredients a certified restaurant uses, versus the restaurant's official health-inspection paperwork. The ingredients and technique are what make it safe; the certificate just proves it to a third party who requires that paperwork (e.g. a specific government/defense procurement rule). If your use case legally _requires_ FIPS-validated modules, this library's logic needs to run inside a validated HSM/crypto boundary — that's a deployment decision outside what any library's code can provide on its own.
335
+ **Is this "military grade"?** It uses the same algorithms — ECDSA/ECDH on curve P-384, AES-256 — that banks, governments, and CNSA-1.0-compliant systems use. The math is the real thing, not a toy. What it is **not** is FIPS 140-2/3 (CMVP) validated: that's a certification of one specific software build/deployment, done by an accredited lab, and it takes years — no realistic npm library ships with that certification. Not being certified doesn't mean it's unsafe: it's the difference between a home-cooked meal made with the exact same top-shelf ingredients a certified restaurant uses, versus the restaurant's official health-inspection paperwork. The ingredients and technique are what make it safe; the certificate just proves it to a third party who requires that paperwork (e.g. a specific government/defense procurement rule). If your use case legally _requires_ FIPS-validated modules, this library's logic needs to run inside a validated HSM/crypto boundary — that's a deployment decision outside what any library's code can provide on its own. The test suite cross-checks AES-256-GCM, P-384 ECDSA, P-384 ECDH and HKDF-SHA384 live against Node's independent `crypto` module; ML-KEM-1024 and ML-DSA-87 have no independent implementation to check against, so their tests are regression pins against the pinned `@noble/post-quantum` version, not official NIST vectors (ADR 0022). That is useful evidence, not a substitute for FIPS 140-3 (CMVP) validation.
336
+
337
+ One specific gap worth naming: the identity file's passphrase-derived key uses **scrypt**, which is not the KDF NIST SP 800-132 names (that's PBKDF2). Scrypt was kept anyway because it's memory-hard — a real defense against GPU/ASIC password-cracking that PBKDF2 doesn't have — but if your use case specifically requires an SP 800-132-compliant KDF, this library doesn't offer one yet.
171
338
 
172
339
  **Is this quantum-safe?** Half of the design is built specifically for that. **ML-KEM-1024** (key exchange) and **ML-DSA-87** (signatures) are NIST's official post-quantum standards (FIPS 203 / FIPS 204, finalized 2024), chosen because classical ECC and RSA can eventually be broken by a large enough quantum computer via Shor's algorithm. We don't rely on the post-quantum algorithms _alone_, though — they're only a few years old, with far less real-world cryptanalysis behind them than P-384. So every signature and every shared secret in this library requires **both** the classical layer and the post-quantum layer to hold:
173
340
 
@@ -183,13 +350,71 @@ If one layer is ever broken — classical by a quantum computer, or the newer po
183
350
  **Two honest limitations, so you can decide if they matter for your use case:**
184
351
 
185
352
  - **No forward secrecy.** `encryptFor`/`decryptFrom` derive their key from _long-term_ keys on both sides: a static ECDH-P384 exchange (deterministic for a given pair of identities) combined with an ML-KEM-1024 encapsulation whose secret still requires the recipient's long-term KEM secret key to decapsulate. Both of those secret keys live in the same `identity.enc.json`. This means that if someone ever recovers your passphrase and that file, they can decrypt **every box anyone ever sent you**, not just future ones — there's no per-message ephemeral key rotation (the kind Signal-style ratcheting provides). Rotate identities (new `storageDir`, `trustParty()` again) if you need to bound the blast radius of a future key compromise.
186
- - **No replay protection.** An `EncryptedBox` decrypts identically no matter how many times, or how much later, it's replayed to the same recipient — there's no session id, counter, or timestamp binding it to a single delivery. Fine for "send this document once"; if your use case cares about replay (e.g. treating receipt of a box as a one-time authorization), add your own nonce/timestamp inside the encrypted payload and check it on the receiving end.
353
+ - **Replay protection is opt-in.** Every box has a unique `id` and a signed `signedAt`. The library does not remember boxes, so pass `decryptFrom(bob, aliceCard, box, { isReplay })` with a function that returns `true` for an `id` you have already seen (it is called only after the signature verifies, and a `true` throws `ReplayedBoxError`). Without it, a valid box decrypts every time it is replayed.
187
354
 
188
- ## What this library deliberately does _not_ do (v1)
355
+ **Backup and recovery.** There is no password-reset flow: losing your passphrase or `identity.enc.json` loses the identity, permanently. Two things actually help:
356
+
357
+ - **Back up `identity.enc.json` itself** (encrypted, so a copied file alone is not enough to compromise you) somewhere separate from the machine it lives on — and back up the passphrase separately from the file, the same way you'd handle any other secret.
358
+ - **Generate a revocation notice immediately after `createIdentity`, before you ever share your card with anyone, and store it separately from the identity** — `createRevocationNotice(identity)`. It's a self-signed, portable statement any peer can verify independently (`applyRevocationNotice`), and it works exactly the same whether you use it the day you made it or years later. This is the only thing that gives you a way out if the identity itself later becomes completely inaccessible: you can't "recover" a lost identity, but you can tell everyone who ever trusted it to stop. See `exportSignedCard`/`trustSignedCard` (self-signed, optionally expiring cards) and `beginRotation`/`completeRotation`/`applyRotation` (a dual-signed handoff from an old card to a new one; each peer must confirm the new fingerprint out of band) for the rest of the key-lifecycle API — see ADRs 0023 and 0029.
359
+
360
+ **`KeyStore`: where your keys come from, not how operations are performed.** `loadIdentityFromKeyStore` lets you source secret-key material from an OS keychain or similar instead of `identity.enc.json`/a passphrase — but once loaded, this library still operates on the raw key bytes directly in its own process memory, exactly like a file-loaded identity. There is no way to make this library forward a signing or key-exchange operation *to* a device so the private key never leaves it — that's a different, much larger feature (every crypto call site would need to become a pluggable remote operation), and it's only partially possible today regardless: no commodity HSM or TPM implements ML-DSA-87 or ML-KEM-1024 yet, so even a "real" hardware-backed identity could only ever cover the classical half. A `KeyStore` backed by an OS keychain that hands back raw bytes to your authorized process is a reasonable, honest use of this interface; wrapping an actual HSM in one, where the whole point is that the key *can't* be extracted, isn't — see ADR 0024.
361
+
362
+ ## Standards this library aims to align with
363
+
364
+ **None of the following is a certification or an independent audit finding.** This section lists the public standards and specifications whose guidance this library's design and a standards-alignment self-review were checked against — all effort has gone into aligning with them, but nothing here has been independently verified by an accredited lab, a formal certification body, or a third-party review. Treat it as "built with these in mind," not "certified to these."
365
+
366
+ | Area | Standard / reference | How this library relates to it |
367
+ | --- | --- | --- |
368
+ | Post-quantum signatures | NIST FIPS 204 (ML-DSA) | Every document/certificate is signed with ML-DSA-87 alongside the classical signature; `verify()` requires both. |
369
+ | Post-quantum key exchange | NIST FIPS 203 (ML-KEM) | Every session/box key derivation includes an ML-KEM-1024 encapsulation alongside the classical ECDH exchange. |
370
+ | Key establishment | NIST SP 800-56A | The hybrid (classical + post-quantum) handshake's key-derivation structure follows this family's conventions for authenticated key establishment. |
371
+ | Key derivation / domain separation | NIST SP 800-56C | Every HKDF-derived key in this library (MAC keys, head-MAC keys, and the new encryption keys from issue #43/ADR 0027) uses its own dedicated info label, so independent uses of the same base secret never share derived key material. |
372
+ | Memorized secret (passphrase) guidance | NIST SP 800-63B | `createIdentity`'s passphrase policy (minimum length, rejecting trivial/common values) follows this standard's spirit; `checkPassphrase` lets a caller plug in a real breach-corpus check, which this library doesn't ship itself (ADR 0021). |
373
+ | Password-based key derivation | NIST SP 800-132 | `identity.enc.json` is encrypted under a passphrase-derived key via **scrypt** rather than the PBKDF2 this standard names — a deliberate, documented deviation for scrypt's memory-hardness (see "Security posture" above). |
374
+ | Key management / mutual authentication | ISO/IEC 11770-3 | The session handshake's key-confirmation step (`ConfirmedSession`, `makeKeyConfirmation`/`verifyKeyConfirmation`) follows this standard's model for proving both sides derived the same key before trusting a channel. |
375
+ | Non-invasive attack resistance (timing) | ISO/IEC 17825, FIPS 140-3 (non-invasive attacks) | Every MAC/tag comparison in this library (trust store, audit log, key confirmation) uses a constant-time compare; `test/timingSafety.test.ts` fuzzes and statistically checks this (issue #42/ADR 0026). JavaScript cannot guarantee constant time the way a lower-level language can — this is evidence of due diligence, not a hard guarantee. |
376
+ | Use of cryptography | ISO/IEC 27002:2022, control 8.24 | The optional encryption for `trust.json` and the audit log (issue #43/ADR 0027) exists specifically to close the gap this control calls out: authenticated is not the same as confidential. |
377
+ | Data classification | OWASP ASVS V6.1 | Same gap as above — "who you trust" and "what you did" are classified as confidential, not just integrity-protected, data. |
378
+ | Stored cryptography | OWASP ASVS V6 | `identity.enc.json`, `trust.json`, and the audit log are all encrypted-at-rest options built from reviewed primitives (the `@noble/*` libraries), never a custom cipher. |
379
+ | Input validation / fuzzing | OWASP ASVS V5 | Every parser that touches externally-controlled or persisted input (canonical-JSON, handshake-message shape validators, the trust-store and audit-log parsers, identity-file deserialization) is fuzz-tested with `fast-check` to prove it fails closed on malformed input instead of crashing (issue #42/ADR 0026). |
380
+ | Canonical JSON | RFC 8785 (JSON Canonicalization Scheme) | `canonicalize()`/`hashDocument()` implement JCS exactly, strictly rejecting anything JSON cannot represent faithfully rather than silently colliding two different documents onto the same hash. |
381
+ | National algorithm suite | CNSA 1.0 | The classical layer (ECDSA/ECDH on P-384, AES-256) uses the same algorithm choices CNSA-1.0-compliant systems use (see "Security posture"). |
382
+ | Conformance evidence | NIST/ISO known-answer test vectors | AES-256-GCM, P-384 ECDSA, P-384 ECDH and HKDF-SHA384 are cross-checked live against Node's independent `crypto` module on every test run. ML-KEM-1024 and ML-DSA-87 are **regression pins** against the pinned `@noble/post-quantum` version, not official NIST ACVP vectors (ADR 0022); adding ACVP vectors for them is open work. None of this is a substitute for FIPS 140-2/3 (CMVP) module validation, which this library does not have. |
383
+
384
+ For the honest limits of all of the above — no independent cryptographic review of the custom handshake/ratchet, no forward secrecy for `encryptFor`/`decryptFrom`, no FIPS-validated module — see "Security posture", "Independent review status", and "Threat model and non-goals" below.
385
+
386
+ ## Independent review status
387
+
388
+ The session handshake and ratchet are custom compositions of well-known primitives. **They have not been independently reviewed or formally modelled.** Do not rely on them for high-stakes use until they have been. [docs/security-review-brief.md](docs/security-review-brief.md) sets out the protocol, the claims, the assumptions and the known limits for a reviewer. Also note that `encryptFor`/`decryptFrom` have no forward secrecy.
389
+
390
+ ## Threat model and non-goals
391
+
392
+ **What it defends against**
393
+
394
+ - A passive or active network attacker who can read, replay, reorder, drop or modify handshake and session traffic: both parties are authenticated with dual (classical + post-quantum) signatures, session keys are ephemeral, and frames are authenticated with strict counters.
395
+ - "Harvest now, decrypt later": every secret needs both the classical and the post-quantum layer to be broken.
396
+ - Someone who steals your files but not your passphrase: `identity.enc.json` is encrypted (scrypt + AES-256-GCM), `trust.json` and the audit log are authenticated with keys derived from your identity, so edits are detected.
397
+ - A counterpart you never added to your trust list: `verify`, `encryptFor` and `decryptFrom` refuse them.
398
+
399
+ **What it does not defend against**
400
+
401
+ - Malware or an attacker running as you on an unlocked machine: if they have your decrypted identity, they are you. `zeroizeIdentity` shortens the window; it is not a secure-erase.
402
+ - A wrong card: the library cannot tell that a card you were handed belongs to the person you think it does. Compare `fingerprint(card)` over a separate channel before `trustParty`.
403
+ - A weak passphrase: the policy is a floor (length, no trivial or very common values). An offline attacker with your `identity.enc.json` can guess against it at scrypt speed.
404
+ - Traffic analysis, endpoint compromise, side channels (timing, cache, power) beyond what the underlying `@noble/*` libraries provide, and denial of service by someone who can corrupt your storage directory.
405
+ - Forward secrecy for `encryptFor`/`decryptFrom` (see above); use the session handshake and ratchet for live traffic.
406
+
407
+ **Status of the cryptography**
408
+
409
+ The primitives are standard (NIST P-384, ML-DSA-87, ML-KEM-1024, AES-256-GCM, HKDF-SHA384, scrypt) and come from the `@noble/*` libraries, which are pinned to exact versions here. `@noble/post-quantum` is still 0.x. The handshake and ratchet are custom compositions of those primitives and have **not** had an independent cryptographic review or formal analysis; weigh that before relying on them for high-stakes use. Report problems privately, see [SECURITY.md](https://github.com/razmans/raz-encrypt/blob/main/SECURITY.md).
410
+
411
+ ## What this library deliberately does _not_ do
189
412
 
190
413
  - No server, no HTTP API, no database — everything is local files.
191
414
  - No live network sync between the two parties' machines — public cards, documents, and certificates are exchanged "by hand" (file/email/etc.), not automatically.
192
415
  - No multi-party or hierarchical signing (buyers/vendors/approval-levels/etc.) — this is a generic building block for exactly two parties trusting each other directly.
416
+ - **Rollback of every local file at once is not detected on its own.** `trust.json` carries a monotonic sequence number and `trust.seq.json` records the latest one, so restoring an older `trust.json` alone (for example to un-revoke someone) throws `TrustStoreIntegrityError`. An attacker who can overwrite both files with an older matching pair, or the whole `storageDir`, is not caught locally. Store `getTrustStoreAnchor(identity)` somewhere they cannot write and call `verifyTrustStoreAnchor` on startup to close that. Trust changes are also written to the audit log (`trust`, `untrust`, `rotate`, `revoke`), but the log has the same limit (ADR 0011, 0019, 0030).
417
+ - **One process per `storageDir`.** Changes to the trust list (`trustParty`, `untrustParty`, `untrustFingerprint`, rotation, revocation, migrations) are serialized within a single Node process, so overlapping calls cannot lose a revocation or corrupt `trust.json` (ADR 0028). Separate OS processes writing to the same `storageDir` at once are not coordinated; the audit log has the same limit (ADR 0011). Run one writer per identity.
193
418
  - **Node.js only, not a browser library.** The underlying `@noble/*` crypto libraries are isomorphic and would run fine in a browser, but the storage layer (`identity.enc.json`, `trust.json`, `log.jsonl`) is built on `node:fs`, which browsers don't have. There's no IndexedDB (or similar) storage adapter — if you need this in a browser, that adapter would need to be built.
194
419
 
195
420
  ## Local storage layout
@@ -197,11 +422,18 @@ If one layer is ever broken — classical by a quantum computer, or the newer po
197
422
  Each identity's `storageDir` contains:
198
423
 
199
424
  ```
200
- identity.enc.json # your keys, encrypted at rest under your passphrase (AES-256-GCM, scrypt-derived key)
201
- trust.json # fingerprint-keyed public cards of parties you've trusted (or revoked) — not secret, just public keys
202
- log.jsonl # hash-chained, append-only audit log: what you've signed/verified/encrypted/decrypted, and when
425
+ identity.enc.json # your keys, encrypted at rest under your passphrase (AES-256-GCM, scrypt-derived key; versioned, KDF cost stored in the file, written atomically)
426
+ log.head.next.json # (transient) journal written while migrateAuditLogToEncrypted runs; recovered automatically after a crash
427
+ trust.seq.json # authenticated freshness counter for trust.json (detects restoring an older trust.json)
428
+ trust.json # fingerprint-keyed public cards of parties you've trusted (or revoked), with an integrity MAC — always authenticated; encrypted too once you call migrateTrustStoreToEncrypted
429
+ log.jsonl # append-only audit log, MAC-chained with a key derived from your identity: what you've signed/verified/encrypted/decrypted, and when — encrypted per entry once you call migrateAuditLogToEncrypted
430
+ log.head.json # authenticated entry count and last MAC, so deleting the newest log entries is detected
203
431
  ```
204
432
 
433
+ Identity files written by earlier versions (no `version` field) still load with the same passphrase, and are upgraded to the current format automatically on the first successful `loadIdentity`. If that upgrade can't be written (read-only directory, full disk) the identity still loads and the original file is left untouched. A file from a *newer* raz-encrypt rejects with `UnsupportedFileVersionError`.
434
+
435
+ New storage directories are created with mode `0700` (an existing directory's mode is never changed). `sign()` now writes version-2 certificates whose signatures also cover `signer.alias` and `signedAt`; certificates from earlier releases (no `version`) still verify, but a v2 certificate is not verifiable by an older release.
436
+
205
437
  ## Development
206
438
 
207
439
  ```bash
@@ -210,3 +442,7 @@ npm run typecheck
210
442
  npm test
211
443
  npm run build
212
444
  ```
445
+
446
+ ## Keywords
447
+
448
+ Post-quantum cryptography, hybrid classical and post-quantum encryption, ML-KEM-1024, ML-DSA-87, FIPS 203, FIPS 204, CRYSTALS-Kyber, CRYSTALS-Dilithium, ECDSA P-384, ECDH P-384, AES-256-GCM, HKDF-SHA384, scrypt key derivation, quantum-safe digital signatures, quantum-resistant key exchange, harvest-now-decrypt-later defense, local-first identity library, Node.js cryptography library, document signing and verification, end-to-end encrypted messaging between two parties, encrypted audit log, encrypted trust store, tamper-evident MAC chain, constant-time comparison, timing side-channel resistance, ISO/IEC 17825, FIPS 140-3 non-invasive attacks, NIST SP 800-56A key establishment, NIST SP 800-56C key derivation, NIST SP 800-63B passphrase policy, NIST SP 800-132 password-based key derivation, ISO/IEC 11770-3 key management, ISO/IEC 27002 8.24 use of cryptography, OWASP ASVS V5 input validation, OWASP ASVS V6 stored cryptography, OWASP ASVS V6.1 data classification, RFC 8785 JSON Canonicalization Scheme, CNSA 1.0, known-answer test vectors, fuzz testing with fast-check, property-based testing, key confirmation, forward secrecy limitations, key rotation and revocation, self-signed card expiry, no server no database npm package.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "raz-encrypt",
3
- "version": "1.1.0",
3
+ "version": "2.3.0",
4
4
  "description": "Local-first, hybrid classical+post-quantum signing and verification for two consenting parties. No server, no database required.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,18 +31,39 @@
31
31
  "demo:sign": "tsx demo/sign-as-alice.ts",
32
32
  "demo:verify": "tsx demo/verify-as-bob.ts",
33
33
  "demo:tamper": "tsx demo/verify-as-bob.ts --tamper",
34
- "demo:clean": "rm -rf demo/.alice-store demo/.bob-store demo/alice-card.json demo/bob-card.json demo/signed-doc.json"
34
+ "demo:clean": "rm -rf demo/.alice-store demo/.bob-store demo/alice-card.json demo/bob-card.json demo/signed-doc.json",
35
+ "prepublishOnly": "npm run typecheck && npm test && npm run build",
36
+ "lint": "eslint src",
37
+ "sbom": "cyclonedx-npm --omit dev --output-file sbom.cdx.json"
35
38
  },
36
39
  "dependencies": {
37
- "@noble/ciphers": "^2.4.0",
38
- "@noble/curves": "^2.4.0",
39
- "@noble/hashes": "^2.4.0",
40
- "@noble/post-quantum": "^0.7.1"
40
+ "@noble/ciphers": "2.4.0",
41
+ "@noble/curves": "2.4.0",
42
+ "@noble/hashes": "2.4.0",
43
+ "@noble/post-quantum": "0.7.1"
41
44
  },
42
45
  "devDependencies": {
43
- "@types/node": "^22.10.2",
46
+ "@cyclonedx/cyclonedx-npm": "6.0.1",
47
+ "@eslint/js": "10.0.1",
48
+ "@types/node": "^26.6.4",
49
+ "eslint": "10.12.0",
50
+ "eslint-plugin-security": "4.2.0",
51
+ "fast-check": "^4.10.2",
44
52
  "tsx": "^4.19.2",
45
53
  "typescript": "^5.7.2",
54
+ "typescript-eslint": "8.71.1",
46
55
  "vitest": "^5.0.0"
56
+ },
57
+ "repository": {
58
+ "type": "git",
59
+ "url": "git+https://github.com/razmans/raz-encrypt.git"
60
+ },
61
+ "bugs": {
62
+ "url": "https://github.com/razmans/raz-encrypt/issues"
63
+ },
64
+ "homepage": "https://github.com/razmans/raz-encrypt#readme",
65
+ "publishConfig": {
66
+ "access": "public",
67
+ "provenance": true
47
68
  }
48
69
  }