// Client-side encryption for drives hosted on Silent Mode. Everything here // runs on the user's machine; the server only ever sees what comes out. // // Keys. One 32-byte master secret per person (from the Theseus vault, or a // local key file on hosts without one). Each drive (bucket) gets its own keys // from it, so a leaked drive key exposes one drive: // HKDF(master, info "pithos/drive/v1/") -> 64-byte name key + 32-byte body key // // Names. Every path segment is encrypted on its own with AES-SIV (RFC 5297), // which is deterministic: the same name in the same folder always gives the // same ciphertext, so prefix listing, folders and overwrite-by-name keep // working on the server. The parent folder's plain path is associated data, // so equal names in different folders do not look equal. An encrypted segment // is "~" + base64url(SIV tag || ciphertext). SIV authenticates, so a segment // that does not decrypt is simply a plain name (public files stay readable). // // Bodies. AES-256-GCM in 1 MiB chunks behind a 24-byte header: // "PTE1" | salt (16) | log2(chunk size) (1) | 0 0 0 // The per-file key is HKDF(body key, salt). Chunk i's nonce is i (8 bytes BE) // plus a last-chunk flag, so chunks cannot be reordered, dropped or cut off // at the end. Each chunk's additional data is the header plus the drive and // plain path, so the server cannot swap one file's contents for another's. // Ciphertext size is a pure function of plaintext size and back, which keeps // Content-Length known up front and lets range reads fetch only the chunks // they need. import crypto from 'node:crypto'; import { Readable } from 'node:stream'; export const HEADER_LEN = 24; export const TAG_LEN = 16; const MAGIC = Buffer.from('PTE1'); const LOG2_CHUNK = 20; // 1 MiB // ---- AES-SIV (RFC 5297), AES-CMAC (RFC 4493) --------------------------- const ZERO = Buffer.alloc(16); function aesEcb(key, block) { const c = crypto.createCipheriv(`aes-${key.length * 8}-ecb`, key, null); c.setAutoPadding(false); return Buffer.concat([c.update(block), c.final()]); } function dbl(b) { const out = Buffer.alloc(16); let carry = 0; for (let i = 15; i >= 0; i--) { out[i] = ((b[i] << 1) | carry) & 0xff; carry = b[i] >> 7; } if (b[0] & 0x80) out[15] ^= 0x87; return out; } function xor(a, b) { const out = Buffer.alloc(a.length); for (let i = 0; i < a.length; i++) out[i] = a[i] ^ b[i]; return out; } export function cmac(key, msg) { const k1 = dbl(aesEcb(key, ZERO)); const k2 = dbl(k1); const n = Math.max(1, Math.ceil(msg.length / 16)); const complete = msg.length > 0 && msg.length % 16 === 0; const last = Buffer.alloc(16); msg.copy(last, 0, (n - 1) * 16); if (!complete) last[msg.length - (n - 1) * 16] = 0x80; const m = Buffer.concat([msg.subarray(0, (n - 1) * 16), xor(last, complete ? k1 : k2)]); const c = crypto.createCipheriv(`aes-${key.length * 8}-cbc`, key, ZERO); c.setAutoPadding(false); const out = Buffer.concat([c.update(m), c.final()]); return out.subarray(out.length - 16); } function s2v(key, ads, plain) { let d = cmac(key, ZERO); for (const ad of ads) d = xor(dbl(d), cmac(key, ad)); let t; if (plain.length >= 16) { t = Buffer.from(plain); const tail = t.length - 16; for (let i = 0; i < 16; i++) t[tail + i] ^= d[i]; } else { const padded = Buffer.alloc(16); plain.copy(padded); padded[plain.length] = 0x80; t = xor(dbl(d), padded); } return cmac(key, t); } function sivCtr(key, v, data) { const q = Buffer.from(v); q[8] &= 0x7f; q[12] &= 0x7f; const c = crypto.createCipheriv(`aes-${key.length * 8}-ctr`, key, q); return Buffer.concat([c.update(data), c.final()]); } // key: 32, 48 or 64 bytes (two AES keys). Returns tag || ciphertext. export function sivEncrypt(key, ads, plain) { const half = key.length / 2; const v = s2v(key.subarray(0, half), ads, plain); return Buffer.concat([v, sivCtr(key.subarray(half), v, plain)]); } // Returns the plaintext, or null when the tag does not match. export function sivDecrypt(key, ads, data) { if (data.length < 16) return null; const half = key.length / 2; const v = data.subarray(0, 16); const plain = sivCtr(key.subarray(half), v, data.subarray(16)); return crypto.timingSafeEqual(s2v(key.subarray(0, half), ads, plain), v) ? plain : null; } // ---- keys ---------------------------------------------------------------- export function driveKeys(master, bucket) { if (!Buffer.isBuffer(master) || master.length !== 32) throw new Error('the encryption key must be 32 bytes'); const okm = Buffer.from(crypto.hkdfSync('sha256', master, Buffer.alloc(0), `pithos/drive/v1/${bucket}`, 96)); return { bucket, name: okm.subarray(0, 64), body: okm.subarray(64) }; } // ---- names ----------------------------------------------------------------- const b64u = (b) => b.toString('base64url'); function segmentAds(dk, parent) { return [Buffer.from(dk.bucket, 'utf8'), Buffer.from(parent, 'utf8')]; } export function encryptSegment(dk, parent, seg) { return '~' + b64u(sivEncrypt(dk.name, segmentAds(dk, parent), Buffer.from(seg, 'utf8'))); } // null when the segment is not one of ours (a plain name). export function decryptSegment(dk, parent, seg) { if (!seg.startsWith('~') || seg.length < 23) return null; let raw; try { raw = Buffer.from(seg.slice(1), 'base64url'); } catch { return null; } if (b64u(raw) !== seg.slice(1)) return null; const p = sivDecrypt(dk.name, segmentAds(dk, parent), raw); return p ? p.toString('utf8') : null; } // "photos/2026/a.jpg" -> "~x/~y/~z"; a trailing "/" (folder marker or prefix) // is kept. Empty segments are refused: S3 allows them but they make paths // ambiguous. export function encryptPath(dk, plain) { if (!plain) return ''; const folder = plain.endsWith('/'); const segs = (folder ? plain.slice(0, -1) : plain).split('/'); const out = []; let parent = ''; for (const s of segs) { if (!s) throw new Error('empty folder names are not supported in encrypted drives'); out.push(encryptSegment(dk, parent, s)); parent += s + '/'; } const key = out.join('/') + (folder ? '/' : ''); if (Buffer.byteLength(key) > 1024) throw new Error('this name is too long once encrypted (S3 allows 1024 bytes)'); return key; } // Returns { path, encrypted }. encrypted is true only if every segment was ours; // a mix (a plain folder inside an encrypted one) reports false. export function decryptPath(dk, key) { if (!key) return { path: '', encrypted: false }; const folder = key.endsWith('/'); const segs = (folder ? key.slice(0, -1) : key).split('/'); const out = []; let parent = ''; let all = true; for (const s of segs) { const d = decryptSegment(dk, parent, s); if (d == null) all = false; const plain = d ?? s; out.push(plain); parent += plain + '/'; } return { path: out.join('/') + (folder ? '/' : ''), encrypted: all }; } // ---- bodies ------------------------------------------------------------------ export function chunkSize(log2 = LOG2_CHUNK) { return 2 ** log2; } export function cipherSize(plainSize, cs = chunkSize()) { const chunks = Math.max(1, Math.ceil(plainSize / cs)); return HEADER_LEN + plainSize + chunks * TAG_LEN; } export function plainSize(cipherSizeBytes, cs = chunkSize()) { const c = cipherSizeBytes - HEADER_LEN; if (c < TAG_LEN) return null; const chunks = Math.ceil(c / (cs + TAG_LEN)); const p = c - chunks * TAG_LEN; return p >= 0 && cipherSize(p, cs) === cipherSizeBytes ? p : null; } export function isEncryptedHeader(buf) { return buf.length >= HEADER_LEN && buf.subarray(0, 4).equals(MAGIC); } function parseHeader(header) { if (!isEncryptedHeader(header)) throw new Error('not an encrypted Pithos file'); const log2 = header[20]; if (log2 < 12 || log2 > 24) throw new Error('unsupported chunk size in an encrypted file'); return { salt: header.subarray(4, 20), cs: 2 ** log2 }; } function fileKey(dk, salt) { return Buffer.from(crypto.hkdfSync('sha256', dk.body, salt, 'pithos/body/v1', 32)); } function nonce(i, last) { const n = Buffer.alloc(12); n.writeBigUInt64BE(BigInt(i)); n[8] = last ? 1 : 0; return n; } function aad(header, dk, plainPath) { return Buffer.concat([header, Buffer.from(dk.bucket + '\0' + plainPath, 'utf8')]); } function newHeader() { const h = Buffer.alloc(HEADER_LEN); MAGIC.copy(h); crypto.randomBytes(16).copy(h, 4); h[20] = LOG2_CHUNK; return h; } // Re-chunks an async iterable of buffers into exact `size`-byte pieces. async function* rechunk(source, size) { let parts = []; let have = 0; for await (const b of source) { let buf = Buffer.isBuffer(b) ? b : Buffer.from(b); while (buf.length) { const take = Math.min(size - have, buf.length); parts.push(buf.subarray(0, take)); have += take; buf = buf.subarray(take); if (have === size) { yield Buffer.concat(parts); parts = []; have = 0; } } } if (have) yield Buffer.concat(parts); } // Encrypts a stream whose length is known (the upload's Content-Length). // Returns { stream, size } so the ciphertext length can be sent up front. export function encryptBody(dk, plainPath, source, plainLen) { const header = newHeader(); const { salt, cs } = parseHeader(header); const key = fileKey(dk, salt); const ad = aad(header, dk, plainPath); const chunks = Math.max(1, Math.ceil(plainLen / cs)); const src = Buffer.isBuffer(source) ? [source] : source; async function* gen() { yield header; let i = 0; let seen = 0; for await (const piece of rechunk(src, cs)) { seen += piece.length; if (i >= chunks || seen > plainLen) throw new Error('the upload is longer than its Content-Length'); yield sealChunk(key, ad, i, i === chunks - 1, piece); i++; } if (seen !== plainLen) throw new Error('the upload ended before its Content-Length'); if (plainLen === 0) yield sealChunk(key, ad, 0, true, Buffer.alloc(0)); } return { stream: Readable.from(gen()), size: cipherSize(plainLen, cs) }; } function sealChunk(key, ad, i, last, piece) { const c = crypto.createCipheriv('aes-256-gcm', key, nonce(i, last)); c.setAAD(ad); return Buffer.concat([c.update(piece), c.final(), c.getAuthTag()]); } function openChunk(key, ad, i, last, sealed) { const d = crypto.createDecipheriv('aes-256-gcm', key, nonce(i, last)); d.setAAD(ad); d.setAuthTag(sealed.subarray(sealed.length - TAG_LEN)); try { return Buffer.concat([d.update(sealed.subarray(0, sealed.length - TAG_LEN)), d.final()]); } catch { throw new Error('this file was changed on the server or belongs to another drive or key'); } } // What to fetch for a plaintext byte range [start, end] (inclusive) of a file // whose ciphertext is `cipherLen` bytes. Without a range, everything. export function cipherRange(cipherLen, range, cs = chunkSize()) { const plain = plainSize(cipherLen, cs); if (plain == null) throw new Error('not an encrypted Pithos file (size does not match)'); const chunks = Math.max(1, Math.ceil(plain / cs)); let start = 0; let end = plain - 1; if (range) { start = range.start; end = Math.min(range.end ?? plain - 1, plain - 1); if (start > end || start >= plain) return { plain, unsatisfiable: true }; } const first = plain ? Math.floor(start / cs) : 0; const lastChunk = plain ? Math.floor(end / cs) : 0; return { plain, start, end, chunks, first, lastChunk, from: HEADER_LEN + first * (cs + TAG_LEN), to: Math.min(cipherLen - 1, HEADER_LEN + (lastChunk + 1) * (cs + TAG_LEN) - 1), }; } // Decrypts chunks first..lastChunk from `source` (the ciphertext bytes // r.from..r.to) and yields only plaintext bytes r.start..r.end. export function decryptBody(dk, plainPath, header, source, r) { const { salt, cs } = parseHeader(header); const key = fileKey(dk, salt); const ad = aad(header, dk, plainPath); async function* gen() { let i = r.first; for await (const sealed of rechunk(source, cs + TAG_LEN)) { if (i > r.lastChunk) break; const plain = openChunk(key, ad, i, i === r.chunks - 1, sealed); const base = i * cs; const a = Math.max(0, r.start - base); const b = Math.min(plain.length, r.end - base + 1); if (b > a) yield plain.subarray(a, b); i++; } if (i <= r.lastChunk && r.plain > 0) throw new Error('the encrypted file ended early'); } return Readable.from(gen()); } // "bytes=a-b" | "bytes=a-" | "bytes=-n" -> { start, end } against a known size. export function parseRange(header, size) { const m = /^bytes=(\d*)-(\d*)$/.exec(String(header || '').trim()); if (!m || (m[1] === '' && m[2] === '')) return null; if (m[1] === '') { const n = Number(m[2]); return { start: Math.max(0, size - n), end: size - 1 }; } return { start: Number(m[1]), end: m[2] === '' ? size - 1 : Number(m[2]) }; }