Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

totp.tsBlame185 lines · 1 contributor
7298a17Claude1/**
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
12const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
13
14/** Encode random bytes as a Base32 string with no padding (TOTP standard). */
15export 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. */
34export 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 */
61export function generateTotpSecret(): string {
62 return base32Encode(crypto.getRandomValues(new Uint8Array(20)));
63}
64
65async function hmacSha1(
66 keyBytes: Uint8Array,
67 msgBytes: Uint8Array
68): Promise<Uint8Array> {
69 const key = await crypto.subtle.importKey(
70 "raw",
71 keyBytes,
72 { name: "HMAC", hash: "SHA-1" },
73 false,
74 ["sign"]
75 );
76 const sig = await crypto.subtle.sign("HMAC", key, msgBytes);
77 return new Uint8Array(sig);
78}
79
80/** Dynamic-truncate the HMAC output into a 6-digit number (RFC 4226). */
81function 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). */
92export 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 */
108export 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
127function 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 */
140export 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 */
160export 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. */
179export 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}