@shaferllc/keel 0.79.0 → 0.81.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.
Files changed (84) hide show
  1. package/README.md +3 -1
  2. package/dist/accounts/accounts.config.stub +50 -0
  3. package/dist/accounts/config.d.ts +46 -0
  4. package/dist/accounts/config.js +39 -0
  5. package/dist/accounts/flows.d.ts +50 -0
  6. package/dist/accounts/flows.js +133 -0
  7. package/dist/accounts/index.d.ts +28 -0
  8. package/dist/accounts/index.js +23 -0
  9. package/dist/accounts/migration.d.ts +14 -0
  10. package/dist/accounts/migration.js +39 -0
  11. package/dist/accounts/provider.d.ts +18 -0
  12. package/dist/accounts/provider.js +37 -0
  13. package/dist/accounts/routes.d.ts +15 -0
  14. package/dist/accounts/routes.js +116 -0
  15. package/dist/accounts/store.d.ts +33 -0
  16. package/dist/accounts/store.js +37 -0
  17. package/dist/accounts/tokens.d.ts +60 -0
  18. package/dist/accounts/tokens.js +116 -0
  19. package/dist/accounts/totp.d.ts +58 -0
  20. package/dist/accounts/totp.js +134 -0
  21. package/dist/accounts/two-factor.d.ts +56 -0
  22. package/dist/accounts/two-factor.js +146 -0
  23. package/dist/billing/billable.d.ts +83 -0
  24. package/dist/billing/billable.js +177 -0
  25. package/dist/billing/billing.config.stub +33 -0
  26. package/dist/billing/builder.d.ts +54 -0
  27. package/dist/billing/builder.js +104 -0
  28. package/dist/billing/config.d.ts +43 -0
  29. package/dist/billing/config.js +35 -0
  30. package/dist/billing/crypto.d.ts +11 -0
  31. package/dist/billing/crypto.js +27 -0
  32. package/dist/billing/drivers/fake.d.ts +58 -0
  33. package/dist/billing/drivers/fake.js +190 -0
  34. package/dist/billing/drivers/index.d.ts +11 -0
  35. package/dist/billing/drivers/index.js +22 -0
  36. package/dist/billing/drivers/paddle.d.ts +39 -0
  37. package/dist/billing/drivers/paddle.js +197 -0
  38. package/dist/billing/drivers/stripe.d.ts +33 -0
  39. package/dist/billing/drivers/stripe.js +278 -0
  40. package/dist/billing/events.d.ts +25 -0
  41. package/dist/billing/events.js +7 -0
  42. package/dist/billing/gateway.d.ts +170 -0
  43. package/dist/billing/gateway.js +24 -0
  44. package/dist/billing/index.d.ts +28 -0
  45. package/dist/billing/index.js +19 -0
  46. package/dist/billing/manager.d.ts +34 -0
  47. package/dist/billing/manager.js +61 -0
  48. package/dist/billing/migration.d.ts +13 -0
  49. package/dist/billing/migration.js +68 -0
  50. package/dist/billing/provider.d.ts +20 -0
  51. package/dist/billing/provider.js +42 -0
  52. package/dist/billing/routes.d.ts +11 -0
  53. package/dist/billing/routes.js +21 -0
  54. package/dist/billing/subscription-item.d.ts +18 -0
  55. package/dist/billing/subscription-item.js +11 -0
  56. package/dist/billing/subscription.d.ts +85 -0
  57. package/dist/billing/subscription.js +157 -0
  58. package/dist/billing/webhooks.d.ts +26 -0
  59. package/dist/billing/webhooks.js +75 -0
  60. package/dist/core/database.d.ts +36 -0
  61. package/dist/core/database.js +141 -4
  62. package/dist/core/index.d.ts +5 -2
  63. package/dist/core/index.js +3 -2
  64. package/dist/core/migrations.d.ts +52 -2
  65. package/dist/core/migrations.js +134 -3
  66. package/dist/core/model-events.d.ts +34 -0
  67. package/dist/core/model-events.js +71 -0
  68. package/dist/core/model-query.d.ts +68 -0
  69. package/dist/core/model-query.js +234 -0
  70. package/dist/core/model.d.ts +91 -4
  71. package/dist/core/model.js +217 -32
  72. package/dist/core/relations.d.ts +53 -0
  73. package/dist/core/relations.js +242 -0
  74. package/docs/accounts.md +214 -0
  75. package/docs/ai-manifest.json +70 -1
  76. package/docs/billing.md +242 -0
  77. package/docs/database.md +33 -0
  78. package/docs/examples/accounts.ts +150 -0
  79. package/docs/migrations.md +32 -3
  80. package/docs/models.md +133 -3
  81. package/docs/packages.md +3 -1
  82. package/llms-full.txt +671 -7
  83. package/llms.txt +3 -0
  84. package/package.json +10 -2
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Where accounts reads and writes users.
3
+ *
4
+ * The module refuses to assume you use a `Model` — it talks to a table through
5
+ * the query builder, exactly like `billing` parameterizes its billable table. If
6
+ * your users live somewhere else entirely (an auth service, a legacy schema),
7
+ * replace the whole thing:
8
+ *
9
+ * setAccountStore({ findById, findByEmail, update });
10
+ */
11
+ import { type Row } from "../core/database.js";
12
+ /** The columns accounts needs. Everything else on your users table is untouched. */
13
+ export interface AccountUser extends Row {
14
+ id: string | number;
15
+ email: string;
16
+ /** The hashed password. Never the plaintext. */
17
+ password?: string | null;
18
+ email_verified_at?: string | null;
19
+ /** The TOTP secret, encrypted at rest. */
20
+ two_factor_secret?: string | null;
21
+ /** Hashed, single-use recovery codes, as a JSON array. */
22
+ two_factor_recovery_codes?: string | null;
23
+ two_factor_confirmed_at?: string | null;
24
+ }
25
+ export interface AccountStore {
26
+ findById(id: string | number): Promise<AccountUser | null>;
27
+ findByEmail(email: string): Promise<AccountUser | null>;
28
+ update(id: string | number, values: Row): Promise<void>;
29
+ }
30
+ /** The default store: a table, through the query builder. */
31
+ export declare function tableStore(table: string): AccountStore;
32
+ export declare function setAccountStore(next: AccountStore | undefined): void;
33
+ export declare function accountStore(): AccountStore;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Where accounts reads and writes users.
3
+ *
4
+ * The module refuses to assume you use a `Model` — it talks to a table through
5
+ * the query builder, exactly like `billing` parameterizes its billable table. If
6
+ * your users live somewhere else entirely (an auth service, a legacy schema),
7
+ * replace the whole thing:
8
+ *
9
+ * setAccountStore({ findById, findByEmail, update });
10
+ */
11
+ import { db } from "../core/database.js";
12
+ /** The default store: a table, through the query builder. */
13
+ export function tableStore(table) {
14
+ return {
15
+ async findById(id) {
16
+ return (await db(table).where("id", id).first());
17
+ },
18
+ async findByEmail(email) {
19
+ // Emails are case-insensitive in practice; store them lowercased and look
20
+ // them up the same way, or "Ada@…" and "ada@…" become two accounts.
21
+ return (await db(table).where("email", email.toLowerCase()).first());
22
+ },
23
+ async update(id, values) {
24
+ await db(table).where("id", id).update(values);
25
+ },
26
+ };
27
+ }
28
+ let store;
29
+ export function setAccountStore(next) {
30
+ store = next;
31
+ }
32
+ export function accountStore() {
33
+ if (!store) {
34
+ throw new Error("No account store. Register AccountsServiceProvider, or call setAccountStore().");
35
+ }
36
+ return store;
37
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The tokens behind password reset, email verification, and the 2FA challenge.
3
+ *
4
+ * There is no tokens table. Keel's `encryption` already carries a **purpose** and
5
+ * an **expiry inside the ciphertext**, and `decrypt` returns `null` — never throws
6
+ * — when either is wrong. So a token is self-describing: nothing to store, nothing
7
+ * to clean up, and no window where a stale row is still redeemable because a cron
8
+ * job didn't run.
9
+ *
10
+ * `purpose` is what stops a token minted for one thing being spent on another. A
11
+ * verification link cannot be replayed as a password reset, because it will not
12
+ * decrypt under that purpose. Every token here gets its own.
13
+ *
14
+ * The interesting problem is **single use**. A stateless token is, by nature,
15
+ * replayable until it expires — so a reset link sitting in an inbox (or a proxy
16
+ * log, or a browser history) would work twice. The fix is to bind the token to
17
+ * something that *changes when it's spent*: the user's current password hash. Once
18
+ * the password is reset, the hash is different, the fingerprint no longer matches,
19
+ * and every token minted against the old one is dead. Single use, no storage.
20
+ */
21
+ import type { AccountUser } from "./store.js";
22
+ export declare const PURPOSE: {
23
+ readonly passwordReset: "accounts:password-reset";
24
+ readonly emailVerification: "accounts:email-verification";
25
+ readonly twoFactorChallenge: "accounts:2fa-challenge";
26
+ };
27
+ /**
28
+ * A reset token for this user. Dies on use (the password changes, so the
29
+ * fingerprint stops matching) and dies on expiry, whichever comes first.
30
+ */
31
+ export declare function passwordResetToken(user: AccountUser, expiresIn?: number | string): Promise<string>;
32
+ /**
33
+ * The user this token resets, or `null` — expired, tampered with, minted for a
34
+ * different purpose, or already spent.
35
+ */
36
+ export declare function verifyPasswordResetToken(token: string, find: (id: string | number) => Promise<AccountUser | null>): Promise<AccountUser | null>;
37
+ export declare function emailVerificationToken(user: AccountUser, expiresIn?: number | string): Promise<string>;
38
+ /**
39
+ * The user this token verifies, or `null`.
40
+ *
41
+ * The address is baked in, so a link sent to the old address cannot verify a new
42
+ * one — otherwise changing your email to someone else's and clicking an older link
43
+ * would mark *their* address as proven.
44
+ */
45
+ export declare function verifyEmailToken(token: string, find: (id: string | number) => Promise<AccountUser | null>): Promise<AccountUser | null>;
46
+ /**
47
+ * Proof that someone got the password right — and **nothing more**.
48
+ *
49
+ * This is deliberately not a session. The usual implementation logs the user in
50
+ * and sets a `needs_2fa` flag for middleware to check, which means they hold a
51
+ * real authenticated session before the second factor: every route that forgets
52
+ * the middleware, and every `auth()` that only asks "is anyone logged in?", is
53
+ * bypassable with just a password. Here, nothing is logged in until the code
54
+ * verifies, so there is no half-authenticated state to forget about.
55
+ *
56
+ * Short-lived on purpose: it is the window in which a stolen password is enough.
57
+ */
58
+ export declare function twoFactorChallenge(user: AccountUser, expiresIn?: number | string): Promise<string>;
59
+ /** Who this challenge is for, or `null` if it's expired, forged, or not a challenge. */
60
+ export declare function verifyTwoFactorChallenge(token: string, find: (id: string | number) => Promise<AccountUser | null>): Promise<AccountUser | null>;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The tokens behind password reset, email verification, and the 2FA challenge.
3
+ *
4
+ * There is no tokens table. Keel's `encryption` already carries a **purpose** and
5
+ * an **expiry inside the ciphertext**, and `decrypt` returns `null` — never throws
6
+ * — when either is wrong. So a token is self-describing: nothing to store, nothing
7
+ * to clean up, and no window where a stale row is still redeemable because a cron
8
+ * job didn't run.
9
+ *
10
+ * `purpose` is what stops a token minted for one thing being spent on another. A
11
+ * verification link cannot be replayed as a password reset, because it will not
12
+ * decrypt under that purpose. Every token here gets its own.
13
+ *
14
+ * The interesting problem is **single use**. A stateless token is, by nature,
15
+ * replayable until it expires — so a reset link sitting in an inbox (or a proxy
16
+ * log, or a browser history) would work twice. The fix is to bind the token to
17
+ * something that *changes when it's spent*: the user's current password hash. Once
18
+ * the password is reset, the hash is different, the fingerprint no longer matches,
19
+ * and every token minted against the old one is dead. Single use, no storage.
20
+ */
21
+ import { encryption } from "../core/crypto.js";
22
+ export const PURPOSE = {
23
+ passwordReset: "accounts:password-reset",
24
+ emailVerification: "accounts:email-verification",
25
+ twoFactorChallenge: "accounts:2fa-challenge",
26
+ };
27
+ /**
28
+ * A reset token for this user. Dies on use (the password changes, so the
29
+ * fingerprint stops matching) and dies on expiry, whichever comes first.
30
+ */
31
+ export async function passwordResetToken(user, expiresIn = "60m") {
32
+ const payload = { id: user.id, fp: await fingerprint(user.password) };
33
+ return encryption.encrypt(payload, { purpose: PURPOSE.passwordReset, expiresIn });
34
+ }
35
+ /**
36
+ * The user this token resets, or `null` — expired, tampered with, minted for a
37
+ * different purpose, or already spent.
38
+ */
39
+ export async function verifyPasswordResetToken(token, find) {
40
+ const payload = await encryption.decrypt(token, {
41
+ purpose: PURPOSE.passwordReset,
42
+ });
43
+ if (!payload)
44
+ return null;
45
+ const user = await find(payload.id);
46
+ if (!user)
47
+ return null;
48
+ // The password has changed since this was minted — so the token has been spent,
49
+ // or the user reset it another way. Either way it is not valid twice.
50
+ if ((await fingerprint(user.password)) !== payload.fp)
51
+ return null;
52
+ return user;
53
+ }
54
+ export async function emailVerificationToken(user, expiresIn = "24h") {
55
+ const payload = { id: user.id, email: user.email.toLowerCase() };
56
+ return encryption.encrypt(payload, { purpose: PURPOSE.emailVerification, expiresIn });
57
+ }
58
+ /**
59
+ * The user this token verifies, or `null`.
60
+ *
61
+ * The address is baked in, so a link sent to the old address cannot verify a new
62
+ * one — otherwise changing your email to someone else's and clicking an older link
63
+ * would mark *their* address as proven.
64
+ */
65
+ export async function verifyEmailToken(token, find) {
66
+ const payload = await encryption.decrypt(token, {
67
+ purpose: PURPOSE.emailVerification,
68
+ });
69
+ if (!payload)
70
+ return null;
71
+ const user = await find(payload.id);
72
+ if (!user)
73
+ return null;
74
+ if (user.email.toLowerCase() !== payload.email)
75
+ return null;
76
+ return user;
77
+ }
78
+ /**
79
+ * Proof that someone got the password right — and **nothing more**.
80
+ *
81
+ * This is deliberately not a session. The usual implementation logs the user in
82
+ * and sets a `needs_2fa` flag for middleware to check, which means they hold a
83
+ * real authenticated session before the second factor: every route that forgets
84
+ * the middleware, and every `auth()` that only asks "is anyone logged in?", is
85
+ * bypassable with just a password. Here, nothing is logged in until the code
86
+ * verifies, so there is no half-authenticated state to forget about.
87
+ *
88
+ * Short-lived on purpose: it is the window in which a stolen password is enough.
89
+ */
90
+ export async function twoFactorChallenge(user, expiresIn = "5m") {
91
+ const payload = { id: user.id };
92
+ return encryption.encrypt(payload, { purpose: PURPOSE.twoFactorChallenge, expiresIn });
93
+ }
94
+ /** Who this challenge is for, or `null` if it's expired, forged, or not a challenge. */
95
+ export async function verifyTwoFactorChallenge(token, find) {
96
+ const payload = await encryption.decrypt(token, {
97
+ purpose: PURPOSE.twoFactorChallenge,
98
+ });
99
+ if (!payload)
100
+ return null;
101
+ return find(payload.id);
102
+ }
103
+ /* --------------------------------- helpers -------------------------------- */
104
+ /**
105
+ * A short digest of the stored password hash. Not a secret — it only ever travels
106
+ * inside an already-encrypted payload — it just has to *change* when the password
107
+ * does. A user with no password (social login) still gets a stable fingerprint.
108
+ */
109
+ async function fingerprint(password) {
110
+ const bytes = new TextEncoder().encode(password ?? "");
111
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
112
+ return [...new Uint8Array(digest)]
113
+ .slice(0, 8)
114
+ .map((b) => b.toString(16).padStart(2, "0"))
115
+ .join("");
116
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * TOTP (RFC 6238) — the authenticator-app second factor.
3
+ *
4
+ * const secret = generateSecret(); // base32, shown once
5
+ * const uri = otpauthUri({ secret, account: "ada@example.com", issuer: "Acme" });
6
+ * const ok = await verifyTotp(secret, "492039");
7
+ *
8
+ * Deliberately dependency-free and edge-safe: the whole thing is WebCrypto plus
9
+ * about thirty lines of base32, so it runs unchanged on Workers. TOTP wants
10
+ * HMAC-SHA1 — not a weakness here (the secret is 160 bits of CSPRNG and the code
11
+ * lives 30 seconds), and it's what every authenticator app implements.
12
+ */
13
+ export declare function base32Encode(bytes: Uint8Array): string;
14
+ export declare function base32Decode(input: string): Uint8Array;
15
+ /** A new TOTP secret: 160 bits, base32-encoded. Show it once, store it encrypted. */
16
+ export declare function generateSecret(bytes?: number): string;
17
+ export interface TotpOptions {
18
+ /** Code length. 6 is what every authenticator app shows. */
19
+ digits?: number;
20
+ /** Seconds each code is valid for. */
21
+ period?: number;
22
+ /** Unix seconds; defaults to now. */
23
+ timestamp?: number;
24
+ }
25
+ /** The code for a secret at a moment in time. */
26
+ export declare function totp(secret: string, options?: TotpOptions): Promise<string>;
27
+ export interface VerifyOptions extends TotpOptions {
28
+ /**
29
+ * How many periods either side of now to accept. 1 (the default) tolerates a
30
+ * phone whose clock drifts by up to 30 seconds — which is common enough that 0
31
+ * generates support tickets, and large values just widen the guessing window.
32
+ */
33
+ window?: number;
34
+ }
35
+ /**
36
+ * Is this the code for that secret, right now?
37
+ *
38
+ * Compared in constant time, and every candidate in the window is checked even
39
+ * after a match, so the time this takes says nothing about which period matched.
40
+ */
41
+ export declare function verifyTotp(secret: string, code: string, options?: VerifyOptions): Promise<boolean>;
42
+ export interface OtpauthOptions {
43
+ secret: string;
44
+ /** Who the account belongs to — shown in the authenticator app. */
45
+ account: string;
46
+ /** Your app's name — shown above the account. */
47
+ issuer: string;
48
+ digits?: number;
49
+ period?: number;
50
+ }
51
+ /**
52
+ * The `otpauth://` URI an authenticator app scans.
53
+ *
54
+ * Render it to a QR code **yourself, locally**. Never post it to a QR-image
55
+ * service: the URI contains the shared secret, so a third-party URL hands your
56
+ * users' second factor to someone else.
57
+ */
58
+ export declare function otpauthUri(options: OtpauthOptions): string;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * TOTP (RFC 6238) — the authenticator-app second factor.
3
+ *
4
+ * const secret = generateSecret(); // base32, shown once
5
+ * const uri = otpauthUri({ secret, account: "ada@example.com", issuer: "Acme" });
6
+ * const ok = await verifyTotp(secret, "492039");
7
+ *
8
+ * Deliberately dependency-free and edge-safe: the whole thing is WebCrypto plus
9
+ * about thirty lines of base32, so it runs unchanged on Workers. TOTP wants
10
+ * HMAC-SHA1 — not a weakness here (the secret is 160 bits of CSPRNG and the code
11
+ * lives 30 seconds), and it's what every authenticator app implements.
12
+ */
13
+ /* --------------------------------- base32 --------------------------------- */
14
+ // RFC 4648, upper-case, no padding — what authenticator apps expect in a URI.
15
+ const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
16
+ export function base32Encode(bytes) {
17
+ let bits = 0;
18
+ let value = 0;
19
+ let out = "";
20
+ for (const byte of bytes) {
21
+ value = (value << 8) | byte;
22
+ bits += 8;
23
+ while (bits >= 5) {
24
+ out += ALPHABET[(value >>> (bits - 5)) & 31];
25
+ bits -= 5;
26
+ }
27
+ }
28
+ // Whatever's left, left-aligned in a final group.
29
+ if (bits > 0)
30
+ out += ALPHABET[(value << (5 - bits)) & 31];
31
+ return out;
32
+ }
33
+ export function base32Decode(input) {
34
+ // Users paste secrets by hand: tolerate spaces, lower case, and padding.
35
+ const clean = input.toUpperCase().replace(/=+$/, "").replace(/\s+/g, "");
36
+ let bits = 0;
37
+ let value = 0;
38
+ const out = [];
39
+ for (const char of clean) {
40
+ const index = ALPHABET.indexOf(char);
41
+ if (index === -1)
42
+ throw new Error(`"${char}" is not valid base32.`);
43
+ value = (value << 5) | index;
44
+ bits += 5;
45
+ if (bits >= 8) {
46
+ out.push((value >>> (bits - 8)) & 255);
47
+ bits -= 8;
48
+ }
49
+ }
50
+ return new Uint8Array(out);
51
+ }
52
+ /* --------------------------------- secrets -------------------------------- */
53
+ /** A new TOTP secret: 160 bits, base32-encoded. Show it once, store it encrypted. */
54
+ export function generateSecret(bytes = 20) {
55
+ return base32Encode(crypto.getRandomValues(new Uint8Array(bytes)));
56
+ }
57
+ /* ---------------------------------- codes --------------------------------- */
58
+ /** The code for a secret at a moment in time. */
59
+ export async function totp(secret, options = {}) {
60
+ const digits = options.digits ?? 6;
61
+ const period = options.period ?? 30;
62
+ const now = options.timestamp ?? Math.floor(Date.now() / 1000);
63
+ const counter = Math.floor(now / period);
64
+ // The counter is a 64-bit big-endian integer. JS bitwise ops are 32-bit, so
65
+ // the halves are written separately rather than shifted.
66
+ const message = new Uint8Array(8);
67
+ new DataView(message.buffer).setUint32(0, Math.floor(counter / 2 ** 32), false);
68
+ new DataView(message.buffer).setUint32(4, counter >>> 0, false);
69
+ const key = await crypto.subtle.importKey("raw", base32Decode(secret), { name: "HMAC", hash: "SHA-1" }, false, ["sign"]);
70
+ const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, message));
71
+ // Dynamic truncation (RFC 4226 §5.3): the low nibble of the last byte picks
72
+ // which four bytes of the MAC become the code.
73
+ const offset = mac[mac.length - 1] & 0x0f;
74
+ const binary = ((mac[offset] & 0x7f) << 24) |
75
+ (mac[offset + 1] << 16) |
76
+ (mac[offset + 2] << 8) |
77
+ mac[offset + 3];
78
+ return String(binary % 10 ** digits).padStart(digits, "0");
79
+ }
80
+ /**
81
+ * Is this the code for that secret, right now?
82
+ *
83
+ * Compared in constant time, and every candidate in the window is checked even
84
+ * after a match, so the time this takes says nothing about which period matched.
85
+ */
86
+ export async function verifyTotp(secret, code, options = {}) {
87
+ const digits = options.digits ?? 6;
88
+ const period = options.period ?? 30;
89
+ const window = options.window ?? 1;
90
+ const now = options.timestamp ?? Math.floor(Date.now() / 1000);
91
+ const supplied = code.replace(/\s+/g, "");
92
+ if (!new RegExp(`^\\d{${digits}}$`).test(supplied))
93
+ return false;
94
+ let matched = false;
95
+ for (let drift = -window; drift <= window; drift++) {
96
+ const candidate = await totp(secret, {
97
+ digits,
98
+ period,
99
+ timestamp: now + drift * period,
100
+ });
101
+ // No early return: the loop runs the same number of times either way.
102
+ if (timingSafeEqual(candidate, supplied))
103
+ matched = true;
104
+ }
105
+ return matched;
106
+ }
107
+ /**
108
+ * The `otpauth://` URI an authenticator app scans.
109
+ *
110
+ * Render it to a QR code **yourself, locally**. Never post it to a QR-image
111
+ * service: the URI contains the shared secret, so a third-party URL hands your
112
+ * users' second factor to someone else.
113
+ */
114
+ export function otpauthUri(options) {
115
+ const label = `${options.issuer}:${options.account}`;
116
+ const params = new URLSearchParams({
117
+ secret: options.secret,
118
+ issuer: options.issuer,
119
+ algorithm: "SHA1",
120
+ digits: String(options.digits ?? 6),
121
+ period: String(options.period ?? 30),
122
+ });
123
+ return `otpauth://totp/${encodeURIComponent(label)}?${params.toString()}`;
124
+ }
125
+ /* --------------------------------- helpers -------------------------------- */
126
+ /** Compare without leaking, through timing, how much of the code was right. */
127
+ function timingSafeEqual(a, b) {
128
+ if (a.length !== b.length)
129
+ return false;
130
+ let diff = 0;
131
+ for (let i = 0; i < a.length; i++)
132
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
133
+ return diff === 0;
134
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The two-factor lifecycle: enable, confirm, challenge, recover, disable.
3
+ *
4
+ * Enabling is **two steps on purpose**. `enableTwoFactor()` generates a secret and
5
+ * stores it, but leaves `two_factor_confirmed_at` null — 2FA is not on yet. Only
6
+ * `confirmTwoFactor()`, which requires a working code from the app, turns it on.
7
+ * A one-step "enable" locks out every user who scans the QR wrong, mistypes the
8
+ * secret, or has a phone whose clock is broken — and they cannot get back in,
9
+ * because getting back in is exactly what's broken.
10
+ *
11
+ * The secret is encrypted at rest with its own purpose, so a leaked database does
12
+ * not hand over everybody's second factor. Recovery codes are hashed, so a leaked
13
+ * database does not hand over the backdoor either.
14
+ */
15
+ import { type AccountUser } from "./store.js";
16
+ export interface TwoFactorSetup {
17
+ /** The plaintext secret. Show it once, for manual entry. */
18
+ secret: string;
19
+ /** Render this to a QR code **locally** — it contains the secret. */
20
+ uri: string;
21
+ /** Show these once. They are hashed the moment they're stored. */
22
+ recoveryCodes: string[];
23
+ }
24
+ export interface TwoFactorOptions {
25
+ /** The name shown in the authenticator app. */
26
+ issuer?: string;
27
+ /** How many recovery codes to mint. */
28
+ recoveryCodes?: number;
29
+ window?: number;
30
+ }
31
+ /** Is two-factor actually on for this user — confirmed, not merely started? */
32
+ export declare function hasTwoFactor(user: AccountUser): boolean;
33
+ /**
34
+ * Step one: mint a secret and recovery codes, and store them — but do **not** turn
35
+ * 2FA on. The user has not proved they can generate a code yet.
36
+ */
37
+ export declare function enableTwoFactor(user: AccountUser, options?: TwoFactorOptions): Promise<TwoFactorSetup>;
38
+ /**
39
+ * Step two: a working code turns it on. Returns false if the code is wrong, and
40
+ * 2FA stays off — which is the whole point of the two-step dance.
41
+ */
42
+ export declare function confirmTwoFactor(user: AccountUser, code: string, options?: TwoFactorOptions): Promise<boolean>;
43
+ /** Turn it off, and destroy the secret and the codes with it. */
44
+ export declare function disableTwoFactor(user: AccountUser): Promise<void>;
45
+ /** Verify a code from the authenticator app. */
46
+ export declare function verifyTwoFactorCode(user: AccountUser, code: string, options?: TwoFactorOptions): Promise<boolean>;
47
+ /**
48
+ * Spend a recovery code. Single use: the code is removed on success, so the same
49
+ * slip of paper cannot be used twice — and someone who reads it over your shoulder
50
+ * gets one shot at a code you have already burned.
51
+ */
52
+ export declare function redeemRecoveryCode(user: AccountUser, code: string): Promise<boolean>;
53
+ /** How many recovery codes are left — worth showing when it gets low. */
54
+ export declare function recoveryCodesRemaining(user: AccountUser): Promise<number>;
55
+ /** Mint a fresh set, invalidating the old ones. Shown once. */
56
+ export declare function regenerateRecoveryCodes(user: AccountUser, count?: number): Promise<string[]>;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The two-factor lifecycle: enable, confirm, challenge, recover, disable.
3
+ *
4
+ * Enabling is **two steps on purpose**. `enableTwoFactor()` generates a secret and
5
+ * stores it, but leaves `two_factor_confirmed_at` null — 2FA is not on yet. Only
6
+ * `confirmTwoFactor()`, which requires a working code from the app, turns it on.
7
+ * A one-step "enable" locks out every user who scans the QR wrong, mistypes the
8
+ * secret, or has a phone whose clock is broken — and they cannot get back in,
9
+ * because getting back in is exactly what's broken.
10
+ *
11
+ * The secret is encrypted at rest with its own purpose, so a leaked database does
12
+ * not hand over everybody's second factor. Recovery codes are hashed, so a leaked
13
+ * database does not hand over the backdoor either.
14
+ */
15
+ import { hash, encryption } from "../core/crypto.js";
16
+ import { accountStore } from "./store.js";
17
+ import { generateSecret, otpauthUri, verifyTotp } from "./totp.js";
18
+ const SECRET_PURPOSE = "accounts:2fa-secret";
19
+ /** Is two-factor actually on for this user — confirmed, not merely started? */
20
+ export function hasTwoFactor(user) {
21
+ return Boolean(user.two_factor_secret && user.two_factor_confirmed_at);
22
+ }
23
+ /**
24
+ * Step one: mint a secret and recovery codes, and store them — but do **not** turn
25
+ * 2FA on. The user has not proved they can generate a code yet.
26
+ */
27
+ export async function enableTwoFactor(user, options = {}) {
28
+ const secret = generateSecret();
29
+ const recoveryCodes = makeRecoveryCodes(options.recoveryCodes ?? 8);
30
+ await accountStore().update(user.id, {
31
+ two_factor_secret: await encryption.encrypt(secret, { purpose: SECRET_PURPOSE }),
32
+ two_factor_recovery_codes: await encryption.encrypt(JSON.stringify(await Promise.all(recoveryCodes.map((code) => hash.make(code)))), { purpose: SECRET_PURPOSE }),
33
+ two_factor_confirmed_at: null,
34
+ });
35
+ return {
36
+ secret,
37
+ uri: otpauthUri({
38
+ secret,
39
+ account: user.email,
40
+ issuer: options.issuer ?? "Keel",
41
+ }),
42
+ recoveryCodes,
43
+ };
44
+ }
45
+ /**
46
+ * Step two: a working code turns it on. Returns false if the code is wrong, and
47
+ * 2FA stays off — which is the whole point of the two-step dance.
48
+ */
49
+ export async function confirmTwoFactor(user, code, options = {}) {
50
+ const secret = await secretFor(user);
51
+ if (!secret)
52
+ return false;
53
+ if (!(await verifyTotp(secret, code, { window: options.window ?? 1 })))
54
+ return false;
55
+ await accountStore().update(user.id, {
56
+ two_factor_confirmed_at: new Date().toISOString(),
57
+ });
58
+ return true;
59
+ }
60
+ /** Turn it off, and destroy the secret and the codes with it. */
61
+ export async function disableTwoFactor(user) {
62
+ await accountStore().update(user.id, {
63
+ two_factor_secret: null,
64
+ two_factor_recovery_codes: null,
65
+ two_factor_confirmed_at: null,
66
+ });
67
+ }
68
+ /** Verify a code from the authenticator app. */
69
+ export async function verifyTwoFactorCode(user, code, options = {}) {
70
+ const secret = await secretFor(user);
71
+ if (!secret)
72
+ return false;
73
+ return verifyTotp(secret, code, { window: options.window ?? 1 });
74
+ }
75
+ /**
76
+ * Spend a recovery code. Single use: the code is removed on success, so the same
77
+ * slip of paper cannot be used twice — and someone who reads it over your shoulder
78
+ * gets one shot at a code you have already burned.
79
+ */
80
+ export async function redeemRecoveryCode(user, code) {
81
+ const codes = await recoveryCodesFor(user);
82
+ if (!codes.length)
83
+ return false;
84
+ const supplied = code.trim().toLowerCase();
85
+ for (const hashed of codes) {
86
+ if (!(await hash.verify(hashed, supplied)))
87
+ continue;
88
+ const remaining = codes.filter((c) => c !== hashed);
89
+ await accountStore().update(user.id, {
90
+ two_factor_recovery_codes: await encryption.encrypt(JSON.stringify(remaining), {
91
+ purpose: SECRET_PURPOSE,
92
+ }),
93
+ });
94
+ return true;
95
+ }
96
+ return false;
97
+ }
98
+ /** How many recovery codes are left — worth showing when it gets low. */
99
+ export async function recoveryCodesRemaining(user) {
100
+ return (await recoveryCodesFor(user)).length;
101
+ }
102
+ /** Mint a fresh set, invalidating the old ones. Shown once. */
103
+ export async function regenerateRecoveryCodes(user, count = 8) {
104
+ const codes = makeRecoveryCodes(count);
105
+ await accountStore().update(user.id, {
106
+ two_factor_recovery_codes: await encryption.encrypt(JSON.stringify(await Promise.all(codes.map((code) => hash.make(code)))), { purpose: SECRET_PURPOSE }),
107
+ });
108
+ return codes;
109
+ }
110
+ /* --------------------------------- internals ------------------------------ */
111
+ /** The decrypted TOTP secret, or null if 2FA was never set up. */
112
+ async function secretFor(user) {
113
+ if (!user.two_factor_secret)
114
+ return null;
115
+ return encryption.decrypt(user.two_factor_secret, { purpose: SECRET_PURPOSE });
116
+ }
117
+ async function recoveryCodesFor(user) {
118
+ if (!user.two_factor_recovery_codes)
119
+ return [];
120
+ const json = await encryption.decrypt(user.two_factor_recovery_codes, {
121
+ purpose: SECRET_PURPOSE,
122
+ });
123
+ if (!json)
124
+ return [];
125
+ try {
126
+ const parsed = JSON.parse(json);
127
+ return Array.isArray(parsed) ? parsed : [];
128
+ }
129
+ catch {
130
+ return [];
131
+ }
132
+ }
133
+ /**
134
+ * Recovery codes people can actually read off paper and type back in: no vowels
135
+ * (so no accidental words), no 0/1/l/o (so no ambiguity with O/I), grouped.
136
+ */
137
+ function makeRecoveryCodes(count) {
138
+ const alphabet = "bcdfghjkmnpqrstvwxyz23456789";
139
+ const codes = [];
140
+ for (let i = 0; i < count; i++) {
141
+ const bytes = crypto.getRandomValues(new Uint8Array(10));
142
+ const chars = [...bytes].map((b) => alphabet[b % alphabet.length]);
143
+ codes.push(`${chars.slice(0, 5).join("")}-${chars.slice(5).join("")}`);
144
+ }
145
+ return codes;
146
+ }