Repository Public
1import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, scrypt, timingSafeEqual } from 'node:crypto';2import { createReadStream, createWriteStream } from 'node:fs';3import { open } from 'node:fs/promises';4import { Readable, Transform } from 'node:stream';5import { pipeline } from 'node:stream/promises';67// --- tokens and password hashing ---------------------------------------------------------------------------8export const sha256hex = (s: string | Buffer) => createHash('sha256').update(s).digest('hex');9/** 256-bit URL-safe token; only its hash is ever stored. */10export const newToken = () => randomBytes(32).toString('base64url');11export const tokenHash = (t: string) => sha256hex(`spd-token-v1:${t}`);12export const TOKEN_RE = /^[A-Za-z0-9_-]{20,64}$/;1314const SCRYPT = { N: 1 << 15, r: 8, p: 1, maxmem: 128 * 1024 * 1024 };15const scryptAsync = (pw: string, salt: Buffer) => new Promise<Buffer>((res, rej) => scrypt(pw.normalize('NFKC'), salt, 32, SCRYPT, (e, k) => (e ? rej(e) : res(k))));16export async function hashPassword(pw: string): Promise<string> {17 const salt = randomBytes(16);18 return `scrypt$15$${salt.toString('base64url')}$${(await scryptAsync(pw, salt)).toString('base64url')}`;19}20export async function verifyPassword(pw: string, stored: string): Promise<boolean> {21 const [alg, , salt, hash] = stored.split('$');22 if (alg !== 'scrypt' || !salt || !hash) return false;23 const want = Buffer.from(hash, 'base64url');24 const got = await scryptAsync(pw, Buffer.from(salt, 'base64url'));25 return want.length === got.length && timingSafeEqual(want, got);26}27export const hmacHex = (key: Buffer, msg: string) => createHmac('sha256', key).update(msg).digest('hex');28export const safeEqual = (a: string, b: string) => { const x = Buffer.from(a), y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y); };2930export async function sha256File(path: string): Promise<string> {31 const h = createHash('sha256');32 await pipeline(createReadStream(path, { highWaterMark: 1 << 20 }), h);33 return h.digest('hex');34}3536// --- key wrapping ------------------------------------------------------------------------------------------37// wrapped = version(1) | nonce(12) | ciphertext(32) | tag(16)38const WRAP_V1 = 1;39export function wrapKey(master: Buffer, dataKey: Buffer): Buffer {40 const nonce = randomBytes(12);41 const c = createCipheriv('aes-256-gcm', master, nonce);42 c.setAAD(Buffer.from('spd-wrap-v1'));43 const ct = Buffer.concat([c.update(dataKey), c.final()]);44 return Buffer.concat([Buffer.from([WRAP_V1]), nonce, ct, c.getAuthTag()]);45}46export function unwrapKey(master: Buffer, wrapped: Buffer): Buffer {47 if (wrapped[0] !== WRAP_V1 || wrapped.length !== 61) throw new Error('unknown key wrapping');48 const d = createDecipheriv('aes-256-gcm', master, wrapped.subarray(1, 13));49 d.setAAD(Buffer.from('spd-wrap-v1'));50 d.setAuthTag(wrapped.subarray(45));51 return Buffer.concat([d.update(wrapped.subarray(13, 45)), d.final()]);52}53/** Small secrets (TOTP seeds) sealed with the master key. */54export function seal(master: Buffer, plain: Buffer, context: string): Buffer {55 const nonce = randomBytes(12);56 const c = createCipheriv('aes-256-gcm', master, nonce);57 c.setAAD(Buffer.from(`spd-seal-v1:${context}`));58 return Buffer.concat([nonce, c.update(plain), c.final(), c.getAuthTag()]);59}60export function unseal(master: Buffer, sealed: Buffer, context: string): Buffer {61 const d = createDecipheriv('aes-256-gcm', master, sealed.subarray(0, 12));62 d.setAAD(Buffer.from(`spd-seal-v1:${context}`));63 d.setAuthTag(sealed.subarray(sealed.length - 16));64 return Buffer.concat([d.update(sealed.subarray(12, sealed.length - 16)), d.final()]);65}6667// --- chunked content encryption ----------------------------------------------------------------------------68// Plaintext is cut in 64 KiB blocks; block i is AES-256-GCM(key, nonce = i, aad = size | i | last?) → ciphertext | tag(16).69// The key is unique per object, so a counter nonce never repeats. The AAD binds every block to its position, to the70// total size and to being last or not: truncation, reordering and splicing all fail authentication.71export const BLOCK = 64 * 1024;72const TAG = 16;73export const sealedSize = (size: number) => size + Math.max(1, Math.ceil(size / BLOCK)) * TAG;74const blockCount = (size: number) => Math.max(1, Math.ceil(size / BLOCK));75function nonceFor(i: number): Buffer { const n = Buffer.alloc(12); n.writeBigUInt64BE(BigInt(i), 4); return n; }76function aadFor(size: number, i: number): Buffer {77 const a = Buffer.alloc(17);78 a.writeBigUInt64BE(BigInt(size), 0); a.writeBigUInt64BE(BigInt(i), 8); a[16] = i === blockCount(size) - 1 ? 1 : 0;79 return a;80}81function sealBlock(key: Buffer, size: number, i: number, plain: Buffer): Buffer {82 const c = createCipheriv('aes-256-gcm', key, nonceFor(i));83 c.setAAD(aadFor(size, i));84 return Buffer.concat([c.update(plain), c.final(), c.getAuthTag()]);85}86function openBlock(key: Buffer, size: number, i: number, sealed: Buffer): Buffer {87 const d = createDecipheriv('aes-256-gcm', key, nonceFor(i));88 d.setAAD(aadFor(size, i));89 d.setAuthTag(sealed.subarray(sealed.length - TAG));90 return Buffer.concat([d.update(sealed.subarray(0, sealed.length - TAG)), d.final()]);91}9293/** Encrypt a plaintext file of known size into `dest`. */94export async function encryptFile(key: Buffer, src: string, dest: string, size: number): Promise<void> {95 let i = 0, pending: Buffer = Buffer.alloc(0), seen = 0;96 const enc = new Transform({97 transform(chunk: Buffer, _e, cb) {98 seen += chunk.length;99 pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;100 try {101 while (pending.length > BLOCK) { this.push(sealBlock(key, size, i++, pending.subarray(0, BLOCK))); pending = pending.subarray(BLOCK); }102 cb();103 } catch (e) { cb(e as Error); }104 },105 flush(cb) {106 if (seen !== size) return cb(new Error(`size changed during encryption (${seen} != ${size})`));107 try { if (pending.length || i === 0) this.push(sealBlock(key, size, i++, pending)); cb(); } catch (e) { cb(e as Error); }108 },109 });110 await pipeline(createReadStream(src, { highWaterMark: 1 << 20 }), enc, createWriteStream(dest, { mode: 0o600, flags: 'wx' }));111}112113/** Random-access reader over a sealed object (a local file today, a ranged GET for an object store). */114export interface BlockSource { read(position: number, length: number): Promise<Buffer>; close(): Promise<void> }115export async function fileSource(path: string): Promise<BlockSource> {116 const fh = await open(path, 'r');117 return {118 async read(position, length) { const buf = Buffer.alloc(length); const { bytesRead } = await fh.read(buf, 0, length, position); return bytesRead === length ? buf : buf.subarray(0, bytesRead); },119 close: () => fh.close(),120 };121}122/** Decrypt the plaintext byte range [start, end] (inclusive) of a sealed object; only the needed blocks are read. */123export function decryptRange(key: Buffer, open_: () => Promise<BlockSource>, size: number, start = 0, end = size - 1): Readable {124 if (size === 0) return Readable.from([]);125 async function* gen() {126 const src = await open_();127 try {128 const first = Math.floor(start / BLOCK), last = Math.floor(end / BLOCK);129 for (let i = first; i <= last; i++) {130 const plainLen = Math.min(BLOCK, size - i * BLOCK);131 const buf = await src.read(i * (BLOCK + TAG), plainLen + TAG);132 if (buf.length !== plainLen + TAG) throw new Error('stored object is truncated');133 const plain = openBlock(key, size, i, buf);134 const from = i === first ? start - i * BLOCK : 0;135 const to = i === last ? end - i * BLOCK + 1 : plainLen;136 yield from === 0 && to === plainLen ? plain : plain.subarray(from, to);137 }138 } finally { await src.close(); }139 }140 return Readable.from(gen(), { objectMode: false, highWaterMark: 4 * BLOCK });141}142143// --- RFC 6238 TOTP (SHA-1, 6 digits, 30 s) -----------------------------------------------------------------144const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';145export function base32(buf: Buffer): string {146 let bits = 0, v = 0, out = '';147 for (const b of buf) { v = (v << 8) | b; bits += 8; while (bits >= 5) { out += B32[(v >>> (bits - 5)) & 31]; bits -= 5; } }148 if (bits) out += B32[(v << (5 - bits)) & 31];149 return out;150}151export function totpAt(secret: Buffer, t: number): string {152 const c = Buffer.alloc(8); c.writeBigUInt64BE(BigInt(Math.floor(t / 30)));153 const h = createHmac('sha1', secret).update(c).digest();154 const o = h[h.length - 1]! & 15;155 return String((h.readUInt32BE(o) & 0x7fffffff) % 1_000_000).padStart(6, '0');156}157/** Accepts the current step and one step either side. Returns the matched step so a code cannot be replayed. */158export function totpVerify(secret: Buffer, code: string, now = Date.now() / 1000): number | null {159 if (!/^\d{6}$/.test(code)) return null;160 for (const d of [0, -1, 1]) { const t = now + d * 30; if (safeEqual(totpAt(secret, t), code)) return Math.floor(t / 30); }161 return null;162}163