@c9up/warden 0.1.4 → 0.1.5

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 (74) hide show
  1. package/README.md +57 -0
  2. package/dist/Guard.d.ts +9 -0
  3. package/dist/Guard.d.ts.map +1 -1
  4. package/dist/Guard.js +16 -0
  5. package/dist/Guard.js.map +1 -1
  6. package/dist/RedisBlacklistDriver.d.ts +32 -0
  7. package/dist/RedisBlacklistDriver.d.ts.map +1 -0
  8. package/dist/RedisBlacklistDriver.js +37 -0
  9. package/dist/RedisBlacklistDriver.js.map +1 -0
  10. package/dist/ResilientBlacklistDriver.d.ts +35 -0
  11. package/dist/ResilientBlacklistDriver.d.ts.map +1 -0
  12. package/dist/ResilientBlacklistDriver.js +61 -0
  13. package/dist/ResilientBlacklistDriver.js.map +1 -0
  14. package/dist/WardenProvider.d.ts.map +1 -1
  15. package/dist/WardenProvider.js +10 -0
  16. package/dist/WardenProvider.js.map +1 -1
  17. package/dist/config.d.ts +9 -0
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/config.js.map +1 -1
  20. package/dist/index.d.ts +15 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +8 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/mfa/BackupCodesProvider.d.ts +39 -0
  25. package/dist/mfa/BackupCodesProvider.d.ts.map +1 -0
  26. package/dist/mfa/BackupCodesProvider.js +87 -0
  27. package/dist/mfa/BackupCodesProvider.js.map +1 -0
  28. package/dist/mfa/MfaManager.d.ts +107 -0
  29. package/dist/mfa/MfaManager.d.ts.map +1 -0
  30. package/dist/mfa/MfaManager.js +219 -0
  31. package/dist/mfa/MfaManager.js.map +1 -0
  32. package/dist/mfa/OtpProvider.d.ts +67 -0
  33. package/dist/mfa/OtpProvider.d.ts.map +1 -0
  34. package/dist/mfa/OtpProvider.js +104 -0
  35. package/dist/mfa/OtpProvider.js.map +1 -0
  36. package/dist/mfa/TotpProvider.d.ts +52 -0
  37. package/dist/mfa/TotpProvider.d.ts.map +1 -0
  38. package/dist/mfa/TotpProvider.js +103 -0
  39. package/dist/mfa/TotpProvider.js.map +1 -0
  40. package/dist/mfa/WebauthnProvider.d.ts +175 -0
  41. package/dist/mfa/WebauthnProvider.d.ts.map +1 -0
  42. package/dist/mfa/WebauthnProvider.js +239 -0
  43. package/dist/mfa/WebauthnProvider.js.map +1 -0
  44. package/dist/mfa/base32.d.ts +13 -0
  45. package/dist/mfa/base32.d.ts.map +1 -0
  46. package/dist/mfa/base32.js +52 -0
  47. package/dist/mfa/base32.js.map +1 -0
  48. package/dist/mfa/webauthn-codec.d.ts +58 -0
  49. package/dist/mfa/webauthn-codec.d.ts.map +1 -0
  50. package/dist/mfa/webauthn-codec.js +221 -0
  51. package/dist/mfa/webauthn-codec.js.map +1 -0
  52. package/dist/middleware.d.ts.map +1 -1
  53. package/dist/middleware.js +13 -1
  54. package/dist/middleware.js.map +1 -1
  55. package/index.darwin-arm64.node +0 -0
  56. package/index.darwin-x64.node +0 -0
  57. package/index.linux-arm64-gnu.node +0 -0
  58. package/index.linux-x64-gnu.node +0 -0
  59. package/index.win32-x64-msvc.node +0 -0
  60. package/package.json +1 -1
  61. package/src/Guard.ts +21 -0
  62. package/src/RedisBlacklistDriver.ts +60 -0
  63. package/src/ResilientBlacklistDriver.ts +84 -0
  64. package/src/WardenProvider.ts +11 -0
  65. package/src/config.ts +7 -0
  66. package/src/index.ts +58 -0
  67. package/src/mfa/BackupCodesProvider.ts +125 -0
  68. package/src/mfa/MfaManager.ts +307 -0
  69. package/src/mfa/OtpProvider.ts +177 -0
  70. package/src/mfa/TotpProvider.ts +140 -0
  71. package/src/mfa/WebauthnProvider.ts +416 -0
  72. package/src/mfa/base32.ts +54 -0
  73. package/src/mfa/webauthn-codec.ts +264 -0
  74. package/src/middleware.ts +14 -0
@@ -0,0 +1,264 @@
1
+ /**
2
+ * WebAuthn wire-format codec — the parsing and crypto primitives behind
3
+ * `WebauthnProvider`, implemented from scratch on `node:crypto` so warden
4
+ * carries no third-party dependency.
5
+ *
6
+ * Scope: the subset of CBOR / COSE / authenticator-data that FIDO2 ceremonies
7
+ * actually use, with public-key algorithms Node can import via JWK:
8
+ * - ES256 / ES384 / ES512 (ECDSA, COSE alg -7 / -35 / -36)
9
+ * - RS256 / RS384 / RS512 (RSASSA-PKCS1-v1_5, alg -257 / -258 / -259)
10
+ * - EdDSA / Ed25519 (alg -8)
11
+ *
12
+ * Attestation *statements* are intentionally not verified (see WebauthnProvider).
13
+ */
14
+
15
+ import {
16
+ createHash,
17
+ createPublicKey,
18
+ verify as cryptoVerify,
19
+ type JsonWebKey,
20
+ type KeyObject,
21
+ } from "node:crypto";
22
+
23
+ export type CborValue =
24
+ | number
25
+ | string
26
+ | Buffer
27
+ | CborValue[]
28
+ | CborMap
29
+ | boolean
30
+ | null;
31
+ export type CborMap = Map<number | string, CborValue>;
32
+
33
+ export function base64urlToBuffer(s: string): Buffer {
34
+ return Buffer.from(s, "base64url");
35
+ }
36
+
37
+ export function bufferToBase64url(b: Buffer | Uint8Array): string {
38
+ return Buffer.from(b).toString("base64url");
39
+ }
40
+
41
+ export function sha256(data: Buffer): Buffer {
42
+ return createHash("sha256").update(data).digest();
43
+ }
44
+
45
+ interface Decoded {
46
+ value: CborValue;
47
+ /** Byte offset immediately after the decoded item. */
48
+ next: number;
49
+ }
50
+
51
+ /**
52
+ * Decode a single CBOR item starting at `start`. Definite-length only —
53
+ * indefinite-length items and floats are rejected (WebAuthn uses neither).
54
+ */
55
+ export function decodeCbor(buf: Buffer, start = 0): Decoded {
56
+ const major = buf[start] >> 5;
57
+ const info = buf[start] & 0x1f;
58
+ let len: number;
59
+ let p = start + 1;
60
+ if (info < 24) {
61
+ len = info;
62
+ } else if (info === 24) {
63
+ len = buf[p];
64
+ p += 1;
65
+ } else if (info === 25) {
66
+ len = buf.readUInt16BE(p);
67
+ p += 2;
68
+ } else if (info === 26) {
69
+ len = buf.readUInt32BE(p);
70
+ p += 4;
71
+ } else if (info === 27) {
72
+ len = Number(buf.readBigUInt64BE(p));
73
+ p += 8;
74
+ } else {
75
+ throw new Error("CBOR: indefinite or reserved length is not supported");
76
+ }
77
+
78
+ switch (major) {
79
+ case 0: // unsigned int
80
+ return { value: len, next: p };
81
+ case 1: // negative int
82
+ return { value: -1 - len, next: p };
83
+ case 2: // byte string
84
+ return { value: buf.subarray(p, p + len), next: p + len };
85
+ case 3: // text string
86
+ return { value: buf.toString("utf8", p, p + len), next: p + len };
87
+ case 4: {
88
+ // array
89
+ const arr: CborValue[] = [];
90
+ let cur = p;
91
+ for (let i = 0; i < len; i++) {
92
+ const d = decodeCbor(buf, cur);
93
+ arr.push(d.value);
94
+ cur = d.next;
95
+ }
96
+ return { value: arr, next: cur };
97
+ }
98
+ case 5: {
99
+ // map
100
+ const map: CborMap = new Map();
101
+ let cur = p;
102
+ for (let i = 0; i < len; i++) {
103
+ const k = decodeCbor(buf, cur);
104
+ cur = k.next;
105
+ const v = decodeCbor(buf, cur);
106
+ cur = v.next;
107
+ if (typeof k.value !== "number" && typeof k.value !== "string") {
108
+ throw new Error("CBOR: only integer/text map keys are supported");
109
+ }
110
+ map.set(k.value, v.value);
111
+ }
112
+ return { value: map, next: cur };
113
+ }
114
+ case 6: {
115
+ // tag — decode and surface the tagged content
116
+ const d = decodeCbor(buf, p);
117
+ return { value: d.value, next: d.next };
118
+ }
119
+ case 7:
120
+ if (info === 20) return { value: false, next: p };
121
+ if (info === 21) return { value: true, next: p };
122
+ if (info === 22) return { value: null, next: p };
123
+ throw new Error("CBOR: simple/float values are not supported");
124
+ default:
125
+ throw new Error(`CBOR: unknown major type ${major}`);
126
+ }
127
+ }
128
+
129
+ export interface AuthenticatorData {
130
+ rpIdHash: Buffer;
131
+ flags: { up: boolean; uv: boolean; at: boolean; ed: boolean };
132
+ signCount: number;
133
+ credentialId?: Buffer;
134
+ cosePublicKey?: CborMap;
135
+ /** Raw COSE_Key bytes, ready to persist and re-decode at sign-in time. */
136
+ cosePublicKeyBytes?: Buffer;
137
+ }
138
+
139
+ /** Parse the raw authenticator-data structure (§6.1 of the WebAuthn spec). */
140
+ export function parseAuthenticatorData(authData: Buffer): AuthenticatorData {
141
+ if (authData.length < 37) {
142
+ throw new Error("authenticatorData is too short");
143
+ }
144
+ const flagsByte = authData[32];
145
+ const flags = {
146
+ up: (flagsByte & 0x01) !== 0,
147
+ uv: (flagsByte & 0x04) !== 0,
148
+ at: (flagsByte & 0x40) !== 0,
149
+ ed: (flagsByte & 0x80) !== 0,
150
+ };
151
+ const result: AuthenticatorData = {
152
+ rpIdHash: authData.subarray(0, 32),
153
+ flags,
154
+ signCount: authData.readUInt32BE(33),
155
+ };
156
+ if (flags.at) {
157
+ if (authData.length < 55) {
158
+ throw new Error("attested credential data is truncated");
159
+ }
160
+ const credIdLen = authData.readUInt16BE(53);
161
+ result.credentialId = authData.subarray(55, 55 + credIdLen);
162
+ const coseStart = 55 + credIdLen;
163
+ const decoded = decodeCbor(authData, coseStart);
164
+ if (!(decoded.value instanceof Map)) {
165
+ throw new Error("COSE public key is not a CBOR map");
166
+ }
167
+ result.cosePublicKey = decoded.value;
168
+ result.cosePublicKeyBytes = authData.subarray(coseStart, decoded.next);
169
+ }
170
+ return result;
171
+ }
172
+
173
+ // COSE algorithm id → the hash Node should use ( `null` = pre-hashed / Ed25519 ).
174
+ const ALG_HASH: Record<number, string | null> = {
175
+ [-7]: "sha256",
176
+ [-35]: "sha384",
177
+ [-36]: "sha512",
178
+ [-257]: "sha256",
179
+ [-258]: "sha384",
180
+ [-259]: "sha512",
181
+ [-8]: null,
182
+ };
183
+
184
+ const EC_CURVES: Record<number, string> = {
185
+ 1: "P-256",
186
+ 2: "P-384",
187
+ 3: "P-521",
188
+ };
189
+
190
+ /** Convert a COSE_Key map into a Node public key plus its COSE algorithm id. */
191
+ export function coseToKeyObject(cose: CborMap): {
192
+ key: KeyObject;
193
+ alg: number;
194
+ } {
195
+ const kty = expectNumber(cose.get(1), "COSE kty");
196
+ const alg = expectNumber(cose.get(3), "COSE alg");
197
+ let jwk: JsonWebKey;
198
+
199
+ if (kty === 2) {
200
+ // EC2
201
+ const curve = EC_CURVES[expectNumber(cose.get(-1), "EC curve")];
202
+ if (!curve) {
203
+ throw new Error("unsupported COSE EC curve");
204
+ }
205
+ jwk = {
206
+ kty: "EC",
207
+ crv: curve,
208
+ x: bufferToBase64url(expectBytes(cose.get(-2), "EC x")),
209
+ y: bufferToBase64url(expectBytes(cose.get(-3), "EC y")),
210
+ };
211
+ } else if (kty === 3) {
212
+ // RSA
213
+ jwk = {
214
+ kty: "RSA",
215
+ n: bufferToBase64url(expectBytes(cose.get(-1), "RSA n")),
216
+ e: bufferToBase64url(expectBytes(cose.get(-2), "RSA e")),
217
+ };
218
+ } else if (kty === 1) {
219
+ // OKP / Ed25519
220
+ if (expectNumber(cose.get(-1), "OKP curve") !== 6) {
221
+ throw new Error("unsupported COSE OKP curve (only Ed25519)");
222
+ }
223
+ jwk = {
224
+ kty: "OKP",
225
+ crv: "Ed25519",
226
+ x: bufferToBase64url(expectBytes(cose.get(-2), "OKP x")),
227
+ };
228
+ } else {
229
+ throw new Error(`unsupported COSE key type ${kty}`);
230
+ }
231
+
232
+ return { key: createPublicKey({ key: jwk, format: "jwk" }), alg };
233
+ }
234
+
235
+ /**
236
+ * Verify a WebAuthn assertion signature over `data` (= authenticatorData ‖
237
+ * SHA-256(clientDataJSON)). ECDSA signatures are DER-encoded, matching what
238
+ * authenticators emit.
239
+ */
240
+ export function verifyWebauthnSignature(
241
+ alg: number,
242
+ key: KeyObject,
243
+ data: Buffer,
244
+ signature: Buffer,
245
+ ): boolean {
246
+ if (!(alg in ALG_HASH)) {
247
+ throw new Error(`unsupported COSE algorithm ${alg}`);
248
+ }
249
+ return cryptoVerify(ALG_HASH[alg], data, key, signature);
250
+ }
251
+
252
+ function expectNumber(v: CborValue | undefined, what: string): number {
253
+ if (typeof v !== "number") {
254
+ throw new Error(`${what}: expected an integer`);
255
+ }
256
+ return v;
257
+ }
258
+
259
+ function expectBytes(v: CborValue | undefined, what: string): Buffer {
260
+ if (!Buffer.isBuffer(v)) {
261
+ throw new Error(`${what}: expected a byte string`);
262
+ }
263
+ return v;
264
+ }
package/src/middleware.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  import {
26
26
  getGuardMetadata,
27
27
  getPermissionMetadata,
28
+ getRequireMfaMetadata,
28
29
  getRoleMetadata,
29
30
  } from "./Guard.js";
30
31
  import type { SessionStore } from "./strategies/SessionStrategy.js";
@@ -169,6 +170,19 @@ export async function wardenMiddleware(ctx: WardenContext, next: WardenNext) {
169
170
  ctx.response.json({ error: { code: "FORBIDDEN", message: denied } });
170
171
  return;
171
172
  }
173
+
174
+ // @RequireMfa gate: the user must carry a truthy `mfa` claim, set by the
175
+ // app's step-up flow once MfaManager.verify() succeeds.
176
+ if (getRequireMfaMetadata(controller, action) && user.mfa !== true) {
177
+ ctx.response.status(403);
178
+ ctx.response.json({
179
+ error: {
180
+ code: "MFA_REQUIRED",
181
+ message: "Multi-factor authentication is required for this action.",
182
+ },
183
+ });
184
+ return;
185
+ }
172
186
  }
173
187
 
174
188
  // Auth successful — attach to context and continue.