@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/README.md +53 -39
- package/adapters/conformance.ts +9 -9
- package/adapters/d1/accounts.ts +1 -1
- package/adapters/d1/audit.ts +8 -5
- package/adapters/d1/credentials.ts +2 -2
- package/adapters/d1/index.ts +5 -5
- package/adapters/d1/providers.ts +14 -10
- package/adapters/memory.ts +6 -6
- package/adapters/r2.ts +4 -5
- package/auth-client.ts +423 -0
- package/auth-grants.ts +56 -0
- package/auth-recovery.ts +360 -0
- package/auth-support.ts +118 -0
- package/base64.ts +15 -0
- package/break-glass.ts +9 -10
- package/crypto.ts +20 -19
- package/envelope-access.ts +1 -1
- package/kdf.ts +12 -20
- package/key-store.ts +4 -3
- package/org-recovery.ts +19 -0
- package/package.json +6 -3
- package/stores.ts +14 -15
- package/vault-session.ts +59 -0
- package/vault-sink.ts +25 -27
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
|
+
}
|