SPB Git

Repository Public

crypto.ts 8.9 KB 163 lines typescript

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
Files main
Menu

Theme