@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.
package/auth-client.ts ADDED
@@ -0,0 +1,423 @@
1
+ // Browser-side orchestration for password signup/login. All crypto primitives
2
+ // live in ./crypto (do not reimplement); this file only wires them to fetch calls against
3
+ // an adopter's own password-auth endpoints. The server never sees a password, a KEK, or a DEK.
4
+ import {
5
+ generateAccountKeypair,
6
+ deriveKekFromPassword,
7
+ deriveAuthHash,
8
+ wrapPrivateKey,
9
+ unwrapPrivateKey,
10
+ generateDEK,
11
+ encryptVaultV2,
12
+ wrapDEKForPublicKey,
13
+ unwrapDEKWithPrivateKey,
14
+ kekFromPrfSecret,
15
+ importPrivateKeyPkcs8,
16
+ } from "./crypto";
17
+ import { startRegistration, startAuthentication, base64URLStringToBuffer } from "@simplewebauthn/browser";
18
+ import type {
19
+ PublicKeyCredentialCreationOptionsJSON,
20
+ PublicKeyCredentialRequestOptionsJSON,
21
+ } from "@simplewebauthn/browser";
22
+ import { bytesToB64 as bytesToBase64, b64ToBytes as base64ToBytes } from "./base64";
23
+ import type { ProviderKind } from "./stores";
24
+ export { bytesToB64 as bytesToBase64, b64ToBytes as base64ToBytes } from "./base64";
25
+
26
+ export const KDF_ITERATIONS = 200_000;
27
+ export const rand = (n: number) => globalThis.crypto.getRandomValues(new Uint8Array(n));
28
+
29
+ export function bytesToHex(bytes: Uint8Array): string {
30
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
31
+ }
32
+
33
+ export function hexToBytes(hex: string): Uint8Array {
34
+ const bytes = new Uint8Array(hex.length / 2);
35
+ for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
36
+ return bytes;
37
+ }
38
+
39
+ // The org-recovery public key, fetched pre-session (signup has no session yet; a public key
40
+ // is public regardless of who asks for it).
41
+ /**
42
+ * The error the server actually wrote, when it wrote one.
43
+ *
44
+ * Nearly every branch below threw `${what} failed: ${res.status}`, discarding a message the
45
+ * endpoint had already composed for exactly this moment ("that recovery code has expired", "this
46
+ * provider already has access", "no access to this vault") and showing an owner a bare number
47
+ * instead. These are the paths a person is on during the worst day they will have with this app, and
48
+ * a status code tells them nothing about whether to retry, wait, or ask someone.
49
+ *
50
+ * The status is kept in the fallback so a failure with no body is still diagnosable. Deliberately NOT
51
+ * used on the salt lookups: those return a decoy for an unknown address, so any detail there would be
52
+ * an answer about whether the account exists.
53
+ */
54
+ export async function failed(res: Response, what: string): Promise<Error> {
55
+ const body = (await res.json().catch(() => null)) as { error?: string } | null;
56
+ return new Error(body?.error?.trim() || `${what} (${res.status})`);
57
+ }
58
+
59
+ export async function getOrgKey(): Promise<{ orgAccountId: string; orgPublicKeyJwk: JsonWebKey }> {
60
+ const res = await fetch("/api/vault/org-key");
61
+ if (!res.ok) throw await failed(res, "org key failed");
62
+ return res.json();
63
+ }
64
+
65
+ // Everything below this point that authenticates a stranger — signup, both login paths, the Google
66
+ // bootstrap, and redeeming a recovery code — deliberately does NOT use `failed()`. A reason there is
67
+ // an answer about whether an address is registered, which is the enumeration oracle the decoy salt at
68
+ // /api/auth/salt exists to close — an adopter's own tests are where "the enumeration oracle stays
69
+ // closed" gets pinned. Once a session exists there is nobody left to enumerate to, and the server's
70
+ // message helps.
71
+ export async function signupPassword(
72
+ email: string,
73
+ displayName: string,
74
+ password: string
75
+ ): Promise<{ accountId: string; vaultId: string }> {
76
+ const { publicKeyJwk, privateKey } = await generateAccountKeypair();
77
+
78
+ const salt = rand(16);
79
+ const kek = await deriveKekFromPassword(password, salt);
80
+ const wrappedPrivateKey = await wrapPrivateKey(privateKey, kek);
81
+ const authHash = await deriveAuthHash(password, salt);
82
+
83
+ const dek = await generateDEK();
84
+ const vaultBlob = await encryptVaultV2({ clients: {} }, dek);
85
+ const ownerEnvelope = await wrapDEKForPublicKey(dek, publicKeyJwk);
86
+ const { orgPublicKeyJwk } = await getOrgKey();
87
+ const orgEnvelope = await wrapDEKForPublicKey(dek, orgPublicKeyJwk);
88
+
89
+ // No recovery credential at signup; the user mints one on demand from the Account menu.
90
+ const res = await fetch("/api/auth/password/signup", {
91
+ method: "POST",
92
+ headers: { "content-type": "application/json" },
93
+ body: JSON.stringify({
94
+ email,
95
+ displayName,
96
+ publicKeyJwk,
97
+ wrappedPrivateKey: bytesToBase64(wrappedPrivateKey),
98
+ kdfParams: { salt: bytesToHex(salt), iterations: KDF_ITERATIONS },
99
+ authHash,
100
+ vaultBlob: bytesToBase64(vaultBlob),
101
+ ownerEnvelope: {
102
+ wrappedDEK: bytesToBase64(ownerEnvelope.wrappedDEK),
103
+ ephemeralPublicKeyJwk: ownerEnvelope.ephemeralPublicKeyJwk,
104
+ },
105
+ orgEnvelope: {
106
+ wrappedDEK: bytesToBase64(orgEnvelope.wrappedDEK),
107
+ ephemeralPublicKeyJwk: orgEnvelope.ephemeralPublicKeyJwk,
108
+ },
109
+ }),
110
+ });
111
+ if (!res.ok) throw new Error(`signup failed: ${res.status}`);
112
+ const { accountId, vaultId } = (await res.json()) as { accountId: string; vaultId: string };
113
+ return { accountId, vaultId };
114
+ }
115
+
116
+ export async function loginPassword(
117
+ email: string,
118
+ password: string
119
+ ): Promise<{
120
+ accountId: string;
121
+ vaultId: string | null;
122
+ r2Key: string | null;
123
+ privateKey: CryptoKey;
124
+ dek: CryptoKey | null;
125
+ rotationPending: boolean;
126
+ }> {
127
+ // Salt is public, but the client needs it before it can derive authHash — look it up
128
+ // by email ahead of the login POST.
129
+ const saltRes = await fetch(`/api/auth/password/salt?email=${encodeURIComponent(email)}`);
130
+ // NOT "unknown account" any more. The endpoint returns a decoy salt for an address it does
131
+ // not know, precisely so the browser cannot report which addresses are registered; a non-200 here
132
+ // now means a malformed request or a server fault, and saying "unknown account" would both be
133
+ // wrong and re-open the oracle in the UI text.
134
+ if (!saltRes.ok) throw new Error(`sign-in is unavailable right now (${saltRes.status})`);
135
+ const { salt } = (await saltRes.json()) as { salt: string; iterations: number };
136
+
137
+ const authHash = await deriveAuthHash(password, hexToBytes(salt));
138
+
139
+ const res = await fetch("/api/auth/password/login", {
140
+ method: "POST",
141
+ headers: { "content-type": "application/json" },
142
+ body: JSON.stringify({ email, authHash }),
143
+ });
144
+ if (!res.ok) throw new Error(`login failed: ${res.status}`);
145
+ const data = (await res.json()) as {
146
+ accountId: string;
147
+ vaultId: string | null;
148
+ r2Key: string | null;
149
+ wrappedPrivateKey: string;
150
+ kdfParams: { salt: string; iterations: number };
151
+ ownerEnvelope: { wrappedDEK: string; ephemeralPublicKeyJwk: JsonWebKey } | null;
152
+ };
153
+
154
+ const kek = await deriveKekFromPassword(password, hexToBytes(data.kdfParams.salt));
155
+ const privateKey = await unwrapPrivateKey(base64ToBytes(data.wrappedPrivateKey), kek);
156
+ const dek = data.ownerEnvelope
157
+ ? await unwrapDEKWithPrivateKey(base64ToBytes(data.ownerEnvelope.wrappedDEK), data.ownerEnvelope.ephemeralPublicKeyJwk, privateKey)
158
+ : null;
159
+
160
+ return { accountId: data.accountId, vaultId: data.vaultId, r2Key: data.r2Key, privateKey, dek, rotationPending: (data as { rotationPending?: boolean }).rotationPending ?? false };
161
+ }
162
+
163
+ // Browser-side orchestration for passkey (WebAuthn + PRF) signup/login. Mirrors
164
+ // signupPassword/loginPassword above: all crypto stays in ./crypto, this file only wires it to
165
+ // startRegistration/startAuthentication and an adopter's own passkey-auth endpoints. The server
166
+ // never sees the PRF secret, a KEK, or a DEK.
167
+
168
+ // The server sends the PRF extension's `eval.first` as a base64url STRING (see webauthn.ts) —
169
+ // startRegistration/startAuthentication spread `extensions` straight into
170
+ // navigator.credentials.{create,get}() without decoding custom extensions, so we must convert
171
+ // it back into a BufferSource ourselves before calling them.
172
+ function preparePrfExtension<T extends { extensions?: unknown }>(optionsJSON: T): T {
173
+ const ext = optionsJSON.extensions as { prf?: { eval?: { first?: string } } } | undefined;
174
+ if (!ext?.prf?.eval?.first) return optionsJSON;
175
+ return {
176
+ ...optionsJSON,
177
+ extensions: { ...ext, prf: { eval: { first: base64URLStringToBuffer(ext.prf.eval.first) } } },
178
+ } as T;
179
+ }
180
+
181
+ function extractPrfSecret(clientExtensionResults: unknown): Uint8Array | null {
182
+ const first = (clientExtensionResults as { prf?: { results?: { first?: ArrayBuffer } } } | undefined)?.prf?.results?.first;
183
+ return first ? new Uint8Array(first) : null;
184
+ }
185
+
186
+ export async function signupPasskey(
187
+ email: string,
188
+ displayName: string
189
+ ): Promise<{ accountId: string; vaultId: string }> {
190
+ const optionsRes = await fetch("/api/auth/passkey/register/options", {
191
+ method: "POST",
192
+ headers: { "content-type": "application/json" },
193
+ body: JSON.stringify({ email, displayName }),
194
+ });
195
+ if (!optionsRes.ok) throw await failed(optionsRes, "register options failed");
196
+ const optionsJSON = (await optionsRes.json()) as PublicKeyCredentialCreationOptionsJSON;
197
+ const prfSaltB64 = (optionsJSON.extensions as { prf?: { eval?: { first?: string } } } | undefined)?.prf?.eval?.first;
198
+ if (!prfSaltB64) throw new Error("registration options missing the PRF extension");
199
+
200
+ const attestationResponse = await startRegistration({ optionsJSON: preparePrfExtension(optionsJSON) });
201
+
202
+ let prfSecret = extractPrfSecret(attestationResponse.clientExtensionResults);
203
+ if (!prfSecret) {
204
+ // Some authenticators don't evaluate PRF during create() — fall back to a local get()
205
+ // ceremony against the credential we just made, purely to read the PRF secret. This is not
206
+ // sent to the server; the create() attestation above is what gets verified server-side.
207
+ const fallbackOptions: PublicKeyCredentialRequestOptionsJSON = {
208
+ challenge: bytesToBase64(rand(16)),
209
+ allowCredentials: [{ id: attestationResponse.id, type: "public-key", transports: attestationResponse.response.transports }],
210
+ userVerification: "preferred",
211
+ extensions: { prf: { eval: { first: base64URLStringToBuffer(prfSaltB64) } } } as PublicKeyCredentialRequestOptionsJSON["extensions"],
212
+ };
213
+ const fallbackAssertion = await startAuthentication({ optionsJSON: fallbackOptions });
214
+ prfSecret = extractPrfSecret(fallbackAssertion.clientExtensionResults);
215
+ if (!prfSecret) throw new Error("authenticator does not support the PRF extension");
216
+ }
217
+
218
+ const kek = await kekFromPrfSecret(prfSecret);
219
+ const { publicKeyJwk, privateKey } = await generateAccountKeypair();
220
+ const wrappedPrivateKey = await wrapPrivateKey(privateKey, kek);
221
+
222
+ const dek = await generateDEK();
223
+ const vaultBlob = await encryptVaultV2({ clients: {} }, dek);
224
+ const ownerEnvelope = await wrapDEKForPublicKey(dek, publicKeyJwk);
225
+ const { orgPublicKeyJwk } = await getOrgKey();
226
+ const orgEnvelope = await wrapDEKForPublicKey(dek, orgPublicKeyJwk);
227
+
228
+ // No recovery credential at signup; minted on demand from the Account menu.
229
+ const verifyRes = await fetch("/api/auth/passkey/register/verify", {
230
+ method: "POST",
231
+ headers: { "content-type": "application/json" },
232
+ body: JSON.stringify({
233
+ attestationResponse,
234
+ publicKeyJwk,
235
+ wrappedPrivateKey: bytesToBase64(wrappedPrivateKey),
236
+ vaultBlob: bytesToBase64(vaultBlob),
237
+ ownerEnvelope: {
238
+ wrappedDEK: bytesToBase64(ownerEnvelope.wrappedDEK),
239
+ ephemeralPublicKeyJwk: ownerEnvelope.ephemeralPublicKeyJwk,
240
+ },
241
+ orgEnvelope: {
242
+ wrappedDEK: bytesToBase64(orgEnvelope.wrappedDEK),
243
+ ephemeralPublicKeyJwk: orgEnvelope.ephemeralPublicKeyJwk,
244
+ },
245
+ prfSaltHex: bytesToHex(new Uint8Array(base64URLStringToBuffer(prfSaltB64))),
246
+ }),
247
+ });
248
+ if (!verifyRes.ok) throw await failed(verifyRes, "register verify failed");
249
+ const { accountId, vaultId } = (await verifyRes.json()) as { accountId: string; vaultId: string };
250
+ return { accountId, vaultId };
251
+ }
252
+
253
+ export async function loginPasskey(
254
+ email: string
255
+ ): Promise<{ accountId: string; vaultId: string | null; r2Key: string | null; privateKey: CryptoKey; dek: CryptoKey | null; rotationPending: boolean }> {
256
+ const optionsRes = await fetch("/api/auth/passkey/login/options", {
257
+ method: "POST",
258
+ headers: { "content-type": "application/json" },
259
+ body: JSON.stringify({ email }),
260
+ });
261
+ if (!optionsRes.ok) throw new Error(`login options failed: ${optionsRes.status}`);
262
+ const optionsJSON = (await optionsRes.json()) as PublicKeyCredentialRequestOptionsJSON;
263
+
264
+ const authenticationResponse = await startAuthentication({ optionsJSON: preparePrfExtension(optionsJSON) });
265
+ const prfSecret = extractPrfSecret(authenticationResponse.clientExtensionResults);
266
+ if (!prfSecret) throw new Error("authenticator did not return a PRF secret");
267
+
268
+ const verifyRes = await fetch("/api/auth/passkey/login/verify", {
269
+ method: "POST",
270
+ headers: { "content-type": "application/json" },
271
+ body: JSON.stringify({ authenticationResponse }),
272
+ });
273
+ if (!verifyRes.ok) throw new Error(`login verify failed: ${verifyRes.status}`);
274
+ const data = (await verifyRes.json()) as {
275
+ accountId: string;
276
+ vaultId: string | null;
277
+ r2Key: string | null;
278
+ wrappedPrivateKey: string;
279
+ prfSalt: string;
280
+ ownerEnvelope: { wrappedDEK: string; ephemeralPublicKeyJwk: JsonWebKey } | null;
281
+ };
282
+
283
+ const kek = await kekFromPrfSecret(prfSecret);
284
+ const privateKey = await unwrapPrivateKey(base64ToBytes(data.wrappedPrivateKey), kek);
285
+ const dek = data.ownerEnvelope
286
+ ? await unwrapDEKWithPrivateKey(base64ToBytes(data.ownerEnvelope.wrappedDEK), data.ownerEnvelope.ephemeralPublicKeyJwk, privateKey)
287
+ : null;
288
+
289
+ return { accountId: data.accountId, vaultId: data.vaultId, r2Key: data.r2Key, privateKey, dek, rotationPending: (data as { rotationPending?: boolean }).rotationPending ?? false };
290
+ }
291
+
292
+ // Google login bootstrap. After the OAuth callback redirects to /?google=1 the session is
293
+ // already set; this fetches the server-unwrapped private key + owner envelope and recovers the DEK
294
+ // client-side. Returns the same shape as loginPassword so it flows straight into an adopter's own
295
+ // account-entry step. Unlike password/passkey, the private key is handed over by the server
296
+ // (server-custody) rather than unwrapped from a client-held secret.
297
+ export async function bootstrapGoogleSession(): Promise<{
298
+ accountId: string;
299
+ vaultId: string | null;
300
+ r2Key: string | null;
301
+ privateKey: CryptoKey;
302
+ dek: CryptoKey | null;
303
+ rotationPending: boolean;
304
+ }> {
305
+ const res = await fetch("/api/auth/google/session", { cache: "no-store" });
306
+ if (!res.ok) throw new Error(`google session failed: ${res.status}`);
307
+ const data = (await res.json()) as {
308
+ accountId: string;
309
+ vaultId: string | null;
310
+ r2Key: string | null;
311
+ rotationPending: boolean;
312
+ privateKeyPkcs8: string;
313
+ ownerEnvelope: { wrappedDEK: string; ephemeralPublicKeyJwk: JsonWebKey } | null;
314
+ };
315
+ const privateKey = await importPrivateKeyPkcs8(base64ToBytes(data.privateKeyPkcs8));
316
+ const dek = data.ownerEnvelope
317
+ ? await unwrapDEKWithPrivateKey(base64ToBytes(data.ownerEnvelope.wrappedDEK), data.ownerEnvelope.ephemeralPublicKeyJwk, privateKey)
318
+ : null;
319
+ return { accountId: data.accountId, vaultId: data.vaultId, r2Key: data.r2Key, privateKey, dek, rotationPending: data.rotationPending };
320
+ }
321
+
322
+ // Plain-refresh resume (password/passkey). Session-gated read of the vault location + owner DEK
323
+ // envelope; the client already holds the account private key (non-extractable, from IndexedDB) and
324
+ // unwraps the DEK locally. Returns null when there's no valid session (401) so the caller can fall
325
+ // back to the lock screen. Does NOT return the wrapped private key — the key never leaves the device.
326
+ export async function resumeSession(): Promise<{
327
+ accountId: string;
328
+ vaultId: string | null;
329
+ r2Key: string | null;
330
+ rotationPending: boolean;
331
+ providerKind: ProviderKind | null;
332
+ ownerEnvelope: { wrappedDEK: string; ephemeralPublicKeyJwk: JsonWebKey } | null;
333
+ } | null> {
334
+ const res = await fetch("/api/auth/session/resume", { cache: "no-store" });
335
+ if (res.status === 401) return null;
336
+ if (!res.ok) throw await failed(res, "resume failed");
337
+ return res.json();
338
+ }
339
+
340
+ // Finish adding Google to the signed-in account. The OAuth popup already set the signed
341
+ // link cookie (bound to this account + the verified sub); here we hand the server the in-memory
342
+ // private key so it can wrap it under the server KEK. The sub is trusted from the cookie, not us.
343
+ export async function addGoogleMethod(privateKey: CryptoKey, currentPassword?: string, email?: string): Promise<void> {
344
+ const currentAuthHash = await currentAuthHashFor(email, currentPassword);
345
+ const pkcs8 = new Uint8Array(await globalThis.crypto.subtle.exportKey("pkcs8", privateKey));
346
+ const res = await fetch("/api/account/methods/google", {
347
+ method: "POST",
348
+ headers: { "content-type": "application/json" },
349
+ body: JSON.stringify({ privateKeyPkcs8: bytesToBase64(pkcs8), ...(currentAuthHash ? { currentAuthHash } : {}) }),
350
+ });
351
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `add google failed: ${res.status}`);
352
+ }
353
+
354
+ export async function getMyAccount(): Promise<{ id: string; email: string | null; emailConfirmed: boolean; displayName: string; providerKind: ProviderKind | null; unitSystem: "metric" | "imperial" | null }> {
355
+ const res = await fetch("/api/account", { cache: "no-store" });
356
+ if (!res.ok) throw await failed(res, "account fetch failed");
357
+ return res.json();
358
+ }
359
+
360
+ // Account settings. Change email/display name; add/remove a login method (password/passkey).
361
+ // Adding a method re-wraps the account's in-memory private key under the new method's KEK — same
362
+ // zero-knowledge model as signup; the server never sees the key.
363
+
364
+ /**
365
+ * The step-up proof, derived the same way the login path derives its authHash.
366
+ *
367
+ * Shared by all three add-a-method calls. Adding a passkey or a Google identity used to need only a
368
+ * session cookie, which meant a captured cookie could mint a credential that outlived it; the server
369
+ * now challenges those paths too, so all three send the same proof.
370
+ *
371
+ * Returns undefined when there is nothing to prove — an account with no password cannot be challenged,
372
+ * and the server treats that case as allowed-and-notified rather than refusing it.
373
+ */
374
+ export async function currentAuthHashFor(email?: string, currentPassword?: string): Promise<string | undefined> {
375
+ if (!currentPassword || !email) return undefined;
376
+ const saltRes = await fetch(`/api/auth/password/salt?email=${encodeURIComponent(email)}`);
377
+ if (!saltRes.ok) throw new Error(`could not verify your current password (${saltRes.status})`);
378
+ const { salt: currentSalt } = (await saltRes.json()) as { salt: string };
379
+ return deriveAuthHash(currentPassword, hexToBytes(currentSalt));
380
+ }
381
+
382
+ export async function addPasskeyMethod(privateKey: CryptoKey, currentPassword?: string, email?: string): Promise<void> {
383
+ const currentAuthHash = await currentAuthHashFor(email, currentPassword);
384
+ const optionsRes = await fetch("/api/account/methods/passkey/options", { method: "POST" });
385
+ if (!optionsRes.ok) throw await failed(optionsRes, "passkey options failed");
386
+ const optionsJSON = (await optionsRes.json()) as PublicKeyCredentialCreationOptionsJSON;
387
+ const prfSaltB64 = (optionsJSON.extensions as { prf?: { eval?: { first?: string } } } | undefined)?.prf?.eval?.first;
388
+ if (!prfSaltB64) throw new Error("registration options missing the PRF extension");
389
+
390
+ const attestationResponse = await startRegistration({ optionsJSON: preparePrfExtension(optionsJSON) });
391
+ let prfSecret = extractPrfSecret(attestationResponse.clientExtensionResults);
392
+ if (!prfSecret) {
393
+ // Some authenticators don't evaluate PRF during create() — read it via a local get() (not sent).
394
+ const fallbackAssertion = await startAuthentication({
395
+ optionsJSON: {
396
+ challenge: bytesToBase64(rand(16)),
397
+ allowCredentials: [{ id: attestationResponse.id, type: "public-key", transports: attestationResponse.response.transports }],
398
+ userVerification: "preferred",
399
+ extensions: { prf: { eval: { first: base64URLStringToBuffer(prfSaltB64) } } } as PublicKeyCredentialRequestOptionsJSON["extensions"],
400
+ },
401
+ });
402
+ prfSecret = extractPrfSecret(fallbackAssertion.clientExtensionResults);
403
+ if (!prfSecret) throw new Error("authenticator does not support the PRF extension");
404
+ }
405
+
406
+ const kek = await kekFromPrfSecret(prfSecret);
407
+ const wrappedPrivateKey = await wrapPrivateKey(privateKey, kek);
408
+ const verifyRes = await fetch("/api/account/methods/passkey/verify", {
409
+ method: "POST",
410
+ headers: { "content-type": "application/json" },
411
+ body: JSON.stringify({
412
+ attestationResponse,
413
+ wrappedPrivateKey: bytesToBase64(wrappedPrivateKey),
414
+ prfSaltHex: bytesToHex(new Uint8Array(base64URLStringToBuffer(prfSaltB64))),
415
+ ...(currentAuthHash ? { currentAuthHash } : {}),
416
+ }),
417
+ });
418
+ if (verifyRes.status === 401) {
419
+ const { error } = (await verifyRes.json().catch(() => ({ error: "" }))) as { error?: string };
420
+ throw new Error(error || "enter your current password to add a passkey");
421
+ }
422
+ if (!verifyRes.ok) throw await failed(verifyRes, "add passkey failed");
423
+ }
package/auth-grants.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { wrapDEKForPublicKey } from "./crypto";
2
+ import { bytesToBase64, failed } from "./auth-client";
3
+ import type { ProviderKind } from "./stores";
4
+
5
+ // Provider escrow (owner side). A logged-in owner grants a provider access by wrapping
6
+ // their in-memory DEK to the provider's public key client-side and posting the opaque envelope; the
7
+ // server never sees a plaintext DEK. Revoke deletes the envelope + marks the link revoked.
8
+
9
+ export interface ProviderLinkView {
10
+ linkId: string;
11
+ providerAccountId: string;
12
+ displayName: string;
13
+ kind: ProviderKind;
14
+ status: "invited" | "active" | "revoked";
15
+ expiresAt: string | null;
16
+ publicKeyJwk?: JsonWebKey | null; // present only for a pending (invited) support request
17
+ }
18
+
19
+ export async function listMyProviders(): Promise<ProviderLinkView[]> {
20
+ const res = await fetch("/api/providers", { cache: "no-store" });
21
+ if (!res.ok) throw await failed(res, "list providers failed");
22
+ return ((await res.json()) as { providers: ProviderLinkView[] }).providers;
23
+ }
24
+
25
+ // Resolve a provider by email to their public key; null if no such provider (uniform 404).
26
+ export async function lookupProvider(
27
+ email: string
28
+ ): Promise<{ providerAccountId: string; displayName: string; providerKind: string; publicKeyJwk: JsonWebKey } | null> {
29
+ const res = await fetch(`/api/providers/lookup?email=${encodeURIComponent(email)}`);
30
+ if (res.status === 404) return null;
31
+ if (!res.ok) throw await failed(res, "lookup failed");
32
+ return res.json();
33
+ }
34
+
35
+ // Grant a looked-up provider access to the caller's vault. `dek` is the owner's in-memory DEK.
36
+ export async function grantProvider(
37
+ dek: CryptoKey,
38
+ provider: { providerAccountId: string; publicKeyJwk: JsonWebKey }
39
+ ): Promise<void> {
40
+ const env = await wrapDEKForPublicKey(dek, provider.publicKeyJwk);
41
+ const res = await fetch("/api/providers/grant", {
42
+ method: "POST",
43
+ headers: { "content-type": "application/json" },
44
+ body: JSON.stringify({
45
+ providerAccountId: provider.providerAccountId,
46
+ wrappedDEK: bytesToBase64(env.wrappedDEK),
47
+ ephemeralPublicKeyJwk: env.ephemeralPublicKeyJwk,
48
+ }),
49
+ });
50
+ if (!res.ok) throw await failed(res, "grant failed");
51
+ }
52
+
53
+ export async function revokeProvider(linkId: string): Promise<void> {
54
+ const res = await fetch(`/api/providers/${encodeURIComponent(linkId)}`, { method: "DELETE" });
55
+ if (!res.ok) throw await failed(res, "revoke failed");
56
+ }