CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
totp.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 7298a17 | 1 | /** |
| 2 | * TOTP (RFC 6238) — standalone, no external deps. | |
| 3 | * | |
| 4 | * Used for 2FA (Block B4). Generates + verifies 6-digit codes with a 30-second | |
| 5 | * step. Verification accepts the current step ±1 to tolerate clock skew. | |
| 6 | * | |
| 7 | * Secrets are stored as Base32 strings (the standard QR-code encoding) and | |
| 8 | * converted to bytes on each verify. At rest the secret is further encrypted | |
| 9 | * (see `src/lib/crypto.ts` for the AES-GCM wrapper introduced in this block). | |
| 10 | */ | |
| 11 | ||
| 12 | const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; | |
| 13 | ||
| 14 | /** Encode random bytes as a Base32 string with no padding (TOTP standard). */ | |
| 15 | export function base32Encode(bytes: Uint8Array): string { | |
| 16 | let bits = 0; | |
| 17 | let value = 0; | |
| 18 | let output = ""; | |
| 19 | for (let i = 0; i < bytes.length; i++) { | |
| 20 | value = (value << 8) | bytes[i]!; | |
| 21 | bits += 8; | |
| 22 | while (bits >= 5) { | |
| 23 | output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31]; | |
| 24 | bits -= 5; | |
| 25 | } | |
| 26 | } | |
| 27 | if (bits > 0) { | |
| 28 | output += BASE32_ALPHABET[(value << (5 - bits)) & 31]; | |
| 29 | } | |
| 30 | return output; | |
| 31 | } | |
| 32 | ||
| 33 | /** Decode a Base32 string back into bytes. Permissive about case + padding. */ | |
| 34 | export function base32Decode(input: string): Uint8Array { | |
| 35 | const clean = input | |
| 36 | .toUpperCase() | |
| 37 | .replace(/=+$/g, "") | |
| 38 | .replace(/\s+/g, ""); | |
| 39 | let bits = 0; | |
| 40 | let value = 0; | |
| 41 | const out: number[] = []; | |
| 42 | for (let i = 0; i < clean.length; i++) { | |
| 43 | const idx = BASE32_ALPHABET.indexOf(clean[i]!); | |
| 44 | if (idx === -1) { | |
| 45 | throw new Error(`Invalid Base32 character: ${clean[i]}`); | |
| 46 | } | |
| 47 | value = (value << 5) | idx; | |
| 48 | bits += 5; | |
| 49 | if (bits >= 8) { | |
| 50 | bits -= 8; | |
| 51 | out.push((value >>> bits) & 0xff); | |
| 52 | } | |
| 53 | } | |
| 54 | return new Uint8Array(out); | |
| 55 | } | |
| 56 | ||
| 57 | /** | |
| 58 | * Generate a cryptographically random TOTP secret. 20 bytes → 32 Base32 chars, | |
| 59 | * the length most auth apps expect and RFC 4226 recommends. | |
| 60 | */ | |
| 61 | export function generateTotpSecret(): string { | |
| 62 | return base32Encode(crypto.getRandomValues(new Uint8Array(20))); | |
| 63 | } | |
| 64 | ||
| 65 | async function hmacSha1( | |
| 66 | keyBytes: Uint8Array, | |
| 67 | msgBytes: Uint8Array | |
| 68 | ): Promise<Uint8Array> { | |
| 69 | const key = await crypto.subtle.importKey( | |
| 70 | "raw", | |
| 772a24f | 71 | keyBytes as BufferSource, |
| 7298a17 | 72 | { name: "HMAC", hash: "SHA-1" }, |
| 73 | false, | |
| 74 | ["sign"] | |
| 75 | ); | |
| 772a24f | 76 | const sig = await crypto.subtle.sign("HMAC", key, msgBytes as BufferSource); |
| 7298a17 | 77 | return new Uint8Array(sig); |
| 78 | } | |
| 79 | ||
| 80 | /** Dynamic-truncate the HMAC output into a 6-digit number (RFC 4226). */ | |
| 81 | function hotpCode(hmac: Uint8Array): string { | |
| 82 | const offset = hmac[hmac.length - 1]! & 0x0f; | |
| 83 | const bin = | |
| 84 | ((hmac[offset]! & 0x7f) << 24) | | |
| 85 | ((hmac[offset + 1]! & 0xff) << 16) | | |
| 86 | ((hmac[offset + 2]! & 0xff) << 8) | | |
| 87 | (hmac[offset + 3]! & 0xff); | |
| 88 | return String(bin % 1_000_000).padStart(6, "0"); | |
| 89 | } | |
| 90 | ||
| 91 | /** Generate the TOTP code for a given secret + unix time (seconds). */ | |
| 92 | export async function totpCode( | |
| 93 | secretBase32: string, | |
| 94 | timeSec: number = Math.floor(Date.now() / 1000) | |
| 95 | ): Promise<string> { | |
| 96 | const step = Math.floor(timeSec / 30); | |
| 97 | const msg = new Uint8Array(8); | |
| 98 | // Big-endian 8-byte counter. | |
| 99 | new DataView(msg.buffer).setBigUint64(0, BigInt(step), false); | |
| 100 | const hmac = await hmacSha1(base32Decode(secretBase32), msg); | |
| 101 | return hotpCode(hmac); | |
| 102 | } | |
| 103 | ||
| 104 | /** | |
| 105 | * Verify a 6-digit code against a secret with ±1 step tolerance. | |
| 106 | * Constant-time-ish string compare (both sides same length). | |
| 107 | */ | |
| 108 | export async function verifyTotpCode( | |
| 109 | secretBase32: string, | |
| 110 | code: string, | |
| 111 | timeSec: number = Math.floor(Date.now() / 1000) | |
| 112 | ): Promise<boolean> { | |
| 113 | if (!/^\d{6}$/.test(code)) return false; | |
| 114 | const candidates = await Promise.all([ | |
| 115 | totpCode(secretBase32, timeSec - 30), | |
| 116 | totpCode(secretBase32, timeSec), | |
| 117 | totpCode(secretBase32, timeSec + 30), | |
| 118 | ]); | |
| 119 | let ok = false; | |
| 120 | for (const c of candidates) { | |
| 121 | // Avoid short-circuit: keep timing close. | |
| 122 | if (constantTimeEqual(c, code)) ok = true; | |
| 123 | } | |
| 124 | return ok; | |
| 125 | } | |
| 126 | ||
| 127 | function constantTimeEqual(a: string, b: string): boolean { | |
| 128 | if (a.length !== b.length) return false; | |
| 129 | let diff = 0; | |
| 130 | for (let i = 0; i < a.length; i++) { | |
| 131 | diff |= a.charCodeAt(i) ^ b.charCodeAt(i); | |
| 132 | } | |
| 133 | return diff === 0; | |
| 134 | } | |
| 135 | ||
| 136 | /** | |
| 137 | * Build an otpauth:// URI suitable for QR codes. Most authenticator apps | |
| 138 | * (Google Authenticator, 1Password, Bitwarden, Authy) accept this format. | |
| 139 | */ | |
| 140 | export function otpauthUrl(opts: { | |
| 141 | secret: string; | |
| 142 | accountName: string; | |
| 143 | issuer: string; | |
| 144 | }): string { | |
| 145 | const label = encodeURIComponent(`${opts.issuer}:${opts.accountName}`); | |
| 146 | const params = new URLSearchParams({ | |
| 147 | secret: opts.secret, | |
| 148 | issuer: opts.issuer, | |
| 149 | algorithm: "SHA1", | |
| 150 | digits: "6", | |
| 151 | period: "30", | |
| 152 | }); | |
| 153 | return `otpauth://totp/${label}?${params.toString()}`; | |
| 154 | } | |
| 155 | ||
| 156 | /** | |
| 157 | * Generate N random recovery codes in the format xxxx-xxxx-xxxx (lowercase | |
| 158 | * alphanumeric). Each code is ~70 bits of entropy and single-use. | |
| 159 | */ | |
| 160 | export function generateRecoveryCodes(count = 10): string[] { | |
| 161 | const codes: string[] = []; | |
| 162 | for (let i = 0; i < count; i++) { | |
| 163 | const parts: string[] = []; | |
| 164 | for (let j = 0; j < 3; j++) { | |
| 165 | const bytes = crypto.getRandomValues(new Uint8Array(3)); | |
| 166 | parts.push( | |
| 167 | Array.from(bytes) | |
| 168 | .map((b) => b.toString(36).padStart(2, "0")) | |
| 169 | .join("") | |
| 170 | .slice(0, 4) | |
| 171 | ); | |
| 172 | } | |
| 173 | codes.push(parts.join("-")); | |
| 174 | } | |
| 175 | return codes; | |
| 176 | } | |
| 177 | ||
| 178 | /** Hash a recovery code with SHA-256 for storage. */ | |
| 179 | export async function hashRecoveryCode(code: string): Promise<string> { | |
| 180 | const bytes = new TextEncoder().encode(code.trim().toLowerCase()); | |
| 181 | const digest = await crypto.subtle.digest("SHA-256", bytes); | |
| 182 | return Array.from(new Uint8Array(digest)) | |
| 183 | .map((b) => b.toString(16).padStart(2, "0")) | |
| 184 | .join(""); | |
| 185 | } |