@yielded/auth-simplewebauthn 0.1.0-beta.0
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/LICENSE +21 -0
- package/README.md +11 -0
- package/dist/Browser.d.mts +4 -0
- package/dist/Browser.mjs +4 -0
- package/dist/Server.d.mts +3 -0
- package/dist/Server.mjs +3 -0
- package/dist/internal/browser/PasskeyBrowser.d.mts +17 -0
- package/dist/internal/browser/PasskeyBrowser.mjs +10 -0
- package/dist/internal/browser/PasskeyBrowser.mjs.map +1 -0
- package/dist/internal/browser/models.d.mts +37 -0
- package/dist/internal/browser/models.mjs +41 -0
- package/dist/internal/browser/models.mjs.map +1 -0
- package/dist/internal/browser/simplewebauthn.d.mts +16 -0
- package/dist/internal/browser/simplewebauthn.mjs +410 -0
- package/dist/internal/browser/simplewebauthn.mjs.map +1 -0
- package/dist/internal/server/configuration.mjs +43 -0
- package/dist/internal/server/configuration.mjs.map +1 -0
- package/dist/internal/server/models.d.mts +20 -0
- package/dist/internal/server/models.mjs +8 -0
- package/dist/internal/server/models.mjs.map +1 -0
- package/dist/internal/server/protocol.d.mts +59 -0
- package/dist/internal/server/protocol.mjs +400 -0
- package/dist/internal/server/protocol.mjs.map +1 -0
- package/package.json +1 -0
- package/src/Browser.ts +19 -0
- package/src/Server.ts +6 -0
- package/src/internal/browser/PasskeyBrowser.ts +29 -0
- package/src/internal/browser/models.ts +66 -0
- package/src/internal/browser/simplewebauthn.ts +621 -0
- package/src/internal/server/configuration.ts +101 -0
- package/src/internal/server/models.ts +8 -0
- package/src/internal/server/protocol.ts +733 -0
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
import {
|
|
2
|
+
generateAuthenticationOptions,
|
|
3
|
+
generateRegistrationOptions,
|
|
4
|
+
verifyAuthenticationResponse,
|
|
5
|
+
verifyRegistrationResponse,
|
|
6
|
+
} from "@simplewebauthn/server";
|
|
7
|
+
import {
|
|
8
|
+
decodeAttestationObject,
|
|
9
|
+
decodeClientDataJSON,
|
|
10
|
+
decodeCredentialPublicKey,
|
|
11
|
+
isoCBOR,
|
|
12
|
+
parseAuthenticatorData,
|
|
13
|
+
} from "@simplewebauthn/server/helpers";
|
|
14
|
+
import {
|
|
15
|
+
type PasskeyConfigurationError,
|
|
16
|
+
PasskeyProtocolRejected,
|
|
17
|
+
PasskeyUnavailable,
|
|
18
|
+
PasskeyAssertion,
|
|
19
|
+
PasskeyAssertionVerified,
|
|
20
|
+
PasskeyAttestation,
|
|
21
|
+
PasskeyAuthenticationOptions,
|
|
22
|
+
PasskeyCeremony,
|
|
23
|
+
PasskeyChallenge,
|
|
24
|
+
PasskeyCounter,
|
|
25
|
+
PasskeyCredential,
|
|
26
|
+
PasskeyDescriptor,
|
|
27
|
+
PasskeyLabel,
|
|
28
|
+
PasskeyProfile,
|
|
29
|
+
PasskeyProtocolCredentialId,
|
|
30
|
+
PasskeyRegistrationOptions,
|
|
31
|
+
PasskeyRegistrationVerified,
|
|
32
|
+
PasskeyUserHandle,
|
|
33
|
+
PasskeyConfig,
|
|
34
|
+
PasskeyProtocol,
|
|
35
|
+
samePasskey,
|
|
36
|
+
snapshotPasskey,
|
|
37
|
+
} from "@yielded/auth/Passkey";
|
|
38
|
+
import { reportAuthFailure } from "@yielded/auth/Persistence";
|
|
39
|
+
import type { Context } from "effect";
|
|
40
|
+
import { Cause, DateTime, Effect, Encoding, Layer, Redacted, Schema } from "effect";
|
|
41
|
+
|
|
42
|
+
import { captureSimpleWebAuthnProfiles } from "./configuration";
|
|
43
|
+
import type { SimpleWebAuthnPasskeyProtocolOptions } from "./models";
|
|
44
|
+
|
|
45
|
+
type Protocol = Context.Service.Shape<typeof PasskeyProtocol>;
|
|
46
|
+
type Input<K extends keyof Protocol> = Parameters<Protocol[K]>[0];
|
|
47
|
+
const rejected = () => PasskeyProtocolRejected.make({});
|
|
48
|
+
const unavailable = () => PasskeyUnavailable.make({});
|
|
49
|
+
|
|
50
|
+
const unexpected = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
51
|
+
effect.pipe(
|
|
52
|
+
Effect.catchCause((cause): Effect.Effect<never, E | PasskeyUnavailable> =>
|
|
53
|
+
Cause.hasDies(cause)
|
|
54
|
+
? reportAuthFailure("passkey-protocol", cause).pipe(
|
|
55
|
+
Effect.andThen(Effect.fail(unavailable())),
|
|
56
|
+
)
|
|
57
|
+
: Effect.failCause(cause),
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const maintained = <A>(call: () => Promise<A>) =>
|
|
62
|
+
Effect.tryPromise(call).pipe(
|
|
63
|
+
Effect.onError((cause) => reportAuthFailure("passkey-protocol", cause)),
|
|
64
|
+
Effect.mapError(unavailable),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const parsed = <A>(call: () => A) => Effect.try({ try: call, catch: rejected });
|
|
68
|
+
|
|
69
|
+
const decode = <S extends Schema.Codec<unknown, unknown, never, never>>(
|
|
70
|
+
schema: S,
|
|
71
|
+
input: unknown,
|
|
72
|
+
) =>
|
|
73
|
+
// Maintained JSON/CBOR parsers and their map lookups return untyped wire data.
|
|
74
|
+
// eslint-disable-next-line no-restricted-properties
|
|
75
|
+
Schema.decodeUnknownEffect(schema)(input).pipe(Effect.mapError(rejected));
|
|
76
|
+
|
|
77
|
+
const capture = <S extends Schema.Codec<unknown, unknown, never, never>>(
|
|
78
|
+
schema: S,
|
|
79
|
+
input: S["Type"],
|
|
80
|
+
) => snapshotPasskey(schema, input).pipe(Effect.mapError(rejected));
|
|
81
|
+
|
|
82
|
+
const sameBytes = (left: Uint8Array, right: Uint8Array) =>
|
|
83
|
+
left.length === right.length && left.every((byte, index) => byte === right[index]);
|
|
84
|
+
|
|
85
|
+
const binary = (minimum: number, maximum: number) =>
|
|
86
|
+
Schema.String.check(
|
|
87
|
+
Schema.isMaxLength(Math.ceil((maximum * 4) / 3)),
|
|
88
|
+
Schema.makeFilter((value) => {
|
|
89
|
+
const result = Encoding.decodeBase64Url(value);
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
result._tag === "Success" &&
|
|
93
|
+
result.success.length >= minimum &&
|
|
94
|
+
result.success.length <= maximum &&
|
|
95
|
+
Encoding.encodeBase64Url(result.success) === value
|
|
96
|
+
);
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const bytes = Effect.fn("simpleWebAuthnBytes")(function* (value: string) {
|
|
101
|
+
const result = Encoding.decodeBase64Url(value);
|
|
102
|
+
|
|
103
|
+
if (result._tag === "Failure") return yield* rejected();
|
|
104
|
+
|
|
105
|
+
return new Uint8Array(result.success);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const duration = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 300000 }));
|
|
109
|
+
|
|
110
|
+
const prepareAuthenticationInput = Schema.Struct({
|
|
111
|
+
profile: PasskeyProfile,
|
|
112
|
+
challenge: PasskeyChallenge,
|
|
113
|
+
timeoutMillis: duration,
|
|
114
|
+
allowedCredentials: Schema.Array(PasskeyDescriptor).check(Schema.isMaxLength(64)),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const prepareRegistrationInput = Schema.Struct({
|
|
118
|
+
profile: PasskeyProfile,
|
|
119
|
+
challenge: PasskeyChallenge,
|
|
120
|
+
timeoutMillis: duration,
|
|
121
|
+
userHandle: PasskeyUserHandle,
|
|
122
|
+
name: PasskeyLabel,
|
|
123
|
+
displayName: PasskeyLabel,
|
|
124
|
+
excludedCredentials: Schema.Array(PasskeyDescriptor).check(Schema.isMaxLength(64)),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const authenticationInput = Schema.Struct({
|
|
128
|
+
ceremony: PasskeyCeremony,
|
|
129
|
+
credential: PasskeyCredential,
|
|
130
|
+
response: PasskeyAssertion,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const registrationInput = Schema.Struct({
|
|
134
|
+
ceremony: PasskeyCeremony,
|
|
135
|
+
response: PasskeyAttestation,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const envelope = {
|
|
139
|
+
id: PasskeyProtocolCredentialId,
|
|
140
|
+
rawId: PasskeyProtocolCredentialId,
|
|
141
|
+
type: Schema.Literal("public-key"),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const authenticationResponse = Schema.Struct({
|
|
145
|
+
...envelope,
|
|
146
|
+
response: Schema.Struct({
|
|
147
|
+
clientDataJSON: binary(1, 8192),
|
|
148
|
+
authenticatorData: binary(37, 16384),
|
|
149
|
+
signature: binary(1, 1024),
|
|
150
|
+
userHandle: Schema.optionalKey(Schema.NullOr(PasskeyUserHandle)),
|
|
151
|
+
}),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const registrationResponse = Schema.Struct({
|
|
155
|
+
...envelope,
|
|
156
|
+
response: Schema.Struct({ clientDataJSON: binary(1, 8192), attestationObject: binary(1, 65536) }),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const clientData = Schema.Struct({
|
|
160
|
+
type: Schema.String,
|
|
161
|
+
challenge: PasskeyChallenge,
|
|
162
|
+
origin: Schema.String.check(Schema.isMaxLength(2048)),
|
|
163
|
+
crossOrigin: Schema.optionalKey(Schema.Boolean),
|
|
164
|
+
topOrigin: Schema.optionalKey(Schema.Unknown),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
type CborData =
|
|
168
|
+
| string
|
|
169
|
+
| number
|
|
170
|
+
| boolean
|
|
171
|
+
| null
|
|
172
|
+
| Uint8Array
|
|
173
|
+
| ReadonlyArray<CborData>
|
|
174
|
+
| ReadonlyMap<string | number, CborData>;
|
|
175
|
+
|
|
176
|
+
const cborLeaf = Schema.Union([
|
|
177
|
+
Schema.String.check(Schema.isMaxLength(8192)),
|
|
178
|
+
Schema.Number.check(Schema.isFinite()),
|
|
179
|
+
Schema.Boolean,
|
|
180
|
+
Schema.Null,
|
|
181
|
+
Schema.Uint8Array.check(Schema.makeFilter((value) => value.length <= 16384)),
|
|
182
|
+
]);
|
|
183
|
+
|
|
184
|
+
const cborValue = (depth: number): Schema.Codec<CborData> => {
|
|
185
|
+
if (depth === 0) return cborLeaf;
|
|
186
|
+
const child = cborValue(depth - 1);
|
|
187
|
+
|
|
188
|
+
return Schema.Union([
|
|
189
|
+
cborLeaf,
|
|
190
|
+
Schema.Array(child).check(Schema.isMaxLength(64)),
|
|
191
|
+
Schema.ReadonlyMap(
|
|
192
|
+
Schema.Union([Schema.String.check(Schema.isMaxLength(128)), Schema.Int]),
|
|
193
|
+
child,
|
|
194
|
+
).check(Schema.makeFilter((value) => value.size <= 32)),
|
|
195
|
+
]);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const cborMap = Schema.ReadonlyMap(
|
|
199
|
+
Schema.Union([Schema.String.check(Schema.isMaxLength(128)), Schema.Int]),
|
|
200
|
+
cborValue(4),
|
|
201
|
+
).check(Schema.makeFilter((value) => value.size <= 32));
|
|
202
|
+
|
|
203
|
+
const encodeCbor = (value: CborData) => {
|
|
204
|
+
// The maintained encoder reads these Schema-validated Maps/arrays; its type
|
|
205
|
+
// requires mutable collections although no mutation is performed.
|
|
206
|
+
return isoCBOR.encode(value as Parameters<typeof isoCBOR.encode>[0]);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const exactMap = Effect.fn("simpleWebAuthnExactMap")(function* (input: Uint8Array, value: unknown) {
|
|
210
|
+
const result = yield* decode(cborMap, value);
|
|
211
|
+
const encoded = yield* parsed(() => encodeCbor(result));
|
|
212
|
+
|
|
213
|
+
if (!sameBytes(input, encoded)) return yield* rejected();
|
|
214
|
+
|
|
215
|
+
return result;
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const byteValue = (minimum: number, maximum: number) =>
|
|
219
|
+
Schema.Uint8Array.check(
|
|
220
|
+
Schema.makeFilter((value) => value.length >= minimum && value.length <= maximum),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const fresh = Effect.fn("simpleWebAuthnFresh")(function* (ceremony: PasskeyCeremony) {
|
|
224
|
+
const now = DateTime.toEpochMillis(yield* DateTime.now);
|
|
225
|
+
|
|
226
|
+
if (
|
|
227
|
+
ceremony.issuedAtMillis > now ||
|
|
228
|
+
now >= ceremony.expiresAtMillis ||
|
|
229
|
+
now >= ceremony.requestBindingExpiresAtMillis ||
|
|
230
|
+
ceremony.expiresAtMillis <= ceremony.issuedAtMillis
|
|
231
|
+
)
|
|
232
|
+
return yield* rejected();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const checkClient = Effect.fn("simpleWebAuthnClientData")(function* (
|
|
236
|
+
value: string,
|
|
237
|
+
ceremony: PasskeyCeremony,
|
|
238
|
+
type: "webauthn.create" | "webauthn.get",
|
|
239
|
+
) {
|
|
240
|
+
const client = yield* decode(clientData, yield* parsed(() => decodeClientDataJSON(value)));
|
|
241
|
+
|
|
242
|
+
if (
|
|
243
|
+
client.type !== type ||
|
|
244
|
+
client.challenge !== ceremony.challenge ||
|
|
245
|
+
!ceremony.profile.origins.includes(client.origin) ||
|
|
246
|
+
client.crossOrigin === true ||
|
|
247
|
+
"topOrigin" in client
|
|
248
|
+
)
|
|
249
|
+
return yield* rejected();
|
|
250
|
+
|
|
251
|
+
return client;
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const checkAuthenticator = Effect.fn("simpleWebAuthnAuthenticatorData")(function* (
|
|
255
|
+
original: Uint8Array,
|
|
256
|
+
registration: boolean,
|
|
257
|
+
profile: PasskeyProfile,
|
|
258
|
+
) {
|
|
259
|
+
if (
|
|
260
|
+
original.length < 37 ||
|
|
261
|
+
original.length > 16384 ||
|
|
262
|
+
Boolean(original[32]! & 64) !== registration
|
|
263
|
+
)
|
|
264
|
+
return yield* rejected();
|
|
265
|
+
let prefix = 37;
|
|
266
|
+
|
|
267
|
+
if (registration) {
|
|
268
|
+
if (original.length < 55) return yield* rejected();
|
|
269
|
+
|
|
270
|
+
const length = new DataView(
|
|
271
|
+
original.buffer,
|
|
272
|
+
original.byteOffset,
|
|
273
|
+
original.byteLength,
|
|
274
|
+
).getUint16(53, false);
|
|
275
|
+
|
|
276
|
+
if (length < 1 || length > 1023 || original.length < 55 + length) return yield* rejected();
|
|
277
|
+
prefix = 55 + length;
|
|
278
|
+
}
|
|
279
|
+
const result = yield* parsed(() => parseAuthenticatorData(new Uint8Array(original)));
|
|
280
|
+
|
|
281
|
+
if (
|
|
282
|
+
!result.flags.up ||
|
|
283
|
+
(profile.userVerification === "required" && !result.flags.uv) ||
|
|
284
|
+
(result.flags.bs && !result.flags.be) ||
|
|
285
|
+
result.flags.at !== registration ||
|
|
286
|
+
result.flags.flagsInt !== original[32]
|
|
287
|
+
)
|
|
288
|
+
return yield* rejected();
|
|
289
|
+
yield* decode(PasskeyCounter, result.counter);
|
|
290
|
+
const chunks: Uint8Array[] = [original.slice(0, prefix)];
|
|
291
|
+
|
|
292
|
+
if (registration) {
|
|
293
|
+
const id = yield* decode(byteValue(1, 1023), result.credentialID);
|
|
294
|
+
const key = yield* decode(byteValue(1, 8192), result.credentialPublicKey);
|
|
295
|
+
|
|
296
|
+
if (!sameBytes(id, original.slice(55, prefix))) return yield* rejected();
|
|
297
|
+
chunks.push(key);
|
|
298
|
+
}
|
|
299
|
+
if (result.flags.ed) {
|
|
300
|
+
const extension = yield* decode(byteValue(1, 16384), result.extensionsDataBuffer);
|
|
301
|
+
|
|
302
|
+
yield* exactMap(
|
|
303
|
+
extension,
|
|
304
|
+
yield* parsed(() => isoCBOR.decodeFirst<unknown>(new Uint8Array(extension))),
|
|
305
|
+
);
|
|
306
|
+
chunks.push(extension);
|
|
307
|
+
} else if (result.extensionsDataBuffer !== undefined) return yield* rejected();
|
|
308
|
+
const length = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
309
|
+
|
|
310
|
+
if (length !== original.length) return yield* rejected();
|
|
311
|
+
let offset = 0;
|
|
312
|
+
|
|
313
|
+
for (const chunk of chunks) {
|
|
314
|
+
if (!sameBytes(chunk, original.slice(offset, offset + chunk.length))) return yield* rejected();
|
|
315
|
+
offset += chunk.length;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const rpHash = new Uint8Array(
|
|
319
|
+
yield* maintained(() =>
|
|
320
|
+
globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(profile.rpId)),
|
|
321
|
+
),
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
if (!sameBytes(rpHash, original.slice(0, 32))) return yield* rejected();
|
|
325
|
+
|
|
326
|
+
return result;
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const checkKey = Effect.fn("simpleWebAuthnPublicKey")(function* (
|
|
330
|
+
input: Uint8Array,
|
|
331
|
+
allowed: ReadonlyArray<-7 | -257>,
|
|
332
|
+
expected?: -7 | -257,
|
|
333
|
+
) {
|
|
334
|
+
const map = yield* exactMap(
|
|
335
|
+
input,
|
|
336
|
+
yield* parsed(() => decodeCredentialPublicKey(new Uint8Array(input))),
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const algorithmId = yield* decode(Schema.Literals([-7, -257]), map.get(3));
|
|
340
|
+
|
|
341
|
+
if (!allowed.includes(algorithmId) || (expected !== undefined && algorithmId !== expected))
|
|
342
|
+
return yield* rejected();
|
|
343
|
+
let jwk: JsonWebKey;
|
|
344
|
+
let algorithm: EcKeyImportParams | RsaHashedImportParams;
|
|
345
|
+
|
|
346
|
+
if (algorithmId === -7) {
|
|
347
|
+
if (
|
|
348
|
+
map.size !== 5 ||
|
|
349
|
+
![1, 3, -1, -2, -3].every((key) => map.has(key)) ||
|
|
350
|
+
map.get(1) !== 2 ||
|
|
351
|
+
map.get(-1) !== 1
|
|
352
|
+
)
|
|
353
|
+
return yield* rejected();
|
|
354
|
+
const x = yield* decode(byteValue(32, 32), map.get(-2));
|
|
355
|
+
const y = yield* decode(byteValue(32, 32), map.get(-3));
|
|
356
|
+
|
|
357
|
+
jwk = {
|
|
358
|
+
kty: "EC",
|
|
359
|
+
crv: "P-256",
|
|
360
|
+
x: Encoding.encodeBase64Url(x),
|
|
361
|
+
y: Encoding.encodeBase64Url(y),
|
|
362
|
+
ext: false,
|
|
363
|
+
};
|
|
364
|
+
algorithm = { name: "ECDSA", namedCurve: "P-256" };
|
|
365
|
+
} else {
|
|
366
|
+
if (map.size !== 4 || ![1, 3, -1, -2].every((key) => map.has(key)) || map.get(1) !== 3)
|
|
367
|
+
return yield* rejected();
|
|
368
|
+
const n = yield* decode(byteValue(256, 512), map.get(-1));
|
|
369
|
+
const e = yield* decode(byteValue(1, 4), map.get(-2));
|
|
370
|
+
const exponent = e.reduce((value, byte) => value * 256 + byte, 0);
|
|
371
|
+
|
|
372
|
+
if (
|
|
373
|
+
n[0]! < 128 ||
|
|
374
|
+
(n[n.length - 1]! & 1) === 0 ||
|
|
375
|
+
e[0] === 0 ||
|
|
376
|
+
exponent < 3 ||
|
|
377
|
+
exponent % 2 !== 1
|
|
378
|
+
)
|
|
379
|
+
return yield* rejected();
|
|
380
|
+
jwk = {
|
|
381
|
+
kty: "RSA",
|
|
382
|
+
alg: "RS256",
|
|
383
|
+
n: Encoding.encodeBase64Url(n),
|
|
384
|
+
e: Encoding.encodeBase64Url(e),
|
|
385
|
+
ext: false,
|
|
386
|
+
};
|
|
387
|
+
algorithm = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" };
|
|
388
|
+
}
|
|
389
|
+
yield* maintained(() =>
|
|
390
|
+
globalThis.crypto.subtle.importKey("jwk", jwk, algorithm, false, ["verify"]),
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
return algorithmId;
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
export const makeSimpleWebAuthnPasskeyProtocol = Effect.fn("makeSimpleWebAuthnPasskeyProtocol")(
|
|
397
|
+
function* (
|
|
398
|
+
options: SimpleWebAuthnPasskeyProtocolOptions,
|
|
399
|
+
): Effect.fn.Return<Protocol, PasskeyConfigurationError> {
|
|
400
|
+
const select = yield* captureSimpleWebAuthnProfiles(options);
|
|
401
|
+
|
|
402
|
+
const prepareAuthentication = Effect.fn("SimpleWebAuthnPasskeyProtocol.prepareAuthentication")(
|
|
403
|
+
function* (input: Input<"prepareAuthentication">) {
|
|
404
|
+
const fixed = yield* capture(prepareAuthenticationInput, input);
|
|
405
|
+
const profile = yield* select(fixed.profile);
|
|
406
|
+
const challenge = yield* bytes(fixed.challenge);
|
|
407
|
+
|
|
408
|
+
const result = yield* maintained(() =>
|
|
409
|
+
generateAuthenticationOptions({
|
|
410
|
+
rpID: profile.rpId,
|
|
411
|
+
challenge,
|
|
412
|
+
timeout: fixed.timeoutMillis,
|
|
413
|
+
userVerification: profile.userVerification,
|
|
414
|
+
allowCredentials: fixed.allowedCredentials.map((value) => ({ ...value })),
|
|
415
|
+
}),
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
const value = yield* decode(PasskeyAuthenticationOptions, {
|
|
419
|
+
challenge: result.challenge,
|
|
420
|
+
rpId: result.rpId,
|
|
421
|
+
timeout: result.timeout,
|
|
422
|
+
userVerification: result.userVerification,
|
|
423
|
+
allowCredentials: result.allowCredentials ?? [],
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
if (
|
|
427
|
+
!(yield* samePasskey(PasskeyAuthenticationOptions, value, {
|
|
428
|
+
challenge: fixed.challenge,
|
|
429
|
+
rpId: profile.rpId,
|
|
430
|
+
timeout: fixed.timeoutMillis,
|
|
431
|
+
userVerification: profile.userVerification,
|
|
432
|
+
allowCredentials: fixed.allowedCredentials,
|
|
433
|
+
}))
|
|
434
|
+
)
|
|
435
|
+
return yield* unavailable();
|
|
436
|
+
|
|
437
|
+
return yield* capture(PasskeyAuthenticationOptions, value);
|
|
438
|
+
},
|
|
439
|
+
unexpected,
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
const prepareRegistration = Effect.fn("SimpleWebAuthnPasskeyProtocol.prepareRegistration")(
|
|
443
|
+
function* (input: Input<"prepareRegistration">) {
|
|
444
|
+
const fixed = yield* capture(prepareRegistrationInput, input);
|
|
445
|
+
const profile = yield* select(fixed.profile);
|
|
446
|
+
|
|
447
|
+
const challenge = yield* bytes(fixed.challenge),
|
|
448
|
+
userID = yield* bytes(fixed.userHandle);
|
|
449
|
+
|
|
450
|
+
const result = yield* maintained(() =>
|
|
451
|
+
generateRegistrationOptions({
|
|
452
|
+
rpName: profile.rpName,
|
|
453
|
+
rpID: profile.rpId,
|
|
454
|
+
userName: fixed.name,
|
|
455
|
+
userDisplayName: fixed.displayName,
|
|
456
|
+
userID,
|
|
457
|
+
challenge,
|
|
458
|
+
timeout: fixed.timeoutMillis,
|
|
459
|
+
attestationType: "none",
|
|
460
|
+
supportedAlgorithmIDs: [...profile.algorithms],
|
|
461
|
+
authenticatorSelection: {
|
|
462
|
+
residentKey: profile.residentKey,
|
|
463
|
+
userVerification: profile.userVerification,
|
|
464
|
+
},
|
|
465
|
+
excludeCredentials: fixed.excludedCredentials.map((value) => ({ ...value })),
|
|
466
|
+
}),
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
const value = yield* decode(PasskeyRegistrationOptions, {
|
|
470
|
+
challenge: result.challenge,
|
|
471
|
+
rp: result.rp,
|
|
472
|
+
user: result.user,
|
|
473
|
+
pubKeyCredParams: result.pubKeyCredParams,
|
|
474
|
+
timeout: result.timeout,
|
|
475
|
+
attestation: result.attestation,
|
|
476
|
+
authenticatorSelection: result.authenticatorSelection,
|
|
477
|
+
excludeCredentials: result.excludeCredentials ?? [],
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
const expected = {
|
|
481
|
+
challenge: fixed.challenge,
|
|
482
|
+
rp: { id: profile.rpId, name: profile.rpName },
|
|
483
|
+
user: { id: fixed.userHandle, name: fixed.name, displayName: fixed.displayName },
|
|
484
|
+
pubKeyCredParams: profile.algorithms.map((alg) => ({ type: "public-key", alg })),
|
|
485
|
+
timeout: fixed.timeoutMillis,
|
|
486
|
+
attestation: "none",
|
|
487
|
+
authenticatorSelection: {
|
|
488
|
+
residentKey: profile.residentKey,
|
|
489
|
+
userVerification: profile.userVerification,
|
|
490
|
+
},
|
|
491
|
+
excludeCredentials: fixed.excludedCredentials,
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
if (
|
|
495
|
+
!(yield* samePasskey(
|
|
496
|
+
PasskeyRegistrationOptions,
|
|
497
|
+
value,
|
|
498
|
+
yield* decode(PasskeyRegistrationOptions, expected),
|
|
499
|
+
))
|
|
500
|
+
)
|
|
501
|
+
return yield* unavailable();
|
|
502
|
+
|
|
503
|
+
return yield* capture(PasskeyRegistrationOptions, value);
|
|
504
|
+
},
|
|
505
|
+
unexpected,
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
const verifyRegistration = Effect.fn("SimpleWebAuthnPasskeyProtocol.verifyRegistration")(
|
|
509
|
+
function* (input: Input<"verifyRegistration">) {
|
|
510
|
+
const fixed = yield* capture(registrationInput, input),
|
|
511
|
+
ceremony = fixed.ceremony;
|
|
512
|
+
|
|
513
|
+
const profile = yield* select(ceremony.profile);
|
|
514
|
+
|
|
515
|
+
if (
|
|
516
|
+
(ceremony.purpose !== "registration" || ceremony.context._tag !== "Registration") &&
|
|
517
|
+
(ceremony.purpose !== "enrollment" || ceremony.context._tag !== "Enrollment")
|
|
518
|
+
)
|
|
519
|
+
return yield* rejected();
|
|
520
|
+
yield* fresh(ceremony);
|
|
521
|
+
|
|
522
|
+
const wire = yield* decode(
|
|
523
|
+
Schema.fromJsonString(registrationResponse),
|
|
524
|
+
Redacted.value(fixed.response),
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
if (wire.id !== wire.rawId) return yield* rejected();
|
|
528
|
+
|
|
529
|
+
const client = yield* checkClient(
|
|
530
|
+
wire.response.clientDataJSON,
|
|
531
|
+
ceremony,
|
|
532
|
+
"webauthn.create",
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
const attestationBytes = yield* bytes(wire.response.attestationObject);
|
|
536
|
+
|
|
537
|
+
const attestation = yield* exactMap(
|
|
538
|
+
attestationBytes,
|
|
539
|
+
yield* parsed(() => decodeAttestationObject(new Uint8Array(attestationBytes))),
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
if (
|
|
543
|
+
attestation.size !== 3 ||
|
|
544
|
+
attestation.get("fmt") !== "none" ||
|
|
545
|
+
!attestation.has("attStmt") ||
|
|
546
|
+
!attestation.has("authData")
|
|
547
|
+
)
|
|
548
|
+
return yield* rejected();
|
|
549
|
+
const statement = yield* decode(cborMap, attestation.get("attStmt"));
|
|
550
|
+
|
|
551
|
+
if (statement.size !== 0) return yield* rejected();
|
|
552
|
+
const authenticatorBytes = yield* decode(byteValue(37, 16384), attestation.get("authData"));
|
|
553
|
+
const auth = yield* checkAuthenticator(authenticatorBytes, true, profile);
|
|
554
|
+
const id = yield* decode(byteValue(1, 1023), auth.credentialID);
|
|
555
|
+
const key = yield* decode(byteValue(1, 8192), auth.credentialPublicKey);
|
|
556
|
+
|
|
557
|
+
if (Encoding.encodeBase64Url(id) !== wire.id) return yield* rejected();
|
|
558
|
+
const algorithm = yield* checkKey(key, profile.algorithms);
|
|
559
|
+
|
|
560
|
+
const result = yield* maintained(() =>
|
|
561
|
+
verifyRegistrationResponse({
|
|
562
|
+
response: { ...wire, clientExtensionResults: {} },
|
|
563
|
+
expectedChallenge: ceremony.challenge,
|
|
564
|
+
expectedOrigin: [...profile.origins],
|
|
565
|
+
expectedRPID: profile.rpId,
|
|
566
|
+
expectedType: "webauthn.create",
|
|
567
|
+
requireUserPresence: true,
|
|
568
|
+
requireUserVerification: profile.userVerification === "required",
|
|
569
|
+
supportedAlgorithmIDs: [...profile.algorithms],
|
|
570
|
+
}),
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
if (!result.verified || result.registrationInfo === undefined) return yield* rejected();
|
|
574
|
+
const info = result.registrationInfo;
|
|
575
|
+
|
|
576
|
+
if (
|
|
577
|
+
info.fmt !== "none" ||
|
|
578
|
+
info.origin !== client.origin ||
|
|
579
|
+
info.rpID !== profile.rpId ||
|
|
580
|
+
info.credential.id !== wire.id ||
|
|
581
|
+
!sameBytes(info.credential.publicKey, key) ||
|
|
582
|
+
info.credential.counter !== auth.counter ||
|
|
583
|
+
info.userVerified !== auth.flags.uv ||
|
|
584
|
+
(info.credentialDeviceType === "multiDevice") !== auth.flags.be ||
|
|
585
|
+
info.credentialBackedUp !== auth.flags.bs
|
|
586
|
+
)
|
|
587
|
+
return yield* rejected();
|
|
588
|
+
yield* fresh(ceremony);
|
|
589
|
+
|
|
590
|
+
return yield* capture(
|
|
591
|
+
PasskeyRegistrationVerified,
|
|
592
|
+
yield* decode(PasskeyRegistrationVerified, {
|
|
593
|
+
protocolCredentialId: info.credential.id,
|
|
594
|
+
publicKey: Encoding.encodeBase64Url(info.credential.publicKey),
|
|
595
|
+
algorithm,
|
|
596
|
+
counter: info.credential.counter,
|
|
597
|
+
userVerified: info.userVerified,
|
|
598
|
+
backupEligible: info.credentialDeviceType === "multiDevice",
|
|
599
|
+
backupState: info.credentialBackedUp,
|
|
600
|
+
}),
|
|
601
|
+
);
|
|
602
|
+
},
|
|
603
|
+
unexpected,
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
const verifyAuthentication = Effect.fn("SimpleWebAuthnPasskeyProtocol.verifyAuthentication")(
|
|
607
|
+
function* (input: Input<"verifyAuthentication">) {
|
|
608
|
+
const fixed = yield* capture(authenticationInput, input),
|
|
609
|
+
ceremony = fixed.ceremony,
|
|
610
|
+
credential = fixed.credential;
|
|
611
|
+
|
|
612
|
+
const profile = yield* select(ceremony.profile),
|
|
613
|
+
original = yield* select(credential.profile);
|
|
614
|
+
|
|
615
|
+
const contexts = {
|
|
616
|
+
"sign-in": "SignIn",
|
|
617
|
+
pending: "Pending",
|
|
618
|
+
"step-up": "StepUp",
|
|
619
|
+
action: "Action",
|
|
620
|
+
} as const;
|
|
621
|
+
|
|
622
|
+
if (
|
|
623
|
+
ceremony.purpose === "registration" ||
|
|
624
|
+
ceremony.purpose === "enrollment" ||
|
|
625
|
+
contexts[ceremony.purpose] !== ceremony.context._tag ||
|
|
626
|
+
!credential.active ||
|
|
627
|
+
credential.rpId !== profile.rpId ||
|
|
628
|
+
original.rpId !== profile.rpId ||
|
|
629
|
+
!original.algorithms.includes(credential.algorithm) ||
|
|
630
|
+
!profile.algorithms.includes(credential.algorithm)
|
|
631
|
+
)
|
|
632
|
+
return yield* rejected();
|
|
633
|
+
yield* fresh(ceremony);
|
|
634
|
+
|
|
635
|
+
const wire = yield* decode(
|
|
636
|
+
Schema.fromJsonString(authenticationResponse),
|
|
637
|
+
Redacted.value(fixed.response),
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
const userHandle = wire.response.userHandle ?? undefined;
|
|
641
|
+
|
|
642
|
+
if (
|
|
643
|
+
wire.id !== wire.rawId ||
|
|
644
|
+
wire.id !== credential.protocolCredentialId ||
|
|
645
|
+
(userHandle !== undefined && userHandle !== credential.userHandle) ||
|
|
646
|
+
(ceremony.context._tag === "SignIn" && userHandle === undefined) ||
|
|
647
|
+
(ceremony.allowedCredentials.length > 0 &&
|
|
648
|
+
!ceremony.allowedCredentials.some((value) => value.id === wire.id))
|
|
649
|
+
)
|
|
650
|
+
return yield* rejected();
|
|
651
|
+
const client = yield* checkClient(wire.response.clientDataJSON, ceremony, "webauthn.get");
|
|
652
|
+
|
|
653
|
+
const auth = yield* checkAuthenticator(
|
|
654
|
+
yield* bytes(wire.response.authenticatorData),
|
|
655
|
+
false,
|
|
656
|
+
profile,
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
if (
|
|
660
|
+
auth.flags.be !== credential.backupEligible ||
|
|
661
|
+
(!credential.backupEligible &&
|
|
662
|
+
(credential.counter > 0 || auth.counter > 0) &&
|
|
663
|
+
auth.counter <= credential.counter)
|
|
664
|
+
)
|
|
665
|
+
return yield* rejected();
|
|
666
|
+
const publicKey = yield* bytes(credential.publicKey);
|
|
667
|
+
|
|
668
|
+
yield* checkKey(publicKey, profile.algorithms, credential.algorithm);
|
|
669
|
+
|
|
670
|
+
const result = yield* maintained(() =>
|
|
671
|
+
verifyAuthenticationResponse({
|
|
672
|
+
response: {
|
|
673
|
+
...wire,
|
|
674
|
+
response: { ...wire.response, userHandle },
|
|
675
|
+
clientExtensionResults: {},
|
|
676
|
+
},
|
|
677
|
+
expectedChallenge: ceremony.challenge,
|
|
678
|
+
expectedOrigin: [...profile.origins],
|
|
679
|
+
expectedRPID: profile.rpId,
|
|
680
|
+
expectedType: "webauthn.get",
|
|
681
|
+
requireUserVerification: profile.userVerification === "required",
|
|
682
|
+
credential: {
|
|
683
|
+
id: credential.protocolCredentialId,
|
|
684
|
+
publicKey,
|
|
685
|
+
counter: credential.backupEligible ? 0 : credential.counter,
|
|
686
|
+
},
|
|
687
|
+
}),
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
if (!result.verified) return yield* rejected();
|
|
691
|
+
const info = result.authenticationInfo;
|
|
692
|
+
|
|
693
|
+
if (
|
|
694
|
+
info.credentialID !== wire.id ||
|
|
695
|
+
info.origin !== client.origin ||
|
|
696
|
+
info.rpID !== profile.rpId ||
|
|
697
|
+
info.newCounter !== auth.counter ||
|
|
698
|
+
info.userVerified !== auth.flags.uv ||
|
|
699
|
+
(info.credentialDeviceType === "multiDevice") !== credential.backupEligible ||
|
|
700
|
+
info.credentialBackedUp !== auth.flags.bs
|
|
701
|
+
)
|
|
702
|
+
return yield* rejected();
|
|
703
|
+
yield* fresh(ceremony);
|
|
704
|
+
|
|
705
|
+
return yield* capture(
|
|
706
|
+
PasskeyAssertionVerified,
|
|
707
|
+
yield* decode(PasskeyAssertionVerified, {
|
|
708
|
+
protocolCredentialId: info.credentialID,
|
|
709
|
+
...(userHandle === undefined ? {} : { userHandle }),
|
|
710
|
+
counter: info.newCounter,
|
|
711
|
+
userVerified: info.userVerified,
|
|
712
|
+
backupEligible: info.credentialDeviceType === "multiDevice",
|
|
713
|
+
backupState: info.credentialBackedUp,
|
|
714
|
+
}),
|
|
715
|
+
);
|
|
716
|
+
},
|
|
717
|
+
unexpected,
|
|
718
|
+
);
|
|
719
|
+
|
|
720
|
+
return PasskeyProtocol.of({
|
|
721
|
+
prepareAuthentication,
|
|
722
|
+
prepareRegistration,
|
|
723
|
+
verifyRegistration,
|
|
724
|
+
verifyAuthentication,
|
|
725
|
+
});
|
|
726
|
+
},
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
/** Requires the same host profiles as the strategy. */
|
|
730
|
+
export const layerSimpleWebAuthnPasskeyProtocol = Layer.effect(
|
|
731
|
+
PasskeyProtocol,
|
|
732
|
+
Effect.flatMap(PasskeyConfig, makeSimpleWebAuthnPasskeyProtocol),
|
|
733
|
+
);
|