pr_01m47d24b0e6n91zwymwxg0vpx/services/identity/src/crypto.ts

98 lines3,059 bytesCodeBlame
1// Workers cap PBKDF2 at 100,000 iterations.
2const PBKDF2_ITERATIONS = 100_000;
3
4const encoder = new TextEncoder();
5
6export function toHex(bytes: ArrayBuffer | Uint8Array): string {
7 return [...new Uint8Array(bytes)]
8 .map((byte) => byte.toString(16).padStart(2, "0"))
9 .join("");
10}
11
12export function fromBase64(value: string): Uint8Array<ArrayBuffer> {
13 return Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
14}
15
16export function toBase64(bytes: ArrayBuffer | Uint8Array): string {
17 return btoa(String.fromCharCode(...new Uint8Array(bytes)));
18}
19
20export async function sha256Hex(value: string): Promise<string> {
21 return toHex(await crypto.subtle.digest("SHA-256", encoder.encode(value)));
22}
23
24export function randomHex(bytes: number): string {
25 return toHex(crypto.getRandomValues(new Uint8Array(bytes)));
26}
27
28async function pbkdf2(
29 password: string,
30 salt: Uint8Array<ArrayBuffer>,
31 iterations: number,
32): Promise<Uint8Array> {
33 const key = await crypto.subtle.importKey(
34 "raw",
35 encoder.encode(password),
36 "PBKDF2",
37 false,
38 ["deriveBits"],
39 );
40 return new Uint8Array(
41 await crypto.subtle.deriveBits(
42 { name: "PBKDF2", hash: "SHA-256", salt, iterations },
43 key,
44 256,
45 ),
46 );
47}
48
49/** Format: `pbkdf2$<iterations>$<salt base64>$<hash base64>`. */
50export async function hashPassword(password: string): Promise<string> {
51 const salt = crypto.getRandomValues(new Uint8Array(16));
52 const hash = await pbkdf2(password, salt, PBKDF2_ITERATIONS);
53 return `pbkdf2$${PBKDF2_ITERATIONS}$${toBase64(salt)}$${toBase64(hash)}`;
54}
55
56export async function verifyPassword(
57 password: string,
58 stored: string,
59): Promise<boolean> {
60 const [scheme, iterations, salt, hash] = stored.split("$");
61 if (scheme !== "pbkdf2") return false;
62 const expected = fromBase64(hash);
63 const given = await pbkdf2(password, fromBase64(salt), Number(iterations));
64 // Constant-time comparison.
65 let diff = given.length ^ expected.length;
66 for (let i = 0; i < expected.length; i++) diff |= given[i] ^ expected[i];
67 return diff === 0;
68}
69
70/**
71 * Parses an OpenSSH public key line. The fingerprint matches
72 * `ssh-keygen -lf` (SHA256, unpadded base64).
73 */
74export async function parseSshKey(
75 line: string,
76): Promise<{ publicKey: string; fingerprint: string; comment: string } | null> {
77 const [type, blob, ...comment] = line.trim().split(/\s+/);
78 if (!/^(ssh-(ed25519|rsa)|ecdsa-sha2-nistp(256|384|521))$/.test(type ?? "")) {
79 return null;
80 }
81 let bytes: Uint8Array<ArrayBuffer>;
82 try {
83 bytes = fromBase64(blob ?? "");
84 // The blob starts with its own length-prefixed copy of the key type.
85 const typeLength = new DataView(bytes.buffer).getUint32(0);
86 if (new TextDecoder().decode(bytes.slice(4, 4 + typeLength)) !== type) {
87 return null;
88 }
89 } catch {
90 return null;
91 }
92 const digest = await crypto.subtle.digest("SHA-256", bytes);
93 return {
94 publicKey: `${type} ${blob}`,
95 fingerprint: `SHA256:${toBase64(digest).replace(/=+$/, "")}`,
96 comment: comment.join(" "),
97 };
98}