raz-encrypt 0.3.0 → 2.1.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.
- package/LICENSE +21 -21
- package/README.md +442 -212
- package/dist/canonicalJson.d.ts +15 -0
- package/dist/canonicalJson.js +73 -13
- package/dist/canonicalJson.js.map +1 -1
- package/dist/core.d.ts +6 -2
- package/dist/core.js +4 -1
- package/dist/core.js.map +1 -1
- package/dist/crypto/classical.d.ts +2 -0
- package/dist/crypto/classical.js +4 -0
- package/dist/crypto/classical.js.map +1 -1
- package/dist/crypto/confirmedSession.d.ts +47 -0
- package/dist/crypto/confirmedSession.js +84 -0
- package/dist/crypto/confirmedSession.js.map +1 -0
- package/dist/crypto/frameCipher.js +8 -2
- package/dist/crypto/frameCipher.js.map +1 -1
- package/dist/crypto/frameSession.d.ts +40 -0
- package/dist/crypto/frameSession.js +96 -0
- package/dist/crypto/frameSession.js.map +1 -0
- package/dist/crypto/hybrid.d.ts +186 -25
- package/dist/crypto/hybrid.js +512 -54
- package/dist/crypto/hybrid.js.map +1 -1
- package/dist/crypto/nodeHybrid.d.ts +6 -4
- package/dist/crypto/nodeHybrid.js +3 -2
- package/dist/crypto/nodeHybrid.js.map +1 -1
- package/dist/crypto/pqc.d.ts +7 -2
- package/dist/crypto/pqc.js +13 -4
- package/dist/crypto/pqc.js.map +1 -1
- package/dist/crypto/ratchet.d.ts +68 -0
- package/dist/crypto/ratchet.js +94 -0
- package/dist/crypto/ratchet.js.map +1 -0
- package/dist/fileCrypto.d.ts +44 -2
- package/dist/fileCrypto.js +197 -28
- package/dist/fileCrypto.js.map +1 -1
- package/dist/identity.d.ts +69 -4
- package/dist/identity.js +111 -12
- package/dist/identity.js.map +1 -1
- package/dist/index.d.ts +8 -7
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/passphrase.d.ts +27 -0
- package/dist/passphrase.js +69 -0
- package/dist/passphrase.js.map +1 -0
- package/dist/store.d.ts +42 -5
- package/dist/store.js +254 -22
- package/dist/store.js.map +1 -1
- package/dist/trust.d.ts +143 -2
- package/dist/trust.js +376 -27
- package/dist/trust.js.map +1 -1
- package/package.json +62 -48
package/README.md
CHANGED
|
@@ -1,212 +1,442 @@
|
|
|
1
|
-
# RAZ Encrypt
|
|
2
|
-
|
|
3
|
-
A local-first npm library for **two consenting parties** to sign documents, verify each other's signatures, and privately encrypt data for each other — with no server, no database, and no third-party account. Each party keeps their own identity and audit log in a small encrypted file on their own machine.
|
|
4
|
-
|
|
5
|
-
Built around a **hybrid classical + post-quantum** cryptography design (see "Security posture" below) rather than any single algorithm.
|
|
6
|
-
|
|
7
|
-
## How it works (the short version)
|
|
8
|
-
|
|
9
|
-
1. Alice and Bob each call `createIdentity()` once. This generates their keys and stores them, encrypted, in a local folder.
|
|
10
|
-
2. They exchange their **public cards** — just public keys, safe to send over email/Slack/whatever — and call `trustParty()` to record each other as trusted.
|
|
11
|
-
3. From then on: Alice can `sign()` a document; Bob can `verify()` it against the certificate. Either of them can `encryptFor()` the other and `decryptFrom()` what they receive.
|
|
12
|
-
|
|
13
|
-
Nothing here requires the two parties' computers to ever talk to each other directly — everything is exchanged "by hand" (file, email, chat), and each side's storage is entirely local and private to them.
|
|
14
|
-
|
|
15
|
-
## Install
|
|
16
|
-
|
|
17
|
-
```bash
|
|
18
|
-
npm install raz-encrypt
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
##
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
import {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
//
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
1
|
+
# RAZ Encrypt
|
|
2
|
+
|
|
3
|
+
A local-first npm library for **two consenting parties** to sign documents, verify each other's signatures, and privately encrypt data for each other — with no server, no database, and no third-party account. Each party keeps their own identity and audit log in a small encrypted file on their own machine.
|
|
4
|
+
|
|
5
|
+
Built around a **hybrid classical + post-quantum** cryptography design (see "Security posture" below) rather than any single algorithm.
|
|
6
|
+
|
|
7
|
+
## How it works (the short version)
|
|
8
|
+
|
|
9
|
+
1. Alice and Bob each call `createIdentity()` once. This generates their keys and stores them, encrypted, in a local folder.
|
|
10
|
+
2. They exchange their **public cards** — just public keys, safe to send over email/Slack/whatever — and call `trustParty()` to record each other as trusted.
|
|
11
|
+
3. From then on: Alice can `sign()` a document; Bob can `verify()` it against the certificate. Either of them can `encryptFor()` the other and `decryptFrom()` what they receive.
|
|
12
|
+
|
|
13
|
+
Nothing here requires the two parties' computers to ever talk to each other directly — everything is exchanged "by hand" (file, email, chat), and each side's storage is entirely local and private to them.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install raz-encrypt
|
|
19
|
+
```
|
|
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
|
+
|
|
43
|
+
## Quickstart
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import {
|
|
47
|
+
createIdentity,
|
|
48
|
+
exportPublicIdentity,
|
|
49
|
+
trustParty,
|
|
50
|
+
sign,
|
|
51
|
+
verify,
|
|
52
|
+
} from "raz-encrypt";
|
|
53
|
+
|
|
54
|
+
// --- Alice, once ---
|
|
55
|
+
const alice = await createIdentity({
|
|
56
|
+
storageDir: "./alice-store",
|
|
57
|
+
passphrase: "alice's secret passphrase",
|
|
58
|
+
alias: "alice",
|
|
59
|
+
});
|
|
60
|
+
const aliceCard = exportPublicIdentity(alice); // send this to Bob
|
|
61
|
+
|
|
62
|
+
// --- Bob, once ---
|
|
63
|
+
const bob = await createIdentity({
|
|
64
|
+
storageDir: "./bob-store",
|
|
65
|
+
passphrase: "bob's secret passphrase",
|
|
66
|
+
alias: "bob",
|
|
67
|
+
});
|
|
68
|
+
const bobCard = exportPublicIdentity(bob); // send this to Alice
|
|
69
|
+
|
|
70
|
+
// --- Before trusting, compare fingerprints over a side channel (phone call, in person) ---
|
|
71
|
+
// This is what catches a spoofed or intercepted card — email/chat alone can't prove
|
|
72
|
+
// a card really came from the person you think it did.
|
|
73
|
+
import { fingerprint } from "raz-encrypt";
|
|
74
|
+
console.log("Alice reads this aloud:", fingerprint(aliceCard));
|
|
75
|
+
console.log("Bob confirms it matches what he sees:", fingerprint(bobCard));
|
|
76
|
+
|
|
77
|
+
// --- Both sides trust each other (after swapping cards AND confirming fingerprints) ---
|
|
78
|
+
await trustParty(alice, bobCard);
|
|
79
|
+
await trustParty(bob, aliceCard);
|
|
80
|
+
|
|
81
|
+
// --- Alice signs a document ---
|
|
82
|
+
const document = { invoiceId: "INV-1", amount: 15000 };
|
|
83
|
+
const certificate = await sign(alice, document);
|
|
84
|
+
|
|
85
|
+
// --- Bob verifies it (send him `document` + `certificate`) ---
|
|
86
|
+
const result = await verify(bob, document, certificate);
|
|
87
|
+
// => { valid: true }
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
If the document is changed even slightly, or the certificate is corrupted, or the signer was never trusted, `verify()` returns `{ valid: false, reason: "..." }` instead of throwing — so a caller always gets a clear, structured answer.
|
|
91
|
+
|
|
92
|
+
### Private data between the two parties
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { encryptFor, decryptFrom } from "raz-encrypt";
|
|
96
|
+
|
|
97
|
+
const box = await encryptFor(alice, bobCard, {
|
|
98
|
+
secretPayload: "only Bob should read this",
|
|
99
|
+
});
|
|
100
|
+
const opened = await decryptFrom(bob, aliceCard, box);
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Two important things people ask when actually wiring this up
|
|
104
|
+
|
|
105
|
+
**1. `sign`/`verify` and `encryptFor`/`decryptFrom` are two separate, independent tools — not a pipeline.**
|
|
106
|
+
|
|
107
|
+
- `sign` + `verify` prove **authenticity**: "this exact document really came from Alice and wasn't altered." The document itself travels in the clear.
|
|
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.
|
|
109
|
+
|
|
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.
|
|
111
|
+
|
|
112
|
+
**2. A "card" (from `exportPublicIdentity`) is just a JSON object.** However Alice actually gets it to Bob — email attachment, Slack message, QR code, USB stick — Bob ends up with the same plain JSON on disk. Loading it back is nothing more than reading that file and `JSON.parse`-ing it; there's no special "import" step.
|
|
113
|
+
|
|
114
|
+
## Full example: Bob receives something from Alice
|
|
115
|
+
|
|
116
|
+
Assume Bob has already run `createIdentity()` once (so he has his own `storageDir` + passphrase) and already has `alice-card.json` — the file Alice sent him after she ran `exportPublicIdentity()` on her side.
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { readFile } from "node:fs/promises";
|
|
120
|
+
import {
|
|
121
|
+
loadIdentity,
|
|
122
|
+
decryptFrom,
|
|
123
|
+
verify,
|
|
124
|
+
type PublicCard,
|
|
125
|
+
type Certificate,
|
|
126
|
+
} from "raz-encrypt";
|
|
127
|
+
|
|
128
|
+
// Step 1 — Bob loads his own identity (needs his own passphrase, not Alice's).
|
|
129
|
+
const bob = await loadIdentity({
|
|
130
|
+
storageDir: "./bob-store",
|
|
131
|
+
passphrase: "bob's secret passphrase",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Step 2 — Bob loads Alice's card. It's just JSON — read whatever file/message it arrived in.
|
|
135
|
+
const aliceCard: PublicCard = JSON.parse(
|
|
136
|
+
await readFile("./alice-card.json", "utf8"),
|
|
137
|
+
);
|
|
138
|
+
// (One-time only: before the first exchange, Bob must also have called
|
|
139
|
+
// trustParty(bob, aliceCard) once — otherwise decryptFrom/verify will refuse Alice's card.)
|
|
140
|
+
|
|
141
|
+
// --- If what Alice sent was ENCRYPTED (confidential) ---
|
|
142
|
+
const box = JSON.parse(await readFile("./received-box.json", "utf8"));
|
|
143
|
+
const opened = await decryptFrom(bob, aliceCard, box);
|
|
144
|
+
|
|
145
|
+
// --- If what Alice sent was SIGNED (a document + a certificate, sent in the clear) ---
|
|
146
|
+
const { document, certificate } = JSON.parse(
|
|
147
|
+
await readFile("./signed-doc.json", "utf8"),
|
|
148
|
+
) as {
|
|
149
|
+
document: unknown;
|
|
150
|
+
certificate: Certificate;
|
|
151
|
+
};
|
|
152
|
+
const result = await verify(bob, document, certificate);
|
|
153
|
+
// => { valid: true } or { valid: false, reason: "..." }
|
|
154
|
+
|
|
155
|
+
// --- If Alice sent something both signed AND encrypted ---
|
|
156
|
+
// She encrypted { document, certificate } as one payload with encryptFor(); Bob does:
|
|
157
|
+
const openedSignedPayload = await decryptFrom<{
|
|
158
|
+
document: unknown;
|
|
159
|
+
certificate: Certificate;
|
|
160
|
+
}>(bob, aliceCard, box);
|
|
161
|
+
const combinedResult = await verify(
|
|
162
|
+
bob,
|
|
163
|
+
openedSignedPayload.document,
|
|
164
|
+
openedSignedPayload.certificate,
|
|
165
|
+
);
|
|
166
|
+
```
|
|
167
|
+
|
|
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.
|
|
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 — both the old and new key must agree — so a compromised old key alone can't redirect anyone's trust:
|
|
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; each runs:
|
|
229
|
+
await applyRotation(bob, statement);
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
**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.
|
|
233
|
+
|
|
234
|
+
## API
|
|
235
|
+
|
|
236
|
+
| Function | What it does |
|
|
237
|
+
| --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
238
|
+
| `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. |
|
|
239
|
+
| `loadIdentity({ storageDir, passphrase })` | Loads a previously-created identity back from disk. |
|
|
240
|
+
| `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 for what this does and doesn't mean. |
|
|
241
|
+
| `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). |
|
|
242
|
+
| `exportPublicIdentity(identity)` | Returns the shareable public card `{ alias, classicalPublicKey, pqcSignPublicKey, pqcKemPublicKey }`. |
|
|
243
|
+
| `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. |
|
|
244
|
+
| `trustParty(identity, card)` | Records another party's public card as trusted, locally. Re-trusting a previously revoked card reinstates it. |
|
|
245
|
+
| `untrustParty(identity, alias)` | Revokes every currently-trusted card under `alias` (e.g. after a suspected key compromise). Tombstoned, not deleted — `verify()` reports `"revoked-signer"` distinctly from `"untrusted-signer"` (never trusted). |
|
|
246
|
+
| `listTrustedParties(identity)` / `getTrustedParty(identity, alias)` | Read back your local trust list (revoked cards excluded). |
|
|
247
|
+
| `getTrustedPartiesByAlias(identity, alias)` / `getTrustRecordsByAlias(identity, alias)` | Since alias isn't unique, these return _every_ card under an alias — the latter includes revoked ones, with their `revokedAt`. |
|
|
248
|
+
| `listTrustRecords(identity)` | Full trust history, revoked included — for audit purposes. |
|
|
249
|
+
| `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). |
|
|
250
|
+
| `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. |
|
|
251
|
+
| `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`). |
|
|
252
|
+
| `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. |
|
|
253
|
+
| `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. |
|
|
254
|
+
| `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. |
|
|
255
|
+
| `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. |
|
|
256
|
+
| `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. |
|
|
257
|
+
| `beginRotation(oldIdentity, newCard)` / `completeRotation(newIdentity, draft)` / `applyRotation(identity, statement)` | A dual-signed statement handing trust from an old card to a new one — both parties must sign, so a compromised old key alone can't redirect trust. Applying a valid statement moves trust automatically; no-ops for a card you never trusted. |
|
|
258
|
+
| `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. |
|
|
259
|
+
| `readLog(identity)` | Reads the local, append-only audit log of everything this identity has signed/verified/encrypted/decrypted. |
|
|
260
|
+
| `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. |
|
|
261
|
+
| `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. |
|
|
262
|
+
|
|
263
|
+
### Checking a passphrase against a real breach corpus
|
|
264
|
+
|
|
265
|
+
`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:
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
import zxcvbn from "zxcvbn"; // or an offline top-N list, or your own HIBP k-anonymity call
|
|
269
|
+
|
|
270
|
+
await createIdentity({
|
|
271
|
+
storageDir,
|
|
272
|
+
passphrase,
|
|
273
|
+
alias: "alice",
|
|
274
|
+
checkPassphrase: (normalized) => {
|
|
275
|
+
if (zxcvbn(normalized).score < 3) {
|
|
276
|
+
throw new Error("Passphrase is too weak — choose something harder to guess.");
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
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.
|
|
283
|
+
|
|
284
|
+
### Live sessions (`raz-encrypt/core`) — key confirmation is a required step
|
|
285
|
+
|
|
286
|
+
`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.
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
const session = new ConfirmedSession("initiator", result, transcript); // or "responder"
|
|
290
|
+
// ...send session.ownConfirmationTag to the peer, receive `theirTag` back...
|
|
291
|
+
if (!session.confirmPeer(theirTag)) {
|
|
292
|
+
/* close the connection: the peer didn't prove it derived the same secrets */
|
|
293
|
+
}
|
|
294
|
+
const frame = session.seal(bytes, aad?); // throws UnconfirmedSessionError before confirmPeer succeeds
|
|
295
|
+
const plain = session.open(frame, aad?); // same — both directions are gated
|
|
296
|
+
session.destroy(); // wipe the copied keys when the session ends
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
`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.
|
|
300
|
+
|
|
301
|
+
**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.
|
|
302
|
+
|
|
303
|
+
### Upgrading the trust store (`trust.json`)
|
|
304
|
+
|
|
305
|
+
`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.
|
|
306
|
+
|
|
307
|
+
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.
|
|
308
|
+
|
|
309
|
+
### Encrypting the trust store and audit log
|
|
310
|
+
|
|
311
|
+
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:
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
import { loadIdentity, migrateTrustStoreToEncrypted, migrateAuditLogToEncrypted } from "raz-encrypt";
|
|
315
|
+
|
|
316
|
+
const alice = await loadIdentity({ storageDir: "./alice-store", passphrase: "alice's secret passphrase" });
|
|
317
|
+
|
|
318
|
+
// One-time opt-in, per store. Safe to call on a brand-new identity (nothing
|
|
319
|
+
// to migrate yet) or an established one (every existing record/entry is
|
|
320
|
+
// re-encrypted in that one call).
|
|
321
|
+
await migrateTrustStoreToEncrypted(alice);
|
|
322
|
+
await migrateAuditLogToEncrypted(alice);
|
|
323
|
+
|
|
324
|
+
// Everything else is unchanged — trustParty, sign, verify, encryptFor,
|
|
325
|
+
// decryptFrom, readLog, verifyLogIntegrity all keep working exactly as
|
|
326
|
+
// before; trust.json and new log entries are now written encrypted.
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
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).
|
|
330
|
+
|
|
331
|
+
## Security posture
|
|
332
|
+
|
|
333
|
+
**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 includes known-answer vectors proving this library's own wiring of each primitive matches the standard (ADR 0022) — conformance evidence, not a substitute for that certification.
|
|
334
|
+
|
|
335
|
+
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.
|
|
336
|
+
|
|
337
|
+
**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:
|
|
338
|
+
|
|
339
|
+
- **Signing**: every document is signed twice — once with ECDSA-P384, once with ML-DSA-87. `verify()` only returns `valid: true` if **both** signatures check out.
|
|
340
|
+
- **Encryption**: the AES-256-GCM key is derived (via HKDF-SHA384) from **both** an ECDH-P384 exchange and an ML-KEM-1024 encapsulation.
|
|
341
|
+
|
|
342
|
+
If one layer is ever broken — classical by a quantum computer, or the newer post-quantum standard by an as-yet-undiscovered flaw — the other still protects you.
|
|
343
|
+
|
|
344
|
+
**Bottom line:** this is genuinely strong, real-world-grade cryptography built from carefully audited, widely used components (the [`@noble/*`](https://github.com/paulmillr/noble-post-quantum) libraries) — not a marketing label. It just isn't a government-certified module, and true safety also depends on this specific implementation being built and tested carefully, which is exactly what the test suite's "try to break it" cases (tampered documents, corrupted individual signatures) are for.
|
|
345
|
+
|
|
346
|
+
**What `sign`/`verify` guarantees, precisely.** Every field that affects a verification outcome — the document hash _and_ the optional `expiresAt` — is part of the signed payload, not just `documentHash` alone. That matters because certificates travel over the exact same untrusted channels (email, chat) this library assumes for everything else: without that, an intermediary with no secret key at all could delete or rewrite `expiresAt` in transit and flip a `verify()` result, which would quietly defeat the whole point of setting an expiry.
|
|
347
|
+
|
|
348
|
+
**Two honest limitations, so you can decide if they matter for your use case:**
|
|
349
|
+
|
|
350
|
+
- **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.
|
|
351
|
+
- **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.
|
|
352
|
+
|
|
353
|
+
**Backup and recovery.** There is no password-reset flow: losing your passphrase or `identity.enc.json` loses the identity, permanently. Two things actually help:
|
|
354
|
+
|
|
355
|
+
- **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.
|
|
356
|
+
- **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, so peers' trust moves automatically) for the rest of the key-lifecycle API — see ADR 0023.
|
|
357
|
+
|
|
358
|
+
**`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.
|
|
359
|
+
|
|
360
|
+
## Standards this library aims to align with
|
|
361
|
+
|
|
362
|
+
**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."
|
|
363
|
+
|
|
364
|
+
| Area | Standard / reference | How this library relates to it |
|
|
365
|
+
| --- | --- | --- |
|
|
366
|
+
| Post-quantum signatures | NIST FIPS 204 (ML-DSA) | Every document/certificate is signed with ML-DSA-87 alongside the classical signature; `verify()` requires both. |
|
|
367
|
+
| 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. |
|
|
368
|
+
| 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. |
|
|
369
|
+
| 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. |
|
|
370
|
+
| 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). |
|
|
371
|
+
| 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). |
|
|
372
|
+
| 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. |
|
|
373
|
+
| 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. |
|
|
374
|
+
| 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. |
|
|
375
|
+
| 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. |
|
|
376
|
+
| 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. |
|
|
377
|
+
| 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). |
|
|
378
|
+
| 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. |
|
|
379
|
+
| 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"). |
|
|
380
|
+
| Conformance evidence | NIST/ISO known-answer test vectors | The test suite includes known-answer vectors cross-checked against the standards, proving this library's own wiring of each primitive matches the specification (ADR 0022) — conformance evidence, not a substitute for FIPS 140-2/3 (CMVP) module validation, which this library does not have. |
|
|
381
|
+
|
|
382
|
+
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.
|
|
383
|
+
|
|
384
|
+
## Independent review status
|
|
385
|
+
|
|
386
|
+
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.
|
|
387
|
+
|
|
388
|
+
## Threat model and non-goals
|
|
389
|
+
|
|
390
|
+
**What it defends against**
|
|
391
|
+
|
|
392
|
+
- 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.
|
|
393
|
+
- "Harvest now, decrypt later": every secret needs both the classical and the post-quantum layer to be broken.
|
|
394
|
+
- 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.
|
|
395
|
+
- A counterpart you never added to your trust list: `verify`, `encryptFor` and `decryptFrom` refuse them.
|
|
396
|
+
|
|
397
|
+
**What it does not defend against**
|
|
398
|
+
|
|
399
|
+
- 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.
|
|
400
|
+
- 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`.
|
|
401
|
+
- 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.
|
|
402
|
+
- 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.
|
|
403
|
+
- Forward secrecy for `encryptFor`/`decryptFrom` (see above); use the session handshake and ratchet for live traffic.
|
|
404
|
+
|
|
405
|
+
**Status of the cryptography**
|
|
406
|
+
|
|
407
|
+
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).
|
|
408
|
+
|
|
409
|
+
## What this library deliberately does _not_ do
|
|
410
|
+
|
|
411
|
+
- No server, no HTTP API, no database — everything is local files.
|
|
412
|
+
- No live network sync between the two parties' machines — public cards, documents, and certificates are exchanged "by hand" (file/email/etc.), not automatically.
|
|
413
|
+
- 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.
|
|
414
|
+
- **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.
|
|
415
|
+
|
|
416
|
+
## Local storage layout
|
|
417
|
+
|
|
418
|
+
Each identity's `storageDir` contains:
|
|
419
|
+
|
|
420
|
+
```
|
|
421
|
+
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)
|
|
422
|
+
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
|
|
423
|
+
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
|
|
424
|
+
log.head.json # authenticated entry count and last MAC, so deleting the newest log entries is detected
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
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`.
|
|
428
|
+
|
|
429
|
+
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.
|
|
430
|
+
|
|
431
|
+
## Development
|
|
432
|
+
|
|
433
|
+
```bash
|
|
434
|
+
npm install
|
|
435
|
+
npm run typecheck
|
|
436
|
+
npm test
|
|
437
|
+
npm run build
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
## Keywords
|
|
441
|
+
|
|
442
|
+
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.
|