@o3co/auth-provider-passkey 0.0.0 → 0.18.0-rc.1

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.
Files changed (54) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +2 -3
  3. package/mfa-webauthn/README.md +24 -0
  4. package/mfa-webauthn/src/kind.d.ts +12 -0
  5. package/mfa-webauthn/src/kind.js +105 -0
  6. package/mfa-webauthn/src/module.d.ts +2 -0
  7. package/mfa-webauthn/src/module.js +16 -0
  8. package/mfa-webauthn/src/name.d.ts +5 -0
  9. package/mfa-webauthn/src/name.js +7 -0
  10. package/mfa-webauthn/src/section.d.ts +10 -0
  11. package/mfa-webauthn/src/section.js +10 -0
  12. package/package.json +57 -3
  13. package/passkey-registration/README.md +27 -0
  14. package/passkey-registration/src/module.d.ts +2 -0
  15. package/passkey-registration/src/module.js +11 -0
  16. package/passkey-registration/src/routes.d.ts +13 -0
  17. package/passkey-registration/src/routes.js +84 -0
  18. package/passkey-registration/src/section.d.ts +6 -0
  19. package/passkey-registration/src/section.js +17 -0
  20. package/webauthn/README.md +47 -0
  21. package/webauthn/config.example +7 -0
  22. package/webauthn/src/ceremony/README.md +24 -0
  23. package/webauthn/src/ceremony/ceremony.d.ts +11 -0
  24. package/webauthn/src/ceremony/ceremony.js +190 -0
  25. package/webauthn/src/contract.d.ts +90 -0
  26. package/webauthn/src/contract.js +6 -0
  27. package/webauthn/src/module.d.ts +2 -0
  28. package/webauthn/src/module.js +35 -0
  29. package/webauthn/src/passkeys.d.ts +10 -0
  30. package/webauthn/src/passkeys.js +49 -0
  31. package/webauthn/src/records.d.ts +47 -0
  32. package/webauthn/src/records.js +188 -0
  33. package/webauthn/src/registration/README.md +19 -0
  34. package/webauthn/src/registration/registrations.d.ts +14 -0
  35. package/webauthn/src/registration/registrations.js +31 -0
  36. package/webauthn/src/section.d.ts +15 -0
  37. package/webauthn/src/section.js +32 -0
  38. package/webauthn/src/store.d.ts +55 -0
  39. package/webauthn/src/store.js +17 -0
  40. package/webauthn/src/testing.d.ts +147 -0
  41. package/webauthn/src/testing.js +354 -0
  42. package/webauthn-login/README.md +39 -0
  43. package/webauthn-login/src/audit-events.d.ts +7 -0
  44. package/webauthn-login/src/audit-events.js +7 -0
  45. package/webauthn-login/src/contract.d.ts +3 -0
  46. package/webauthn-login/src/contract.js +3 -0
  47. package/webauthn-login/src/grant.d.ts +14 -0
  48. package/webauthn-login/src/grant.js +95 -0
  49. package/webauthn-login/src/module.d.ts +2 -0
  50. package/webauthn-login/src/module.js +24 -0
  51. package/webauthn-login/src/options.d.ts +5 -0
  52. package/webauthn-login/src/options.js +29 -0
  53. package/webauthn-login/src/section.d.ts +6 -0
  54. package/webauthn-login/src/section.js +6 -0
@@ -0,0 +1,17 @@
1
+ import { z } from "zod";
2
+ export const section = z.strictObject({
3
+ requests: z
4
+ .number()
5
+ .int()
6
+ .min(1)
7
+ .max(100_000)
8
+ .default(60)
9
+ .describe("Requests allowed per source in each window, over both registration routes."),
10
+ "window-seconds": z
11
+ .number()
12
+ .int()
13
+ .min(1)
14
+ .max(86_400)
15
+ .default(300)
16
+ .describe("The window, in seconds, that requests are counted over."),
17
+ });
@@ -0,0 +1,47 @@
1
+ # webauthn
2
+
3
+ ## Responsibilities
4
+ - Runs WebAuthn ceremonies: options, response verification, the signature count and the user handle, by its internal module `ceremony`.
5
+ - Keeps passkeys: each subject's user handle, and the credentials registered for it; its internal module `registration` registers them.
6
+ - Answers assertions by the passkeys it keeps, and assertions whose challenge and credential the caller keeps.
7
+
8
+ ## Not here
9
+ - What an assertion leads to, and the amr it adds (the login and second-factor modules). Issuing tokens. Second-factor credentials (`mfa` keeps them). The routes that register passkeys, and who may register.
10
+
11
+ ## Offers
12
+ - `ceremonies`: creation options (a discoverable credential unless asked otherwise) and attestation, request options and assertion. The caller keeps the challenge and the credential and hands them over once.
13
+ - Options also answer the challenge's end, `until`: now on `kernel`'s clock plus `challenge-seconds`.
14
+ - An answer at or past an `until` handed back is `challenge`; with none, the caller judges the lifetime.
15
+ - `passkeys`: a challenge the same for everyone, made for a requester, and the assertion of a discoverable passkey by that requester → subject, challenge time, user verified, backup eligible; or a refusal.
16
+ - `registrations`: options for a subject's new passkey, and keeping the one a response creates → the credential, or a refusal (the ceremony's, `unreadable`, `no-challenge`, `conflict`, `full`).
17
+ - The caller answers for who the subject is; webauthn takes it as given, so only a module that has authenticated the subject may ask.
18
+ - `credentialOf`: a value read as a credential in the shape webauthn writes, with its fields alone and the canonical checks and limits of its id, key and handle; nothing for any other value.
19
+ - `freshHandle`: a new user handle from the randomness given, by the rule webauthn makes its own by.
20
+ - Store interfaces, which the assembly wires. The store never sees what a record holds.
21
+ - Passkeys: credentials and subjects, each by a key webauthn makes, saved once, read, changed from what they hold, kept without end. Needs: no eviction, survives a crash, never rolled back by failover, confirmed reads.
22
+ - Challenges: opened for a lifetime, taken by putting a mark in place. Needs: survives a crash, never rolled back by failover, names derived from the challenge.
23
+ - Operator section: `rp-id`, `rp-name`, `origins` (required), `top-origins` (default none), `challenge-seconds` (default 120), `user-verification` (default `preferred`), `max-credentials` (per subject, default 10).
24
+ - Test configuration builders, the contract tests of both store interfaces, and a software authenticator behind a browser (`./testing`), which answers options as a browser does and takes twists for refusal tests.
25
+
26
+ ## Invariants
27
+ - A challenge is 32 random bytes, taken once, within its lifetime on `kernel`'s clock, whatever the store's expiry. A taken mark stays for the rest of it.
28
+ - An assertion's challenge is redeemed by its requester alone; any other is `challenge`, after the challenge is taken. Its record keeps the requester's SHA-256, never the requester.
29
+ - Origins match the list exactly: https, or http on `localhost`; never an IP literal. An Android app origin is `android:apk-key-hash:` and the unpadded base64url SHA-256 of its signing certificate, never a top origin. A reported top origin not listed is refused, and with none listed any frame is, for registration and assertion alike. The relying party ID must match; boot refuses one that is not each web origin's host or a domain it lies under. Algorithms are EdDSA, ES256 and RS256.
30
+ - The count is compared after the signature verified, and must go up (0 after 0 passes). It is written only over the count judged; a lost race is `raced`.
31
+ - `registrations`' creation options require a discoverable credential, as `passkeys` lists none to ask for.
32
+ - `passkeys` refuses an assertion without a user handle before any store is read. The handle must be its holder's, judged after the signature.
33
+ - A user handle is 32 random bytes per subject, carrying neither the subject nor a name. Handles and credential ids are written and compared only as unpadded base64url; a credential that would not read back is refused before a write. Transports are kept only as a short list of short strings. A record read in another shape is unavailable.
34
+ - A credential id its holder lists is refused at registration. One no holder lists, left by a registration that failed between its two writes or found its subject full, is taken over. Options exclude the subject's own.
35
+ - Registration asks for no attestation, so it takes the public key as sent: nothing proves the client holds its private key until the first assertion.
36
+ - `conflict` tells the caller that some subject lists an id. Ids are high-entropy, made by the authenticator, so this answers only for an id already known.
37
+ - `required` refuses a response without user verification. A backup eligibility other than the registered one is refused.
38
+ - A store that cannot tell is unavailable, never a refusal.
39
+
40
+ ## Data
41
+ - Passkeys and subjects through the passkey store; challenges through the challenge store.
42
+
43
+ ## Depends on
44
+ - `kernel`
45
+
46
+ ## Vocabulary
47
+ - holder: a subject's user handle and the ids of its passkeys. passkey: a credential this module keeps for a subject.
@@ -0,0 +1,7 @@
1
+ webauthn {
2
+ rp-id = login.example.invalid
3
+ rp-name = "Example"
4
+ origins = [
5
+ "https://login.example.invalid"
6
+ ]
7
+ }
@@ -0,0 +1,24 @@
1
+ # ceremony
2
+
3
+ Internal module of `webauthn`: the WebAuthn ceremonies, over a pinned version of `@simplewebauthn/server`.
4
+
5
+ ## Responsibilities
6
+ - Makes creation and request options, and verifies attestations and assertions.
7
+
8
+ ## Not here
9
+ - Where challenges and credentials are kept, and which subject they belong to (the package).
10
+
11
+ ## Offers
12
+ - `ceremoniesFor` (relying party, randomness, clock) → the package's `Ceremonies`. No library type crosses it.
13
+ - `challengeOf` (response) → the challenge the client data names, read by the library's decoder, the one that verifies it.
14
+
15
+ ## Invariants
16
+ - A challenge is 32 random bytes. Options list the three algorithms, ask for no attestation, and require a discoverable credential unless the caller asks for one that need not be.
17
+ - A challenge ends the relying party's timeout after it was made.
18
+ - Before the library runs: an end given that the clock has reached is `challenge`; client data the library cannot read is `malformed`; a challenge other than the one given is `challenge`; a reported top origin that is not listed, or not in a frame, is `top-origin`, and so is any frame when none is listed.
19
+ - The library's refusals map by how its messages start; any other is `malformed`. The library never judges the count (it is handed 0).
20
+ - After the signature: a handle carried must be the credential's (none carried passes), the backup eligibility must be the credential's, and the count must go up (0 after 0 passes).
21
+ - An assertion by a credential other than the one given is `malformed`.
22
+
23
+ ## Depends on
24
+ - `@simplewebauthn/server`; the package's contract
@@ -0,0 +1,11 @@
1
+ import type { Ceremonies } from "../contract.js";
2
+ export interface RelyingParty {
3
+ readonly id: string;
4
+ readonly name: string;
5
+ readonly origins: readonly string[];
6
+ readonly topOrigins: readonly string[];
7
+ readonly timeoutMs: number;
8
+ }
9
+ /** The challenge a response answers, read as its verification reads it. */
10
+ export declare function challengeOf(response: unknown): string | undefined;
11
+ export declare function ceremoniesFor(rp: RelyingParty, random: (n: number) => Uint8Array, now: () => number): Ceremonies;
@@ -0,0 +1,190 @@
1
+ import { verifyAuthenticationResponse, verifyRegistrationResponse } from "@simplewebauthn/server";
2
+ import { decodeClientDataJSON } from "@simplewebauthn/server/helpers";
3
+ /** EdDSA, ES256 and RS256, as COSE names them. */
4
+ const algorithms = [-8, -7, -257];
5
+ const base64url = (bytes) => Buffer.from(bytes).toString("base64url");
6
+ /** The client data as the library decodes it; none when that is not a JSON object. */
7
+ function clientDataOf(response) {
8
+ const encoded = response?.response
9
+ ?.clientDataJSON;
10
+ if (typeof encoded !== "string")
11
+ return undefined;
12
+ try {
13
+ const data = decodeClientDataJSON(encoded);
14
+ return typeof data === "object" && data !== null && !Array.isArray(data)
15
+ ? data
16
+ : undefined;
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ }
22
+ /** The challenge a response answers, read as its verification reads it. */
23
+ export function challengeOf(response) {
24
+ const challenge = clientDataOf(response)?.challenge;
25
+ return typeof challenge === "string" ? challenge : undefined;
26
+ }
27
+ /** The library's messages for a refusal, by how they start; any other refusal is `malformed`. */
28
+ const refusals = [
29
+ ["Unexpected public key alg ", "algorithm"],
30
+ ["Unexpected registration response origin ", "origin"],
31
+ ["Unexpected authentication response origin ", "origin"],
32
+ ["Unexpected registration response challenge ", "challenge"],
33
+ ["Unexpected authentication response challenge ", "challenge"],
34
+ ["Unexpected RP ID hash", "rp"],
35
+ ["User verification", "user-verification"],
36
+ ];
37
+ const refusalOf = (error) => refusals.find(([start]) => error instanceof Error && error.message.startsWith(start))?.[1] ??
38
+ "malformed";
39
+ const refused = (reason) => ({ kind: "refused", reason });
40
+ /** The library's answer, or the refusal its error names. */
41
+ async function libraryAnswer(verify) {
42
+ try {
43
+ return await verify();
44
+ }
45
+ catch (error) {
46
+ return refusalOf(error);
47
+ }
48
+ }
49
+ // The client's transports, which the library passes on unchecked: a short list of short strings.
50
+ const hintOf = (value) => Array.isArray(value) &&
51
+ value.length <= 8 &&
52
+ value.every((t) => typeof t === "string" && t.length <= 32)
53
+ ? [...value]
54
+ : undefined;
55
+ export function ceremoniesFor(rp, random, now) {
56
+ const fresh = () => ({ challenge: base64url(random(32)), until: now() + rp.timeoutMs });
57
+ /** Before the library: a challenge past its end or other than the one given, and a frame not listed. */
58
+ const precheck = (response, challenge, until) => {
59
+ if (until !== undefined && now() >= until)
60
+ return "challenge";
61
+ const data = clientDataOf(response);
62
+ if (!data)
63
+ return "malformed";
64
+ if (data.challenge !== challenge)
65
+ return "challenge";
66
+ // With no page listed, a frame is refused even when the browser names no page.
67
+ if (data.crossOrigin === true && rp.topOrigins.length === 0)
68
+ return "top-origin";
69
+ if (data.topOrigin === undefined)
70
+ return undefined;
71
+ const listed = typeof data.topOrigin === "string" && rp.topOrigins.includes(data.topOrigin);
72
+ return data.crossOrigin === true && listed ? undefined : "top-origin";
73
+ };
74
+ const expected = (challenge, v) => ({
75
+ expectedChallenge: challenge,
76
+ expectedOrigin: [...rp.origins],
77
+ expectedRPID: rp.id,
78
+ requireUserVerification: v === "required",
79
+ });
80
+ return {
81
+ creation: (user, exclude, v, asked = {}) => {
82
+ const discoverable = asked.discoverable ?? true;
83
+ const { challenge, until } = fresh();
84
+ const options = {
85
+ rp: { id: rp.id, name: rp.name },
86
+ user: { id: user.handle, name: user.name, displayName: user.name },
87
+ challenge,
88
+ pubKeyCredParams: algorithms.map((alg) => ({ alg, type: "public-key" })),
89
+ timeout: rp.timeoutMs,
90
+ attestation: "none",
91
+ excludeCredentials: exclude.map((id) => ({ id, type: "public-key" })),
92
+ // Passkeys are asked for with no credential listed, which reaches only a discoverable one.
93
+ authenticatorSelection: {
94
+ residentKey: discoverable ? "required" : "discouraged",
95
+ requireResidentKey: discoverable,
96
+ userVerification: v,
97
+ },
98
+ };
99
+ return { options, challenge, until };
100
+ },
101
+ attest: async (response, challenge, handle, v, until) => {
102
+ const early = precheck(response, challenge, until);
103
+ if (early)
104
+ return refused(early);
105
+ const verified = await libraryAnswer(() => verifyRegistrationResponse({
106
+ // The library checks the shape it reads.
107
+ response: response,
108
+ ...expected(challenge, v),
109
+ supportedAlgorithmIDs: algorithms,
110
+ }));
111
+ if (typeof verified === "string")
112
+ return refused(verified);
113
+ const info = verified.registrationInfo;
114
+ if (!verified.verified || !info)
115
+ return refused("signature");
116
+ const transports = hintOf(info.credential.transports);
117
+ return {
118
+ kind: "attested",
119
+ credential: {
120
+ id: info.credential.id,
121
+ publicKey: base64url(info.credential.publicKey),
122
+ signCount: info.credential.counter,
123
+ userHandle: handle,
124
+ backupEligible: info.credentialDeviceType === "multiDevice",
125
+ backedUp: info.credentialBackedUp,
126
+ ...(transports ? { transports } : {}),
127
+ },
128
+ };
129
+ },
130
+ request: (allow, v) => {
131
+ const { challenge, until } = fresh();
132
+ const options = {
133
+ challenge,
134
+ timeout: rp.timeoutMs,
135
+ rpId: rp.id,
136
+ allowCredentials: allow.map((c) => ({
137
+ id: c.id,
138
+ type: "public-key",
139
+ ...(c.transports ? { transports: c.transports } : {}),
140
+ })),
141
+ userVerification: v,
142
+ };
143
+ return { options, challenge, until };
144
+ },
145
+ assert: async (response, challenge, credential, v, until) => {
146
+ const early = precheck(response, challenge, until);
147
+ if (early)
148
+ return refused(early);
149
+ const answered = response;
150
+ if (answered.id !== credential.id)
151
+ return refused("malformed");
152
+ const verified = await libraryAnswer(() => verifyAuthenticationResponse({
153
+ response: answered,
154
+ ...expected(challenge, v),
155
+ // The frame was judged above; the library asks for the list to accept one at all.
156
+ ...(rp.topOrigins.length > 0 ? { expectedTopOrigin: [...rp.topOrigins] } : {}),
157
+ // 0, so the library never judges the count before the signature; it is judged below.
158
+ credential: {
159
+ id: credential.id,
160
+ publicKey: new Uint8Array(Buffer.from(credential.publicKey, "base64url")),
161
+ counter: 0,
162
+ },
163
+ }));
164
+ if (typeof verified === "string")
165
+ return refused(verified);
166
+ if (!verified.verified)
167
+ return refused("signature");
168
+ return afterSignature(credential, verified.authenticationInfo, answered.response.userHandle);
169
+ },
170
+ };
171
+ }
172
+ /** What a verified signature still has to meet: the handle, the backup eligibility, the count. */
173
+ function afterSignature(credential, info, userHandle) {
174
+ // None carried passes: a credential that is not discoverable may carry none.
175
+ if (userHandle !== undefined && userHandle !== null && userHandle !== credential.userHandle) {
176
+ return refused("user-handle");
177
+ }
178
+ if ((info.credentialDeviceType === "multiDevice") !== credential.backupEligible) {
179
+ return refused("backup-eligibility");
180
+ }
181
+ // An authenticator that keeps no count answers 0 every time.
182
+ const kept = info.newCounter === 0 && credential.signCount === 0;
183
+ if (!kept && info.newCounter <= credential.signCount)
184
+ return refused("sign-count");
185
+ return {
186
+ kind: "asserted",
187
+ credential: { ...credential, signCount: info.newCounter, backedUp: info.credentialBackedUp },
188
+ userVerified: info.userVerified,
189
+ };
190
+ }
@@ -0,0 +1,90 @@
1
+ /** WebAuthn's user verification requirement; only `required` refuses a response without it. */
2
+ export type Verification = "required" | "preferred" | "discouraged";
3
+ /** A credential to check assertions with; ids, keys and handles are unpadded base64url. Its holder may keep it. */
4
+ export interface Credential {
5
+ readonly id: string;
6
+ readonly publicKey: string;
7
+ readonly signCount: number;
8
+ readonly userHandle: string;
9
+ readonly backupEligible: boolean;
10
+ readonly backedUp: boolean;
11
+ readonly transports?: readonly string[];
12
+ }
13
+ export type CeremonyRefusal = "malformed" | "challenge" | "origin" | "top-origin" | "rp" | "algorithm" | "signature" | "sign-count" | "user-handle" | "user-verification" | "backup-eligibility";
14
+ export type Asserted =
15
+ /** `credential` carries the new sign count and backup state, for the holder to keep. */
16
+ {
17
+ readonly kind: "asserted";
18
+ readonly credential: Credential;
19
+ readonly userVerified: boolean;
20
+ } | {
21
+ readonly kind: "refused";
22
+ readonly reason: CeremonyRefusal;
23
+ };
24
+ /** Ceremonies whose challenge, its end (`until`, on `kernel`'s clock) and credential the caller keeps and hands over once. */
25
+ export interface Ceremonies {
26
+ /** Options for a new credential, and the challenge to keep for its answer; a discoverable one unless told otherwise. */
27
+ creation(user: {
28
+ readonly handle: string;
29
+ readonly name: string;
30
+ }, exclude: readonly string[], v: Verification, asked?: {
31
+ readonly discoverable?: boolean;
32
+ }): {
33
+ readonly options: unknown;
34
+ readonly challenge: string;
35
+ readonly until: number;
36
+ };
37
+ attest(response: unknown, challenge: string, handle: string, v: Verification, until?: number): Promise<{
38
+ readonly kind: "attested";
39
+ readonly credential: Credential;
40
+ } | {
41
+ readonly kind: "refused";
42
+ readonly reason: CeremonyRefusal;
43
+ }>;
44
+ /** Options for an assertion by one of `allow`, and the challenge to keep for its answer. */
45
+ request(allow: readonly Credential[], v: Verification): {
46
+ readonly options: unknown;
47
+ readonly challenge: string;
48
+ readonly until: number;
49
+ };
50
+ assert(response: unknown, challenge: string, credential: Credential, v: Verification, until?: number): Promise<Asserted>;
51
+ }
52
+ export declare const ceremonies: import("@o3co/auth-provider-foundation/kernel").Contract<Ceremonies>;
53
+ /** Assertions by the discoverable passkeys webauthn keeps. Throws Unavailable when the store cannot tell. */
54
+ export interface Passkeys {
55
+ /** The same for everyone: reads nothing and lists no credential. Only `requester` redeems it. */
56
+ challenge(requester: string): Promise<{
57
+ readonly options: unknown;
58
+ }>;
59
+ /** `requester` is who redeems: a challenge made for another is refused as `challenge`. */
60
+ assert(response: unknown, requester: string): Promise<{
61
+ readonly kind: "asserted";
62
+ readonly subject: string;
63
+ /** When the challenge was made, on `kernel`'s clock. */
64
+ readonly challengedAt: number;
65
+ readonly userVerified: boolean;
66
+ readonly backupEligible: boolean;
67
+ } | {
68
+ readonly kind: "refused";
69
+ readonly reason: CeremonyRefusal | "unknown-credential" | "no-user-handle" | "raced";
70
+ }>;
71
+ }
72
+ export declare const passkeys: import("@o3co/auth-provider-foundation/kernel").Contract<Passkeys>;
73
+ export type RegistrationRefusal = CeremonyRefusal | "unreadable" | "no-challenge" | "conflict" | "full";
74
+ /** Passkeys for a subject the caller vouches for, each answering a challenge made for it, once. Throws Unavailable when the store cannot tell. */
75
+ export interface Registrations {
76
+ /** Options for a new passkey of `subject`, excluding its own; its first options make its user handle. */
77
+ creation(subject: string): Promise<{
78
+ readonly options: unknown;
79
+ }>;
80
+ register(subject: string, response: unknown, nickname?: string): Promise<{
81
+ readonly kind: "registered";
82
+ readonly credential: Credential;
83
+ } | {
84
+ readonly kind: "refused";
85
+ readonly reason: RegistrationRefusal;
86
+ }>;
87
+ }
88
+ export declare const registrations: import("@o3co/auth-provider-foundation/kernel").Contract<Registrations>;
89
+ export { credentialOf, freshHandle } from "./records.js";
90
+ export * from "./store.js";
@@ -0,0 +1,6 @@
1
+ import { contract } from "@o3co/auth-provider-foundation/kernel";
2
+ export const ceremonies = contract("webauthn.ceremonies");
3
+ export const passkeys = contract("webauthn.passkeys");
4
+ export const registrations = contract("webauthn.registrations");
5
+ export { credentialOf, freshHandle } from "./records.js";
6
+ export * from "./store.js";
@@ -0,0 +1,2 @@
1
+ declare const _default: import("@o3co/auth-provider-foundation/kernel").Module;
2
+ export default _default;
@@ -0,0 +1,35 @@
1
+ import { defineModule } from "@o3co/auth-provider-foundation/kernel";
2
+ import { ceremoniesFor } from "./ceremony/ceremony.js";
3
+ import { ceremonies, challengeStore, passkeyStore, passkeys, registrations } from "./contract.js";
4
+ import { passkeysOver } from "./passkeys.js";
5
+ import { freshHandle, keptChallenges, keptPasskeys } from "./records.js";
6
+ import { registrationsOver } from "./registration/registrations.js";
7
+ import { section } from "./section.js";
8
+ const relyingParty = (s) => ({
9
+ id: s["rp-id"],
10
+ name: s["rp-name"],
11
+ origins: s.origins,
12
+ topOrigins: s["top-origins"],
13
+ timeoutMs: s["challenge-seconds"] * 1000,
14
+ });
15
+ const passkeysOf = async (c) => keptPasskeys(await c.get(passkeyStore));
16
+ const challengesOf = async (c) => keptChallenges(await c.get(challengeStore), c.clock.now, c.section["challenge-seconds"] * 1000);
17
+ export default defineModule({ name: "webauthn", section, replicaSafe: true }, (m) => [
18
+ m
19
+ .provide(ceremonies)
20
+ .by((c) => ceremoniesFor(relyingParty(c.section), c.random.bytes, c.clock.now)),
21
+ m.provide(passkeys).by(async (c) => passkeysOver({
22
+ ceremonies: await c.get(ceremonies),
23
+ records: await passkeysOf(c),
24
+ challenges: await challengesOf(c),
25
+ verification: c.section["user-verification"],
26
+ })),
27
+ m.provide(registrations).by(async (c) => registrationsOver({
28
+ ceremonies: await c.get(ceremonies),
29
+ records: await passkeysOf(c),
30
+ challenges: await challengesOf(c),
31
+ verification: c.section["user-verification"],
32
+ max: c.section["max-credentials"],
33
+ freshHandle: () => freshHandle(c.random.bytes),
34
+ })),
35
+ ]);
@@ -0,0 +1,10 @@
1
+ import type { Ceremonies, Passkeys, Verification } from "./contract.js";
2
+ import type { KeptChallenges, KeptPasskeys } from "./records.js";
3
+ export interface PasskeyParts {
4
+ readonly ceremonies: Ceremonies;
5
+ readonly records: KeptPasskeys;
6
+ readonly challenges: KeptChallenges;
7
+ readonly verification: Verification;
8
+ }
9
+ /** Assertions by the passkeys webauthn keeps, each found by the credential it names. */
10
+ export declare function passkeysOver(p: PasskeyParts): Passkeys;
@@ -0,0 +1,49 @@
1
+ import { createHash } from "node:crypto";
2
+ import { challengeOf } from "./ceremony/ceremony.js";
3
+ const refused = (reason) => ({ kind: "refused", reason });
4
+ // A digest keeps the challenge's record one size, whatever the requester sent.
5
+ const digestOf = (requester) => createHash("sha256").update(requester).digest("base64url");
6
+ /** Assertions by the passkeys webauthn keeps, each found by the credential it names. */
7
+ export function passkeysOver(p) {
8
+ return {
9
+ challenge: async (requester) => {
10
+ const { options, challenge } = p.ceremonies.request([], p.verification);
11
+ await p.challenges.open(challenge, { kind: "assertion", requester: digestOf(requester) });
12
+ return { options };
13
+ },
14
+ assert: async (response, requester) => {
15
+ const answered = response;
16
+ // Without a handle nothing ties the key to its holder: refused before the store is asked.
17
+ const handle = answered?.response?.userHandle;
18
+ if (typeof handle !== "string" || handle === "")
19
+ return refused("no-user-handle");
20
+ const challenge = challengeOf(response);
21
+ if (challenge === undefined || typeof answered?.id !== "string")
22
+ return refused("malformed");
23
+ const opened = await p.challenges.take(challenge);
24
+ if (opened?.kind !== "assertion" || opened.requester !== digestOf(requester)) {
25
+ return refused("challenge");
26
+ }
27
+ const found = await p.records.passkey(answered.id);
28
+ if (!found)
29
+ return refused("unknown-credential");
30
+ const holder = await p.records.holder(found.subject);
31
+ if (!holder?.credentials.includes(answered.id))
32
+ return refused("unknown-credential");
33
+ const credential = { ...found.credential, userHandle: holder.handle };
34
+ const asserted = await p.ceremonies.assert(response, challenge, credential, p.verification);
35
+ if (asserted.kind === "refused")
36
+ return asserted;
37
+ if ((await p.records.count(credential, asserted.credential)) === "raced") {
38
+ return refused("raced");
39
+ }
40
+ return {
41
+ kind: "asserted",
42
+ subject: found.subject,
43
+ challengedAt: opened.at,
44
+ userVerified: asserted.userVerified,
45
+ backupEligible: credential.backupEligible,
46
+ };
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,47 @@
1
+ import type { Credential } from "./contract.js";
2
+ import type { ChallengeStore, PasskeyStore } from "./store.js";
3
+ /** Whether `text` is unpadded base64url of 1 to `max` bytes: the one spelling of those bytes. */
4
+ export declare function isCanonical(text: unknown, max: number): text is string;
5
+ export declare const handleBytes = 64;
6
+ export declare const idBytes = 1023;
7
+ export interface Holder {
8
+ readonly handle: string;
9
+ readonly credentials: readonly string[];
10
+ }
11
+ export interface Passkey {
12
+ readonly subject: string;
13
+ readonly credential: Omit<Credential, "userHandle">;
14
+ }
15
+ /** A credential in the shape webauthn writes, with its fields alone; none for any other value. */
16
+ export declare function credentialOf(value: unknown): Credential | undefined;
17
+ /** A new subject's user handle: 32 random bytes, unpadded base64url. */
18
+ export declare const freshHandle: (random: (n: number) => Uint8Array) => string;
19
+ /** Passkeys and their holders, written only in canonical form and read only in the shape written. */
20
+ export declare function keptPasskeys(store: PasskeyStore): {
21
+ holder: (subject: string) => Promise<Holder | undefined>;
22
+ /** The subject's handle; `fresh` becomes it when the subject has none yet. */
23
+ handleFor: (subject: string, fresh: string) => Promise<string>;
24
+ passkey: (id: string) => Promise<Passkey | undefined>;
25
+ /** Keeps a new passkey for the subject, whose handle it was made for; an id its holder lists is a conflict. */
26
+ register: (subject: string, credential: Credential, max: number, nickname?: string) => Promise<"registered" | "conflict" | "full" | "malformed">;
27
+ /** Moves the count on from the one judged; another change first is a lost race. */
28
+ count: (judged: Credential, next: Credential) => Promise<"counted" | "raced">;
29
+ };
30
+ export type KeptPasskeys = ReturnType<typeof keptPasskeys>;
31
+ /** What a challenge was made for: a subject's registration, or an assertion by anyone, redeemed by its requester (kept as a digest). */
32
+ export type Purpose = {
33
+ readonly kind: "registration";
34
+ readonly subject: string;
35
+ } | {
36
+ readonly kind: "assertion";
37
+ readonly requester: string;
38
+ };
39
+ /** Challenges of 32 bytes, each taken once, with when they were made. */
40
+ export declare function keptChallenges(store: ChallengeStore, now: () => number, lifetimeMs: number): {
41
+ open: (challenge: string, purpose: Purpose) => Promise<void>;
42
+ /** The purpose and time of a challenge made and not yet taken; none for any other. */
43
+ take: (challenge: string) => Promise<(Purpose & {
44
+ readonly at: number;
45
+ }) | undefined>;
46
+ };
47
+ export type KeptChallenges = ReturnType<typeof keptChallenges>;