@c9up/warden 0.1.4 → 0.1.6
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 +58 -0
- package/dist/Guard.d.ts +9 -0
- package/dist/Guard.d.ts.map +1 -1
- package/dist/Guard.js +16 -0
- package/dist/Guard.js.map +1 -1
- package/dist/RedisBlacklistDriver.d.ts +32 -0
- package/dist/RedisBlacklistDriver.d.ts.map +1 -0
- package/dist/RedisBlacklistDriver.js +37 -0
- package/dist/RedisBlacklistDriver.js.map +1 -0
- package/dist/ResilientBlacklistDriver.d.ts +35 -0
- package/dist/ResilientBlacklistDriver.d.ts.map +1 -0
- package/dist/ResilientBlacklistDriver.js +61 -0
- package/dist/ResilientBlacklistDriver.js.map +1 -0
- package/dist/TokenBlacklist.d.ts +6 -2
- package/dist/TokenBlacklist.d.ts.map +1 -1
- package/dist/TokenBlacklist.js +2 -2
- package/dist/TokenBlacklist.js.map +1 -1
- package/dist/WardenProvider.d.ts.map +1 -1
- package/dist/WardenProvider.js +47 -6
- package/dist/WardenProvider.js.map +1 -1
- package/dist/config.d.ts +65 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +15 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/mfa/BackupCodesProvider.d.ts +39 -0
- package/dist/mfa/BackupCodesProvider.d.ts.map +1 -0
- package/dist/mfa/BackupCodesProvider.js +87 -0
- package/dist/mfa/BackupCodesProvider.js.map +1 -0
- package/dist/mfa/MfaManager.d.ts +107 -0
- package/dist/mfa/MfaManager.d.ts.map +1 -0
- package/dist/mfa/MfaManager.js +219 -0
- package/dist/mfa/MfaManager.js.map +1 -0
- package/dist/mfa/OtpProvider.d.ts +67 -0
- package/dist/mfa/OtpProvider.d.ts.map +1 -0
- package/dist/mfa/OtpProvider.js +104 -0
- package/dist/mfa/OtpProvider.js.map +1 -0
- package/dist/mfa/TotpProvider.d.ts +52 -0
- package/dist/mfa/TotpProvider.d.ts.map +1 -0
- package/dist/mfa/TotpProvider.js +103 -0
- package/dist/mfa/TotpProvider.js.map +1 -0
- package/dist/mfa/WebauthnProvider.d.ts +175 -0
- package/dist/mfa/WebauthnProvider.d.ts.map +1 -0
- package/dist/mfa/WebauthnProvider.js +239 -0
- package/dist/mfa/WebauthnProvider.js.map +1 -0
- package/dist/mfa/base32.d.ts +13 -0
- package/dist/mfa/base32.d.ts.map +1 -0
- package/dist/mfa/base32.js +52 -0
- package/dist/mfa/base32.js.map +1 -0
- package/dist/mfa/webauthn-codec.d.ts +58 -0
- package/dist/mfa/webauthn-codec.d.ts.map +1 -0
- package/dist/mfa/webauthn-codec.js +221 -0
- package/dist/mfa/webauthn-codec.js.map +1 -0
- package/dist/middleware.d.ts +31 -0
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +77 -4
- package/dist/middleware.js.map +1 -1
- package/dist/standalone.d.ts.map +1 -1
- package/dist/standalone.js +12 -0
- package/dist/standalone.js.map +1 -1
- package/dist/strategies/ApiKeyStrategy.d.ts.map +1 -1
- package/dist/strategies/ApiKeyStrategy.js +14 -1
- package/dist/strategies/ApiKeyStrategy.js.map +1 -1
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +2 -2
- package/src/Guard.ts +21 -0
- package/src/RedisBlacklistDriver.ts +60 -0
- package/src/ResilientBlacklistDriver.ts +84 -0
- package/src/TokenBlacklist.ts +7 -3
- package/src/WardenProvider.ts +53 -7
- package/src/config.ts +60 -1
- package/src/index.ts +58 -0
- package/src/mfa/BackupCodesProvider.ts +125 -0
- package/src/mfa/MfaManager.ts +307 -0
- package/src/mfa/OtpProvider.ts +177 -0
- package/src/mfa/TotpProvider.ts +140 -0
- package/src/mfa/WebauthnProvider.ts +416 -0
- package/src/mfa/base32.ts +54 -0
- package/src/mfa/webauthn-codec.ts +264 -0
- package/src/middleware.ts +111 -2
- package/src/standalone.ts +14 -1
- package/src/strategies/ApiKeyStrategy.ts +14 -1
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebauthnProvider — passkeys / FIDO2 via WebAuthn, implemented in-house on
|
|
3
|
+
* `node:crypto` (see `webauthn-codec.ts`). No third-party dependency: warden
|
|
4
|
+
* keeps its zero-runtime-dependency footprint.
|
|
5
|
+
*
|
|
6
|
+
* The browser side produces the standard `PublicKeyCredential` JSON; this
|
|
7
|
+
* provider builds the ceremony options and verifies the responses server-side.
|
|
8
|
+
*
|
|
9
|
+
* Attestation statements are NOT verified — this is a deliberate
|
|
10
|
+
* trust-on-registration posture (request `attestation: "none"` on the client),
|
|
11
|
+
* which is what the vast majority of relying parties use for 2FA / passkeys.
|
|
12
|
+
* Full attestation (packed/tpm/apple/MDS cert-chain validation) can be layered
|
|
13
|
+
* on later without changing this API.
|
|
14
|
+
*
|
|
15
|
+
* Both the per-ceremony challenge and the persisted passkeys go through
|
|
16
|
+
* pluggable stores (in-memory defaults), mirroring the rest of warden.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { randomBytes } from "node:crypto";
|
|
20
|
+
import { WardenError } from "../errors.js";
|
|
21
|
+
import {
|
|
22
|
+
base64urlToBuffer,
|
|
23
|
+
bufferToBase64url,
|
|
24
|
+
type CborMap,
|
|
25
|
+
coseToKeyObject,
|
|
26
|
+
decodeCbor,
|
|
27
|
+
parseAuthenticatorData,
|
|
28
|
+
sha256,
|
|
29
|
+
verifyWebauthnSignature,
|
|
30
|
+
} from "./webauthn-codec.js";
|
|
31
|
+
|
|
32
|
+
/** Browser → server payload from `navigator.credentials.create()`. */
|
|
33
|
+
export interface RegistrationResponseJSON {
|
|
34
|
+
id: string;
|
|
35
|
+
rawId: string;
|
|
36
|
+
type: "public-key";
|
|
37
|
+
response: {
|
|
38
|
+
clientDataJSON: string;
|
|
39
|
+
attestationObject: string;
|
|
40
|
+
transports?: string[];
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Browser → server payload from `navigator.credentials.get()`. */
|
|
45
|
+
export interface AuthenticationResponseJSON {
|
|
46
|
+
id: string;
|
|
47
|
+
rawId: string;
|
|
48
|
+
type: "public-key";
|
|
49
|
+
response: {
|
|
50
|
+
clientDataJSON: string;
|
|
51
|
+
authenticatorData: string;
|
|
52
|
+
signature: string;
|
|
53
|
+
userHandle?: string;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface RegistrationOptionsJSON {
|
|
58
|
+
challenge: string;
|
|
59
|
+
rp: { name: string; id: string };
|
|
60
|
+
user: { id: string; name: string; displayName: string };
|
|
61
|
+
pubKeyCredParams: Array<{ type: "public-key"; alg: number }>;
|
|
62
|
+
timeout: number;
|
|
63
|
+
attestation: "none";
|
|
64
|
+
excludeCredentials: Array<{
|
|
65
|
+
id: string;
|
|
66
|
+
type: "public-key";
|
|
67
|
+
transports?: string[];
|
|
68
|
+
}>;
|
|
69
|
+
authenticatorSelection: { residentKey: string; userVerification: string };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface AuthenticationOptionsJSON {
|
|
73
|
+
challenge: string;
|
|
74
|
+
timeout: number;
|
|
75
|
+
rpId: string;
|
|
76
|
+
userVerification: string;
|
|
77
|
+
allowCredentials?: Array<{
|
|
78
|
+
id: string;
|
|
79
|
+
type: "public-key";
|
|
80
|
+
transports?: string[];
|
|
81
|
+
}>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Transient per-ceremony challenge storage. */
|
|
85
|
+
export interface WebauthnChallengeStore {
|
|
86
|
+
save(state: string, challenge: string): Promise<void>;
|
|
87
|
+
/** Return the challenge for `state` and remove it (single-use). */
|
|
88
|
+
take(state: string): Promise<string | null>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A registered passkey, persisted between ceremonies. */
|
|
92
|
+
export interface StoredPasskey {
|
|
93
|
+
/** Base64URL credential id. */
|
|
94
|
+
id: string;
|
|
95
|
+
userId: string;
|
|
96
|
+
/** Base64URL of the raw COSE public-key bytes. */
|
|
97
|
+
publicKey: string;
|
|
98
|
+
/** COSE algorithm id (e.g. -7 for ES256). */
|
|
99
|
+
alg: number;
|
|
100
|
+
counter: number;
|
|
101
|
+
transports?: string[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface WebauthnCredentialStore {
|
|
105
|
+
save(passkey: StoredPasskey): Promise<void>;
|
|
106
|
+
findById(id: string): Promise<StoredPasskey | null>;
|
|
107
|
+
findByUser(userId: string): Promise<StoredPasskey[]>;
|
|
108
|
+
updateCounter(id: string, counter: number): Promise<void>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export class MemoryWebauthnChallengeStore implements WebauthnChallengeStore {
|
|
112
|
+
#store = new Map<string, string>();
|
|
113
|
+
async save(state: string, challenge: string): Promise<void> {
|
|
114
|
+
this.#store.set(state, challenge);
|
|
115
|
+
}
|
|
116
|
+
async take(state: string): Promise<string | null> {
|
|
117
|
+
const c = this.#store.get(state) ?? null;
|
|
118
|
+
this.#store.delete(state);
|
|
119
|
+
return c;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export class MemoryWebauthnCredentialStore implements WebauthnCredentialStore {
|
|
124
|
+
#store = new Map<string, StoredPasskey>();
|
|
125
|
+
async save(passkey: StoredPasskey): Promise<void> {
|
|
126
|
+
this.#store.set(passkey.id, passkey);
|
|
127
|
+
}
|
|
128
|
+
async findById(id: string): Promise<StoredPasskey | null> {
|
|
129
|
+
return this.#store.get(id) ?? null;
|
|
130
|
+
}
|
|
131
|
+
async findByUser(userId: string): Promise<StoredPasskey[]> {
|
|
132
|
+
return [...this.#store.values()].filter((p) => p.userId === userId);
|
|
133
|
+
}
|
|
134
|
+
async updateCounter(id: string, counter: number): Promise<void> {
|
|
135
|
+
const p = this.#store.get(id);
|
|
136
|
+
if (p) {
|
|
137
|
+
this.#store.set(id, { ...p, counter });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface WebauthnConfig {
|
|
143
|
+
/** Human-readable relying-party name (your app). */
|
|
144
|
+
rpName: string;
|
|
145
|
+
/** Relying-party ID — your registrable domain (e.g. `fluveo.ch`). */
|
|
146
|
+
rpID: string;
|
|
147
|
+
/** Expected origin(s) of the ceremony (e.g. `https://fluveo.ch`). */
|
|
148
|
+
origin: string | string[];
|
|
149
|
+
challengeStore?: WebauthnChallengeStore;
|
|
150
|
+
credentialStore?: WebauthnCredentialStore;
|
|
151
|
+
/** Ceremony timeout in ms. Default `60000`. */
|
|
152
|
+
timeout?: number;
|
|
153
|
+
/**
|
|
154
|
+
* COSE algorithms offered to the authenticator, in preference order.
|
|
155
|
+
* Default ES256, RS256, EdDSA.
|
|
156
|
+
*/
|
|
157
|
+
supportedAlgorithms?: number[];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface WebauthnUser {
|
|
161
|
+
id: string;
|
|
162
|
+
name: string;
|
|
163
|
+
displayName?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const DEFAULT_ALGS = [-7, -257, -8];
|
|
167
|
+
|
|
168
|
+
export class WebauthnProvider {
|
|
169
|
+
readonly kind = "webauthn" as const;
|
|
170
|
+
readonly #rpName: string;
|
|
171
|
+
readonly #rpID: string;
|
|
172
|
+
readonly #origins: string[];
|
|
173
|
+
readonly #timeout: number;
|
|
174
|
+
readonly #algorithms: number[];
|
|
175
|
+
readonly #challenges: WebauthnChallengeStore;
|
|
176
|
+
readonly #credentials: WebauthnCredentialStore;
|
|
177
|
+
|
|
178
|
+
constructor(config: WebauthnConfig) {
|
|
179
|
+
if (!config?.rpID || !config?.rpName || !config?.origin) {
|
|
180
|
+
throw new WardenError(
|
|
181
|
+
"INVALID_CONFIG",
|
|
182
|
+
"WebauthnProvider requires rpName, rpID and origin",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
this.#rpName = config.rpName;
|
|
186
|
+
this.#rpID = config.rpID;
|
|
187
|
+
this.#origins = Array.isArray(config.origin)
|
|
188
|
+
? config.origin
|
|
189
|
+
: [config.origin];
|
|
190
|
+
this.#timeout = config.timeout ?? 60_000;
|
|
191
|
+
this.#algorithms = config.supportedAlgorithms ?? DEFAULT_ALGS;
|
|
192
|
+
this.#challenges =
|
|
193
|
+
config.challengeStore ?? new MemoryWebauthnChallengeStore();
|
|
194
|
+
this.#credentials =
|
|
195
|
+
config.credentialStore ?? new MemoryWebauthnCredentialStore();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Begin passkey registration. Returns the options to hand to the browser,
|
|
200
|
+
* plus an opaque `state` token the caller stashes in the session and passes
|
|
201
|
+
* back to `finishRegistration()`.
|
|
202
|
+
*/
|
|
203
|
+
async startRegistration(
|
|
204
|
+
user: WebauthnUser,
|
|
205
|
+
): Promise<{ options: RegistrationOptionsJSON; state: string }> {
|
|
206
|
+
const existing = await this.#credentials.findByUser(user.id);
|
|
207
|
+
const challenge = bufferToBase64url(randomBytes(32));
|
|
208
|
+
const options: RegistrationOptionsJSON = {
|
|
209
|
+
challenge,
|
|
210
|
+
rp: { name: this.#rpName, id: this.#rpID },
|
|
211
|
+
user: {
|
|
212
|
+
id: bufferToBase64url(Buffer.from(user.id, "utf8")),
|
|
213
|
+
name: user.name,
|
|
214
|
+
displayName: user.displayName ?? user.name,
|
|
215
|
+
},
|
|
216
|
+
pubKeyCredParams: this.#algorithms.map((alg) => ({
|
|
217
|
+
type: "public-key",
|
|
218
|
+
alg,
|
|
219
|
+
})),
|
|
220
|
+
timeout: this.#timeout,
|
|
221
|
+
attestation: "none",
|
|
222
|
+
excludeCredentials: existing.map((c) => ({
|
|
223
|
+
id: c.id,
|
|
224
|
+
type: "public-key",
|
|
225
|
+
transports: c.transports,
|
|
226
|
+
})),
|
|
227
|
+
authenticatorSelection: {
|
|
228
|
+
residentKey: "preferred",
|
|
229
|
+
userVerification: "preferred",
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
const state = randomBytes(16).toString("hex");
|
|
233
|
+
await this.#challenges.save(state, challenge);
|
|
234
|
+
return { options, state };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Complete registration: validate client data + authenticator data against
|
|
239
|
+
* the stored challenge and persist the new passkey on success.
|
|
240
|
+
*/
|
|
241
|
+
async finishRegistration(
|
|
242
|
+
state: string,
|
|
243
|
+
userId: string,
|
|
244
|
+
response: RegistrationResponseJSON,
|
|
245
|
+
): Promise<{ verified: boolean }> {
|
|
246
|
+
const expectedChallenge = await this.#challenges.take(state);
|
|
247
|
+
if (!expectedChallenge) {
|
|
248
|
+
return { verified: false };
|
|
249
|
+
}
|
|
250
|
+
if (
|
|
251
|
+
!this.#validClientData(
|
|
252
|
+
response.response.clientDataJSON,
|
|
253
|
+
"webauthn.create",
|
|
254
|
+
expectedChallenge,
|
|
255
|
+
)
|
|
256
|
+
) {
|
|
257
|
+
return { verified: false };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const attestation = decodeCbor(
|
|
261
|
+
base64urlToBuffer(response.response.attestationObject),
|
|
262
|
+
).value;
|
|
263
|
+
if (!(attestation instanceof Map)) {
|
|
264
|
+
return { verified: false };
|
|
265
|
+
}
|
|
266
|
+
const authDataRaw = attestation.get("authData");
|
|
267
|
+
if (!Buffer.isBuffer(authDataRaw)) {
|
|
268
|
+
return { verified: false };
|
|
269
|
+
}
|
|
270
|
+
const authData = parseAuthenticatorData(authDataRaw);
|
|
271
|
+
if (
|
|
272
|
+
!this.#validAuthenticator(authData) ||
|
|
273
|
+
!authData.cosePublicKey ||
|
|
274
|
+
!authData.cosePublicKeyBytes ||
|
|
275
|
+
!authData.credentialId
|
|
276
|
+
) {
|
|
277
|
+
return { verified: false };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const { alg } = coseToKeyObject(authData.cosePublicKey);
|
|
281
|
+
await this.#credentials.save({
|
|
282
|
+
id: bufferToBase64url(authData.credentialId),
|
|
283
|
+
userId,
|
|
284
|
+
publicKey: bufferToBase64url(authData.cosePublicKeyBytes),
|
|
285
|
+
alg,
|
|
286
|
+
counter: authData.signCount,
|
|
287
|
+
transports: response.response.transports,
|
|
288
|
+
});
|
|
289
|
+
return { verified: true };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Begin authentication. Pass `userId` to restrict to that user's passkeys
|
|
294
|
+
* (2FA step); omit it for a usernameless / discoverable-credential sign-in.
|
|
295
|
+
*/
|
|
296
|
+
async startAuthentication(
|
|
297
|
+
userId?: string,
|
|
298
|
+
): Promise<{ options: AuthenticationOptionsJSON; state: string }> {
|
|
299
|
+
const challenge = bufferToBase64url(randomBytes(32));
|
|
300
|
+
const allow = userId ? await this.#credentials.findByUser(userId) : [];
|
|
301
|
+
const options: AuthenticationOptionsJSON = {
|
|
302
|
+
challenge,
|
|
303
|
+
timeout: this.#timeout,
|
|
304
|
+
rpId: this.#rpID,
|
|
305
|
+
userVerification: "preferred",
|
|
306
|
+
allowCredentials: userId
|
|
307
|
+
? allow.map((c) => ({
|
|
308
|
+
id: c.id,
|
|
309
|
+
type: "public-key",
|
|
310
|
+
transports: c.transports,
|
|
311
|
+
}))
|
|
312
|
+
: undefined,
|
|
313
|
+
};
|
|
314
|
+
const state = randomBytes(16).toString("hex");
|
|
315
|
+
await this.#challenges.save(state, challenge);
|
|
316
|
+
return { options, state };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Complete authentication: look up the asserted credential, verify the
|
|
321
|
+
* signature against the stored challenge, and advance the replay counter.
|
|
322
|
+
* Returns the owning `userId` on success.
|
|
323
|
+
*/
|
|
324
|
+
async finishAuthentication(
|
|
325
|
+
state: string,
|
|
326
|
+
response: AuthenticationResponseJSON,
|
|
327
|
+
): Promise<{ verified: boolean; userId?: string }> {
|
|
328
|
+
const expectedChallenge = await this.#challenges.take(state);
|
|
329
|
+
if (!expectedChallenge) {
|
|
330
|
+
return { verified: false };
|
|
331
|
+
}
|
|
332
|
+
if (
|
|
333
|
+
!this.#validClientData(
|
|
334
|
+
response.response.clientDataJSON,
|
|
335
|
+
"webauthn.get",
|
|
336
|
+
expectedChallenge,
|
|
337
|
+
)
|
|
338
|
+
) {
|
|
339
|
+
return { verified: false };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const stored = await this.#credentials.findById(response.id);
|
|
343
|
+
if (!stored) {
|
|
344
|
+
return { verified: false };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const authDataRaw = base64urlToBuffer(response.response.authenticatorData);
|
|
348
|
+
const authData = parseAuthenticatorData(authDataRaw);
|
|
349
|
+
if (!this.#validAuthenticator(authData)) {
|
|
350
|
+
return { verified: false };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Signed payload = authenticatorData ‖ SHA-256(clientDataJSON).
|
|
354
|
+
const clientHash = sha256(
|
|
355
|
+
base64urlToBuffer(response.response.clientDataJSON),
|
|
356
|
+
);
|
|
357
|
+
const signedData = Buffer.concat([authDataRaw, clientHash]);
|
|
358
|
+
const { key } = coseToKeyObject(
|
|
359
|
+
expectCoseMap(base64urlToBuffer(stored.publicKey)),
|
|
360
|
+
);
|
|
361
|
+
const ok = verifyWebauthnSignature(
|
|
362
|
+
stored.alg,
|
|
363
|
+
key,
|
|
364
|
+
signedData,
|
|
365
|
+
base64urlToBuffer(response.response.signature),
|
|
366
|
+
);
|
|
367
|
+
if (!ok) {
|
|
368
|
+
return { verified: false };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Replay guard: a non-zero counter must strictly advance. Authenticators
|
|
372
|
+
// that always report 0 (e.g. many platform passkeys) are exempt.
|
|
373
|
+
if (authData.signCount !== 0 && authData.signCount <= stored.counter) {
|
|
374
|
+
return { verified: false };
|
|
375
|
+
}
|
|
376
|
+
await this.#credentials.updateCounter(stored.id, authData.signCount);
|
|
377
|
+
return { verified: true, userId: stored.userId };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Validate clientDataJSON: type, challenge match, and origin allow-list. */
|
|
381
|
+
#validClientData(
|
|
382
|
+
clientDataB64: string,
|
|
383
|
+
expectedType: "webauthn.create" | "webauthn.get",
|
|
384
|
+
expectedChallenge: string,
|
|
385
|
+
): boolean {
|
|
386
|
+
let parsed: { type?: string; challenge?: string; origin?: string };
|
|
387
|
+
try {
|
|
388
|
+
parsed = JSON.parse(base64urlToBuffer(clientDataB64).toString("utf8"));
|
|
389
|
+
} catch {
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
return (
|
|
393
|
+
parsed.type === expectedType &&
|
|
394
|
+
parsed.challenge === expectedChallenge &&
|
|
395
|
+
typeof parsed.origin === "string" &&
|
|
396
|
+
this.#origins.includes(parsed.origin)
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Validate authenticator data: rpIdHash match and user-presence flag. */
|
|
401
|
+
#validAuthenticator(authData: {
|
|
402
|
+
rpIdHash: Buffer;
|
|
403
|
+
flags: { up: boolean };
|
|
404
|
+
}): boolean {
|
|
405
|
+
const expectedRpIdHash = sha256(Buffer.from(this.#rpID, "utf8"));
|
|
406
|
+
return authData.flags.up && expectedRpIdHash.equals(authData.rpIdHash);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function expectCoseMap(bytes: Buffer): CborMap {
|
|
411
|
+
const decoded = decodeCbor(bytes).value;
|
|
412
|
+
if (!(decoded instanceof Map)) {
|
|
413
|
+
throw new WardenError("INVALID_CREDENTIAL", "stored COSE key is malformed");
|
|
414
|
+
}
|
|
415
|
+
return decoded;
|
|
416
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 4648 base32 (no padding) — the encoding authenticator apps expect for
|
|
3
|
+
* TOTP secrets. Kept dependency-free and local to the MFA subsystem.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
7
|
+
const CHAR_TO_VAL: Record<string, number> = {};
|
|
8
|
+
for (let i = 0; i < ALPHABET.length; i++) {
|
|
9
|
+
CHAR_TO_VAL[ALPHABET[i]] = i;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Encode bytes to an unpadded, uppercase base32 string. */
|
|
13
|
+
export function base32Encode(bytes: Uint8Array): string {
|
|
14
|
+
let bits = 0;
|
|
15
|
+
let value = 0;
|
|
16
|
+
let out = "";
|
|
17
|
+
for (const byte of bytes) {
|
|
18
|
+
value = (value << 8) | byte;
|
|
19
|
+
bits += 8;
|
|
20
|
+
while (bits >= 5) {
|
|
21
|
+
out += ALPHABET[(value >>> (bits - 5)) & 31];
|
|
22
|
+
bits -= 5;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (bits > 0) {
|
|
26
|
+
out += ALPHABET[(value << (5 - bits)) & 31];
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Decode a base32 string to bytes. Tolerant of lowercase, spaces, and `=`
|
|
33
|
+
* padding (authenticator apps and manual entry produce all three). Throws on
|
|
34
|
+
* any character outside the alphabet.
|
|
35
|
+
*/
|
|
36
|
+
export function base32Decode(input: string): Uint8Array {
|
|
37
|
+
const clean = input.replace(/[\s=]/g, "").toUpperCase();
|
|
38
|
+
let bits = 0;
|
|
39
|
+
let value = 0;
|
|
40
|
+
const out: number[] = [];
|
|
41
|
+
for (const ch of clean) {
|
|
42
|
+
const v = CHAR_TO_VAL[ch];
|
|
43
|
+
if (v === undefined) {
|
|
44
|
+
throw new Error(`Invalid base32 character: ${ch}`);
|
|
45
|
+
}
|
|
46
|
+
value = (value << 5) | v;
|
|
47
|
+
bits += 5;
|
|
48
|
+
if (bits >= 8) {
|
|
49
|
+
out.push((value >>> (bits - 8)) & 0xff);
|
|
50
|
+
bits -= 8;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return Uint8Array.from(out);
|
|
54
|
+
}
|