@tinytars/vault 0.1.15 → 0.1.17

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,360 @@
1
+ import {
2
+ generateAccountKeypair,
3
+ deriveKekFromPassword,
4
+ deriveAuthHash,
5
+ wrapPrivateKey,
6
+ unwrapPrivateKey,
7
+ wrapDEKWithKek,
8
+ unwrapDEKWithKek,
9
+ wrapDEKForPublicKey,
10
+ unwrapDEKWithPrivateKey,
11
+ } from "./crypto";
12
+ import { bytesToBase64, base64ToBytes, bytesToHex, hexToBytes, rand, failed, KDF_ITERATIONS, currentAuthHashFor } from "./auth-client";
13
+
14
+ // Not a security boundary (the recovery KEK is still PBKDF2-derived from this code) —
15
+ // just a printable code the user can write down. Excludes ambiguous chars (0/O, 1/I).
16
+ const RECOVERY_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
17
+ function randomRecoveryCode(): string {
18
+ return Array.from(rand(20), (b) => RECOVERY_ALPHABET[b % RECOVERY_ALPHABET.length]).join("");
19
+ }
20
+
21
+ // Account settings: change email/display name; add/remove a login method (password/passkey).
22
+ // Adding a method re-wraps the account's in-memory private key under the new method's KEK — same
23
+ // zero-knowledge model as signup; the server never sees the key.
24
+
25
+ export interface LoginMethod {
26
+ method: "password" | "passkey" | "google" | "recovery";
27
+ createdAt: string;
28
+ isRecovery: boolean;
29
+ }
30
+
31
+ export async function listMethods(): Promise<LoginMethod[]> {
32
+ const res = await fetch("/api/account/methods", { cache: "no-store" });
33
+ if (!res.ok) throw await failed(res, "list methods failed");
34
+ return ((await res.json()) as { methods: LoginMethod[] }).methods;
35
+ }
36
+
37
+ export async function updateProfile(updates: { email?: string; displayName?: string; unitSystem?: "metric" | "imperial" }): Promise<void> {
38
+ const res = await fetch("/api/account", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(updates) });
39
+ if (!res.ok) throw await failed(res, "profile update failed");
40
+ }
41
+
42
+ export async function removeMethod(method: "password" | "passkey" | "google"): Promise<void> {
43
+ const res = await fetch("/api/account/methods", { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ method }) });
44
+ if (!res.ok) throw await failed(res, "remove method failed");
45
+ }
46
+
47
+ /**
48
+ * Sets or replaces the account password.
49
+ *
50
+ * `currentPassword` is the step-up proof, and is REQUIRED by the server whenever a password
51
+ * already exists. It is derived against the account's CURRENT salt, fetched by email, because an
52
+ * authHash is only meaningful paired with the salt it was derived from; deriving the proof against
53
+ * the new salt would produce a value that matches nothing.
54
+ *
55
+ * Undefined is correct for an account setting its first password — a passkey-only or Google-only
56
+ * account has nothing to prove and nothing is being destroyed.
57
+ */
58
+ export async function addPasswordMethod(
59
+ privateKey: CryptoKey,
60
+ newPassword: string,
61
+ currentPassword?: string,
62
+ email?: string,
63
+ ): Promise<void> {
64
+ const currentAuthHash = await currentAuthHashFor(email, currentPassword);
65
+
66
+ const salt = rand(16);
67
+ const kek = await deriveKekFromPassword(newPassword, salt);
68
+ const wrappedPrivateKey = await wrapPrivateKey(privateKey, kek);
69
+ const authHash = await deriveAuthHash(newPassword, salt);
70
+ const res = await fetch("/api/account/methods", {
71
+ method: "POST",
72
+ headers: { "content-type": "application/json" },
73
+ body: JSON.stringify({
74
+ method: "password",
75
+ wrappedPrivateKey: bytesToBase64(wrappedPrivateKey),
76
+ kdfParams: { salt: bytesToHex(salt), iterations: KDF_ITERATIONS },
77
+ authHash,
78
+ ...(currentAuthHash ? { currentAuthHash } : {}),
79
+ }),
80
+ });
81
+ if (res.status === 401) {
82
+ const { error } = (await res.json().catch(() => ({ error: "" }))) as { error?: string };
83
+ throw new Error(error || "enter your current password to set a new one");
84
+ }
85
+ if (!res.ok) throw await failed(res, "add password failed");
86
+ }
87
+
88
+ // Recover with a recovery code (mirrors loginPassword, keyed on the recovery credential).
89
+ // Returns the same shape as loginPassword so it flows straight into the app's enter-account path.
90
+ export async function recoverAccount(
91
+ email: string,
92
+ recoveryCode: string,
93
+ /**
94
+ * The replacement password, set as part of redeeming the code.
95
+ *
96
+ * Without this the flow was a trap: redeeming signed you in but left the forgotten password in place,
97
+ * so the next sign-in put you right back where you started. It cannot be a follow-up call to
98
+ * `/api/account/methods` either — the account still holds its old password credential, so the
99
+ * add-a-method step-up would demand the very password being recovered.
100
+ *
101
+ * So the code is redeemed TWICE against the same endpoint: once to obtain the wrapped key (which is
102
+ * the only way to get it — the server releases it on proof, never on request), and once to install
103
+ * the re-wrapped key. The second call re-proves the recovery code, so the replacement is authorised
104
+ * by the code rather than by the session the first call happened to mint.
105
+ */
106
+ newPassword?: string,
107
+ ): Promise<{ accountId: string; vaultId: string | null; r2Key: string | null; privateKey: CryptoKey; dek: CryptoKey | null; rotationPending: boolean }> {
108
+ const saltRes = await fetch(`/api/auth/recovery/salt?email=${encodeURIComponent(email)}`);
109
+ // Same as the password path above: a decoy is returned for an address with no recovery credential,
110
+ // so a non-200 is a fault, not an answer about the account.
111
+ if (!saltRes.ok) throw new Error(`recovery is unavailable right now (${saltRes.status})`);
112
+ const { salt } = (await saltRes.json()) as { salt: string; iterations: number };
113
+
114
+ const recoveryAuthHash = await deriveAuthHash(recoveryCode, hexToBytes(salt));
115
+ const res = await fetch("/api/auth/recovery/login", {
116
+ method: "POST",
117
+ headers: { "content-type": "application/json" },
118
+ body: JSON.stringify({ email, recoveryAuthHash }),
119
+ });
120
+ if (!res.ok) throw new Error(`recovery failed: ${res.status}`);
121
+ const data = (await res.json()) as {
122
+ accountId: string;
123
+ vaultId: string | null;
124
+ r2Key: string | null;
125
+ wrappedPrivateKey: string;
126
+ kdfParams: { salt: string; iterations: number };
127
+ ownerEnvelope: { wrappedDEK: string; ephemeralPublicKeyJwk: JsonWebKey } | null;
128
+ };
129
+
130
+ const kek = await deriveKekFromPassword(recoveryCode, hexToBytes(data.kdfParams.salt));
131
+ const privateKey = await unwrapPrivateKey(base64ToBytes(data.wrappedPrivateKey), kek);
132
+ const dek = data.ownerEnvelope
133
+ ? await unwrapDEKWithPrivateKey(base64ToBytes(data.ownerEnvelope.wrappedDEK), data.ownerEnvelope.ephemeralPublicKeyJwk, privateKey)
134
+ : null;
135
+
136
+ if (newPassword) {
137
+ // The SAME private key, re-wrapped under a KEK derived from the new password. It is not a new
138
+ // keypair, which is why the account's passkey and Google credentials keep working afterwards.
139
+ const newSalt = rand(16);
140
+ const newKek = await deriveKekFromPassword(newPassword, newSalt);
141
+ const install = await fetch("/api/auth/recovery/login", {
142
+ method: "POST",
143
+ headers: { "content-type": "application/json" },
144
+ body: JSON.stringify({
145
+ email,
146
+ recoveryAuthHash,
147
+ newCredential: {
148
+ wrappedPrivateKey: bytesToBase64(await wrapPrivateKey(privateKey, newKek)),
149
+ kdfParams: { salt: bytesToHex(newSalt), iterations: KDF_ITERATIONS },
150
+ authHash: await deriveAuthHash(newPassword, newSalt),
151
+ },
152
+ }),
153
+ });
154
+ // Deliberately loud rather than silent: the caller is already signed in at this point, so a
155
+ // swallowed failure would leave the user believing they had set a password they had not.
156
+ if (!install.ok) throw new Error(`your code was accepted but the new password could not be saved (${install.status}) — try setting it from Account settings`);
157
+ }
158
+
159
+ return { accountId: data.accountId, vaultId: data.vaultId, r2Key: data.r2Key, privateKey, dek, rotationPending: (data as { rotationPending?: boolean }).rotationPending ?? false };
160
+ }
161
+
162
+ // Regenerate the recovery code (owner session): re-wrap the in-memory private key under a fresh code +
163
+ // store the verifier. Returns the new code to display once.
164
+ // ── Provider-issued recovery ──────────────────────────────────────────────────
165
+ // Apple's recovery-contact model: a clinician who already holds the patient's DEK re-wraps it under a
166
+ // one-time code and READS THE CODE TO THEM. It is never emailed — see RECOVERY.md I2; the server would
167
+ // have to be given the code, and it already holds the wrapped DEK.
168
+
169
+ // Crockford base32, minus the characters that are misheard or mistyped (I/L/O/U). 12 characters ≈ 60
170
+ // bits, which is only safe because the server caps attempts and expires the grant in an hour — a code
171
+ // short enough to read down a phone line cannot also resist an unbounded oracle.
172
+ const GRANT_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
173
+ export function randomGrantCode(): string {
174
+ const raw = Array.from(rand(12), (b) => GRANT_ALPHABET[b % GRANT_ALPHABET.length]).join("");
175
+ return `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8)}`;
176
+ }
177
+
178
+ /** Strips the display grouping so "a7k2-9qmf-3xpb" and "A7K29QMF3XPB" are the same code. */
179
+ export function normalizeGrantCode(input: string): string {
180
+ return input.replace(/[^0-9A-Za-z]/g, "").toUpperCase();
181
+ }
182
+
183
+ /**
184
+ * Which ladder rung a pasted/typed string belongs to, by shape alone. `randomRecoveryCode`
185
+ * always emits 20 raw characters; `randomGrantCode` always emits 12 (displayed grouped). No
186
+ * collision is possible by construction, so the stripped length alone is enough to route —
187
+ * this never has to look at what the string actually contains.
188
+ */
189
+ export function detectRecoveryKind(raw: string): "code" | "grant" {
190
+ return normalizeGrantCode(raw).length === 12 ? "grant" : "code";
191
+ }
192
+
193
+ /**
194
+ * Clinician side. Unwraps the patient's DEK with the clinician's own key — which is what provider
195
+ * access already is — and re-wraps it under the code. Returns the code to display once.
196
+ */
197
+ export async function issueRecoveryCode(
198
+ patientAccountId: string,
199
+ envelope: { wrappedDEK: string; ephemeralPublicKeyJwk: JsonWebKey },
200
+ providerKey: CryptoKey,
201
+ ): Promise<{ code: string; expiresAt: string }> {
202
+ const code = randomGrantCode();
203
+ const normalized = normalizeGrantCode(code);
204
+ const dek = await unwrapDEKWithPrivateKey(base64ToBytes(envelope.wrappedDEK), envelope.ephemeralPublicKeyJwk, providerKey);
205
+ const salt = rand(16);
206
+ const res = await fetch("/api/recovery/grant", {
207
+ method: "POST",
208
+ headers: { "content-type": "application/json" },
209
+ body: JSON.stringify({
210
+ patientAccountId,
211
+ wrappedDek: bytesToBase64(await wrapDEKWithKek(dek, await deriveKekFromPassword(normalized, salt))),
212
+ kdfParams: { salt: bytesToHex(salt), iterations: KDF_ITERATIONS },
213
+ codeAuthHash: await deriveAuthHash(normalized, salt),
214
+ }),
215
+ });
216
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `could not issue a recovery code (${res.status})`);
217
+ return { code, expiresAt: (await res.json()).expiresAt };
218
+ }
219
+
220
+ /**
221
+ * Patient side. Two calls against the same endpoint: the first proves the code and returns the wrapped
222
+ * DEK, the second installs a brand-new keypair locked under the new password. The code is proved on
223
+ * both — the second is not authorised by the first having happened.
224
+ *
225
+ * Unlike `recoverAccount`, this MINTS A NEW KEYPAIR rather than re-wrapping the old one, because the
226
+ * old private key is exactly what the patient no longer has. That is why the server clears the other
227
+ * credentials: they wrap a key nothing references any more.
228
+ */
229
+ export async function redeemRecoveryCode(
230
+ email: string,
231
+ code: string,
232
+ newPassword: string,
233
+ ): Promise<{ accountId: string; vaultId: string | null; r2Key: string | null; privateKey: CryptoKey; dek: CryptoKey; rotationPending: boolean }> {
234
+ const normalized = normalizeGrantCode(code);
235
+ const saltRes = await fetch(`/api/auth/recovery/grant-salt?email=${encodeURIComponent(email)}`);
236
+ if (!saltRes.ok) throw new Error(`recovery is unavailable right now (${saltRes.status})`);
237
+ const { salt } = (await saltRes.json()) as { salt: string };
238
+ const codeAuthHash = await deriveAuthHash(normalized, hexToBytes(salt));
239
+
240
+ const first = await fetch("/api/auth/recovery/grant-redeem", {
241
+ method: "POST",
242
+ headers: { "content-type": "application/json" },
243
+ body: JSON.stringify({ email, codeAuthHash }),
244
+ });
245
+ if (!first.ok) throw new Error((await first.json().catch(() => ({}))).error || "that code is not valid, or it has expired");
246
+ const { wrappedDek } = (await first.json()) as { wrappedDek: string };
247
+ const dek = await unwrapDEKWithKek(base64ToBytes(wrappedDek), await deriveKekFromPassword(normalized, hexToBytes(salt)));
248
+
249
+ const fresh = await generateAccountKeypair();
250
+ const pwSalt = rand(16);
251
+ const envelope = await wrapDEKForPublicKey(dek, fresh.publicKeyJwk);
252
+ const second = await fetch("/api/auth/recovery/grant-redeem", {
253
+ method: "POST",
254
+ headers: { "content-type": "application/json" },
255
+ body: JSON.stringify({
256
+ email,
257
+ codeAuthHash,
258
+ newIdentity: {
259
+ publicKeyJwk: fresh.publicKeyJwk,
260
+ wrappedPrivateKey: bytesToBase64(await wrapPrivateKey(fresh.privateKey, await deriveKekFromPassword(newPassword, pwSalt))),
261
+ kdfParams: { salt: bytesToHex(pwSalt), iterations: KDF_ITERATIONS },
262
+ authHash: await deriveAuthHash(newPassword, pwSalt),
263
+ envelope: { wrappedDEK: bytesToBase64(envelope.wrappedDEK), ephemeralPublicKeyJwk: envelope.ephemeralPublicKeyJwk },
264
+ },
265
+ }),
266
+ });
267
+ if (!second.ok) throw new Error((await second.json().catch(() => ({}))).error || `could not finish recovery (${second.status})`);
268
+ const data = (await second.json()) as { accountId: string; vaultId: string | null; r2Key: string | null; rotationPending: boolean };
269
+ return { ...data, privateKey: fresh.privateKey, dek };
270
+ }
271
+
272
+ // DEK rotation. Fetch the re-wrap targets (owner + org + active providers), then commit the
273
+ // new envelope set after the client re-encrypts the vault under a fresh DEK.
274
+ export interface VaultPrincipals {
275
+ selfAccountId: string;
276
+ orgAccountId: string;
277
+ selfPublicKeyJwk: JsonWebKey;
278
+ orgPublicKeyJwk: JsonWebKey;
279
+ providers: { accountId: string; publicKeyJwk: JsonWebKey }[];
280
+ envelopePrincipalIds: string[];
281
+ orgRecoveryRevokedAt: string | null;
282
+ vaultId: string;
283
+ }
284
+ export async function getVaultPrincipals(): Promise<VaultPrincipals> {
285
+ const res = await fetch("/api/vault/principals", { cache: "no-store" });
286
+ if (!res.ok) throw await failed(res, "principals failed");
287
+ return res.json();
288
+ }
289
+ /**
290
+ * Reserve the object the re-key will write to. The server mints the id; the caller PUTs the
291
+ * blob re-encrypted under the new DEK there, then calls `rotateVault` to make the swap real.
292
+ */
293
+ export async function stageVaultRotation(vaultId: string): Promise<string> {
294
+ const res = await fetch("/api/vault/rotate", {
295
+ method: "POST",
296
+ headers: { "content-type": "application/json" },
297
+ body: JSON.stringify({ phase: "stage", vaultId }),
298
+ });
299
+ if (!res.ok) throw await failed(res, "stage rotation failed");
300
+ return (await res.json()).newVaultId as string;
301
+ }
302
+
303
+ export async function rotateVault(args: {
304
+ vaultId: string;
305
+ newVaultId: string;
306
+ envelopes: { principalAccountId: string; wrappedDEK: string; ephemeralPublicKeyJwk: JsonWebKey }[];
307
+ }): Promise<void> {
308
+ const res = await fetch("/api/vault/rotate", {
309
+ method: "POST",
310
+ headers: { "content-type": "application/json" },
311
+ body: JSON.stringify({ phase: "commit", ...args }),
312
+ });
313
+ if (!res.ok) throw await failed(res, "rotate failed");
314
+ }
315
+
316
+ export async function regenerateRecoveryCode(privateKey: CryptoKey): Promise<string> {
317
+ const recoveryCode = randomRecoveryCode();
318
+ const salt = rand(16);
319
+ const kek = await deriveKekFromPassword(recoveryCode, salt);
320
+ const wrappedPrivateKey = await wrapPrivateKey(privateKey, kek);
321
+ const recoveryAuthHash = await deriveAuthHash(recoveryCode, salt);
322
+ const res = await fetch("/api/account/recovery", {
323
+ method: "POST",
324
+ headers: { "content-type": "application/json" },
325
+ body: JSON.stringify({ wrappedPrivateKey: bytesToBase64(wrappedPrivateKey), kdfParams: { salt: bytesToHex(salt), iterations: KDF_ITERATIONS }, recoveryAuthHash }),
326
+ });
327
+ if (!res.ok) throw await failed(res, "regenerate recovery failed");
328
+ return recoveryCode;
329
+ }
330
+
331
+ export async function putRecoveryEnvelope(e: { wrappedDEK: string; ephemeralPublicKeyJwk: unknown }): Promise<{ status: string }> {
332
+ const res = await fetch("/api/vault/recovery-envelope", {
333
+ method: "POST",
334
+ headers: { "content-type": "application/json" },
335
+ body: JSON.stringify(e),
336
+ });
337
+ if (!res.ok) throw await failed(res, "put recovery envelope failed");
338
+ return res.json();
339
+ }
340
+
341
+ export async function revokeRecoveryEnvelope(): Promise<{ status: string; revokedAt: string }> {
342
+ const res = await fetch("/api/vault/recovery-envelope", { method: "DELETE" });
343
+ if (!res.ok) throw await failed(res, "revoke recovery envelope failed");
344
+ return res.json();
345
+ }
346
+
347
+ export interface AccessEventRow {
348
+ id: string;
349
+ action: string;
350
+ actorAccountId: string;
351
+ vaultId: string | null;
352
+ consentRef: string | null;
353
+ meta: unknown;
354
+ createdAt: string;
355
+ }
356
+ export async function getAccessEvents(): Promise<{ events: AccessEventRow[] }> {
357
+ const res = await fetch("/api/account/access-events", { cache: "no-store" });
358
+ if (!res.ok) throw await failed(res, "access events failed");
359
+ return res.json();
360
+ }
@@ -0,0 +1,118 @@
1
+ import { wrapDEKForPublicKey } from "./crypto";
2
+ import { bytesToBase64, failed } from "./auth-client";
3
+
4
+ // Support consented-access. A patient approves a pending support request by wrapping their
5
+ // in-memory DEK to the support agent's public key (time-boxed); support enters via an audited endpoint.
6
+
7
+ // Patient side — approve a pending support request (linkId + the agent's publicKeyJwk from GET /api/providers).
8
+ export async function approveSupport(linkId: string, dek: CryptoKey, publicKeyJwk: JsonWebKey, ttlHours: number): Promise<void> {
9
+ const env = await wrapDEKForPublicKey(dek, publicKeyJwk);
10
+ const res = await fetch("/api/support/approve", {
11
+ method: "POST",
12
+ headers: { "content-type": "application/json" },
13
+ body: JSON.stringify({ linkId, wrappedDEK: bytesToBase64(env.wrappedDEK), ephemeralPublicKeyJwk: env.ephemeralPublicKeyJwk, ttlHours }),
14
+ });
15
+ if (!res.ok) throw await failed(res, "approve failed");
16
+ }
17
+
18
+ // Support side.
19
+ export interface SupportPatient {
20
+ patientAccountId: string;
21
+ displayName: string;
22
+ expiresAt: string | null;
23
+ }
24
+
25
+ export async function requestSupportAccess(patientEmail: string): Promise<void> {
26
+ const res = await fetch("/api/support/request", {
27
+ method: "POST",
28
+ headers: { "content-type": "application/json" },
29
+ body: JSON.stringify({ patientEmail }),
30
+ });
31
+ if (!res.ok) throw await failed(res, "request failed");
32
+ }
33
+
34
+ export async function listSupportPatients(): Promise<SupportPatient[]> {
35
+ const res = await fetch("/api/support/patients", { cache: "no-store" });
36
+ if (!res.ok) throw await failed(res, "support patients failed");
37
+ return ((await res.json()) as { patients: SupportPatient[] }).patients;
38
+ }
39
+
40
+ // Enter a patient (audited server-side); returns the envelope for client-side DEK unwrap.
41
+ export async function enterSupportPatient(patientAccountId: string): Promise<{
42
+ patientAccountId: string;
43
+ displayName: string;
44
+ email: string | null;
45
+ vaultId: string;
46
+ r2Key: string;
47
+ envelope: { wrappedDEK: string; ephemeralPublicKeyJwk: JsonWebKey };
48
+ }> {
49
+ const res = await fetch("/api/support/access", {
50
+ method: "POST",
51
+ headers: { "content-type": "application/json" },
52
+ body: JSON.stringify({ patientAccountId }),
53
+ });
54
+ if (!res.ok) throw await failed(res, "support access failed");
55
+ return res.json();
56
+ }
57
+
58
+ // Support→provider roster access. A support agent can request access to a clinician too (same
59
+ // /api/support/request, which classifies by the target's kind). The clinician approves (metadata only,
60
+ // no DEK), then support sees the clinician's roster and can open the patients who separately consented.
61
+
62
+ export interface SupportProvider {
63
+ linkId: string;
64
+ providerAccountId: string;
65
+ displayName: string;
66
+ email: string | null;
67
+ expiresAt: string | null;
68
+ }
69
+
70
+ export interface SupportRosterPatient {
71
+ patientAccountId: string;
72
+ displayName: string;
73
+ email: string | null;
74
+ openable: boolean;
75
+ pending: boolean;
76
+ }
77
+
78
+ export interface SupportRequest {
79
+ linkId: string;
80
+ targetAccountId: string;
81
+ displayName: string;
82
+ email: string | null;
83
+ kind: "patient" | "provider";
84
+ }
85
+
86
+ // Provider side — approve a pending support roster request (no DEK; a provider owns no vault).
87
+ export async function approveSupportAsProvider(linkId: string, ttlHours: number): Promise<void> {
88
+ const res = await fetch("/api/providers/approve-support", {
89
+ method: "POST",
90
+ headers: { "content-type": "application/json" },
91
+ body: JSON.stringify({ linkId, ttlHours }),
92
+ });
93
+ if (!res.ok) throw await failed(res, "approve failed");
94
+ }
95
+
96
+ export async function listSupportProviders(): Promise<SupportProvider[]> {
97
+ const res = await fetch("/api/support/providers", { cache: "no-store" });
98
+ if (!res.ok) throw await failed(res, "support providers failed");
99
+ return ((await res.json()) as { providers: SupportProvider[] }).providers;
100
+ }
101
+
102
+ export async function getProviderRoster(providerId: string): Promise<SupportRosterPatient[]> {
103
+ const res = await fetch(`/api/support/provider-roster?providerId=${encodeURIComponent(providerId)}`, { cache: "no-store" });
104
+ if (!res.ok) throw await failed(res, "provider roster failed");
105
+ return ((await res.json()) as { roster: SupportRosterPatient[] }).roster;
106
+ }
107
+
108
+ export async function listSupportRequests(): Promise<SupportRequest[]> {
109
+ const res = await fetch("/api/support/requests", { cache: "no-store" });
110
+ if (!res.ok) throw await failed(res, "support requests failed");
111
+ return ((await res.json()) as { requests: SupportRequest[] }).requests;
112
+ }
113
+
114
+ // Cancel a pending request the support agent made (support is the provider side of the link).
115
+ export async function cancelSupportRequest(linkId: string): Promise<void> {
116
+ const res = await fetch(`/api/providers/${encodeURIComponent(linkId)}`, { method: "DELETE" });
117
+ if (!res.ok) throw await failed(res, "cancel failed");
118
+ }
package/base64.ts ADDED
@@ -0,0 +1,15 @@
1
+ // Byte <-> base64. Pure, no reactive state — used throughout this package's client layer wherever
2
+ // key material or an envelope needs to cross a JSON boundary.
3
+
4
+ export function b64ToBytes(b64: string): Uint8Array {
5
+ const bin = atob(b64);
6
+ const out = new Uint8Array(bin.length);
7
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
8
+ return out;
9
+ }
10
+
11
+ export function bytesToB64(bytes: Uint8Array): string {
12
+ let bin = "";
13
+ for (const b of bytes) bin += String.fromCharCode(b);
14
+ return btoa(bin);
15
+ }
package/break-glass.ts CHANGED
@@ -1,9 +1,8 @@
1
- // The time-boxed "support agent gets temporary access, then it lapses or is pulled" pattern. It
2
- // exists because this package's first adopter had several routes that each hardcoded their own TTL
3
- // clamp, consent-string prefix, and audit shape one for granting with an envelope, one for
4
- // granting metadata-only access, one for checking/expiring, one for revoking. Centralizing the
5
- // pattern here means every one of those routes shares the same TTL clamp, consent format, and audit
6
- // trail instead of drifting independently.
1
+ // The time-boxed "support agent gets temporary access, then it lapses or is pulled" pattern, factored
2
+ // out of routes that would otherwise each hardcode their own TTL clamp, consent-string prefix, and
3
+ // audit shape: a grant-with-envelope route, a grant-metadata-only route, a check/expire route, and a
4
+ // revoke route. Parameterized TTL rather than a fixed default, so an adopter with different
5
+ // access-window needs isn't stuck with ours.
7
6
 
8
7
  import type { AuditStore, EnvelopeStore, ProviderLink, ProviderLinkStore } from "./stores";
9
8
 
package/crypto.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { toArrayBuffer as toAB } from "./bytes";
2
2
  import { deriveAesKey, deriveBits, IV_LEN, SALT_LEN } from "./kdf";
3
3
 
4
- // HD1 is this app's own envelope and stays here. Only the derivation is shared with the QBO vault's
5
- // EB1 — see the header of @tinytars/vault/kdf for why the two formats must NOT be merged.
4
+ // HD1 is this package's own envelope and stays here. Only the derivation is shared with a sibling
5
+ // encrypted-blob format elsewhere — see kdf.ts's header for why the two formats must NOT be merged.
6
6
  const MAGIC = new Uint8Array([0x48, 0x44, 0x31]); // "HD1"
7
7
  const VERSION = 1;
8
8
 
@@ -12,18 +12,22 @@ const dec = new TextDecoder();
12
12
 
13
13
  const deriveKey = (passphrase: string, salt: Uint8Array) => deriveAesKey(subtle, passphrase, salt);
14
14
 
15
+ function bytesToBase64Url(bytes: Uint8Array): string {
16
+ let bin = "";
17
+ for (const b of bytes) bin += String.fromCharCode(b);
18
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
19
+ }
20
+
15
21
  // Deterministic, non-reversible bearer derived from the passphrase: base64url(SHA-256).
16
22
  //
17
- // This is a generator for a stable, non-reversible token an adopter can use to gate a script or an
18
- // allowlisted bearer-token integration off the same passphrase used for the interactive login path,
19
- // without storing the passphrase itself anywhere. It is not session auth — an interactive route
20
- // should be protected by a real session cookie/token, not a static bearer derived here. Keep this
21
- // only for a script-facing bearer allowlist you still rely on; drop it once you don't.
23
+ // A stable, non-reversible token an adopter can hand to a script or an allowlisted bearer-token
24
+ // integration gated off the same passphrase used for the interactive login path, without storing
25
+ // the passphrase itself anywhere. Not session auth — an interactive route should be protected by a
26
+ // real session cookie/token, not a static bearer derived here. Keep this only for a script-facing
27
+ // bearer allowlist you still rely on; drop it once you don't.
22
28
  export async function deriveBearerToken(passphrase: string): Promise<string> {
23
29
  const digest = new Uint8Array(await subtle.digest("SHA-256", toAB(enc.encode(passphrase))));
24
- let bin = "";
25
- for (const b of digest) bin += String.fromCharCode(b);
26
- return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
30
+ return bytesToBase64Url(digest);
27
31
  }
28
32
 
29
33
  export async function encryptVault<T = Record<string, unknown>>(data: T, passphrase: string): Promise<Uint8Array> {
@@ -114,9 +118,7 @@ export async function deriveAuthHash(password: string, salt: Uint8Array): Promis
114
118
  domainSalt.set(salt, 0);
115
119
  domainSalt.set(suffix, salt.length);
116
120
  const bits = await deriveBits(subtle, password, domainSalt);
117
- let bin = "";
118
- for (const b of bits) bin += String.fromCharCode(b);
119
- return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
121
+ return bytesToBase64Url(bits);
120
122
  }
121
123
 
122
124
  // Wrap/unwrap the account private key under a KEK: AES-GCM over its PKCS8 bytes. Blob = iv(12)+ct.
@@ -139,18 +141,17 @@ export async function unwrapPrivateKey(blob: Uint8Array, kek: CryptoKey): Promis
139
141
  } catch {
140
142
  throw new Error("cannot unwrap private key (wrong KEK or corrupt blob)");
141
143
  }
142
- // Extractable so an authorized holder can RE-WRAP it — needed to add a login method or regenerate a
143
- // recovery code, both of which wrap this same key under a new KEK. Same rationale/posture as the
144
+ // Extractable so an authorized holder can RE-WRAP it — needed to add a login method or regenerate the
145
+ // recovery code, which wraps this same key under a new KEK. Same rationale/posture as the
144
146
  // DEK returned by unwrapDEKWithPrivateKey (also extractable for re-granting). The key stays in-memory
145
147
  // only, same trust boundary as the session's DEK.
146
148
  return subtle.importKey("pkcs8", pkcs8, EC_PARAMS, true, ["deriveKey", "deriveBits"]);
147
149
  }
148
150
 
149
151
  // Import a raw PKCS8 ECDH private key (extractable, so it can be re-wrapped to add a login method —
150
- // same trust boundary as unwrapPrivateKey's output). A server-custody login path (e.g. SSO, where
151
- // the server itself bootstraps the session) moves the plaintext key over the wire instead of a
152
- // KEK-wrapped blob, so both server (re-wrap under the server KEK) and client (recover the DEK)
153
- // import it here rather than unwrapping.
152
+ // same trust boundary as unwrapPrivateKey's output). A federated-login path that moves the plaintext key
153
+ // over the wire (server-custody) needs both server (re-wrap under the server KEK) and client (recover
154
+ // the DEK) to import it here instead of unwrapping a KEK-wrapped blob.
154
155
  export async function importPrivateKeyPkcs8(pkcs8: Uint8Array): Promise<CryptoKey> {
155
156
  return subtle.importKey("pkcs8", toAB(pkcs8), EC_PARAMS, true, ["deriveKey", "deriveBits"]);
156
157
  }
package/kdf.ts CHANGED
@@ -1,23 +1,15 @@
1
- // The passphrase→key derivation shared by every passphrase-encrypted blob that builds on this
2
- // package.
1
+ // The passphrase→key derivation shared by every passphrase-encrypted blob this package produces.
3
2
  //
4
- // This derivation is deliberately factored out on its own, separate from any particular envelope
5
- // format: PBKDF2-SHA256, 200_000 iterations, AES-GCM-256, a 16-byte salt and a 12-byte IV. An
6
- // adopter building a second, unrelated encrypted-blob format alongside this package's own HD1
7
- // envelope (`crypto.ts`) should reuse this file rather than reimplementing the derivation
8
- // reimplementing it independently is exactly how a later decision to raise the iteration count
9
- // gets taken in one copy and silently not the other.
3
+ // This module derives a key from a passphrase and salt and stops there — it does not define an
4
+ // envelope format, version byte, or framing. A caller with its own envelope needs (see `crypto.ts`
5
+ // for this package's own HD1 format) keeps its magic bytes, version handling, and framing in its own
6
+ // module. A unified envelope would make one format's blob a syntactically valid input to another
7
+ // format's reader, which is a confusion this separation prevents for free. Sharing only the
8
+ // derivation, never the envelope, is the design, not an oversight to "fix" by merging.
10
9
  //
11
- // WHAT IS DELIBERATELY NOT SHARED: the envelope. HD1 keeps its own magic bytes, its own version
12
- // handling and its own framing in its own module a sibling format should do the same in its own.
13
- // A unified envelope would make a foreign blob a syntactically valid input to this package's vault
14
- // reader, which is a confusion this separation prevents for free. If you are here to "finish the
15
- // job" by merging formats — that is the bug this comment exists to stop.
16
- //
17
- // RUNTIME-AGNOSTIC BY CONSTRUCTION: this module imports nothing and touches no global. One caller is
18
- // Node-only (`node:crypto`'s webcrypto), the others run in the browser and in Workers, so the
19
- // `SubtleCrypto` is passed in rather than reached for. That also makes the parameters testable
20
- // without a runtime shim.
10
+ // RUNTIME-AGNOSTIC BY CONSTRUCTION: this module imports nothing and touches no global. Callers run
11
+ // in Node, the browser, and Workers alike, so the `SubtleCrypto` instance is passed in rather than
12
+ // reached for globally. That also makes the parameters testable without a runtime shim.
21
13
 
22
14
  import { toArrayBuffer } from "./bytes";
23
15
 
@@ -56,8 +48,8 @@ export async function deriveAesKey(
56
48
  }
57
49
 
58
50
  /**
59
- * PBKDF2-SHA256 → raw bits, for a derivation whose output is not itself a key — health-dash's
60
- * server-verifiable auth hash. Separate entry point rather than a flag on `deriveAesKey`, because a
51
+ * PBKDF2-SHA256 → raw bits, for a derivation whose output is not itself a key — this package's own
52
+ * server-verifiable auth hash (see `crypto.ts`'s `deriveAuthHash`). Separate entry point rather than a flag on `deriveAesKey`, because a
61
53
  * function that sometimes returns exportable bytes and sometimes an unexportable key is exactly the
62
54
  * kind of thing a reviewer has to read twice.
63
55
  */
package/key-store.ts CHANGED
@@ -1,10 +1,11 @@
1
- // Persist the account private key across a browser refresh so a valid session doesn't force
1
+ // Persist the account private key across a browser refresh so a valid session cookie doesn't force
2
2
  // re-auth. The key is stored as a NON-EXTRACTABLE CryptoKey (structured-cloned into IndexedDB): it can
3
3
  // still unwrap the vault DEK (ECDH deriveKey) on the next load, but its raw bytes can't be read back
4
4
  // out, so an XSS payload can't exfiltrate it (it could still USE it while the page is open — inherent
5
- // to any "stay signed in"). Password/passkey only; Google re-bootstraps from server-custody material.
5
+ // to any "stay signed in"). Password/passkey only; a federated-login method re-bootstraps from
6
+ // server-custody material instead.
6
7
 
7
- const DB_NAME = "hd-session";
8
+ const DB_NAME = "security-session";
8
9
  const STORE = "keys";
9
10
  const KEY_ID = "account-private-key";
10
11
  const EC_PARAMS = { name: "ECDH", namedCurve: "P-256" } as const;