Skip to content

Commit f7b7f10

Browse files
stackdumpclaude
andcommitted
Phase B / B5.8a: sk-creator client-side store + backup module
public/sk-creator-store.js owns the lifecycle of the v3 poll creator's secret key. generateSkCreator draws 32 bytes from the platform CSPRNG and reduces mod the BabyJubJub subgroup order; save/load/clear persist under per-poll localStorage keys; the backup writer/reader serializes a versioned JSON envelope and recomputes pk = G·sk on read so a tampered file fails loudly at restore time rather than later during proof submission. Includes a memory-storage shim for tests so the unit test runs without a browser. 19 Node-side checks cover the CSPRNG path, storage roundtrip, malformed-entry tolerance, backup parse/tamper-detection, and version/format gating, wired into go test ./internal/server/... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3e133b2 commit f7b7f10

3 files changed

Lines changed: 354 additions & 0 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package server
2+
3+
import (
4+
"os/exec"
5+
"testing"
6+
)
7+
8+
// TestSkCreatorStoreJS runs the Node-side unit tests for
9+
// public/sk-creator-store.js. The module is pure-data (save/load
10+
// roundtrip, backup parse, tamper detection) and a divergence here
11+
// would silently break the v3 close flow at runtime, so we gate it
12+
// on `go test`.
13+
func TestSkCreatorStoreJS(t *testing.T) {
14+
if _, err := exec.LookPath("node"); err != nil {
15+
t.Skip("node not installed; skipping sk-creator-store JS tests")
16+
}
17+
cmd := exec.Command("node", "public/sk_creator_store_test.mjs")
18+
cmd.Dir = findProjectRoot(t)
19+
out, err := cmd.CombinedOutput()
20+
if err != nil {
21+
t.Fatalf("sk-creator-store JS tests failed:\n%s\n%v", out, err)
22+
}
23+
t.Logf("%s", out)
24+
}

public/sk-creator-store.js

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
// sk-creator-store.js — local storage and backup for the v3 poll
2+
// creator's secret key (sk_creator).
3+
//
4+
// Per the locked-in custody decision (client-only), sk_creator is
5+
// generated in the browser at poll creation, saved to localStorage,
6+
// and a JSON backup is offered for download. The server NEVER sees it.
7+
// Loss of localStorage + backup means the poll cannot be closed —
8+
// callers must surface that risk in the UI before calling generate.
9+
10+
import { SUBGROUP_ORDER, scalarMulBase, encodePointHex, decodePointHex } from './pedersen.js';
11+
12+
// localStorage key prefix. Includes the poll id so multiple polls'
13+
// keys coexist without ambiguity.
14+
const KEY_PREFIX = 'bitwrap-sk-creator-';
15+
16+
// Backup file format version. Bump if the schema changes; restoreFromFile
17+
// rejects unknown versions to avoid silently mis-parsing future formats.
18+
const BACKUP_FORMAT_VERSION = 1;
19+
20+
// generateSkCreator returns a fresh sk_creator drawn uniformly from
21+
// [0, ℓ) where ℓ is the BabyJubJub prime-order subgroup. Uses the
22+
// platform CSPRNG; throws if crypto.getRandomValues is unavailable.
23+
export function generateSkCreator() {
24+
if (typeof crypto === 'undefined' || !crypto.getRandomValues) {
25+
throw new Error('sk-creator-store: crypto.getRandomValues unavailable');
26+
}
27+
// 32 random bytes ≈ 256 bits, then reduce mod ℓ (~252-bit). The
28+
// resulting bias is < 2^-252 — negligible for the cryptosystem.
29+
const bytes = new Uint8Array(32);
30+
crypto.getRandomValues(bytes);
31+
let v = 0n;
32+
for (const b of bytes) v = (v << 8n) | BigInt(b);
33+
return v % SUBGROUP_ORDER;
34+
}
35+
36+
// derivePkCreator returns the BabyJubJub point pk = G·sk encoded as
37+
// 32-byte compressed hex. Matches the server's pkCreator wire format.
38+
export function derivePkCreator(sk) {
39+
const pk = scalarMulBase(BigInt(sk));
40+
return encodePointHex(pk);
41+
}
42+
43+
// saveSkCreator persists sk under the per-poll localStorage key.
44+
// Pass `storage` to use a custom backend (test shim); defaults to
45+
// browser localStorage. Throws if localStorage is unavailable.
46+
export function saveSkCreator(pollId, sk, storage) {
47+
const s = storage || _defaultStorage();
48+
if (!s) throw new Error('sk-creator-store: localStorage unavailable');
49+
s.setItem(KEY_PREFIX + pollId, BigInt(sk).toString(10));
50+
}
51+
52+
// loadSkCreator returns the BigInt sk for pollId, or null if absent.
53+
// A malformed value (non-decimal) is treated as absent so a junk
54+
// localStorage entry doesn't cascade into a crypto failure later.
55+
export function loadSkCreator(pollId, storage) {
56+
const s = storage || _defaultStorage();
57+
if (!s) return null;
58+
const raw = s.getItem(KEY_PREFIX + pollId);
59+
if (!raw) return null;
60+
try {
61+
return BigInt(raw);
62+
} catch {
63+
return null;
64+
}
65+
}
66+
67+
// clearSkCreator removes the persisted key. Call after a successful
68+
// close — the key is no longer needed and lingering keys are an
69+
// avoidable footgun.
70+
export function clearSkCreator(pollId, storage) {
71+
const s = storage || _defaultStorage();
72+
if (!s) return;
73+
s.removeItem(KEY_PREFIX + pollId);
74+
}
75+
76+
// makeSkBackupBlob serializes the backup payload as a Blob suitable
77+
// for FileSaver-style download. Pure data; the actual download is
78+
// triggered by downloadSkBackup which uses the browser DOM.
79+
export function makeSkBackupPayload(pollId, sk, pkHex, extras = {}) {
80+
return {
81+
format: 'bitwrap-sk-creator',
82+
version: BACKUP_FORMAT_VERSION,
83+
pollId,
84+
sk: BigInt(sk).toString(10),
85+
pkCreator: pkHex,
86+
timestamp: new Date().toISOString(),
87+
warning: 'Anyone with this file can decrypt the poll tally. Store it securely.',
88+
...extras,
89+
};
90+
}
91+
92+
// downloadSkBackup triggers a browser download of the sk backup file.
93+
// Filename includes the poll id prefix. Caller must run in a context
94+
// where document is defined.
95+
export function downloadSkBackup(pollId, sk, pkHex, extras = {}) {
96+
const payload = makeSkBackupPayload(pollId, sk, pkHex, extras);
97+
const json = JSON.stringify(payload, null, 2);
98+
const blob = new Blob([json], { type: 'application/json' });
99+
const url = URL.createObjectURL(blob);
100+
const a = document.createElement('a');
101+
a.href = url;
102+
a.download = `bitwrap-poll-${pollId.slice(0, 12)}-creator-key.json`;
103+
document.body.appendChild(a);
104+
a.click();
105+
document.body.removeChild(a);
106+
URL.revokeObjectURL(url);
107+
}
108+
109+
// restoreFromFile reads a previously-downloaded sk backup file and
110+
// returns {pollId, sk: BigInt, pkHex}. Validates format/version and
111+
// asserts pk = G·sk so a tampered file (mismatched sk and pk) fails
112+
// loudly at restore time rather than later during proof submission.
113+
export async function restoreFromFile(file) {
114+
const text = await readFileAsText(file);
115+
return parseSkBackupText(text);
116+
}
117+
118+
// parseSkBackupText is the synchronous parser used by restoreFromFile;
119+
// exposed so callers with the JSON already in memory (e.g. a
120+
// drag-n-drop preview) can validate without touching the File API.
121+
export function parseSkBackupText(text) {
122+
let parsed;
123+
try {
124+
parsed = JSON.parse(text);
125+
} catch (e) {
126+
throw new Error('not a valid JSON backup: ' + e.message);
127+
}
128+
if (parsed.format !== 'bitwrap-sk-creator') {
129+
throw new Error(`unexpected backup format: ${parsed.format}`);
130+
}
131+
if (parsed.version !== BACKUP_FORMAT_VERSION) {
132+
throw new Error(`unsupported backup version ${parsed.version} (this build expects ${BACKUP_FORMAT_VERSION})`);
133+
}
134+
if (!parsed.pollId || !parsed.sk || !parsed.pkCreator) {
135+
throw new Error('backup missing required fields (pollId, sk, pkCreator)');
136+
}
137+
let sk;
138+
try {
139+
sk = BigInt(parsed.sk);
140+
} catch (e) {
141+
throw new Error('backup sk is not a decimal big integer');
142+
}
143+
// Recompute pk = G·sk and check against the embedded pkCreator. A
144+
// mismatch means the file has been corrupted or hand-edited; reject
145+
// before the caller tries to use the key.
146+
const expectedPk = derivePkCreator(sk);
147+
if (expectedPk !== parsed.pkCreator.toLowerCase()) {
148+
throw new Error('backup pkCreator does not match G·sk — file is corrupted or has been tampered with');
149+
}
150+
// Round-trip pk through decode to confirm it parses cleanly.
151+
decodePointHex(parsed.pkCreator);
152+
return {
153+
pollId: parsed.pollId,
154+
sk,
155+
pkHex: parsed.pkCreator,
156+
};
157+
}
158+
159+
// readFileAsText — Promise-wrap FileReader. Lives here rather than
160+
// poll.js so the test harness can use restoreFromFile against a Blob
161+
// without pulling in DOM-only globals at module load time.
162+
function readFileAsText(file) {
163+
return new Promise((resolve, reject) => {
164+
const reader = new FileReader();
165+
reader.onload = () => resolve(reader.result);
166+
reader.onerror = () => reject(reader.error || new Error('file read failed'));
167+
reader.readAsText(file);
168+
});
169+
}
170+
171+
function _defaultStorage() {
172+
if (typeof localStorage !== 'undefined') return localStorage;
173+
return null;
174+
}
175+
176+
// Test-friendly export: a tiny in-memory storage shim that mimics the
177+
// Web Storage API. Lets unit tests run without a browser.
178+
export function makeMemoryStorage() {
179+
const map = new Map();
180+
return {
181+
getItem: (k) => (map.has(k) ? map.get(k) : null),
182+
setItem: (k, v) => map.set(k, String(v)),
183+
removeItem: (k) => map.delete(k),
184+
clear: () => map.clear(),
185+
};
186+
}

public/sk_creator_store_test.mjs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// Unit tests for sk-creator-store.js. Pure-data paths only — the
2+
// download flow is DOM-bound and exercised by the Playwright E2E in
3+
// B5.10. Run: node public/sk_creator_store_test.mjs
4+
5+
import { SUBGROUP_ORDER } from './pedersen.js';
6+
import {
7+
generateSkCreator,
8+
derivePkCreator,
9+
saveSkCreator,
10+
loadSkCreator,
11+
clearSkCreator,
12+
makeSkBackupPayload,
13+
parseSkBackupText,
14+
makeMemoryStorage,
15+
} from './sk-creator-store.js';
16+
17+
let failures = 0;
18+
function ok(cond, msg) {
19+
if (!cond) { console.error('FAIL:', msg); failures++; }
20+
else console.log('OK: ', msg);
21+
}
22+
function eq(got, want, msg) {
23+
if (got !== want) {
24+
console.error(`FAIL: ${msg}\n got: ${got}\n want: ${want}`);
25+
failures++;
26+
} else console.log('OK: ', msg);
27+
}
28+
29+
// 1. generateSkCreator returns a BigInt in [0, ℓ).
30+
{
31+
const sk = generateSkCreator();
32+
ok(typeof sk === 'bigint', 'generateSkCreator returns BigInt');
33+
ok(sk >= 0n && sk < SUBGROUP_ORDER, 'sk is in [0, ℓ)');
34+
}
35+
36+
// 2. Two consecutive generations differ (CSPRNG, not stub).
37+
{
38+
const a = generateSkCreator();
39+
const b = generateSkCreator();
40+
ok(a !== b, 'two generations differ');
41+
}
42+
43+
// 3. derivePkCreator returns a 32-byte (64-hex-char) compressed hex string.
44+
{
45+
const sk = generateSkCreator();
46+
const pk = derivePkCreator(sk);
47+
eq(pk.length, 64, 'pk is 32 bytes (64 hex chars)');
48+
ok(/^[0-9a-f]+$/.test(pk), 'pk is lowercase hex');
49+
}
50+
51+
// 4. save/load roundtrip.
52+
{
53+
const storage = makeMemoryStorage();
54+
const sk = 0xfeedfacecafebeef0123456789abcdefn;
55+
saveSkCreator('poll-1', sk, storage);
56+
const loaded = loadSkCreator('poll-1', storage);
57+
ok(loaded === sk, 'save/load roundtrip');
58+
59+
// distinct poll
60+
ok(loadSkCreator('poll-2', storage) === null, 'unrelated poll returns null');
61+
}
62+
63+
// 5. load returns null on absent / malformed entries.
64+
{
65+
const storage = makeMemoryStorage();
66+
ok(loadSkCreator('nope', storage) === null, 'absent returns null');
67+
storage.setItem('bitwrap-sk-creator-junk', 'not-a-bigint');
68+
ok(loadSkCreator('junk', storage) === null, 'malformed returns null');
69+
}
70+
71+
// 6. clearSkCreator removes the entry.
72+
{
73+
const storage = makeMemoryStorage();
74+
saveSkCreator('p', 42n, storage);
75+
clearSkCreator('p', storage);
76+
ok(loadSkCreator('p', storage) === null, 'clear removes entry');
77+
}
78+
79+
// 7. makeSkBackupPayload + parseSkBackupText roundtrip.
80+
{
81+
const sk = generateSkCreator();
82+
const pk = derivePkCreator(sk);
83+
const payload = makeSkBackupPayload('poll-abc', sk, pk);
84+
const text = JSON.stringify(payload);
85+
const parsed = parseSkBackupText(text);
86+
eq(parsed.pollId, 'poll-abc', 'parsed pollId');
87+
ok(parsed.sk === sk, 'parsed sk matches');
88+
eq(parsed.pkHex, pk, 'parsed pkHex matches');
89+
}
90+
91+
// 8. parseSkBackupText rejects mismatched sk/pk (tamper detection).
92+
{
93+
const sk = generateSkCreator();
94+
const pk = derivePkCreator(sk);
95+
const altSk = generateSkCreator(); // different key
96+
const tampered = JSON.stringify({
97+
format: 'bitwrap-sk-creator',
98+
version: 1,
99+
pollId: 'poll-x',
100+
sk: altSk.toString(10),
101+
pkCreator: pk, // belongs to the original sk, not altSk
102+
});
103+
let threw = false;
104+
try { parseSkBackupText(tampered); }
105+
catch (e) {
106+
threw = true;
107+
ok(/G·sk/.test(e.message), 'tampered file rejected with G·sk message');
108+
}
109+
ok(threw, 'tampered file throws');
110+
}
111+
112+
// 9. parseSkBackupText rejects unknown format / version.
113+
{
114+
let threw = false;
115+
try { parseSkBackupText('{"format":"other-format"}'); }
116+
catch { threw = true; }
117+
ok(threw, 'rejects unknown format');
118+
119+
threw = false;
120+
try {
121+
parseSkBackupText(JSON.stringify({
122+
format: 'bitwrap-sk-creator', version: 999,
123+
pollId: 'p', sk: '1', pkCreator: 'x',
124+
}));
125+
} catch { threw = true; }
126+
ok(threw, 'rejects unknown version');
127+
}
128+
129+
// 10. parseSkBackupText handles missing fields.
130+
{
131+
let threw = false;
132+
try { parseSkBackupText(JSON.stringify({ format: 'bitwrap-sk-creator', version: 1 })); }
133+
catch (e) {
134+
threw = true;
135+
ok(/required fields/.test(e.message), 'missing-fields error message');
136+
}
137+
ok(threw, 'rejects missing fields');
138+
}
139+
140+
if (failures > 0) {
141+
console.error(`\n${failures} check(s) FAILED`);
142+
process.exit(1);
143+
}
144+
console.log('\nAll sk-creator-store checks passed.');

0 commit comments

Comments
 (0)