Skip to content

Commit d52787c

Browse files
fix(identity): key the device-seed cache by account (spec 9.3) + zeroize handoff buffers
Audit blocker: the Minister-derived seed was adopted into the single un-keyed localStorage slot, silently clobbering any pre-existing local random-seed identity on the next Minister login and letting accounts sharing a browser overwrite each other's seeds. - Adoption now writes ONLY an account-scoped slot (deforum_device_seed_b64:<userNullifierHash> - the nullifier hash is a stable 1:1 stand-in for the Minister sub, which never reaches the client). The legacy un-keyed slot is never an adoption target, so a pre-existing local seed stays intact and restorable. - vault.ts owns slot resolution: reads and generic writes use the scoped slot only when the session is scoped AND that slot exists, else the legacy slot - byte-identical behavior when no fragment ever arrived. The root layout pins the scope on every full page load. - device.ts routes through the vault store (drops its duplicate key handling). - New fail-closed branch: a fragment with no signed-in account scope derives nothing (never writes the shared legacy slot). - Best-effort zeroization of the decoded mix secret, per-app secret, and derived seed buffers after use. - Tests: non-clobber of a pre-existing legacy seed, distinct slots for two accounts in one browser, deterministic re-derivation across page loads, and the null-scope fail-closed path; golden vector unchanged.
1 parent 4533151 commit d52787c

6 files changed

Lines changed: 257 additions & 109 deletions

File tree

src/lib/client/device.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
// identity) read the SAME seed from here, so a member's anon proofs are against
66
// the very commitment they joined with.
77
//
8-
// localStorage is the store (mirrors the join page's original inline handling).
9-
// @ministryofmany/identity's generateDeviceSeed is lazy-imported so the Semaphore
10-
// identity graph never loads at module eval (client-only, like the proof helpers).
8+
// The store is vault.ts (localStorage, resolved per-account per anon-identity
9+
// spec 9.3: a Minister-derived seed lives in an account-scoped slot, a local/
10+
// random one in the legacy un-keyed slot). @ministryofmany/identity's
11+
// generateDeviceSeed is lazy-imported so the Semaphore identity graph never
12+
// loads at module eval (client-only, like the proof helpers).
1113

1214
import { ministerAnonSeedSettled } from './minister-anon';
15+
import { hasSeed, persistSeed, readSeed } from './vault';
1316

14-
const SEED_KEY = 'deforum_device_seed_b64';
1517
const DEVICE_ID_KEY = 'deforum_device_id';
1618

1719
function assertBrowser(fn: string): void {
@@ -29,22 +31,19 @@ export async function loadOrCreateDeviceSeed(): Promise<Uint8Array> {
2931
// a locally-generated seed can never race the Minister-derived one
3032
// (minister-anon.ts). Resolves immediately when no handoff is in flight.
3133
await ministerAnonSeedSettled();
32-
const existing = localStorage.getItem(SEED_KEY);
33-
if (existing) {
34-
return Uint8Array.from(atob(existing), (c) => c.charCodeAt(0));
35-
}
34+
const existing = readSeed();
35+
if (existing) return existing;
3636
const { generateDeviceSeed } = await import('@ministryofmany/identity');
3737
const seed = generateDeviceSeed();
38-
localStorage.setItem(SEED_KEY, btoa(String.fromCharCode(...seed)));
38+
persistSeed(seed);
3939
return seed;
4040
}
4141

4242
/** True if a device seed has already been created in this browser. A member who
4343
* joined on THIS device has one; without it the anon tier cannot prove (the
4444
* derived commitment would not match the snapshot leaf). */
4545
export function hasDeviceSeed(): boolean {
46-
if (typeof localStorage === 'undefined') return false;
47-
return localStorage.getItem(SEED_KEY) !== null;
46+
return hasSeed();
4847
}
4948

5049
/** Load the stable per-device id, generating + persisting one on first use. */

src/lib/client/minister-anon.ts

Lines changed: 69 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,20 @@
1414
// 2. Validates the captured value with the SDK's extractMinisterAppSecret
1515
// (injected synthetic location, scrub already done), mixes in Deforum's
1616
// own MINISTER_ANON_RP_MIX_SECRET via deriveDeviceSeedFromMinister (HKDF,
17-
// spec 9.2), and persists the result as THE device seed - the same
18-
// localStorage slot the existing chain reads (vault.ts / device.ts), so
17+
// spec 9.2), and persists the result as THE device seed for the signed-in
18+
// ACCOUNT - an account-scoped localStorage slot (spec 9.3, vault.ts), so
19+
// it never overwrites a pre-existing local/random seed in the legacy
20+
// un-keyed slot and accounts sharing a browser never clobber each other.
21+
// The chain (vault.ts / device.ts) resolves the same slot on read, so
1922
// deriveIdentity(seed, subforumId) and everything downstream is untouched.
2023
//
2124
// FAIL-CLOSED everywhere (spec 8.3): no fragment, a malformed fragment, an
22-
// unset/short mix secret, or any error leaves the existing locally-generated
23-
// seed behavior byte-identical. Login always succeeds; only the anonymous
24-
// identity fails to derive. Nothing here (secret, seed) ever leaves the browser.
25+
// unset/short mix secret, a missing account scope, or any error leaves the
26+
// existing locally-generated seed behavior byte-identical. Login always
27+
// succeeds; only the anonymous identity fails to derive. Nothing here (secret,
28+
// seed) ever leaves the browser.
2529

26-
import { readSeed, persistSeed } from './vault';
30+
import { persistScopedSeed, setSeedScope } from './vault';
2731

2832
// Mirrors MINISTER_ANON_PARAM in @ministryofmany/identity - duplicated because
2933
// the capture phase must run synchronously, before the lazy SDK import resolves.
@@ -51,49 +55,64 @@ function hexToBytes(hex: string): Uint8Array | null {
5155
return out;
5256
}
5357

54-
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
55-
if (a.byteLength !== b.byteLength) return false;
56-
for (let i = 0; i < a.byteLength; i++) if (a[i] !== b[i]) return false;
57-
return true;
58-
}
59-
60-
async function adopt(raw: string, rpMixHex: string | null): Promise<void> {
58+
async function adopt(raw: string, rpMixHex: string | null, scope: string | null): Promise<void> {
6159
const mix = rpMixHex === null ? null : hexToBytes(rpMixHex);
6260
if (mix === null || mix.byteLength < MIX_MIN_BYTES) {
6361
// FAIL CLOSED: the fragment arrived but the RP mix secret is not
6462
// provisioned (or too short). No anonymous identity is derived; the
6563
// existing seed behavior stands. Surfaced so the operator can see the
6664
// misconfiguration - never logs any secret value.
65+
mix?.fill(0);
6766
console.warn(
6867
'minister-anon: a minister_anon fragment arrived but MINISTER_ANON_RP_MIX_SECRET is unset or shorter than 32 bytes; anonymous identity NOT derived (fail-closed)'
6968
);
7069
return;
7170
}
71+
if (scope === null) {
72+
// FAIL CLOSED: a fragment arrived with no signed-in account to scope the
73+
// seed to (should be impossible - the OIDC callback sets the session
74+
// cookie before its 3xx). Never write the shared legacy slot (spec 9.3).
75+
mix.fill(0);
76+
console.warn(
77+
'minister-anon: a minister_anon fragment arrived without a signed-in account to scope the seed to; anonymous identity NOT derived (fail-closed)'
78+
);
79+
return;
80+
}
7281

73-
const { extractMinisterAppSecret, deriveDeviceSeedFromMinister } =
74-
await import('@ministryofmany/identity');
75-
76-
// Re-run the SDK's validator over the captured value (the real URL was
77-
// already scrubbed): a synthetic location keeps grammar + decoding in the
78-
// SDK, so Deforum never re-implements them.
79-
const synthetic = new URLSearchParams();
80-
synthetic.set(PARAM, raw);
81-
const appSecret = extractMinisterAppSecret({
82-
location: { pathname: '', search: '', hash: `#${synthetic.toString()}` },
83-
scrub: false
84-
});
85-
if (appSecret === null) return; // malformed / unknown version: fail closed (spec 8.3)
86-
87-
const seed = await deriveDeviceSeedFromMinister(appSecret, mix);
88-
89-
// The Minister-derived seed IS the device seed from here on (deterministic:
90-
// the same user + app + mix secret always re-derives it, which is exactly
91-
// what makes the anonymous identity recoverable after storage loss). A
92-
// differing pre-handoff local seed is replaced per spec 9.3; the Settings
93-
// vault remains the backup/restore surface for it.
94-
const existing = readSeed();
95-
if (existing !== null && bytesEqual(existing, seed)) return;
96-
persistSeed(seed);
82+
let appSecret: Uint8Array | null = null;
83+
let seed: Uint8Array | null = null;
84+
try {
85+
const { extractMinisterAppSecret, deriveDeviceSeedFromMinister } =
86+
await import('@ministryofmany/identity');
87+
88+
// Re-run the SDK's validator over the captured value (the real URL was
89+
// already scrubbed): a synthetic location keeps grammar + decoding in the
90+
// SDK, so Deforum never re-implements them.
91+
const synthetic = new URLSearchParams();
92+
synthetic.set(PARAM, raw);
93+
appSecret = extractMinisterAppSecret({
94+
location: { pathname: '', search: '', hash: `#${synthetic.toString()}` },
95+
scrub: false
96+
});
97+
if (appSecret === null) return; // malformed / unknown version: fail closed (spec 8.3)
98+
99+
seed = await deriveDeviceSeedFromMinister(appSecret, mix);
100+
101+
// The Minister-derived seed IS this account's device seed from here on
102+
// (deterministic: the same user + app + mix secret always re-derives it,
103+
// which is exactly what makes the anonymous identity recoverable after
104+
// storage loss - so an idempotent overwrite of the scoped slot is safe).
105+
// It lands in the ACCOUNT-SCOPED slot only (spec 9.3): a pre-existing
106+
// local/random seed in the legacy slot stays intact, and the Settings
107+
// vault remains the backup/restore surface.
108+
persistScopedSeed(seed);
109+
} finally {
110+
// Best-effort zeroization: JS cannot scrub every copy (the b64 string in
111+
// localStorage IS the cache), but the working buffers need not linger.
112+
mix.fill(0);
113+
appSecret?.fill(0);
114+
seed?.fill(0);
115+
}
97116
}
98117

99118
/**
@@ -103,12 +122,20 @@ async function adopt(raw: string, rpMixHex: string | null): Promise<void> {
103122
* any JS navigation (spec finding S3).
104123
*
105124
* `rpMixHex` is MINISTER_ANON_RP_MIX_SECRET as delivered by the layout server
106-
* load (null when unset/invalid or signed out). The heavy work (SDK import,
107-
* HKDF) runs async after the synchronous scrub; ministerAnonSeedSettled()
108-
* exposes its completion.
125+
* load (null when unset/invalid or signed out). `seedScope` is the signed-in
126+
* account's stable seed-scope key (the user-nullifier hash, null when signed
127+
* out): it is pinned on EVERY call - fragment or not - so the seed store
128+
* resolves this account's scoped slot (spec 9.3) on every page load, and a
129+
* fragment can never adopt into the shared legacy slot. The heavy work (SDK
130+
* import, HKDF) runs async after the synchronous scrub;
131+
* ministerAnonSeedSettled() exposes its completion.
109132
*/
110-
export function captureMinisterAnonFragment(rpMixHex: string | null): void {
133+
export function captureMinisterAnonFragment(
134+
rpMixHex: string | null,
135+
seedScope: string | null
136+
): void {
111137
if (typeof window === 'undefined') return;
138+
setSeedScope(seedScope);
112139
const hash = window.location.hash;
113140
if (hash === '' || hash === '#') return;
114141

@@ -127,7 +154,7 @@ export function captureMinisterAnonFragment(rpMixHex: string | null): void {
127154
window.location.pathname + window.location.search + (rest ? `#${rest}` : '')
128155
);
129156

130-
pending = adopt(raw, rpMixHex).catch((e: unknown) => {
157+
pending = adopt(raw, rpMixHex, seedScope).catch((e: unknown) => {
131158
// Fail closed on ANY error (SDK import failure, WebCrypto absence,
132159
// storage rejection): the existing behavior stands, login is unaffected.
133160
// SDK error messages are static - no secret material can land in logs.

0 commit comments

Comments
 (0)