@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,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OtpProvider — delivered one-time passcodes (email / SMS). Unlike TOTP this is
|
|
3
|
+
* a challenge/response flow: `start()` mints a code, persists it (hashed, with
|
|
4
|
+
* an expiry and an attempt budget) and hands it to a delivery channel; the user
|
|
5
|
+
* later submits it to `verify()`.
|
|
6
|
+
*
|
|
7
|
+
* Delivery is pluggable — the app supplies an email or SMS channel. The store
|
|
8
|
+
* is pluggable too, with an in-memory default mirroring the rest of warden.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
createHash,
|
|
13
|
+
randomBytes,
|
|
14
|
+
randomInt,
|
|
15
|
+
timingSafeEqual,
|
|
16
|
+
} from "node:crypto";
|
|
17
|
+
import { WardenError } from "../errors.js";
|
|
18
|
+
|
|
19
|
+
/** Sends the code to the user. Implemented by the consuming app (email/SMS). */
|
|
20
|
+
export interface OtpDeliveryChannel {
|
|
21
|
+
send(recipient: string, code: string): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A persisted, pending OTP challenge. */
|
|
25
|
+
export interface OtpChallenge {
|
|
26
|
+
id: string;
|
|
27
|
+
recipient: string;
|
|
28
|
+
/** Salted hash of the code — never the plaintext. */
|
|
29
|
+
hash: string;
|
|
30
|
+
expiresAt: number;
|
|
31
|
+
attempts: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Storage for pending challenges. */
|
|
35
|
+
export interface OtpChallengeStore {
|
|
36
|
+
save(challenge: OtpChallenge): Promise<void>;
|
|
37
|
+
find(id: string): Promise<OtpChallenge | null>;
|
|
38
|
+
delete(id: string): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class MemoryOtpChallengeStore implements OtpChallengeStore {
|
|
42
|
+
#store = new Map<string, OtpChallenge>();
|
|
43
|
+
async save(c: OtpChallenge): Promise<void> {
|
|
44
|
+
this.#store.set(c.id, c);
|
|
45
|
+
}
|
|
46
|
+
async find(id: string): Promise<OtpChallenge | null> {
|
|
47
|
+
return this.#store.get(id) ?? null;
|
|
48
|
+
}
|
|
49
|
+
async delete(id: string): Promise<void> {
|
|
50
|
+
this.#store.delete(id);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface OtpConfig {
|
|
55
|
+
channel: OtpDeliveryChannel;
|
|
56
|
+
/** Where pending challenges live. Default in-memory. */
|
|
57
|
+
store?: OtpChallengeStore;
|
|
58
|
+
/** Number of digits in the code. Default `6`. */
|
|
59
|
+
digits?: number;
|
|
60
|
+
/** How long a code stays valid, in seconds. Default `300` (5 min). */
|
|
61
|
+
ttlSeconds?: number;
|
|
62
|
+
/** Max verification attempts before the challenge is burned. Default `5`. */
|
|
63
|
+
maxAttempts?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface OtpStartResult {
|
|
67
|
+
challengeId: string;
|
|
68
|
+
expiresAt: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type OtpFailureReason =
|
|
72
|
+
| "not_found"
|
|
73
|
+
| "expired"
|
|
74
|
+
| "too_many_attempts"
|
|
75
|
+
| "mismatch";
|
|
76
|
+
|
|
77
|
+
export interface OtpVerification {
|
|
78
|
+
ok: boolean;
|
|
79
|
+
reason?: OtpFailureReason;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class OtpProvider {
|
|
83
|
+
readonly kind = "otp" as const;
|
|
84
|
+
readonly #channel: OtpDeliveryChannel;
|
|
85
|
+
readonly #store: OtpChallengeStore;
|
|
86
|
+
readonly #digits: number;
|
|
87
|
+
readonly #ttlMs: number;
|
|
88
|
+
readonly #maxAttempts: number;
|
|
89
|
+
|
|
90
|
+
constructor(config: OtpConfig) {
|
|
91
|
+
if (!config?.channel) {
|
|
92
|
+
throw new WardenError(
|
|
93
|
+
"INVALID_CONFIG",
|
|
94
|
+
"OtpProvider requires a delivery channel (email/SMS)",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
this.#channel = config.channel;
|
|
98
|
+
this.#store = config.store ?? new MemoryOtpChallengeStore();
|
|
99
|
+
this.#digits = config.digits ?? 6;
|
|
100
|
+
this.#ttlMs = (config.ttlSeconds ?? 300) * 1000;
|
|
101
|
+
this.#maxAttempts = config.maxAttempts ?? 5;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Mint a code, persist the challenge, and deliver it to `recipient`. */
|
|
105
|
+
async start(
|
|
106
|
+
recipient: string,
|
|
107
|
+
nowMs: number = Date.now(),
|
|
108
|
+
): Promise<OtpStartResult> {
|
|
109
|
+
const code = this.#randomCode();
|
|
110
|
+
const id = randomBytes(16).toString("hex");
|
|
111
|
+
const expiresAt = nowMs + this.#ttlMs;
|
|
112
|
+
await this.#store.save({
|
|
113
|
+
id,
|
|
114
|
+
recipient,
|
|
115
|
+
hash: saltedHash(code),
|
|
116
|
+
expiresAt,
|
|
117
|
+
attempts: 0,
|
|
118
|
+
});
|
|
119
|
+
await this.#channel.send(recipient, code);
|
|
120
|
+
return { challengeId: id, expiresAt };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Verify a submitted code. Wrong codes consume an attempt; the challenge is
|
|
125
|
+
* deleted on success, on expiry, or once the attempt budget is exhausted.
|
|
126
|
+
*/
|
|
127
|
+
async verify(
|
|
128
|
+
challengeId: string,
|
|
129
|
+
code: string,
|
|
130
|
+
nowMs: number = Date.now(),
|
|
131
|
+
): Promise<OtpVerification> {
|
|
132
|
+
const challenge = await this.#store.find(challengeId);
|
|
133
|
+
if (!challenge) {
|
|
134
|
+
return { ok: false, reason: "not_found" };
|
|
135
|
+
}
|
|
136
|
+
if (nowMs > challenge.expiresAt) {
|
|
137
|
+
await this.#store.delete(challengeId);
|
|
138
|
+
return { ok: false, reason: "expired" };
|
|
139
|
+
}
|
|
140
|
+
if (matchesStored(challenge.hash, code.replace(/\s/g, ""))) {
|
|
141
|
+
await this.#store.delete(challengeId);
|
|
142
|
+
return { ok: true };
|
|
143
|
+
}
|
|
144
|
+
const attempts = challenge.attempts + 1;
|
|
145
|
+
if (attempts >= this.#maxAttempts) {
|
|
146
|
+
await this.#store.delete(challengeId);
|
|
147
|
+
return { ok: false, reason: "too_many_attempts" };
|
|
148
|
+
}
|
|
149
|
+
await this.#store.save({ ...challenge, attempts });
|
|
150
|
+
return { ok: false, reason: "mismatch" };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
#randomCode(): string {
|
|
154
|
+
const max = 10 ** this.#digits;
|
|
155
|
+
return randomInt(0, max).toString().padStart(this.#digits, "0");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function saltedHash(code: string): string {
|
|
160
|
+
const salt = randomBytes(8);
|
|
161
|
+
const digest = createHash("sha256").update(salt).update(code).digest();
|
|
162
|
+
return `${salt.toString("hex")}$${digest.toString("hex")}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function matchesStored(stored: string, candidate: string): boolean {
|
|
166
|
+
const sep = stored.indexOf("$");
|
|
167
|
+
if (sep === -1) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
const salt = Buffer.from(stored.slice(0, sep), "hex");
|
|
171
|
+
const expected = Buffer.from(stored.slice(sep + 1), "hex");
|
|
172
|
+
const actual = createHash("sha256").update(salt).update(candidate).digest();
|
|
173
|
+
if (actual.length !== expected.length) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
return timingSafeEqual(actual, expected);
|
|
177
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TotpProvider — RFC 6238 time-based one-time passwords (HOTP/RFC 4226 under
|
|
3
|
+
* the hood). Pure TypeScript on Node's `crypto.createHmac` — no native binding
|
|
4
|
+
* and no third-party dependency: a TOTP is a single HMAC, not CPU-bound work.
|
|
5
|
+
*
|
|
6
|
+
* Compatible with Google Authenticator / 1Password / Authy by default
|
|
7
|
+
* (SHA1, 6 digits, 30s period).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
11
|
+
import { WardenError } from "../errors.js";
|
|
12
|
+
import { base32Decode, base32Encode } from "./base32.js";
|
|
13
|
+
|
|
14
|
+
export type TotpAlgorithm = "SHA1" | "SHA256" | "SHA512";
|
|
15
|
+
|
|
16
|
+
export interface TotpConfig {
|
|
17
|
+
/** HMAC algorithm. Default `SHA1` (authenticator-app compatible). */
|
|
18
|
+
algorithm?: TotpAlgorithm;
|
|
19
|
+
/** Number of digits in the generated code. Default `6`. */
|
|
20
|
+
digits?: number;
|
|
21
|
+
/** Time step in seconds. Default `30`. */
|
|
22
|
+
period?: number;
|
|
23
|
+
/**
|
|
24
|
+
* How many time steps before/after `now` are accepted on `verify`, to
|
|
25
|
+
* tolerate clock skew. Default `1` (i.e. ±30s with the default period).
|
|
26
|
+
*/
|
|
27
|
+
window?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TotpEnrollment {
|
|
31
|
+
/** Base32 secret to persist against the user. */
|
|
32
|
+
secret: string;
|
|
33
|
+
/** `otpauth://` URI to render as a QR code during enrollment. */
|
|
34
|
+
uri: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULTS = {
|
|
38
|
+
algorithm: "SHA1" as TotpAlgorithm,
|
|
39
|
+
digits: 6,
|
|
40
|
+
period: 30,
|
|
41
|
+
window: 1,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export class TotpProvider {
|
|
45
|
+
readonly kind = "totp" as const;
|
|
46
|
+
readonly #cfg: Required<TotpConfig>;
|
|
47
|
+
|
|
48
|
+
constructor(config: TotpConfig = {}) {
|
|
49
|
+
this.#cfg = { ...DEFAULTS, ...config };
|
|
50
|
+
if (this.#cfg.digits < 6 || this.#cfg.digits > 8) {
|
|
51
|
+
throw new WardenError(
|
|
52
|
+
"INVALID_CONFIG",
|
|
53
|
+
`TOTP digits must be 6-8, got ${this.#cfg.digits}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Generate a fresh secret and the `otpauth://` provisioning URI. Persist
|
|
60
|
+
* `secret`; render `uri` as a QR code for the user to scan.
|
|
61
|
+
*
|
|
62
|
+
* @param account Identifies the account in the authenticator (usually the
|
|
63
|
+
* user's email or username).
|
|
64
|
+
* @param issuer Your app/brand name, shown as the entry label.
|
|
65
|
+
*/
|
|
66
|
+
enroll(account: string, issuer: string): TotpEnrollment {
|
|
67
|
+
const secret = base32Encode(randomBytes(20));
|
|
68
|
+
return { secret, uri: this.uri(secret, account, issuer) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Build the `otpauth://totp/...` provisioning URI for an existing secret. */
|
|
72
|
+
uri(secret: string, account: string, issuer: string): string {
|
|
73
|
+
const label = encodeURIComponent(`${issuer}:${account}`);
|
|
74
|
+
const params = new URLSearchParams({
|
|
75
|
+
secret,
|
|
76
|
+
issuer,
|
|
77
|
+
algorithm: this.#cfg.algorithm,
|
|
78
|
+
digits: String(this.#cfg.digits),
|
|
79
|
+
period: String(this.#cfg.period),
|
|
80
|
+
});
|
|
81
|
+
return `otpauth://totp/${label}?${params.toString()}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Generate the code for a given secret at a given time (ms since epoch). */
|
|
85
|
+
generate(secret: string, atMs: number = Date.now()): string {
|
|
86
|
+
const counter = Math.floor(atMs / 1000 / this.#cfg.period);
|
|
87
|
+
return this.#hotp(base32Decode(secret), counter);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Verify a user-supplied code against the secret, accepting codes from the
|
|
92
|
+
* surrounding `window` time steps to tolerate clock skew. Constant-time.
|
|
93
|
+
*/
|
|
94
|
+
verify(secret: string, code: string, atMs: number = Date.now()): boolean {
|
|
95
|
+
const normalized = code.replace(/\s/g, "");
|
|
96
|
+
if (normalized.length !== this.#cfg.digits) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
const key = base32Decode(secret);
|
|
100
|
+
const current = Math.floor(atMs / 1000 / this.#cfg.period);
|
|
101
|
+
for (let offset = -this.#cfg.window; offset <= this.#cfg.window; offset++) {
|
|
102
|
+
if (constantTimeEqual(this.#hotp(key, current + offset), normalized)) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** RFC 4226 HOTP: HMAC over the 8-byte counter, dynamically truncated. */
|
|
110
|
+
#hotp(key: Uint8Array, counter: number): string {
|
|
111
|
+
const buf = Buffer.alloc(8);
|
|
112
|
+
// 64-bit big-endian counter. Codes stay valid well past year 2^53,
|
|
113
|
+
// so splitting hi/lo at 2^32 is safe and avoids BigInt overhead.
|
|
114
|
+
buf.writeUInt32BE(Math.floor(counter / 2 ** 32), 0);
|
|
115
|
+
buf.writeUInt32BE(counter >>> 0, 4);
|
|
116
|
+
|
|
117
|
+
const hmac = createHmac(this.#cfg.algorithm.toLowerCase(), Buffer.from(key))
|
|
118
|
+
.update(buf)
|
|
119
|
+
.digest();
|
|
120
|
+
const off = hmac[hmac.length - 1] & 0xf;
|
|
121
|
+
const bin =
|
|
122
|
+
((hmac[off] & 0x7f) << 24) |
|
|
123
|
+
((hmac[off + 1] & 0xff) << 16) |
|
|
124
|
+
((hmac[off + 2] & 0xff) << 8) |
|
|
125
|
+
(hmac[off + 3] & 0xff);
|
|
126
|
+
return (bin % 10 ** this.#cfg.digits)
|
|
127
|
+
.toString()
|
|
128
|
+
.padStart(this.#cfg.digits, "0");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Length-safe constant-time string compare (avoids early-exit timing leaks). */
|
|
133
|
+
function constantTimeEqual(a: string, b: string): boolean {
|
|
134
|
+
const ba = Buffer.from(a);
|
|
135
|
+
const bb = Buffer.from(b);
|
|
136
|
+
if (ba.length !== bb.length) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
return timingSafeEqual(ba, bb);
|
|
140
|
+
}
|