@thecrossroads42/crypto 0.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.
@@ -0,0 +1,10 @@
1
+ d24a152bb8cf1513b82232acc910690b84616e9f3cb0283fa46ef48efa822456 LICENSE
2
+ 3d347bc9a6c493a5d9dbeffe024d27e246508816ef9121ab06d14f234736b095 README.md
3
+ c84f4886dfd5b7a9ab68db9c9ad2fac703190c8dd7400a80b74d8612df27e87f demo.mjs
4
+ 7642bcc1c90141a01dee72e7fc5e7de63ddeb695bdd08de7cdddf156e6fb566c envelope.js
5
+ 1f5ae4748121665a2e8b78dc574a1901c0aa953c2c7936c2687f1ccd40efc7f7 keyring.js
6
+ 297407eb85fbe115640b5d08ab282f98e3bd1363b7fbd45cc3877f10add484e4 keyring.mjs
7
+ 075a27c3dff8f3936b9e6001a54fd8a29a41dadddb6d20c6da0757a85db458ad longitudinal.mjs
8
+ 1d2c09291b60177cb3a138b7151e1a5c7803141771a495d21efcd631e5ff514d longitudinalLogic.js
9
+ b53fbb4ad4f85fa5b4af71ab14c57accf6bf6660fef9832936725d8a08d4c817 roundtrip.mjs
10
+ bbf751830bf141d0b1a2fce680568be0fd81fda558a72c30d60f7e7cf2131129 visitEnvelope.js
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Crossroads
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # The Crossroads — client-side encryption (audit kit)
2
+
3
+ This is the privacy-critical encryption code from
4
+ [The Crossroads](https://thecrossroads.to), published so you can **check our
5
+ claims instead of trusting them**. It is the real code that runs in the
6
+ browser — the part that decides whether your stored record is readable by
7
+ anyone but you — separated from the rest of the app (which stays closed; it
8
+ holds no secret that affects your privacy).
9
+
10
+ The security here does not depend on this code being secret (Kerckhoffs's
11
+ principle): publishing it costs us nothing and lets you verify it. Licensed
12
+ MIT (see `LICENSE`).
13
+
14
+ > **One honest limit, stated up front.** Opening this code lets you audit the
15
+ > *design*, and lets you spot-check your *own* session (below). It does **not**
16
+ > cryptographically guarantee that the JavaScript your browser ran on a given
17
+ > day is byte-for-byte this code — a web app serves its code each load, so a
18
+ > compromised or coerced server could serve different code to a targeted user,
19
+ > and this repo would not catch that. That is the standard limit of in-browser
20
+ > crypto. The path to closing it (subresource integrity, reproducible builds, a
21
+ > pinning browser extension, a native client) is on our roadmap; until then,
22
+ > treat web delivery as a *soft* boundary, the same way we label the in-visit
23
+ > moment as soft.
24
+
25
+ ## Run the proofs yourself
26
+
27
+ No build step, no dependencies beyond Node 20+ and `@noble/hashes`:
28
+
29
+ ```
30
+ node demo.mjs # the §11 envelope + all three tiers + the re-wrap upgrade
31
+ node roundtrip.mjs # encrypt-before-PUT round-trip (content opaque at rest, etc.)
32
+ node keyring.mjs # tier manager: provision / unlock / upgrade / labeling
33
+ node longitudinal.mjs # the consent gate (a declined read never resurfaces)
34
+ ```
35
+
36
+ Each harness verifies its claims and prints a pass/fail count.
37
+
38
+ ## Claims → code
39
+
40
+ | Claim | Where it lives |
41
+ |---|---|
42
+ | Stored content is opaque at rest (AES-256-GCM, unique IV per record) | `encryptRecord` / `decryptRecord` in `envelope.js` |
43
+ | A passphrase account is unreadable to the operator | `wrapCEK_passphrase` / `unwrapCEK_passphrase` + `deriveKEKFromPassphrase` (`envelope.js`); the server stores only `{ kdf, salt, iv, wrapped }` — never the passphrase or key |
44
+ | The passphrase key is Argon2id (memory-hard), not a fast hash | `deriveKEKFromPassphrase` (`envelope.js`), params recorded per record |
45
+ | A device account is unreadable to the operator | `wrapCEK_device` / `unwrapCEK_device` (`envelope.js`); the device key never leaves local storage |
46
+ | The managed tier is operator-*readable* by design (the honest soft tier) | `TIER_INFO.managed` (`keyring.js`); its key is held server-side |
47
+ | Switching tiers re-wraps the key without re-encrypting your data | `changeTier` (`keyring.js`) — see `keyring.mjs` step 4 |
48
+ | Consent gate: a rejected read never resurfaces; only accepted reads carry | `reconcilePendingJudgments` (dedup vs **all** statuses) + `confirmedJudgments` (accepted only) in `longitudinalLogic.js` |
49
+ | A forgotten passphrase is unrecoverable (the proof the guarantee is real) | `reset` (`keyring.js`) — see `keyring.mjs` step 6 |
50
+
51
+ ## What the server actually sees
52
+
53
+ Every stored content object is an envelope `{ v, alg:"AES-GCM", iv, ct }` — `ct`
54
+ is ciphertext. The shapes at rest:
55
+
56
+ - **Visit:** cleartext operational metadata (`id`, dates, `draft`, `cost`) plus
57
+ `encrypted:true` and `enc: { card, content }` — both opaque envelopes.
58
+ - **Held forks / judgments, profile, action plan, notes:** stored as
59
+ `{ encrypted:true, enc }`.
60
+ - **Key record (server-side):** `{ tier, env }`. For **passphrase**, `env` is
61
+ `{ kdf, salt, iv, wrapped }` — the operator cannot derive the key without your
62
+ passphrase. For **device**, `env` is `{ iv, wrapped }` and the unwrapping key
63
+ is only in your browser. For **managed**, the key is held server-side and the
64
+ operator *can* unwrap (that tier's stated trade).
65
+
66
+ What the server never receives: your passphrase, the device key, or the
67
+ unwrapped content key, for the passphrase and device tiers.
68
+
69
+ ## Verify your own session (no code required)
70
+
71
+ Open your browser's dev tools → Network, start and end a visit, and inspect the
72
+ `PUT /api/visits/:id` body: you should see `enc: { content: { iv, ct } }`
73
+ ciphertext and **no** plaintext of what you wrote. Then check that your
74
+ passphrase never appears in any request.
75
+
76
+ ## What's here, and what isn't
77
+
78
+ **Here:** the pure, security-determining code — `envelope.js`,
79
+ `visitEnvelope.js`, `longitudinalLogic.js`, `keyring.js` — and the runnable
80
+ harnesses.
81
+
82
+ **Not here (and not where your privacy lives):** the glue that wires this core
83
+ to auth, HTTP, and local storage; the server; and the app's content (the voices
84
+ and prompts). None of that holds a secret that affects whether your stored
85
+ record is readable.
86
+
87
+ `KIT-MANIFEST.txt` lists the SHA-256 of each file in this kit; `verify-kit.mjs`
88
+ regenerates and checks it, so this published copy can be shown to match the code
89
+ that builds the app.
package/demo.mjs ADDED
@@ -0,0 +1,148 @@
1
+ // =============================================================================
2
+ // Client-side encryption — runnable demo (PROTOTYPE)
3
+ //
4
+ // node frontend/src/services/crypto/demo.mjs
5
+ //
6
+ // Walks the §11 envelope end to end on a real-shaped visit: encrypt content
7
+ // under a per-user CEK, wrap that CEK under each of the three tiers, show what
8
+ // the operator actually has on disk, prove who can and cannot read it, and
9
+ // demonstrate the re-wrap upgrade path. No app, no network, no dependencies.
10
+ // =============================================================================
11
+
12
+ import {
13
+ generateCEK, encryptRecord, decryptRecord, generateRawKey,
14
+ wrapCEK_passphrase, unwrapCEK_passphrase,
15
+ wrapCEK_device, unwrapCEK_device,
16
+ wrapCEK_managed, unwrapCEK_managed,
17
+ } from './envelope.js';
18
+
19
+ const line = (s = '') => console.log(s);
20
+ const h = (s) => { line(); line('\x1b[1m' + s + '\x1b[0m'); };
21
+ const ok = (s) => line(' \x1b[32m✓\x1b[0m ' + s);
22
+ const no = (s) => line(' \x1b[31m✗\x1b[0m ' + s);
23
+ const dim = (s) => '\x1b[2m' + s + '\x1b[0m';
24
+
25
+ // A visit shaped like the real ones (frontend persists this via PUT /visits/:id).
26
+ // Operational metadata (id/dates/draft/cost) stays cleartext — the §6 envelope.
27
+ // Everything under `content` is what must become opaque at rest.
28
+ const visit = {
29
+ id: 7,
30
+ startDate: 1733200000000,
31
+ endDate: 1733201800000,
32
+ draft: false,
33
+ cost: { total: { usd: 0.04 } },
34
+ content: {
35
+ name: 'Whether to leave the job',
36
+ icon: '🪧',
37
+ messages: [
38
+ { role: 'user', text: 'I have a stable job but I feel like it is quietly costing me my twenties.' },
39
+ { role: 'keeper', text: 'Two voices want to take this up — the Steward and the Lever…' },
40
+ ],
41
+ summary: {
42
+ headline: 'Security versus the cost of staying',
43
+ keyTopics: ['career', 'risk', 'identity'],
44
+ openThreads: ['What would you regret not having tried at 40?'],
45
+ },
46
+ plan: '- Name the worst realistic outcome of leaving\n- Give it a deadline, not an open question',
47
+ },
48
+ };
49
+
50
+ function showServerView(stored) {
51
+ line(dim(' what the operator has on disk:'));
52
+ line(dim(' enc.content = ' + JSON.stringify(stored.enc.content).slice(0, 72) + '…'));
53
+ line(dim(' wrappedCEK = ' + JSON.stringify(stored.wrappedCEK).slice(0, 72) + '…'));
54
+ // Prove no plaintext leaked into the at-rest blob.
55
+ const blob = JSON.stringify(stored);
56
+ const leaked = ['twenties', 'Steward', 'Security versus', 'regret'].filter((w) => blob.includes(w));
57
+ if (leaked.length === 0) ok('at-rest blob contains no plaintext content');
58
+ else no('LEAK: plaintext found in blob: ' + leaked.join(', '));
59
+ }
60
+
61
+ async function tryRead(label, openFn) {
62
+ try {
63
+ const cek = await openFn();
64
+ const content = await decryptRecord(cek, atRest.enc.content);
65
+ return { read: true, headline: content.summary.headline };
66
+ } catch {
67
+ return { read: false };
68
+ }
69
+ }
70
+
71
+ let atRest; // the stored blob, reused across reader attempts
72
+
73
+ line('\x1b[1m═══ Client-side encryption — envelope demo ═══\x1b[0m');
74
+ line(dim('visit #7 plaintext headline: "' + visit.content.summary.headline + '"'));
75
+
76
+ // --- Encrypt once under a per-user CEK --------------------------------------
77
+ h('1. Encrypt the visit content under a per-user CEK (§3)');
78
+ const cek = await generateCEK();
79
+ const encContent = await encryptRecord(cek, visit.content);
80
+ ok('content encrypted (AES-GCM, unique IV) — CEK never leaves the client');
81
+
82
+ // =============================================================================
83
+ // TIER 1 — passphrase (the §1/§2 hard guarantee)
84
+ // =============================================================================
85
+ h('2. Tier: PASSPHRASE — operator must not be able to read');
86
+ const passphrase = 'correct horse battery staple';
87
+ const wrappedPass = await wrapCEK_passphrase(cek, passphrase);
88
+ atRest = { id: visit.id, startDate: visit.startDate, draft: visit.draft, cost: visit.cost,
89
+ enc: { content: encContent }, wrappedCEK: wrappedPass };
90
+ showServerView(atRest);
91
+
92
+ const operatorNoPass = await tryRead('operator', async () => {
93
+ // The operator has the full disk but not the passphrase. Best they can do is guess.
94
+ return unwrapCEK_passphrase(atRest.wrappedCEK, 'password123');
95
+ });
96
+ operatorNoPass.read ? no('operator READ it (should not happen)') : ok('operator cannot read (no passphrase → unwrap fails)');
97
+
98
+ const userPass = await tryRead('user', () => unwrapCEK_passphrase(atRest.wrappedCEK, passphrase));
99
+ userPass.read ? ok('user unlocks with passphrase → "' + userPass.headline + '"') : no('user could not read (bug)');
100
+
101
+ // =============================================================================
102
+ // TIER 2 — device key (no-signing default; operator still cannot read)
103
+ // =============================================================================
104
+ h('3. Tier: DEVICE — no passphrase typed; operator still cannot read');
105
+ const deviceKey = generateRawKey(); // lives only on the device (localStorage / PRF in prod)
106
+ const wrappedDev = await wrapCEK_device(cek, deviceKey);
107
+ atRest = { ...atRest, wrappedCEK: wrappedDev };
108
+ showServerView(atRest);
109
+
110
+ const operatorNoDevice = await tryRead('operator', () => unwrapCEK_device(atRest.wrappedCEK, generateRawKey()));
111
+ operatorNoDevice.read ? no('operator READ it (should not happen)') : ok('operator cannot read (device key never sent to server)');
112
+
113
+ const userDevice = await tryRead('user', () => unwrapCEK_device(atRest.wrappedCEK, deviceKey));
114
+ userDevice.read ? ok('device unlocks silently → "' + userDevice.headline + '" (no passphrase)') : no('device could not read (bug)');
115
+
116
+ // =============================================================================
117
+ // TIER 3 — managed / KMS (zero friction; operator CAN read — §12 weaker tier)
118
+ // =============================================================================
119
+ h('4. Tier: MANAGED — operator holds the key, so operator CAN read (weaker tier)');
120
+ const kmsKey = generateRawKey(); // stands in for a server-held KMS/HSM key (§12)
121
+ const wrappedMgd = await wrapCEK_managed(cek, kmsKey);
122
+ atRest = { ...atRest, wrappedCEK: wrappedMgd };
123
+ showServerView(atRest);
124
+
125
+ const operatorManaged = await tryRead('operator', () => unwrapCEK_managed(atRest.wrappedCEK, kmsKey));
126
+ operatorManaged.read
127
+ ? ok('operator CAN read → "' + operatorManaged.headline + '" ' + dim('(expected: this is the recoverable tier)'))
128
+ : no('operator could not read (bug — managed tier should be operator-readable)');
129
+ line(dim(' §12: this only resists a leak where the KMS key is NOT also taken.'));
130
+ line(dim(' Co-locating kmsKey with the disk (e.g. CREDENTIALS_KEY in env) defeats it.'));
131
+
132
+ // =============================================================================
133
+ // Re-wrap upgrade path (§11): change tier WITHOUT re-encrypting the records.
134
+ // =============================================================================
135
+ h('5. Upgrade managed → passphrase by RE-WRAPPING the CEK (no re-encryption)');
136
+ const contentBefore = JSON.stringify(atRest.enc.content);
137
+ // Operator-readable today (managed). User upgrades to the hard guarantee:
138
+ const cekRecovered = await unwrapCEK_managed(atRest.wrappedCEK, kmsKey);
139
+ const upgraded = await wrapCEK_passphrase(cekRecovered, 'a brand new passphrase only I know');
140
+ atRest = { ...atRest, wrappedCEK: upgraded };
141
+ const contentAfter = JSON.stringify(atRest.enc.content);
142
+
143
+ contentBefore === contentAfter ? ok('record ciphertext untouched (only the wrapped CEK changed)') : no('records were rewritten (should not be)');
144
+ const operatorAfter = await tryRead('operator', () => unwrapCEK_managed(atRest.wrappedCEK, kmsKey));
145
+ operatorAfter.read ? no('operator still readable after upgrade (bug)') : ok('operator can no longer read — upgraded to the hard guarantee');
146
+
147
+ line();
148
+ line('\x1b[1m═══ done ═══\x1b[0m');
package/envelope.js ADDED
@@ -0,0 +1,197 @@
1
+ // =============================================================================
2
+ // Client-side encryption — envelope core (PROTOTYPE)
3
+ //
4
+ // Implements §11 of todo/client-side-encryption.md: every record is encrypted
5
+ // at rest under a per-user *content key* (CEK); what differs per account is only
6
+ // how that CEK is *wrapped*. One engine, three tiers, an upgrade path between
7
+ // them by re-wrapping the CEK (no re-encryption of the records).
8
+ //
9
+ // record plaintext --AES-GCM(CEK)--> { iv, ct } (the at-rest blob)
10
+ // CEK --wrap(KEK)-----> wrappedCEK (the only per-tier part)
11
+ //
12
+ // Tiers (§11):
13
+ // - passphrase : KEK = KDF(passphrase, salt). Operator cannot read. (§1/§2 hard guarantee)
14
+ // - device : KEK = a random key held only on the device. Operator cannot read.
15
+ // - managed : KEK = a key the operator/KMS holds. Operator CAN read. (§12 weaker tier)
16
+ //
17
+ // Runs unchanged in the browser and in Node 20+ (both expose globalThis.crypto
18
+ // with SubtleCrypto), so the same module backs the live app and demo.mjs.
19
+ //
20
+ // KDF is Argon2id (memory-hard, @noble/hashes — pure JS, runs in browser + RN +
21
+ // Node). The wrapped record stores the exact params so a record always reproduces
22
+ // its own KEK. Legacy PBKDF2 records (early prototype) are still read, by the
23
+ // `kdf.name` branch in deriveKEKFromPassphrase, so switching the default didn't
24
+ // strand any existing passphrase account.
25
+ //
26
+ // REMAINING PROTOTYPE CAVEAT:
27
+ // * The device tier uses a random local key as a stand-in for the production
28
+ // WebAuthn-PRF / non-extractable platform key. Same envelope either way.
29
+ // =============================================================================
30
+
31
+ import { argon2id } from '@noble/hashes/argon2';
32
+
33
+ // Optional-chained so this module is import-safe on platforms without Web Crypto
34
+ // (e.g. RN/Hermes). `subtle` is only ever *used* inside functions, which only
35
+ // run when ENCRYPTED_VISITS is on (web). Touching it at import must not throw.
36
+ const subtle = globalThis.crypto?.subtle;
37
+ const randomBytes = (n) => globalThis.crypto.getRandomValues(new Uint8Array(n));
38
+
39
+ // --- base64 <-> bytes (browser + Node, no Buffer dependency) -----------------
40
+ const b64 = {
41
+ encode(bytes) {
42
+ let s = '';
43
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
44
+ // btoa exists in browsers and in Node 16+ globals.
45
+ return btoa(s);
46
+ },
47
+ decode(str) {
48
+ const s = atob(str);
49
+ const out = new Uint8Array(s.length);
50
+ for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
51
+ return out;
52
+ },
53
+ };
54
+
55
+ const utf8 = {
56
+ encode: (str) => new TextEncoder().encode(str),
57
+ decode: (bytes) => new TextDecoder().decode(bytes),
58
+ };
59
+
60
+ // Argon2id parameters (memory-hard). m is KiB → 64 MiB here; t = passes;
61
+ // p = lanes. Stored in the wrapped record so a record reproduces its own KEK;
62
+ // tune for the device class without breaking existing records. Derivation runs
63
+ // once per provision/unlock, not per turn.
64
+ const KDF = { name: 'argon2id', t: 3, m: 65536, p: 1 };
65
+
66
+ // =============================================================================
67
+ // CEK — the per-user content key (§3, §11). One per account; never changes.
68
+ // Extractable so it can be wrapped under each tier's KEK; the raw bytes only
69
+ // ever exist in memory during wrap/unwrap, never persisted unwrapped.
70
+ // =============================================================================
71
+
72
+ export async function generateCEK() {
73
+ return subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
74
+ }
75
+
76
+ export async function exportCEKRaw(cek) {
77
+ return new Uint8Array(await subtle.exportKey('raw', cek));
78
+ }
79
+
80
+ export async function importCEKRaw(rawBytes) {
81
+ return subtle.importKey('raw', rawBytes, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
82
+ }
83
+
84
+ // =============================================================================
85
+ // Per-record content encryption (§3). Unique IV per call — never reused under a
86
+ // key. The output { iv, ct } is exactly what the server stores; it is opaque
87
+ // without the CEK.
88
+ // =============================================================================
89
+
90
+ export async function encryptRecord(cek, plainObject) {
91
+ const iv = randomBytes(12);
92
+ const plaintext = utf8.encode(JSON.stringify(plainObject));
93
+ const ct = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv }, cek, plaintext));
94
+ return { v: 1, alg: 'AES-GCM', iv: b64.encode(iv), ct: b64.encode(ct) };
95
+ }
96
+
97
+ export async function decryptRecord(cek, env) {
98
+ const iv = b64.decode(env.iv);
99
+ const ct = b64.decode(env.ct);
100
+ const plaintext = new Uint8Array(await subtle.decrypt({ name: 'AES-GCM', iv }, cek, ct));
101
+ return JSON.parse(utf8.decode(plaintext));
102
+ }
103
+
104
+ // =============================================================================
105
+ // KEK derivation / import. A KEK only ever wraps the CEK — it never touches
106
+ // record content directly.
107
+ // =============================================================================
108
+
109
+ // Passphrase tier: KEK = KDF(passphrase, salt). The passphrase and KEK never
110
+ // leave the client; the server stores only `salt` (not secret), the KDF params,
111
+ // and the wrapped CEK. `kdf` (from the stored record) selects the algorithm so a
112
+ // record reproduces its own KEK — including legacy PBKDF2 records.
113
+ async function deriveKEKFromPassphrase(passphrase, salt, kdf = KDF) {
114
+ if (kdf.name === 'PBKDF2') {
115
+ // Legacy early-prototype records — still readable.
116
+ const baseKey = await subtle.importKey('raw', utf8.encode(passphrase), 'PBKDF2', false, ['deriveKey']);
117
+ return subtle.deriveKey(
118
+ { name: 'PBKDF2', hash: kdf.hash, salt, iterations: kdf.iterations },
119
+ baseKey,
120
+ { name: 'AES-GCM', length: 256 },
121
+ false,
122
+ ['encrypt', 'decrypt'],
123
+ );
124
+ }
125
+ // Argon2id (default). @noble returns 32 raw bytes → AES-256 key. Awaited so a
126
+ // platform may swap in an async native Argon2 (RN/libsodium — noble freezes
127
+ // Hermes); `await` is a no-op on noble's synchronous return.
128
+ const raw = await argon2id(utf8.encode(passphrase), salt, { t: kdf.t, m: kdf.m, p: kdf.p, dkLen: 32 });
129
+ return subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
130
+ }
131
+
132
+ // Device / managed tiers: KEK is a raw 256-bit key (held on the device, or by the
133
+ // operator/KMS). Imported as a non-extractable AES-GCM key used only to wrap.
134
+ async function importRawKEK(rawBytes) {
135
+ return subtle.importKey('raw', rawBytes, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
136
+ }
137
+
138
+ // A fresh random 256-bit key — used for the device tier (stand-in for WebAuthn-PRF)
139
+ // and to simulate the operator/KMS key in the managed tier.
140
+ export function generateRawKey() {
141
+ return randomBytes(32);
142
+ }
143
+
144
+ // =============================================================================
145
+ // Wrap / unwrap the CEK under a KEK. wrappedCEK is AES-GCM(KEK, rawCEK).
146
+ // =============================================================================
147
+
148
+ async function wrapCEKWithKEK(cek, kek) {
149
+ const iv = randomBytes(12);
150
+ const rawCEK = await exportCEKRaw(cek);
151
+ const wrapped = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv }, kek, rawCEK));
152
+ return { iv: b64.encode(iv), wrapped: b64.encode(wrapped) };
153
+ }
154
+
155
+ async function unwrapCEKWithKEK(blob, kek) {
156
+ const iv = b64.decode(blob.iv);
157
+ const wrapped = b64.decode(blob.wrapped);
158
+ const rawCEK = new Uint8Array(await subtle.decrypt({ name: 'AES-GCM', iv }, kek, wrapped));
159
+ return importCEKRaw(rawCEK);
160
+ }
161
+
162
+ // --- Tier: passphrase --------------------------------------------------------
163
+ export async function wrapCEK_passphrase(cek, passphrase) {
164
+ const salt = randomBytes(16);
165
+ const kek = await deriveKEKFromPassphrase(passphrase, salt);
166
+ const { iv, wrapped } = await wrapCEKWithKEK(cek, kek);
167
+ return { tier: 'passphrase', kdf: { ...KDF }, salt: b64.encode(salt), iv, wrapped };
168
+ }
169
+
170
+ export async function unwrapCEK_passphrase(blob, passphrase) {
171
+ const kek = await deriveKEKFromPassphrase(passphrase, b64.decode(blob.salt), blob.kdf || KDF);
172
+ return unwrapCEKWithKEK(blob, kek);
173
+ }
174
+
175
+ // --- Tier: device (random local key; WebAuthn-PRF is the production upgrade) --
176
+ export async function wrapCEK_device(cek, deviceKeyRaw) {
177
+ const kek = await importRawKEK(deviceKeyRaw);
178
+ const { iv, wrapped } = await wrapCEKWithKEK(cek, kek);
179
+ return { tier: 'device', iv, wrapped };
180
+ }
181
+
182
+ export async function unwrapCEK_device(blob, deviceKeyRaw) {
183
+ return unwrapCEKWithKEK(blob, await importRawKEK(deviceKeyRaw));
184
+ }
185
+
186
+ // --- Tier: managed (operator/KMS-held key) -----------------------------------
187
+ export async function wrapCEK_managed(cek, managedKeyRaw) {
188
+ const kek = await importRawKEK(managedKeyRaw);
189
+ const { iv, wrapped } = await wrapCEKWithKEK(cek, kek);
190
+ return { tier: 'managed', iv, wrapped };
191
+ }
192
+
193
+ export async function unwrapCEK_managed(blob, managedKeyRaw) {
194
+ return unwrapCEKWithKEK(blob, await importRawKEK(managedKeyRaw));
195
+ }
196
+
197
+ export const _internal = { b64, utf8, KDF };
package/keyring.js ADDED
@@ -0,0 +1,208 @@
1
+ // =============================================================================
2
+ // Client-side encryption — keyring / tier manager (PROTOTYPE)
3
+ //
4
+ // The single authority over a user's CEK and which §11 tier wraps it. Holds the
5
+ // unlocked CEK in memory for the session.
6
+ //
7
+ // The wrapped-CEK *record* lives SERVER-SIDE (backend/encryption, via keyringApi)
8
+ // so clearing browser storage doesn't lose the key, the passphrase tier works
9
+ // across devices, and the managed tier is operator-recoverable. The one thing
10
+ // that must NOT go to the server is the device tier's KEK — the *device key* —
11
+ // which stays in local storage (that's what keeps the operator out for that
12
+ // tier). Managed is wrapped by a server-held key, so its record carries the raw
13
+ // CEK over the wire (the tier's intended operator-can-read property).
14
+ //
15
+ // The backends are injectable (__setRecordBackend / __setStorageBackend /
16
+ // __setSessionBackend) so the Node harness (keyring.mjs) runs the real logic
17
+ // in-memory. envelope.js is pure.
18
+ // =============================================================================
19
+
20
+ import {
21
+ generateCEK, generateRawKey, exportCEKRaw, importCEKRaw, _internal,
22
+ wrapCEK_passphrase, unwrapCEK_passphrase,
23
+ wrapCEK_device, unwrapCEK_device,
24
+ } from './envelope.js';
25
+
26
+ const { b64 } = _internal;
27
+
28
+ // Honest, scope-bounded labeling copy (§8). `guarantee: 'hard'` = operator
29
+ // cannot read; `'soft'` = operator can read.
30
+ export const TIER_INFO = {
31
+ passphrase: {
32
+ label: 'Passphrase',
33
+ guarantee: 'hard',
34
+ summary: 'Encrypted with a key only your passphrase unlocks. We never receive the passphrase, so we cannot read your stored record, and it works on any device you sign in from. If you forget the passphrase it is permanently unrecoverable.',
35
+ },
36
+ device: {
37
+ label: 'This device',
38
+ guarantee: 'hard',
39
+ summary: 'Encrypted with a key kept on this device — no passphrase to type. We cannot read your stored record. It is tied to this device: Clear this browser / app memory, and the record becomes unreadable.',
40
+ },
41
+ managed: {
42
+ label: 'Managed',
43
+ guarantee: 'soft',
44
+ summary: 'Encrypted at rest, but we hold the key, so we can read your records if compelled — but you can never lock yourself out, since there is no passphrase or device key for you to lose. Protects against a data leak.',
45
+ },
46
+ };
47
+
48
+ // --- injectable backends -----------------------------------------------------
49
+ // Record backend (server): { get(userId) -> record|null, put(userId, record) }.
50
+ let recordBackend = null;
51
+ export function __setRecordBackend(b) { recordBackend = b; }
52
+ async function records() {
53
+ if (!recordBackend) recordBackend = await import('./keyringApi.js');
54
+ return recordBackend;
55
+ }
56
+ const getRecord = async (userId) => (await records()).get(userId);
57
+ const putRecord = async (userId, record) => (await records()).put(userId, record);
58
+
59
+ // Local storage backend (device key only — must never reach the server).
60
+ let storageBackend = null;
61
+ export function __setStorageBackend(b) { storageBackend = b; }
62
+ async function storage() {
63
+ if (!storageBackend) storageBackend = (await import('../storage')).storage;
64
+ return storageBackend;
65
+ }
66
+ const deviceKeyKey = (userId) => `crossroads_enc_devicekey:${userId}`;
67
+ async function loadDeviceKey(userId) {
68
+ const raw = await (await storage()).getItem(deviceKeyKey(userId));
69
+ return raw ? b64.decode(raw) : null;
70
+ }
71
+ async function saveDeviceKey(userId, rawBytes) {
72
+ await (await storage()).setItem(deviceKeyKey(userId), b64.encode(rawBytes));
73
+ }
74
+
75
+ // Session backend (the *unlocked* CEK, cached so a web refresh doesn't force the
76
+ // passphrase tier to re-unlock — and re-run Argon2 — on every load). Web:
77
+ // sessionStorage (survives refresh, cleared on tab/browser close, never on disk),
78
+ // plus an optional requestFromPeers() that fetches the CEK from another open
79
+ // same-origin tab (so a fresh tab needn't re-prompt); native/Node: in-memory
80
+ // only. Distinct from the device-key (localStorage) backend. Injectable for the
81
+ // harness (which omits requestFromPeers).
82
+ let sessionBackend = null;
83
+ export function __setSessionBackend(b) { sessionBackend = b; }
84
+ async function sessionStore() {
85
+ if (!sessionBackend) sessionBackend = (await import('../sessionStore')).sessionStore;
86
+ return sessionBackend;
87
+ }
88
+ const cekKey = (userId) => `crossroads_enc_cek:${userId}`;
89
+ async function cacheUnlockedCEK(userId, cek) {
90
+ try { await (await sessionStore()).setItem(cekKey(userId), b64.encode(await exportCEKRaw(cek))); }
91
+ catch { /* session cache is best-effort — never block an unlock on it */ }
92
+ }
93
+ async function clearUnlockedCEK(userId) {
94
+ try { await (await sessionStore()).removeItem(cekKey(userId)); } catch { /* ignore */ }
95
+ }
96
+ async function clearAllUnlockedCEKs() {
97
+ try { await (await sessionStore()).clear(); } catch { /* ignore */ }
98
+ }
99
+
100
+ // In-memory unlocked CEKs (cleared on lock / logout).
101
+ const unlocked = new Map();
102
+
103
+ // Build the server record for a CEK under a tier (mints + stores the device key
104
+ // locally for the device tier; emits the raw CEK for managed).
105
+ async function buildRecord(userId, cek, tier, opts = {}) {
106
+ if (tier === 'device') {
107
+ const dk = generateRawKey();
108
+ await saveDeviceKey(userId, dk);
109
+ return { tier: 'device', env: await wrapCEK_device(cek, dk) };
110
+ }
111
+ if (tier === 'passphrase') {
112
+ if (!opts.passphrase) throw new Error('PASSPHRASE_REQUIRED');
113
+ return { tier: 'passphrase', env: await wrapCEK_passphrase(cek, opts.passphrase) };
114
+ }
115
+ if (tier === 'managed') {
116
+ return { tier: 'managed', cek: b64.encode(await exportCEKRaw(cek)) };
117
+ }
118
+ throw new Error(`UNKNOWN_TIER:${tier}`);
119
+ }
120
+
121
+ async function cekFromRecord(userId, rec, opts = {}) {
122
+ if (rec.tier === 'device') {
123
+ const dk = await loadDeviceKey(userId);
124
+ if (!dk) throw new Error('NO_DEVICE_KEY'); // device-tier record, but not this device
125
+ return unwrapCEK_device(rec.env, dk);
126
+ }
127
+ if (rec.tier === 'passphrase') {
128
+ if (!opts.passphrase) throw new Error('PASSPHRASE_REQUIRED');
129
+ return unwrapCEK_passphrase(rec.env, opts.passphrase);
130
+ }
131
+ if (rec.tier === 'managed') {
132
+ return importCEKRaw(b64.decode(rec.cek)); // server already unwrapped
133
+ }
134
+ throw new Error(`UNKNOWN_TIER:${rec.tier}`);
135
+ }
136
+
137
+ // --- public API --------------------------------------------------------------
138
+ export async function getTier(userId) { return (await getRecord(userId))?.tier ?? null; }
139
+ export function getTierInfo(tier) { return TIER_INFO[tier] ?? null; }
140
+ export async function isProvisioned(userId) { return (await getRecord(userId)) != null; }
141
+ export function getUnlockedCEK(userId) { return unlocked.get(userId) ?? null; }
142
+ export function lock(userId) { unlocked.delete(userId); clearUnlockedCEK(userId); }
143
+ export function lockAll() { unlocked.clear(); clearAllUnlockedCEKs(); }
144
+
145
+ // Re-hydrate the unlocked CEK so the passphrase tier doesn't re-prompt: from this
146
+ // tab's session cache (after a web refresh wiped memory), or — failing that —
147
+ // from an already-open same-origin tab over the session backend's peer channel
148
+ // (a fresh tab has its own empty sessionStorage). A peer answer is cached locally
149
+ // so later refreshes in this tab are fast. No-op if it's already in memory or
150
+ // nothing is available anywhere. Returns the CEK, or null.
151
+ export async function restoreSession(userId) {
152
+ const live = unlocked.get(userId);
153
+ if (live) return live;
154
+ const store = await sessionStore();
155
+ const key = cekKey(userId);
156
+ let raw;
157
+ try { raw = await store.getItem(key); } catch { return null; }
158
+ if (!raw && store.requestFromPeers) {
159
+ try { raw = await store.requestFromPeers(key); } catch { raw = null; }
160
+ if (raw) { try { await store.setItem(key, raw); } catch { /* best-effort local cache */ } }
161
+ }
162
+ if (!raw) return null;
163
+ const cek = await importCEKRaw(b64.decode(raw));
164
+ unlocked.set(userId, cek);
165
+ return cek;
166
+ }
167
+
168
+ export async function provision(userId, tier, opts = {}) {
169
+ const cek = await generateCEK();
170
+ await putRecord(userId, await buildRecord(userId, cek, tier, opts));
171
+ unlocked.set(userId, cek);
172
+ await cacheUnlockedCEK(userId, cek);
173
+ return cek;
174
+ }
175
+
176
+ export async function unlock(userId, opts = {}) {
177
+ const rec = await getRecord(userId);
178
+ if (!rec) throw new Error('NOT_PROVISIONED');
179
+ const cek = await cekFromRecord(userId, rec, opts);
180
+ unlocked.set(userId, cek);
181
+ await cacheUnlockedCEK(userId, cek);
182
+ return cek;
183
+ }
184
+
185
+ // Default no-signing path: device tier, auto-provisioned then auto-unlocked.
186
+ export async function ensureDevice(userId) {
187
+ if (unlocked.has(userId)) return unlocked.get(userId);
188
+ const rec = await getRecord(userId);
189
+ if (!rec) return provision(userId, 'device');
190
+ return unlock(userId);
191
+ }
192
+
193
+ // Move tiers by RE-WRAPPING the in-memory CEK (no re-encryption of records).
194
+ export async function changeTier(userId, newTier, opts = {}) {
195
+ const cek = unlocked.get(userId);
196
+ if (!cek) throw new Error('LOCKED');
197
+ await putRecord(userId, await buildRecord(userId, cek, newTier, opts));
198
+ return cek;
199
+ }
200
+
201
+ // Forgotten-passphrase escape: discard the old record + device key and provision
202
+ // a fresh device-tier CEK. The old record stays unreadable (unrecoverable, by
203
+ // design — the proof the passphrase guarantee was real). Caller warns first.
204
+ export async function reset(userId) {
205
+ lock(userId);
206
+ await (await storage()).removeItem(deviceKeyKey(userId));
207
+ return provision(userId, 'device'); // overwrites the server record
208
+ }
package/keyring.mjs ADDED
@@ -0,0 +1,106 @@
1
+ // =============================================================================
2
+ // Client-side encryption — keyring/tier verification (PROTOTYPE)
3
+ //
4
+ // node frontend/src/services/crypto/keyring.mjs
5
+ //
6
+ // Exercises the REAL keyring against an injected in-memory store: provision /
7
+ // lock / unlock per tier, the passphrase gate, the device no-signing path, the
8
+ // managed auto-unlock, the re-wrap upgrade (same CEK, no re-encryption), and the
9
+ // honest per-tier labeling (§8).
10
+ // =============================================================================
11
+
12
+ import { encryptRecord, decryptRecord } from './envelope.js';
13
+ import * as keyring from './keyring.js';
14
+
15
+ // In-memory local storage backend (device key only).
16
+ const mem = new Map();
17
+ keyring.__setStorageBackend({
18
+ async getItem(k) { return mem.has(k) ? mem.get(k) : null; },
19
+ async setItem(k, v) { mem.set(k, v); },
20
+ async removeItem(k) { mem.delete(k); },
21
+ });
22
+ // In-memory record backend (stands in for the server-side keyring store; passes
23
+ // records through verbatim, incl. managed's raw cek — the real server wraps it).
24
+ const recs = new Map();
25
+ keyring.__setRecordBackend({
26
+ async get(uid) { return recs.has(uid) ? recs.get(uid) : null; },
27
+ async put(uid, record) { recs.set(uid, record); },
28
+ });
29
+ // In-memory session backend (stands in for web sessionStorage — the unlocked-CEK
30
+ // cache that lets a refresh skip re-unlock).
31
+ const sess = new Map();
32
+ keyring.__setSessionBackend({
33
+ async getItem(k) { return sess.has(k) ? sess.get(k) : null; },
34
+ async setItem(k, v) { sess.set(k, v); },
35
+ async removeItem(k) { sess.delete(k); },
36
+ async clear() { sess.clear(); },
37
+ });
38
+
39
+ let pass = 0, fail = 0;
40
+ const ok = (c, m) => { if (c) { pass++; console.log(' \x1b[32m✓\x1b[0m ' + m); } else { fail++; console.log(' \x1b[31m✗ ' + m + '\x1b[0m'); } };
41
+ async function throws(fn, code, m) {
42
+ try { await fn(); ok(false, m + ' (did not throw)'); }
43
+ catch (e) { ok(e.message.startsWith(code) || e.name === code, m + ' → ' + (e.name === code ? e.name : e.message)); }
44
+ }
45
+ // Prove a CEK works by round-tripping a record through it.
46
+ async function cekReads(cek, env) { try { return (await decryptRecord(cek, env)).t === 'secret'; } catch { return false; } }
47
+
48
+ console.log('\x1b[1m═══ keyring / tier manager ═══\x1b[0m');
49
+
50
+ // --- device tier (no-signing default) ---------------------------------------
51
+ console.log('\n1. Device tier — auto-provision, lock, auto-unlock');
52
+ const dCek = await keyring.ensureDevice('user_dev');
53
+ const dEnv = await encryptRecord(dCek, { t: 'secret' });
54
+ ok(await keyring.getTier('user_dev') === 'device', 'provisioned as device');
55
+ keyring.lock('user_dev');
56
+ ok(keyring.getUnlockedCEK('user_dev') === null, 'lock clears in-memory CEK');
57
+ const dCek2 = await keyring.ensureDevice('user_dev'); // auto-unlock, no secret
58
+ ok(await cekReads(dCek2, dEnv), 'device auto-unlock recovers the SAME CEK (no passphrase)');
59
+
60
+ // --- passphrase tier (hard guarantee + gate) --------------------------------
61
+ console.log('\n2. Passphrase tier — gate on the passphrase');
62
+ const pCek = await keyring.provision('user_pp', 'passphrase', { passphrase: 'right pass' });
63
+ const pEnv = await encryptRecord(pCek, { t: 'secret' });
64
+ keyring.lock('user_pp');
65
+ await throws(() => keyring.unlock('user_pp'), 'PASSPHRASE_REQUIRED', 'unlock without passphrase refused');
66
+ await throws(() => keyring.unlock('user_pp', { passphrase: 'wrong' }), 'OperationError', 'unlock with wrong passphrase fails');
67
+ const pCek2 = await keyring.unlock('user_pp', { passphrase: 'right pass' });
68
+ ok(await cekReads(pCek2, pEnv), 'unlock with correct passphrase recovers the CEK');
69
+
70
+ // --- managed tier (operator-held key; auto-unlock, recoverable) -------------
71
+ console.log('\n3. Managed tier — recoverable, no user secret');
72
+ const mCek = await keyring.provision('user_mg', 'managed');
73
+ const mEnv = await encryptRecord(mCek, { t: 'secret' });
74
+ keyring.lock('user_mg');
75
+ const mCek2 = await keyring.unlock('user_mg'); // no secret needed
76
+ ok(await cekReads(mCek2, mEnv), 'managed unlock needs no user secret (recoverable tier)');
77
+
78
+ // --- re-wrap upgrade (§11): managed → passphrase, same CEK, no re-encryption -
79
+ console.log('\n4. Upgrade managed → passphrase by re-wrapping');
80
+ const upEnv = await encryptRecord(keyring.getUnlockedCEK('user_mg'), { t: 'secret' });
81
+ await keyring.changeTier('user_mg', 'passphrase', { passphrase: 'new secret' });
82
+ ok(await keyring.getTier('user_mg') === 'passphrase', 'tier is now passphrase');
83
+ ok(await cekReads(keyring.getUnlockedCEK('user_mg'), upEnv), 'pre-upgrade ciphertext still decrypts (same CEK — no re-encryption)');
84
+ keyring.lock('user_mg');
85
+ await throws(() => keyring.unlock('user_mg'), 'PASSPHRASE_REQUIRED', 'after upgrade, unlock now demands the passphrase');
86
+ ok(await cekReads(await keyring.unlock('user_mg', { passphrase: 'new secret' }), upEnv), 'unlocks with the new passphrase');
87
+
88
+ // --- honest labeling (§8) ----------------------------------------------------
89
+ console.log('\n5. Per-account labeling guarantees (§8)');
90
+ ok(keyring.getTierInfo('passphrase').guarantee === 'hard', 'passphrase = hard guarantee');
91
+ ok(keyring.getTierInfo('device').guarantee === 'hard', 'device = hard guarantee');
92
+ ok(keyring.getTierInfo('managed').guarantee === 'soft', 'managed = soft guarantee (operator can read)');
93
+ ok(/cannot read/.test(keyring.getTierInfo('passphrase').summary), 'passphrase copy states operator cannot read');
94
+ ok(/we hold the key/.test(keyring.getTierInfo('managed').summary), 'managed copy states operator holds the key');
95
+
96
+ // --- reset (forgotten passphrase) — fresh key, old data abandoned -----------
97
+ console.log('\n6. Reset (forgotten passphrase) — fresh key, old record abandoned');
98
+ const rCek = await keyring.provision('user_rs', 'passphrase', { passphrase: 'the old one' });
99
+ const rEnv = await encryptRecord(rCek, { t: 'secret' });
100
+ keyring.lock('user_rs');
101
+ const freshCek = await keyring.reset('user_rs');
102
+ ok(await keyring.getTier('user_rs') === 'device', 'reset drops to the device (no-signing) tier');
103
+ ok(!(await cekReads(freshCek, rEnv)), 'pre-reset ciphertext is unreadable under the fresh CEK (unrecoverable, by design)');
104
+
105
+ console.log(`\n\x1b[1m═══ ${fail === 0 ? '\x1b[32mall ' + pass + ' checks passed' : '\x1b[31m' + fail + ' FAILED'}\x1b[0m\x1b[1m ═══\x1b[0m`);
106
+ process.exit(fail === 0 ? 0 : 1);
@@ -0,0 +1,76 @@
1
+ // =============================================================================
2
+ // Client-side encryption — longitudinal consent-gate verification (PROTOTYPE)
3
+ //
4
+ // node frontend/src/services/crypto/longitudinal.mjs
5
+ //
6
+ // Locks the consent gate at the client-side pure-logic level — the same claims
7
+ // backend/discussion/carry-path.test.js makes server-side: a declined read never
8
+ // resurfaces, only accepted reads carry, resolved forks leave the greeting set.
9
+ // Plus the store-encryption round-trip (the array encrypts opaque and restores).
10
+ // =============================================================================
11
+
12
+ import {
13
+ reconcileHeldForks, updateHeldFork, openForks,
14
+ reconcilePendingJudgments, decideJudgment, confirmedJudgments,
15
+ } from './longitudinalLogic.js';
16
+ import { generateCEK, encryptRecord, decryptRecord } from './envelope.js';
17
+
18
+ let pass = 0, fail = 0;
19
+ const ok = (c, m) => { if (c) { pass++; console.log(' \x1b[32m✓\x1b[0m ' + m); } else { fail++; console.log(' \x1b[31m✗ ' + m + '\x1b[0m'); } };
20
+
21
+ console.log('\x1b[1m═══ longitudinal consent gate (client-side) ═══\x1b[0m');
22
+
23
+ // --- judgments: the consent gate --------------------------------------------
24
+ console.log('\n1. Judgments — rejected never resurfaces, only accepted carries');
25
+ let J = [];
26
+ J = reconcilePendingJudgments(J, 1, [{ text: 'You avoid conflict', basis: '...' }, { text: 'You over-plan', basis: '...' }]);
27
+ ok(J.length === 2 && J.every(j => j.status === 'pending'), 'two reads land as pending');
28
+ ok(confirmedJudgments(J).length === 0, 'pending reads do NOT carry');
29
+
30
+ // User rejects the first, accepts the second.
31
+ J = decideJudgment(J, J[0].id, 'rejected').store;
32
+ J = decideJudgment(J, J[1].id, 'accepted').store;
33
+ ok(confirmedJudgments(J).length === 1 && confirmedJudgments(J)[0].text === 'You over-plan', 'only the accepted read carries');
34
+
35
+ // A later visit re-proposes BOTH (stateless summary). The gate must not resurface them.
36
+ const before = J.length;
37
+ J = reconcilePendingJudgments(J, 2, [{ text: 'You avoid conflict' }, { text: 'You over-plan' }]);
38
+ ok(J.length === before, 'a re-proposed rejected/accepted read is NOT re-added (dedup vs ALL statuses)');
39
+ ok(!confirmedJudgments(J).some(j => j.text === 'You avoid conflict'), 'the rejected read never enters the carry set');
40
+
41
+ // A genuinely new read still lands.
42
+ J = reconcilePendingJudgments(J, 2, [{ text: 'You seek others approval' }]);
43
+ ok(J.some(j => j.text === 'You seek others approval' && j.status === 'pending'), 'a new read still lands as pending');
44
+
45
+ // --- held forks: open-only carry, resolved may recur ------------------------
46
+ console.log('\n2. Held forks — greeting revisits OPEN only; resolved may recur');
47
+ let F = [];
48
+ F = reconcileHeldForks(F, 1, [{ fork: 'Stay vs leave', valueQuestion: 'security vs growth' }]);
49
+ ok(openForks(F).length === 1, 'fork lands open');
50
+ const dup = reconcileHeldForks(F, 2, [{ fork: 'Stay vs leave', valueQuestion: 'security vs growth' }]);
51
+ ok(dup.length === 1, 'duplicate of an OPEN fork is not re-added');
52
+
53
+ // User resolves it → leaves the greeting set.
54
+ F = updateHeldFork(F, F[0].id, { status: 'resolved', resolution: 'left' }).store;
55
+ ok(openForks(F).length === 0, 'resolved fork drops out of the greeting open set');
56
+ // The same tension genuinely recurs later → allowed to re-open.
57
+ F = reconcileHeldForks(F, 3, [{ fork: 'Stay vs leave', valueQuestion: 'security vs growth' }]);
58
+ ok(openForks(F).length === 1, 'a resolved fork may re-open when it recurs');
59
+ ok(F.length === 2, 'reconcile is append-only (old resolved entry retained)');
60
+
61
+ // reconcile never mutates an existing entry's user-owned status.
62
+ const resolvedStill = F.find(f => f.status === 'resolved');
63
+ ok(!!resolvedStill, 'reconcile never flipped the resolved entry back to open');
64
+
65
+ // --- store encryption round-trip --------------------------------------------
66
+ console.log('\n3. Encrypted store round-trip');
67
+ const cek = await generateCEK();
68
+ const env = await encryptRecord(cek, J);
69
+ const blob = JSON.stringify(env);
70
+ ok(!blob.includes('over-plan') && !blob.includes('approval'), 'encrypted judgments store is opaque at rest');
71
+ const restored = await decryptRecord(cek, env);
72
+ ok(JSON.stringify(restored) === JSON.stringify(J), 'store decrypts back exactly');
73
+ ok(confirmedJudgments(restored).length === confirmedJudgments(J).length, 'consent gate intact after round-trip');
74
+
75
+ console.log(`\n\x1b[1m═══ ${fail === 0 ? '\x1b[32mall ' + pass + ' checks passed' : '\x1b[31m' + fail + ' FAILED'}\x1b[0m\x1b[1m ═══\x1b[0m`);
76
+ process.exit(fail === 0 ? 0 : 1);
@@ -0,0 +1,99 @@
1
+ // =============================================================================
2
+ // Client-side encryption — longitudinal logic, ported pure (PROTOTYPE)
3
+ //
4
+ // When the longitudinal stores (heldForks / judgments) are client-encrypted,
5
+ // the server can't read them, so the reconcile + consent-gate logic that lives
6
+ // in backend/longitudinal/db.js must run CLIENT-SIDE. This is that logic, ported
7
+ // as pure array→array functions (no fs, no per-user paths) so it is identical in
8
+ // behavior and Node-verifiable (longitudinal.mjs).
9
+ //
10
+ // The load-bearing invariants preserved verbatim from the server:
11
+ // * Held forks: append-only; status (resolve/retire) is user-only and never
12
+ // touched by reconcile; dedup against OPEN forks only (a resolved fork may
13
+ // legitimately re-open).
14
+ // * Judgments (consent gate): dedup against ALL statuses incl. `rejected`, so
15
+ // a declined read is never silently re-surfaced; only `accepted` judgments
16
+ // are ever eligible to carry into a prompt.
17
+ // =============================================================================
18
+
19
+ const uuid = () => globalThis.crypto.randomUUID();
20
+ const nowMs = () => Date.now();
21
+
22
+ function normalize(s) {
23
+ return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
24
+ }
25
+
26
+ // --- held forks --------------------------------------------------------------
27
+ export function reconcileHeldForks(store, fromVisitId, newForks) {
28
+ if (!Array.isArray(newForks) || newForks.length === 0) return store;
29
+ const next = [...store];
30
+ const openKeys = new Set(
31
+ next.filter(f => f.status === 'open').map(f => `${normalize(f.fork)}|${normalize(f.valueQuestion)}`)
32
+ );
33
+ const now = nowMs();
34
+ for (const f of newForks) {
35
+ if (!f || !f.fork) continue;
36
+ const key = `${normalize(f.fork)}|${normalize(f.valueQuestion)}`;
37
+ if (openKeys.has(key)) continue;
38
+ openKeys.add(key);
39
+ next.push({
40
+ id: `hf_${uuid()}`,
41
+ fork: f.fork, sideA: f.sideA || '', sideB: f.sideB || '', valueQuestion: f.valueQuestion || '',
42
+ fromVisitId, createdAt: now, status: 'open', resolution: null, updatedAt: now,
43
+ });
44
+ }
45
+ return next;
46
+ }
47
+
48
+ const HELD_FORK_STATUSES = new Set(['open', 'resolved', 'retired']);
49
+
50
+ export function updateHeldFork(store, id, updates) {
51
+ const next = store.map(f => ({ ...f }));
52
+ const entry = next.find(f => f.id === id);
53
+ if (!entry) return { store, entry: null };
54
+ if (updates.status !== undefined) {
55
+ if (!HELD_FORK_STATUSES.has(updates.status)) throw new Error(`Invalid status: ${updates.status}`);
56
+ entry.status = updates.status;
57
+ }
58
+ if (updates.resolution !== undefined) entry.resolution = updates.resolution;
59
+ entry.updatedAt = nowMs();
60
+ return { store: next, entry };
61
+ }
62
+
63
+ // Open forks, for the returning greeting (the only forks it should revisit).
64
+ export function openForks(store) {
65
+ return store.filter(f => f.status === 'open');
66
+ }
67
+
68
+ // --- judgments (consent gate) ------------------------------------------------
69
+ export function reconcilePendingJudgments(store, fromVisitId, newJudgments) {
70
+ if (!Array.isArray(newJudgments) || newJudgments.length === 0) return store;
71
+ const next = [...store];
72
+ const seen = new Set(next.map(j => normalize(j.text))); // ALL statuses, incl. rejected
73
+ const now = nowMs();
74
+ for (const j of newJudgments) {
75
+ if (!j || !j.text) continue;
76
+ const key = normalize(j.text);
77
+ if (seen.has(key)) continue;
78
+ seen.add(key);
79
+ next.push({ id: `jd_${uuid()}`, text: j.text, basis: j.basis || '', fromVisitId, status: 'pending', createdAt: now, decidedAt: null });
80
+ }
81
+ return next;
82
+ }
83
+
84
+ const JUDGMENT_DECISIONS = new Set(['accepted', 'rejected']);
85
+
86
+ export function decideJudgment(store, id, status) {
87
+ if (!JUDGMENT_DECISIONS.has(status)) throw new Error(`Invalid decision: ${status}`);
88
+ const next = store.map(j => ({ ...j }));
89
+ const entry = next.find(j => j.id === id);
90
+ if (!entry) return { store, entry: null };
91
+ entry.status = status;
92
+ entry.decidedAt = nowMs();
93
+ return { store: next, entry };
94
+ }
95
+
96
+ // The ONLY judgments eligible to carry into a prompt (the consent gate).
97
+ export function confirmedJudgments(store) {
98
+ return store.filter(j => j.status === 'accepted');
99
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@thecrossroads42/crypto",
3
+ "version": "0.1.0",
4
+ "description": "The Crossroads — client-side encryption primitives (audit kit). AES-GCM record envelope with Argon2id passphrase / device / managed key tiers. The privacy-critical code, published so the claims can be verified rather than trusted.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "engines": {
11
+ "node": ">=20"
12
+ },
13
+ "exports": {
14
+ "./envelope": "./envelope.js",
15
+ "./keyring": "./keyring.js",
16
+ "./visitEnvelope": "./visitEnvelope.js",
17
+ "./longitudinalLogic": "./longitudinalLogic.js",
18
+ "./package.json": "./package.json"
19
+ },
20
+ "files": [
21
+ "envelope.js",
22
+ "keyring.js",
23
+ "visitEnvelope.js",
24
+ "longitudinalLogic.js",
25
+ "demo.mjs",
26
+ "keyring.mjs",
27
+ "longitudinal.mjs",
28
+ "roundtrip.mjs",
29
+ "verify-kit.mjs",
30
+ "KIT-MANIFEST.txt",
31
+ "LICENSE",
32
+ "README.md"
33
+ ],
34
+ "dependencies": {
35
+ "@noble/hashes": "^1.4.0"
36
+ },
37
+ "scripts": {
38
+ "verify": "node verify-kit.mjs",
39
+ "test": "node roundtrip.mjs && node keyring.mjs && node longitudinal.mjs && node demo.mjs"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/thecrossroads42/crypto.git"
44
+ },
45
+ "keywords": [
46
+ "encryption",
47
+ "client-side-encryption",
48
+ "argon2",
49
+ "aes-gcm",
50
+ "crypto",
51
+ "thecrossroads"
52
+ ]
53
+ }
package/roundtrip.mjs ADDED
@@ -0,0 +1,135 @@
1
+ // =============================================================================
2
+ // Client-side encryption — Layer 2 round-trip verification (PROTOTYPE)
3
+ //
4
+ // node frontend/src/services/crypto/roundtrip.mjs
5
+ //
6
+ // Drives the REAL pure transform (envelope.js + visitEnvelope.js) through a
7
+ // simulated client<->server cycle and asserts the Layer 2 contract:
8
+ // * content stored on the server is opaque (no plaintext leaks)
9
+ // * the server retains no cleartext content keys
10
+ // * partial updates merge correctly client-side (the moved field-merge)
11
+ // * GET round-trips to the exact original content
12
+ // * the list card decrypts for display
13
+ //
14
+ // The RN-coupled wrapper (visitCrypto.js / session.js) is exercised only when
15
+ // the app runs with ENCRYPTED_VISITS=true; here we inject the CEK + a plain Map
16
+ // cache and mirror the ~4 lines of server merge/strip so the test runs in Node.
17
+ // =============================================================================
18
+
19
+ import { generateCEK } from './envelope.js';
20
+ import { splitContent, encryptUpdate, decryptFullVisit, decryptCard, CONTENT_FIELDS } from './visitEnvelope.js';
21
+
22
+ let pass = 0, fail = 0;
23
+ const ok = (c, m) => { if (c) { pass++; console.log(' \x1b[32m✓\x1b[0m ' + m); } else { fail++; console.log(' \x1b[31m✗ ' + m + '\x1b[0m'); } };
24
+ const eq = (a, b, m) => ok(JSON.stringify(a) === JSON.stringify(b), m);
25
+
26
+ const cek = await generateCEK();
27
+
28
+ // --- client side: merge cache (mirrors session.mergeContent) ----------------
29
+ const cache = new Map();
30
+ function mergeContent(id, partial) {
31
+ const merged = { ...(cache.get(id) || {}), ...partial };
32
+ cache.set(id, merged);
33
+ return merged;
34
+ }
35
+ async function clientEncryptUpdate(id, updates) {
36
+ const { content, meta } = splitContent(updates);
37
+ return encryptUpdate(cek, meta, mergeContent(id, content));
38
+ }
39
+
40
+ // --- server side: store + merge/strip (mirrors handleUpdateVisit) -----------
41
+ const disk = new Map();
42
+ function serverPut(id, wire) {
43
+ const existing = disk.get(id) || { id, draft: true, cost: { total: { usd: 0 } } };
44
+ const { cost, ...body } = wire; // cost is server-authoritative
45
+ const updated = { ...existing, ...body, id };
46
+ if (updated.encrypted) {
47
+ for (const f of ['messages', 'name', 'icon', 'summary', 'plan']) delete updated[f];
48
+ }
49
+ disk.set(id, updated);
50
+ }
51
+ function serverGet(id) { return disk.get(id); }
52
+ function serverListEntry(id) {
53
+ const v = disk.get(id);
54
+ return v.encrypted
55
+ ? { id: v.id, startDate: v.startDate, endDate: v.endDate, encrypted: true, enc: { card: v.enc?.card } }
56
+ : { id: v.id, name: v.name, icon: v.icon, summary: v.summary };
57
+ }
58
+
59
+ console.log('\x1b[1m═══ Layer 2 round-trip (encrypt-before-PUT) ═══\x1b[0m');
60
+
61
+ const ID = 42;
62
+ disk.set(ID, { id: ID, startDate: 111, draft: true, cost: { total: { usd: 0 } } });
63
+
64
+ // 1. First save: just messages (the chat turn).
65
+ console.log('\n1. First save — messages only');
66
+ serverPut(ID, await clientEncryptUpdate(ID, {
67
+ draft: false,
68
+ messages: [
69
+ { role: 'user', text: 'I feel my stable job is costing me my twenties.' },
70
+ { role: 'keeper', text: 'The Steward and the Lever both want this…' },
71
+ ],
72
+ }));
73
+ const after1 = serverGet(ID);
74
+ ok(after1.encrypted === true, 'visit marked encrypted on disk');
75
+ ok(after1.draft === false, 'cleartext meta (draft) passed through');
76
+ ok(!('messages' in after1), 'no cleartext `messages` key on disk');
77
+ ok(after1.cost.total.usd === 0, 'server-authoritative cost preserved (not clobbered)');
78
+
79
+ // 2. Second save: partial — adds end/summary/name/icon/plan (visit conclusion).
80
+ console.log('\n2. Second save — partial fields merge client-side');
81
+ serverPut(ID, await clientEncryptUpdate(ID, {
82
+ endDate: 222,
83
+ name: 'Whether to leave the job',
84
+ icon: '🪧',
85
+ summary: { headline: 'Security versus the cost of staying', keyTopics: ['career', 'risk'], outcome: 'leaning toward a deadline' },
86
+ plan: '- Name the worst realistic outcome\n- Give it a deadline',
87
+ }));
88
+ const after2 = serverGet(ID);
89
+ ok(after2.endDate === 222, 'cleartext meta (endDate) passed through');
90
+ for (const f of CONTENT_FIELDS) ok(!(f in after2), `no cleartext \`${f}\` key on disk`);
91
+
92
+ // 3. Opaqueness: no plaintext anywhere in the stored blob.
93
+ console.log('\n3. At-rest opaqueness');
94
+ const blob = JSON.stringify(after2);
95
+ const leaked = ['twenties', 'Steward', 'Security versus', 'deadline', 'career'].filter(w => blob.includes(w));
96
+ ok(leaked.length === 0, 'stored blob contains no plaintext content' + (leaked.length ? ' (LEAKED: ' + leaked + ')' : ''));
97
+
98
+ // 4. GET round-trip: decrypt back to the exact merged content.
99
+ console.log('\n4. GET round-trip (decrypt)');
100
+ const { visit } = await decryptFullVisit(cek, serverGet(ID));
101
+ eq(visit.messages?.length, 2, 'messages restored');
102
+ eq(visit.name, 'Whether to leave the job', 'name restored');
103
+ eq(visit.summary.headline, 'Security versus the cost of staying', 'summary restored');
104
+ eq(visit.plan?.startsWith('- Name'), true, 'plan restored');
105
+ ok(visit.endDate === 222 && visit.draft === false, 'meta + content both present after decrypt');
106
+
107
+ // 5. List card decrypts for display.
108
+ console.log('\n5. List row card');
109
+ const row = await decryptCard(cek, serverListEntry(ID));
110
+ eq(row.name, 'Whether to leave the job', 'card name decrypts');
111
+ eq(row.icon, '🪧', 'card icon decrypts');
112
+ eq(row.summary.headline, 'Security versus the cost of staying', 'card headline decrypts');
113
+
114
+ // 6. Wrong key cannot read (sanity: a different CEK can't recover the content).
115
+ // Decryption degrades gracefully (no throw) — the record is marked
116
+ // undecryptable and the plaintext is not exposed.
117
+ console.log('\n6. Wrong key cannot read');
118
+ const otherCek = await generateCEK();
119
+ const wrong = await decryptFullVisit(otherCek, serverGet(ID));
120
+ ok(wrong.undecryptable === true, 'a different CEK is rejected (record marked undecryptable, no throw)');
121
+ ok(!wrong.visit.messages?.length && wrong.visit.name === 'Unreadable visit', 'no plaintext leaks; shows an unreadable placeholder');
122
+
123
+ // 7. Layer 3b contract: a decrypted visit, shaped as buildClientContext does,
124
+ // exposes the fields the server's formatSummaryEntries reads for the
125
+ // mega-batch prior-context block.
126
+ console.log('\n7. Layer 3b — client-supplied prior summary shape');
127
+ const dv = (await decryptFullVisit(cek, serverGet(ID))).visit;
128
+ const shaped = { id: dv.id, startDate: dv.startDate, endDate: dv.endDate, ...dv.summary };
129
+ ok(shaped.id === ID && typeof shaped.startDate !== 'undefined', 'shaped entry carries id + startDate (server reads these)');
130
+ ok(typeof shaped.headline === 'string', 'shaped entry carries summary.headline (the Focus line)');
131
+ ok(Array.isArray(shaped.keyTopics), 'shaped entry carries summary.keyTopics (the Topics line)');
132
+ ok(!('enc' in shaped) && !('messages' in shaped), 'shaped entry leaks no ciphertext / raw messages');
133
+
134
+ console.log(`\n\x1b[1m═══ ${fail === 0 ? '\x1b[32mall ' + pass + ' checks passed' : '\x1b[31m' + fail + ' FAILED'}\x1b[0m\x1b[1m ═══\x1b[0m`);
135
+ process.exit(fail === 0 ? 0 : 1);
package/verify-kit.mjs ADDED
@@ -0,0 +1,76 @@
1
+ // =============================================================================
2
+ // Audit-kit drift guard
3
+ //
4
+ // node verify-kit.mjs # check files against KIT-MANIFEST.txt (exit 1 on drift)
5
+ // node verify-kit.mjs --write # (re)generate KIT-MANIFEST.txt
6
+ //
7
+ // KIT-MANIFEST.txt is the SHA-256 of every file in the published audit kit. It
8
+ // is the bridge between this repo and the public mirror: CI here runs `--check`
9
+ // so the committed manifest always reflects the real files, and anyone with the
10
+ // mirror can run the same check (or a bare `sha256sum`) to confirm the mirror
11
+ // is byte-for-byte these files. See README.md for the trust model and its limit.
12
+ // =============================================================================
13
+
14
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
15
+ import { createHash } from 'crypto';
16
+ import { join, dirname } from 'path';
17
+ import { fileURLToPath } from 'url';
18
+
19
+ const DIR = dirname(fileURLToPath(import.meta.url));
20
+ const MANIFEST = 'KIT-MANIFEST.txt';
21
+
22
+ // The published kit. Excludes this script and the manifest itself.
23
+ const KIT = [
24
+ 'LICENSE',
25
+ 'README.md',
26
+ 'envelope.js',
27
+ 'visitEnvelope.js',
28
+ 'longitudinalLogic.js',
29
+ 'keyring.js',
30
+ 'demo.mjs',
31
+ 'roundtrip.mjs',
32
+ 'keyring.mjs',
33
+ 'longitudinal.mjs',
34
+ ].sort();
35
+
36
+ function sha256(relPath) {
37
+ return createHash('sha256').update(readFileSync(join(DIR, relPath))).digest('hex');
38
+ }
39
+
40
+ function currentManifest() {
41
+ return KIT.map((f) => `${sha256(f)} ${f}`).join('\n') + '\n';
42
+ }
43
+
44
+ const write = process.argv.includes('--write');
45
+
46
+ if (write) {
47
+ writeFileSync(join(DIR, MANIFEST), currentManifest());
48
+ console.log(`wrote ${MANIFEST} (${KIT.length} files)`);
49
+ process.exit(0);
50
+ }
51
+
52
+ // --check (default)
53
+ const manifestPath = join(DIR, MANIFEST);
54
+ if (!existsSync(manifestPath)) {
55
+ console.error(`✗ ${MANIFEST} missing — run: node verify-kit.mjs --write`);
56
+ process.exit(1);
57
+ }
58
+ const expected = readFileSync(manifestPath, 'utf-8').trim();
59
+ const actual = currentManifest().trim();
60
+
61
+ if (expected === actual) {
62
+ console.log(`✓ audit kit matches ${MANIFEST} (${KIT.length} files)`);
63
+ process.exit(0);
64
+ }
65
+
66
+ // Report the specific drift.
67
+ const exp = new Map(expected.split('\n').map((l) => { const [h, f] = l.split(/\s+/); return [f, h]; }));
68
+ const act = new Map(actual.split('\n').map((l) => { const [h, f] = l.split(/\s+/); return [f, h]; }));
69
+ console.error(`✗ audit kit drifted from ${MANIFEST}:`);
70
+ for (const f of KIT) {
71
+ if (!exp.has(f)) console.error(` + ${f} (not in manifest)`);
72
+ else if (exp.get(f) !== act.get(f)) console.error(` ~ ${f} (changed)`);
73
+ }
74
+ for (const f of exp.keys()) if (!act.has(f)) console.error(` - ${f} (in manifest, file gone)`);
75
+ console.error(' run: node verify-kit.mjs --write (after intended changes)');
76
+ process.exit(1);
@@ -0,0 +1,89 @@
1
+ // =============================================================================
2
+ // Client-side encryption — pure visit transform (PROTOTYPE, Layer 2)
3
+ //
4
+ // The content-split / encrypt / decrypt logic, with NO React-Native or session
5
+ // coupling: every function takes an explicit CEK. visitCrypto.js is the thin
6
+ // wrapper that resolves the userId + session CEK and calls these. Keeping this
7
+ // pure means it runs in Node for the round-trip test (roundtrip.mjs) and is the
8
+ // actual code under test, not a re-implementation.
9
+ // =============================================================================
10
+
11
+ import { encryptRecord, decryptRecord } from './envelope.js';
12
+
13
+ // Fields that are CONTENT (encrypted). Everything else is cleartext operational
14
+ // metadata (§6) and passes through.
15
+ export const CONTENT_FIELDS = ['messages', 'name', 'icon', 'summary', 'plan'];
16
+
17
+ export function splitContent(obj) {
18
+ const content = {};
19
+ const meta = {};
20
+ for (const [k, v] of Object.entries(obj)) {
21
+ if (CONTENT_FIELDS.includes(k)) content[k] = v;
22
+ else meta[k] = v;
23
+ }
24
+ return { content, meta };
25
+ }
26
+
27
+ // The lightweight digest the History/Landing list needs (§6: minimize what the
28
+ // list reads). Decrypted per row instead of the full content.
29
+ export function cardOf(content) {
30
+ return {
31
+ name: content.name ?? null,
32
+ icon: content.icon ?? null,
33
+ headline: content.summary?.headline ?? null,
34
+ keyTopics: content.summary?.keyTopics ?? null,
35
+ outcome: content.summary?.outcome ?? null,
36
+ };
37
+ }
38
+
39
+ // Build the encrypted wire body from cleartext meta + the FULL merged content.
40
+ export async function encryptUpdate(cek, meta, fullContent) {
41
+ return {
42
+ ...meta,
43
+ encrypted: true,
44
+ enc: {
45
+ card: await encryptRecord(cek, cardOf(fullContent)),
46
+ content: await encryptRecord(cek, fullContent),
47
+ },
48
+ };
49
+ }
50
+
51
+ // Decrypt a full visit back into plaintext fields. Returns { meta, content } so
52
+ // the caller can seed its cache with the content. Pass-through marker if not
53
+ // encrypted.
54
+ export async function decryptFullVisit(cek, wireVisit) {
55
+ if (!wireVisit?.encrypted || !wireVisit.enc?.content) {
56
+ return { passthrough: true, visit: wireVisit };
57
+ }
58
+ const { enc, encrypted, ...meta } = wireVisit;
59
+ try {
60
+ const content = await decryptRecord(cek, wireVisit.enc.content);
61
+ return { passthrough: false, content, visit: { ...meta, ...content } };
62
+ } catch (err) {
63
+ // Wrong/rotated key or corrupt ciphertext — degrade instead of throwing, so
64
+ // one undecryptable record can't break the screen (or, in a list, the
65
+ // readable records alongside it). The cleartext metadata still renders.
66
+ console.warn(`Visit ${meta.id}: content could not be decrypted (${err?.name || 'error'}) — key mismatch?`);
67
+ return { passthrough: false, undecryptable: true, content: {}, visit: { ...meta, undecryptable: true, name: 'Unreadable visit', messages: [] } };
68
+ }
69
+ }
70
+
71
+ // Decrypt a list row's card into display fields. Pass-through if not encrypted.
72
+ export async function decryptCard(cek, entry) {
73
+ if (!entry?.encrypted || !entry.enc?.card) return entry;
74
+ const { enc, encrypted, ...meta } = entry;
75
+ try {
76
+ const card = await decryptRecord(cek, entry.enc.card);
77
+ return {
78
+ ...meta,
79
+ name: card.name,
80
+ icon: card.icon,
81
+ summary: meta.summary || (card.headline
82
+ ? { headline: card.headline, keyTopics: card.keyTopics, outcome: card.outcome }
83
+ : null),
84
+ };
85
+ } catch (err) {
86
+ console.warn(`Visit ${meta.id}: card could not be decrypted (${err?.name || 'error'}) — key mismatch?`);
87
+ return { ...meta, name: 'Unreadable visit', icon: '🔒', summary: null, undecryptable: true };
88
+ }
89
+ }