okengine 0.5.0 → 0.5.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.
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Minimal WebAuthn assertion / registration verifier (ES256 / P-256).
3
+ *
4
+ * Validates clientDataJSON (type · challenge · origin), authenticatorData
5
+ * (rpId hash · user-presence), and ECDSA signature over
6
+ * `authenticatorData || SHA-256(clientDataJSON)` against a stored SPKI public key.
7
+ */
8
+
9
+ import { constantTimeEqual } from "../auth/constant-time.ts";
10
+
11
+ /** Result of a failed WebAuthn verify. */
12
+ export type WebAuthnVerifyFailure = {
13
+ readonly ok: false;
14
+ readonly reason: string;
15
+ };
16
+
17
+ /** Successful assertion verify (with updated sign counter). */
18
+ export type WebAuthnVerifySuccess = {
19
+ readonly ok: true;
20
+ readonly signCount: number;
21
+ };
22
+
23
+ /** Options for {@link verifyWebAuthnCeremony}. */
24
+ export interface WebAuthnVerifyOptions {
25
+ readonly expectedType: "webauthn.create" | "webauthn.get";
26
+ readonly expectedChallenge: string;
27
+ readonly expectedOrigins: readonly string[];
28
+ readonly rpId: string;
29
+ /** Base64url SPKI (SubjectPublicKeyInfo) of the credential public key. */
30
+ readonly publicKeySpkiB64url: string;
31
+ /** Base64url clientDataJSON bytes. */
32
+ readonly clientDataJSON: string;
33
+ /** Base64url authenticatorData bytes. */
34
+ readonly authenticatorData: string;
35
+ /** Base64url ECDSA P-1363 signature. */
36
+ readonly signature: string;
37
+ /** Previous stored sign counter (authenticate only). */
38
+ readonly previousSignCount?: number;
39
+ }
40
+
41
+ /**
42
+ * Verify a WebAuthn create/get ceremony payload.
43
+ *
44
+ * @param opts - Expected RP values + assertion fields
45
+ */
46
+ export async function verifyWebAuthnCeremony(
47
+ opts: WebAuthnVerifyOptions,
48
+ ): Promise<WebAuthnVerifySuccess | WebAuthnVerifyFailure> {
49
+ let clientDataBytes: Uint8Array;
50
+ let authData: Uint8Array;
51
+ let signature: Uint8Array;
52
+ try {
53
+ clientDataBytes = b64urlDecode(opts.clientDataJSON);
54
+ authData = b64urlDecode(opts.authenticatorData);
55
+ signature = b64urlDecode(opts.signature);
56
+ } catch {
57
+ return { ok: false, reason: "invalid_credentials" };
58
+ }
59
+
60
+ if (authData.length < 37) return { ok: false, reason: "invalid_credentials" };
61
+
62
+ let clientData: { type?: unknown; challenge?: unknown; origin?: unknown };
63
+ try {
64
+ clientData = JSON.parse(new TextDecoder().decode(clientDataBytes)) as typeof clientData;
65
+ } catch {
66
+ return { ok: false, reason: "invalid_credentials" };
67
+ }
68
+
69
+ if (typeof clientData.type !== "string" || clientData.type !== opts.expectedType) {
70
+ return { ok: false, reason: "invalid_credentials" };
71
+ }
72
+ if (typeof clientData.challenge !== "string") {
73
+ return { ok: false, reason: "invalid_credentials" };
74
+ }
75
+ if (!constantTimeEqual(clientData.challenge, opts.expectedChallenge)) {
76
+ return { ok: false, reason: "invalid_credentials" };
77
+ }
78
+ if (typeof clientData.origin !== "string") {
79
+ return { ok: false, reason: "invalid_credentials" };
80
+ }
81
+ if (!opts.expectedOrigins.includes(clientData.origin)) {
82
+ return { ok: false, reason: "invalid_origin" };
83
+ }
84
+
85
+ const rpHash = new Uint8Array(
86
+ await crypto.subtle.digest("SHA-256", new TextEncoder().encode(opts.rpId)),
87
+ );
88
+ for (let i = 0; i < 32; i++) {
89
+ if (authData[i] !== rpHash[i]) return { ok: false, reason: "invalid_credentials" };
90
+ }
91
+
92
+ const flags = authData[32]!;
93
+ // User Present (bit 0) required.
94
+ if ((flags & 0x01) === 0) return { ok: false, reason: "invalid_credentials" };
95
+
96
+ const signCount =
97
+ ((authData[33]! << 24) | (authData[34]! << 16) | (authData[35]! << 8) | authData[36]!) >>> 0;
98
+
99
+ if (opts.previousSignCount !== undefined) {
100
+ // Spec: if both non-zero, authenticator count must strictly increase.
101
+ if (opts.previousSignCount > 0 && signCount > 0 && signCount <= opts.previousSignCount) {
102
+ return { ok: false, reason: "invalid_credentials" };
103
+ }
104
+ }
105
+
106
+ const clientDataHash = new Uint8Array(
107
+ await crypto.subtle.digest("SHA-256", asBufferSource(clientDataBytes)),
108
+ );
109
+ const signed = new Uint8Array(authData.length + clientDataHash.length);
110
+ signed.set(authData, 0);
111
+ signed.set(clientDataHash, authData.length);
112
+
113
+ let key: CryptoKey;
114
+ try {
115
+ key = await crypto.subtle.importKey(
116
+ "spki",
117
+ asBufferSource(b64urlDecode(opts.publicKeySpkiB64url)),
118
+ { name: "ECDSA", namedCurve: "P-256" },
119
+ false,
120
+ ["verify"],
121
+ );
122
+ } catch {
123
+ return { ok: false, reason: "invalid_credentials" };
124
+ }
125
+
126
+ const valid = await crypto.subtle.verify(
127
+ { name: "ECDSA", hash: "SHA-256" },
128
+ key,
129
+ asBufferSource(signature),
130
+ asBufferSource(signed),
131
+ );
132
+ if (!valid) return { ok: false, reason: "invalid_credentials" };
133
+
134
+ return { ok: true, signCount };
135
+ }
136
+
137
+ /**
138
+ * Encode bytes as unpadded base64url.
139
+ *
140
+ * @param bytes - Raw bytes
141
+ */
142
+ export function b64urlEncode(bytes: Uint8Array): string {
143
+ let s = "";
144
+ for (const b of bytes) s += String.fromCharCode(b);
145
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
146
+ }
147
+
148
+ /**
149
+ * Decode unpadded base64url to bytes.
150
+ *
151
+ * @param input - Base64url string
152
+ */
153
+ export function b64urlDecode(input: string): Uint8Array<ArrayBuffer> {
154
+ const pad = "=".repeat((4 - (input.length % 4)) % 4);
155
+ const b64 = (input + pad).replace(/-/g, "+").replace(/_/g, "/");
156
+ const bin = atob(b64);
157
+ const out = new Uint8Array(bin.length);
158
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
159
+ return out;
160
+ }
161
+
162
+ /**
163
+ * Build minimal authenticatorData (rpIdHash · flags · signCount).
164
+ *
165
+ * @param rpId - Relying party id
166
+ * @param signCount - Counter
167
+ * @param flags - Flag byte (default UP=0x01)
168
+ */
169
+ export async function buildAuthenticatorData(
170
+ rpId: string,
171
+ signCount: number,
172
+ flags = 0x01,
173
+ ): Promise<Uint8Array> {
174
+ const rpHash = new Uint8Array(
175
+ await crypto.subtle.digest("SHA-256", new TextEncoder().encode(rpId)),
176
+ );
177
+ const out = new Uint8Array(37);
178
+ out.set(rpHash, 0);
179
+ out[32] = flags;
180
+ out[33] = (signCount >>> 24) & 0xff;
181
+ out[34] = (signCount >>> 16) & 0xff;
182
+ out[35] = (signCount >>> 8) & 0xff;
183
+ out[36] = signCount & 0xff;
184
+ return out;
185
+ }
186
+
187
+ /**
188
+ * Sign `authenticatorData || SHA-256(clientDataJSON)` with an ECDSA P-256 key.
189
+ *
190
+ * @param privateKey - Signer
191
+ * @param authenticatorData - Auth data bytes
192
+ * @param clientDataJSON - Raw clientDataJSON bytes
193
+ */
194
+ export async function signWebAuthnAssertion(
195
+ privateKey: CryptoKey,
196
+ authenticatorData: Uint8Array,
197
+ clientDataJSON: Uint8Array,
198
+ ): Promise<Uint8Array> {
199
+ const clientDataHash = new Uint8Array(
200
+ await crypto.subtle.digest("SHA-256", asBufferSource(clientDataJSON)),
201
+ );
202
+ const signed = new Uint8Array(authenticatorData.length + clientDataHash.length);
203
+ signed.set(authenticatorData, 0);
204
+ signed.set(clientDataHash, authenticatorData.length);
205
+ return new Uint8Array(
206
+ await crypto.subtle.sign(
207
+ { name: "ECDSA", hash: "SHA-256" },
208
+ privateKey,
209
+ asBufferSource(signed),
210
+ ),
211
+ );
212
+ }
213
+
214
+ /** Fresh buffer for Web Crypto `BufferSource` typing. */
215
+ function asBufferSource(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
216
+ return new Uint8Array(bytes);
217
+ }
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Passkey (WebAuthn-shaped) Gate auth method plugin — simplified v1 ceremony.
2
+ * Passkey (WebAuthn) Gate auth method plugin.
3
3
  *
4
- * Registration / authentication accept attestation-like payloads for tests
5
- * without a full WebAuthn library. Production should replace the ceremony
6
- * with a standards-compliant verifier.
4
+ * Registration and authentication verify clientDataJSON origin + challenge,
5
+ * authenticatorData rpId hash, and ECDSA P-256 signature against the stored
6
+ * SPKI public key. Presence-only authenticate is rejected.
7
7
  */
8
8
 
9
+ import { constantTimeEqual } from "../auth/constant-time.ts";
9
10
  import {
10
11
  createVerificationStore,
11
12
  hashChallenge,
@@ -26,11 +27,13 @@ import {
26
27
  z,
27
28
  type AuthMethodOptions,
28
29
  } from "./auth/shared.ts";
30
+ import { verifyWebAuthnCeremony } from "./passkey-webauthn.ts";
29
31
 
30
- /** Stored passkey credential (simplified). */
32
+ /** Stored passkey credential. */
31
33
  export interface PasskeyCredential {
32
34
  readonly credentialId: string;
33
35
  readonly userId: string;
36
+ /** Base64url SPKI public key (ECDSA P-256). */
34
37
  readonly publicKey: string;
35
38
  counter: number;
36
39
  readonly createdAt: number;
@@ -55,18 +58,34 @@ export interface PasskeyOptions extends AuthMethodOptions {
55
58
  readonly challenges?: VerificationStore;
56
59
  /** Relying party id (default `localhost`). */
57
60
  readonly rpId?: string;
61
+ /**
62
+ * Allowed `clientDataJSON.origin` values.
63
+ * Default: `http://localhost` and `https://localhost`.
64
+ */
65
+ readonly origins?: readonly string[];
58
66
  }
59
67
 
68
+ const CeremonyIn = z.object({
69
+ credentialId: z.string().min(1),
70
+ publicKey: z.string().min(1).optional(),
71
+ userId: z.string().min(1).optional(),
72
+ challenge: z.string().min(1),
73
+ clientDataJSON: z.string().min(1),
74
+ authenticatorData: z.string().min(1),
75
+ signature: z.string().min(1),
76
+ });
77
+
60
78
  /**
61
- * Passkey register / authenticate options + simplified ceremony (`oke_passkeys`).
79
+ * Passkey register / authenticate with cryptographic WebAuthn verify (`oke_passkeys`).
62
80
  *
63
- * @param opts - Stores / RP id
81
+ * @param opts - Stores / RP id / allowed origins
64
82
  */
65
83
  export function passkey(opts: PasskeyOptions = {}): PluginDef {
66
84
  const runtime = createMethodRuntime(opts);
67
85
  const passkeys = opts.passkeys ?? createPasskeyStore();
68
86
  const challenges = opts.challenges ?? createVerificationStore();
69
87
  const rpId = opts.rpId ?? "localhost";
88
+ const origins = opts.origins ?? ["http://localhost", "https://localhost"];
70
89
 
71
90
  const registerOptions = flow({
72
91
  name: "auth.passkeyRegisterOptions",
@@ -100,11 +119,9 @@ export function passkey(opts: PasskeyOptions = {}): PluginDef {
100
119
  name: "auth.passkeyRegister",
101
120
  unit: "auth",
102
121
  plane: "user",
103
- in: z.object({
104
- credentialId: z.string().min(1),
122
+ in: CeremonyIn.extend({
105
123
  publicKey: z.string().min(1),
106
124
  userId: z.string().min(1),
107
- challenge: z.string().optional(),
108
125
  }),
109
126
  out: z.object({ ok: z.literal(true) }),
110
127
  errors: { AuthFailed },
@@ -113,20 +130,28 @@ export function passkey(opts: PasskeyOptions = {}): PluginDef {
113
130
  if (!sessionUser || sessionUser !== input.userId) {
114
131
  return fail("AuthFailed", { reason: "unauthenticated" });
115
132
  }
116
- if (input.challenge) {
117
- const hash = await hashChallenge(input.challenge);
118
- const now = runtime.now();
119
- let found = false;
120
- for (const row of challenges.rows.values()) {
121
- if (row.identifier !== `passkey-reg:${input.userId}`) continue;
122
- if (row.consumedAt !== null || row.expiresAt <= now) continue;
123
- if (row.value === hash) {
124
- row.consumedAt = now;
125
- found = true;
126
- break;
127
- }
128
- }
129
- if (!found) return fail("AuthFailed", { reason: "invalid_credentials" });
133
+ const consumed = await consumeChallenge(
134
+ challenges,
135
+ `passkey-reg:${input.userId}`,
136
+ input.challenge,
137
+ runtime.now(),
138
+ );
139
+ if (!consumed) return fail("AuthFailed", { reason: "invalid_credentials" });
140
+
141
+ const verified = await verifyWebAuthnCeremony({
142
+ expectedType: "webauthn.create",
143
+ expectedChallenge: input.challenge,
144
+ expectedOrigins: origins,
145
+ rpId,
146
+ publicKeySpkiB64url: input.publicKey,
147
+ clientDataJSON: input.clientDataJSON,
148
+ authenticatorData: input.authenticatorData,
149
+ signature: input.signature,
150
+ });
151
+ if (!verified.ok) {
152
+ return fail("AuthFailed", {
153
+ reason: verified.reason === "invalid_origin" ? "invalid_origin" : "invalid_credentials",
154
+ });
130
155
  }
131
156
  if (passkeys.byCredentialId.has(input.credentialId)) {
132
157
  return fail("AuthFailed", { reason: "invalid_credentials" });
@@ -135,7 +160,7 @@ export function passkey(opts: PasskeyOptions = {}): PluginDef {
135
160
  credentialId: input.credentialId,
136
161
  userId: input.userId,
137
162
  publicKey: input.publicKey,
138
- counter: 0,
163
+ counter: verified.signCount,
139
164
  createdAt: runtime.now(),
140
165
  };
141
166
  passkeys.byCredentialId.set(cred.credentialId, cred);
@@ -170,7 +195,6 @@ export function passkey(opts: PasskeyOptions = {}): PluginDef {
170
195
  consumedAt: null,
171
196
  attempts: 0,
172
197
  });
173
- // Simplified: empty allow list when no email mapping (caller supplies credentialId).
174
198
  return { challenge, rpId, allowCredentials: [] as string[] };
175
199
  },
176
200
  });
@@ -179,19 +203,44 @@ export function passkey(opts: PasskeyOptions = {}): PluginDef {
179
203
  name: "auth.passkeyAuthenticate",
180
204
  unit: "auth",
181
205
  plane: "user",
182
- in: z.object({
183
- credentialId: z.string().min(1),
184
- userId: z.string().min(1),
206
+ in: CeremonyIn.extend({
207
+ challenge: z.string().min(1),
208
+ /** Challenge bucket key from authenticate options (default `anonymous`). */
209
+ email: z.string().optional(),
185
210
  }),
186
211
  out: SessionTokensOut,
187
212
  errors: { AuthFailed, AuthRateLimited },
188
213
  do: async (input) => {
189
- // Simplified v1: presence of stored credential + matching userId issues a session.
190
214
  const cred = passkeys.byCredentialId.get(input.credentialId);
191
- if (!cred || cred.userId !== input.userId) {
192
- return fail("AuthFailed", { reason: "invalid_credentials" });
215
+ if (!cred) return fail("AuthFailed", { reason: "invalid_credentials" });
216
+
217
+ const bucket = input.email?.trim().toLowerCase() || "anonymous";
218
+ const consumed = await consumeChallenge(
219
+ challenges,
220
+ `passkey-auth:${bucket}`,
221
+ input.challenge,
222
+ runtime.now(),
223
+ );
224
+ if (!consumed) return fail("AuthFailed", { reason: "invalid_credentials" });
225
+
226
+ const verified = await verifyWebAuthnCeremony({
227
+ expectedType: "webauthn.get",
228
+ expectedChallenge: input.challenge,
229
+ expectedOrigins: origins,
230
+ rpId,
231
+ publicKeySpkiB64url: cred.publicKey,
232
+ clientDataJSON: input.clientDataJSON,
233
+ authenticatorData: input.authenticatorData,
234
+ signature: input.signature,
235
+ previousSignCount: cred.counter,
236
+ });
237
+ if (!verified.ok) {
238
+ return fail("AuthFailed", {
239
+ reason: verified.reason === "invalid_origin" ? "invalid_origin" : "invalid_credentials",
240
+ });
193
241
  }
194
- cred.counter += 1;
242
+ cred.counter = verified.signCount;
243
+
195
244
  const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
196
245
  id: cred.userId,
197
246
  plane: "user",
@@ -214,3 +263,20 @@ export function passkey(opts: PasskeyOptions = {}): PluginDef {
214
263
  .binding(bindPublicAuth("/passkey/authenticate/options", authenticateOptions, "otp"))
215
264
  .binding(bindPublicAuth("/passkey/authenticate", authenticate, "otp"));
216
265
  }
266
+
267
+ async function consumeChallenge(
268
+ store: VerificationStore,
269
+ identifier: string,
270
+ challenge: string,
271
+ now: number,
272
+ ): Promise<boolean> {
273
+ const hash = await hashChallenge(challenge);
274
+ for (const row of store.rows.values()) {
275
+ if (row.identifier !== identifier) continue;
276
+ if (row.consumedAt !== null || row.expiresAt <= now) continue;
277
+ if (!constantTimeEqual(row.value, hash)) continue;
278
+ row.consumedAt = now;
279
+ return true;
280
+ }
281
+ return false;
282
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Shared response-header mutation for HTTP middleware plugins.
3
+ * Web `Headers` are immutable-in-place on a built `Response` — rebuild instead.
4
+ */
5
+
6
+ /**
7
+ * Rebuild a response with headers mutated by `fn`.
8
+ *
9
+ * @param response - Original response
10
+ * @param fn - Header mutation
11
+ */
12
+ export function withHeaders(response: Response, fn: (headers: Headers) => void): Response {
13
+ const headers = new Headers(response.headers);
14
+ fn(headers);
15
+ return new Response(response.body, {
16
+ status: response.status,
17
+ statusText: response.statusText,
18
+ headers,
19
+ });
20
+ }
21
+
22
+ /**
23
+ * Set a header only when absent (or when `override` is on).
24
+ *
25
+ * @param headers - Mutable headers
26
+ * @param name - Header name
27
+ * @param value - Header value
28
+ * @param override - Replace an app-set value
29
+ */
30
+ export function setUnlessPresent(
31
+ headers: Headers,
32
+ name: string,
33
+ value: string,
34
+ override: boolean,
35
+ ): void {
36
+ if (!override && headers.has(name)) return;
37
+ headers.set(name, value);
38
+ }
39
+
40
+ /**
41
+ * Append a token to `Vary` without duplicating it.
42
+ *
43
+ * @param headers - Mutable headers
44
+ * @param token - Vary token (e.g. `"origin"`)
45
+ */
46
+ export function appendVary(headers: Headers, token: string): void {
47
+ const vary = headers.get("vary");
48
+ if (vary === null) {
49
+ headers.set("vary", token);
50
+ return;
51
+ }
52
+ const pattern = new RegExp(`\\b${token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i");
53
+ if (!pattern.test(vary)) headers.set("vary", `${vary}, ${token}`);
54
+ }
@@ -4,6 +4,7 @@
4
4
  * v1 verify accepts `{ userId, code }` after password sign-in when 2FA is enabled.
5
5
  */
6
6
 
7
+ import { constantTimeEqual } from "../auth/constant-time.ts";
7
8
  import { issueSessionWithScopes } from "../auth/sessions.ts";
8
9
  import { plugin, type PluginDef } from "../kernel/plugin.ts";
9
10
  import {
@@ -163,11 +164,14 @@ export async function verifyTotp(
163
164
  if (!/^\d{6}$/.test(code)) return false;
164
165
  const key = base32Decode(secretBase32);
165
166
  const counter = Math.floor(nowSec / 30);
167
+ // Compare every window with constant-time equality — never short-circuit on
168
+ // the first match (avoids leaking which step matched via `===` timing).
169
+ let ok = 0;
166
170
  for (let w = -1; w <= 1; w++) {
167
171
  const otp = await hotp(key, counter + w);
168
- if (otp === code) return true;
172
+ ok |= constantTimeEqual(otp, code) ? 1 : 0;
169
173
  }
170
- return false;
174
+ return ok === 1;
171
175
  }
172
176
 
173
177
  async function hotp(key: Uint8Array, counter: number): Promise<string> {