@tinytars/vault 0.1.16 → 0.1.18

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 provider who already holds the owner'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
+ * Provider side. Unwraps the owner's DEK with the provider'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
+ ownerAccountId: 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
+ ownerAccountId,
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
+ * Owner 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 owner 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 vault owner 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
+ // Owner 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 SupportOwner {
20
+ ownerAccountId: string;
21
+ displayName: string;
22
+ expiresAt: string | null;
23
+ }
24
+
25
+ export async function requestSupportAccess(ownerEmail: string): Promise<void> {
26
+ const res = await fetch("/api/support/request", {
27
+ method: "POST",
28
+ headers: { "content-type": "application/json" },
29
+ body: JSON.stringify({ ownerEmail }),
30
+ });
31
+ if (!res.ok) throw await failed(res, "request failed");
32
+ }
33
+
34
+ export async function listSupportOwners(): Promise<SupportOwner[]> {
35
+ const res = await fetch("/api/support/owners", { cache: "no-store" });
36
+ if (!res.ok) throw await failed(res, "support owners failed");
37
+ return ((await res.json()) as { owners: SupportOwner[] }).owners;
38
+ }
39
+
40
+ // Enter an owner's vault (audited server-side); returns the envelope for client-side DEK unwrap.
41
+ export async function enterSupportOwner(ownerAccountId: string): Promise<{
42
+ ownerAccountId: 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({ ownerAccountId }),
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 primary provider too (same
59
+ // /api/support/request, which classifies by the target's kind). The primary provider approves (metadata
60
+ // only, no DEK), then support sees their roster and can open the owners 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 SupportRosterOwner {
71
+ ownerAccountId: 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: "owner" | "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<SupportRosterOwner[]> {
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: SupportRosterOwner[] }).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
 
@@ -27,7 +26,7 @@ export interface BreakGlassGrantStores {
27
26
 
28
27
  /**
29
28
  * Approves a pending support-role link: validates it's the approver's own pending request, clamps the
30
- * requested TTL, stamps a consent ref, optionally writes an envelope (patient approvals only — a
29
+ * requested TTL, stamps a consent ref, optionally writes an envelope (owner approvals only — a
31
30
  * provider approving a roster request owns nothing encrypted), flips the link active, and audits.
32
31
  */
33
32
  export async function grantBreakGlass(
@@ -44,7 +43,7 @@ export async function grantBreakGlass(
44
43
  }
45
44
  ): Promise<BreakGlassGrantResult> {
46
45
  const link = await stores.links.get(opts.linkId);
47
- if (!link || link.patientAccountId !== opts.approverAccountId || link.role !== "support" || link.status !== "invited") {
46
+ if (!link || link.ownerAccountId !== opts.approverAccountId || link.role !== "support" || link.status !== "invited") {
48
47
  return { ok: false, error: "no_pending_link" };
49
48
  }
50
49
 
@@ -125,10 +124,10 @@ export interface BreakGlassRevokeStores {
125
124
  }
126
125
 
127
126
  /**
128
- * Ends a link early — either side may call this (the patient revoking, or the provider dropping it).
127
+ * Ends a link early — either side may call this (the owner revoking, or the provider dropping it).
129
128
  * Deletes the provider's envelope (if any) so no new read can unwrap the DEK, and marks the link
130
129
  * revoked. Idempotent: a second call re-deletes (no-op) and re-marks revoked (no-op); `auditAction` is
131
- * omitted for link kinds that don't carry a disclosure-audit obligation (clinician links).
130
+ * omitted for link kinds that don't carry a disclosure-audit obligation (primary links).
132
131
  */
133
132
  export async function revokeBreakGlass(
134
133
  stores: BreakGlassRevokeStores,
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
  }
@@ -8,7 +8,7 @@ export interface EnvelopeAccessSource {
8
8
  getVault(vaultId: string): Promise<VaultRow | null>;
9
9
  }
10
10
  export interface ProviderLinkSource {
11
- getActive(patientAccountId: string, providerAccountId: string): Promise<ProviderLink | null>;
11
+ getActive(ownerAccountId: string, providerAccountId: string): Promise<ProviderLink | null>;
12
12
  }
13
13
 
14
14
  /**