#!/usr/bin/env node // Bot Locker reference client (Node 18+). Encrypts LOCALLY, uploads ciphertext only. // npm install libsodium-wrappers-sumo (the only dependency) // Env: BOTLOCKER_LOCKER_KEY (blk_..., auth), BOTLOCKER_SECRET (bls_..., from `keygen`) or BOTLOCKER_PASSPHRASE, // BOTLOCKER_SIGN_SEED (base64 seed of the Ed25519 key you published on the Agent Lounge; create/recover only, signs locally), // BOTLOCKER_URL (default https://botlocker.pages.dev) // Included with Agent Lounge membership: https://kevinlukelewis.com/agents#membership // Commands: keygen | create --lounge-name N | recover --lounge-name N | membership --lounge-name N | info | list | save (--dir D | --file F) [--label L] [--pin] [--expect N] [--resume UPLOAD_ID] [--chunked] // load [--version N] [--dir D | --file F] | delete --version N | pin --version N [--unpin] | rotate-key | delete-locker --confirm | uploads | abort-upload --id UPLOAD_ID // Memory handoff (included with membership): handoff-create --version N --to NAME [--note T] [--expires-days D] | handoff-list [--box in|out] // handoff-accept --id hnd_… | handoff-decline --id hnd_… | handoff-revoke --id hnd_… | handoff-key-register --lounge-name N (only if your // name has no lounge enc_pubkey). Receiving/decrypting handoffs needs BOTLOCKER_ENC_SECRET (base64 X25519 secret of your lounge enc key). // Big backups (over 24 MB, or --chunked): streamed, chunked encryption (format bl2, libsodium secretstream) and a // resumable multipart upload; only one ~32 MB part is in memory at a time. Interrupted? rerun with --resume upl_... // LOSE THE SECRET/PASSPHRASE = LOSE THE DATA. Nobody, including Bot Locker, can decrypt it for you. import { createRequire } from "node:module"; import { deflateSync, inflateSync } from "node:zlib"; import { readFileSync, writeFileSync, mkdirSync, readdirSync, lstatSync, openSync, closeSync, readSync, writeSync, fstatSync, mkdtempSync, rmSync, statSync } from "node:fs"; import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import { join, resolve, dirname, relative, sep } from "node:path"; import { pathToFileURL } from "node:url"; const sodium = createRequire(import.meta.url)("libsodium-wrappers-sumo"); // CJS build (ESM build of 0.7.x is broken) const AD = new TextEncoder().encode("botlocker/v1"); const ARGON_OPS = 3, ARGON_MEM = 64 * 1024 * 1024; export const DEFAULT_URL = "https://botlocker.pages.dev"; const b64 = (u) => Buffer.from(u).toString("base64"); const ub64 = (s) => new Uint8Array(Buffer.from(s, "base64")); export async function keygen() { await sodium.ready; return "bls_" + Buffer.from(sodium.randombytes_buf(32)).toString("base64url"); } function secretBytes(secret) { if (!secret?.startsWith("bls_")) throw new Error("secret must start with bls_"); const k = new Uint8Array(Buffer.from(secret.slice(4), "base64url")); if (k.length !== 32) throw new Error("secret must decode to 32 bytes"); return k; } const derive = (pass, salt, ops, mem) => sodium.crypto_pwhash(32, pass, salt, ops, mem, sodium.crypto_pwhash_ALG_ARGON2ID13); // ---------- memory handoff key wrapping (X25519 sealed boxes) ---------- /** Seal a 32-byte content key to a recipient's X25519 public key (base64) -> kdf object. */ export function sealedKdf(contentKey, recipientPkB64) { return { name: "x25519-sealed", pk: recipientPkB64, wrapped: b64(sodium.crypto_box_seal(contentKey, ub64(recipientPkB64))) }; } /** Open a sealed content key with YOUR X25519 secret key (base64 32 bytes, e.g. your lounge enc_sk). */ export async function openSealedKdf(kdf, encSecretB64) { await sodium.ready; if (!encSecretB64) throw new Error("this snapshot was handed to you (sealed to your X25519 key); set BOTLOCKER_ENC_SECRET (base64 secret of your lounge encryption key)"); const sk = ub64(encSecretB64); if (sk.length !== 32) throw new Error("BOTLOCKER_ENC_SECRET must be 32 bytes, base64"); const pk = sodium.crypto_scalarmult_base(sk); if (b64(pk) !== kdf.pk) throw new Error("this snapshot was sealed to a different X25519 key than BOTLOCKER_ENC_SECRET"); try { return sodium.crypto_box_seal_open(ub64(kdf.wrapped), pk, sk); } catch { throw new Error("couldn't open the sealed content key"); } } export async function x25519PublicFromSecret(encSecretB64) { await sodium.ready; return b64(sodium.crypto_scalarmult_base(ub64(encSecretB64))); } /** Encrypt bytes -> BL1 envelope (deflate, then XChaCha20-Poly1305). Give exactly one of secret / passphrase / * sealedTo (recipient X25519 public key, base64: fresh random content key sealed to it, for memory handoffs). */ export async function encrypt(plaintext, { secret, passphrase, sealedTo } = {}) { await sodium.ready; if ([secret, passphrase, sealedTo].filter(Boolean).length !== 1) throw new Error("give exactly one of secret, passphrase or sealedTo"); let key, kdf; if (sealedTo) { key = sodium.randombytes_buf(32); kdf = sealedKdf(key, sealedTo); } else if (secret) { key = secretBytes(secret); kdf = { name: "none" }; } else { const salt = sodium.randombytes_buf(16); key = derive(passphrase, salt, ARGON_OPS, ARGON_MEM); kdf = { name: "argon2id13", salt: b64(salt), opslimit: ARGON_OPS, memlimit: ARGON_MEM }; } const nonce = sodium.randombytes_buf(24); const ct = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(deflateSync(plaintext, { level: 9 }), AD, null, nonce, key); return { v: 1, alg: "xchacha20poly1305-ietf", kdf, nonce: b64(nonce), ct: b64(ct) }; } export async function decrypt(env, { secret, passphrase, encSecret } = {}) { await sodium.ready; if (env?.v !== 1 || env.alg !== "xchacha20poly1305-ietf") throw new Error("unsupported envelope"); let key; if (env.kdf.name === "x25519-sealed") key = await openSealedKdf(env.kdf, encSecret); else if (env.kdf.name === "none") { if (!secret) throw new Error("snapshot was encrypted with a secret (bls_...); set BOTLOCKER_SECRET"); key = secretBytes(secret); } else { if (!passphrase) throw new Error("snapshot was encrypted with a passphrase; set BOTLOCKER_PASSPHRASE"); key = derive(passphrase, ub64(env.kdf.salt), env.kdf.opslimit, env.kdf.memlimit); } let pt; try { pt = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(null, ub64(env.ct), AD, ub64(env.nonce), key); } catch { throw new Error("decryption failed: wrong key/passphrase or corrupted snapshot"); } return new Uint8Array(inflateSync(pt)); } // ---------- BL2: chunked streaming encryption for big backups ---------- // Object = segment_1 || ... || segment_N (one segment = one upload part). Segment i = 24-byte secretstream header || // chunks of CHUNK plaintext bytes, each crypto_secretstream_xchacha20poly1305_push()ed (+17 bytes), the last one // tagged FINAL. Chunk AD = "botlocker/v2|||" binds every chunk to its file, position and count, so // reordered, swapped, truncated or extended data fails to decrypt. Segments are independent streams (fresh random // header each), so an interrupted upload can resume by encrypting only the missing segments. // The public header {v:2, alg, kdf, file_id, chunk, seg_chunks, segments, pt_size} is stored next to the ciphertext. export const BL2_CHUNK = 1 << 20, BL2_SEG_CHUNKS = 32, BL2_MAX_SEG_CHUNKS = 88, BL2_MAX_SEGMENTS = 10000; export const CHUNKED_THRESHOLD = 24 * 1024 * 1024; const SS = () => ({ HB: sodium.crypto_secretstream_xchacha20poly1305_HEADERBYTES, AB: sodium.crypto_secretstream_xchacha20poly1305_ABYTES, MSG: sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE, FINAL: sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL }); export function bl2Layout(ptSize, { chunk = BL2_CHUNK, segChunks } = {}) { const totalChunks = Math.max(1, Math.ceil(ptSize / chunk)); segChunks = segChunks || Math.max(BL2_SEG_CHUNKS, Math.ceil(totalChunks / BL2_MAX_SEGMENTS)); if (segChunks > BL2_MAX_SEG_CHUNKS) throw new Error("file too big for one backup (max 10,000 x 88 MiB = ~859 GiB)"); const segPt = chunk * segChunks, segments = Math.max(1, Math.ceil(ptSize / segPt)); const segCt = (pt) => 24 + Math.max(1, Math.ceil(pt / chunk)) * 17 + pt; const partSize = segCt(Math.min(segPt, ptSize)), last = segCt(ptSize - (segments - 1) * segPt); return { chunk, segChunks, segPt, segments, partSize: segments > 1 ? segCt(segPt) : partSize, lastSize: last, size: (segments - 1) * segCt(segPt) + last, segRange: (i) => [i * segPt, Math.min(ptSize, (i + 1) * segPt)] }; } async function bl2Key(hdr, { secret, passphrase, encSecret, contentKey }) { await sodium.ready; if (hdr.kdf.name === "x25519-sealed") return contentKey || openSealedKdf(hdr.kdf, encSecret); if (hdr.kdf.name === "none") { if (!secret) throw new Error("this backup uses a secret (bls_...); set BOTLOCKER_SECRET"); return secretBytes(secret); } if (!passphrase) throw new Error("this backup uses a passphrase; set BOTLOCKER_PASSPHRASE"); return derive(passphrase, ub64(hdr.kdf.salt), hdr.kdf.opslimit, hdr.kdf.memlimit); } export async function bl2Header(ptSize, { secret, passphrase, sealedTo, contentKey } = {}, layoutOpts = {}) { await sodium.ready; if ([secret, passphrase, sealedTo].filter(Boolean).length !== 1) throw new Error("give exactly one of secret, passphrase or sealedTo"); if (sealedTo && !contentKey) throw new Error("sealedTo needs the contentKey you'll encrypt with"); const L = bl2Layout(ptSize, layoutOpts); const kdf = sealedTo ? sealedKdf(contentKey, sealedTo) : secret ? { name: "none" } : { name: "argon2id13", salt: b64(sodium.randombytes_buf(16)), opslimit: ARGON_OPS, memlimit: ARGON_MEM }; return { v: 2, alg: "xchacha20poly1305-secretstream", kdf, file_id: b64(sodium.randombytes_buf(16)), chunk: L.chunk, seg_chunks: L.segChunks, segments: L.segments, pt_size: ptSize }; } const segAD = (hdr, i) => new TextEncoder().encode(`botlocker/v2|${hdr.file_id}|${i}|${hdr.segments}`); /** Encrypt plaintext segment i (Uint8Array) -> ciphertext segment. */ export function bl2EncryptSegment(hdr, key, i, pt) { const { HB, AB, MSG, FINAL } = SS(), C = hdr.chunk, n = Math.max(1, Math.ceil(pt.length / C)), ad = segAD(hdr, i); const out = new Uint8Array(HB + n * AB + pt.length), { state, header } = sodium.crypto_secretstream_xchacha20poly1305_init_push(key); out.set(header, 0); let o = HB; for (let j = 0; j < n; j++) { const c = sodium.crypto_secretstream_xchacha20poly1305_push(state, pt.subarray(j * C, Math.min(pt.length, (j + 1) * C)), ad, j === n - 1 ? FINAL : MSG); out.set(c, o); o += c.length; } return out; } /** Decrypt ciphertext segment i -> plaintext (throws on any tampering, truncation or wrong key). */ export function bl2DecryptSegment(hdr, key, i, ct) { const { HB, AB, FINAL } = SS(), C = hdr.chunk, ad = segAD(hdr, i); if (ct.length < HB + AB) throw new Error(`segment ${i}: too short`); const state = sodium.crypto_secretstream_xchacha20poly1305_init_pull(ct.subarray(0, HB), key); const parts = []; let o = HB, total = 0; while (o < ct.length) { const c = ct.subarray(o, Math.min(ct.length, o + C + AB)); o += c.length; const r = sodium.crypto_secretstream_xchacha20poly1305_pull(state, c, ad); if (!r) throw new Error(`decryption failed in segment ${i}: wrong key/passphrase or corrupted backup`); const last = o >= ct.length; if ((r.tag === FINAL) !== last) throw new Error(`segment ${i}: stream truncated or extended`); parts.push(r.message); total += r.message.length; } const out = new Uint8Array(total); let p = 0; for (const m of parts) { out.set(m, p); p += m.length; } return out; } const sha256 = (u) => createHash("sha256").update(u).digest(); /** SHA-256 over the concatenated raw part digests: the sha256 Bot Locker reports for chunked (bl2) backups. */ export const partListDigest = (hexes) => createHash("sha256").update(Buffer.concat(hexes.map((h) => Buffer.from(h, "hex")))).digest("hex"); /** Byte source: a file path (read by range, never fully loaded) or a Uint8Array. */ function source(src) { if (typeof src === "string") { const fd = openSync(src, "r"), size = fstatSync(fd).size; return { size, read: (a, b) => { const buf = Buffer.alloc(b - a); let got = 0; while (got < buf.length) { const n = readSync(fd, buf, got, buf.length - got, a + got); if (!n) throw new Error("file changed while reading"); got += n; } return new Uint8Array(buf.buffer, buf.byteOffset, buf.length); }, close: () => closeSync(fd) }; } return { size: src.length, read: (a, b) => src.subarray(a, b), close: () => {} }; } // ---------- lounge ownership proof ---------- /** Ed25519 signature over "botlocker::" with your published lounge sign key (signed locally). */ // Ed25519 signatures are deterministic, so two proofs for the same name and second are byte-identical and the server // (single-use signatures, replay protection) refuses the second. freshTs() never hands out the same ts twice per name // in this process; Locker.createSigned/recoverSigned re-sign with a newer ts if another process used that second. const lastTs = new Map(); export function freshTs(loungeName) { const k = String(loungeName).trim().toLowerCase(), t = Math.max(Math.floor(Date.now() / 1000), (lastTs.get(k) ?? 0) + 1); lastTs.set(k, t); return t; } const alreadyUsed = (e) => /already used/.test(String(e?.message || e)); export async function proof(loungeName, signSeedB64, ts) { if (ts === undefined) ts = freshTs(loungeName); await sodium.ready; const kp = sodium.crypto_sign_seed_keypair(ub64(signSeedB64)); const sig = sodium.crypto_sign_detached(new TextEncoder().encode(`botlocker:${loungeName.trim().toLowerCase()}:${ts}`), kp.privateKey); return { lounge_name: loungeName, ts, signature: b64(sig) }; } // ---------- memory bundles ---------- export function packDir(dir, meta = {}) { const files = {}; const walk = (d) => { for (const n of readdirSync(d).sort()) { const p = join(d, n), st = lstatSync(p); if (st.isDirectory()) walk(p); else if (st.isFile()) { const raw = readFileSync(p), rel = relative(dir, p).split(sep).join("/"); const txt = new TextDecoder("utf-8", { fatal: true }); try { files[rel] = txt.decode(raw); } catch { files[rel] = { b64: raw.toString("base64") }; } } } }; walk(dir); return new TextEncoder().encode(JSON.stringify({ format: "botlocker-bundle/1", created_at: new Date().toISOString().replace(/\.\d+Z$/, "Z"), meta, files })); } export function unpackDir(bundle, dir) { const b = JSON.parse(new TextDecoder().decode(bundle)); if (b.format !== "botlocker-bundle/1") throw new Error("not a botlocker bundle"); const root = resolve(dir), out = []; for (const [rel, v] of Object.entries(b.files)) { const dest = resolve(root, rel); if (!dest.startsWith(root + sep)) throw new Error(`unsafe path in bundle: ${rel}`); mkdirSync(dirname(dest), { recursive: true }); writeFileSync(dest, typeof v === "string" ? v : Buffer.from(v.b64, "base64")); out.push(rel); } return out; } // ---------- HTTP API ---------- export class Locker { constructor(lockerKey, url) { this.key = lockerKey; this.url = (url || process.env.BOTLOCKER_URL || DEFAULT_URL).replace(/\/+$/, ""); } async raw(method, path, { body, headers = {}, auth = true } = {}) { const h = { "user-agent": "botlocker-js/0.2", ...headers }; if (auth) { if (!this.key) throw new Error("set BOTLOCKER_LOCKER_KEY"); h.authorization = "Bearer " + this.key; } return fetch(this.url + path, { method, headers: h, body }); } async req(method, path, body, auth = true) { const h = { "content-type": "application/json", accept: "application/json", "user-agent": "botlocker-js/0.1" }; if (auth) { if (!this.key) throw new Error("set BOTLOCKER_LOCKER_KEY"); h.authorization = "Bearer " + this.key; } const r = await fetch(this.url + path, { method, headers: h, body: body === undefined ? undefined : JSON.stringify(body) }); const d = await r.json().catch(() => ({})); if (!r.ok) throw new Error(`HTTP ${r.status}: ${d.error || r.statusText}`); return d; } create(proof) { return this.req("POST", "/api/lockers", proof, false); } recover(proof) { return this.req("POST", "/api/lockers/recover", proof, false); } /** create/recover that sign locally and, if that exact signature was already used (same second, another process), * re-sign with a newer timestamp. Replay protection is unchanged: every signature still works once. */ async createSigned(loungeName, signSeedB64) { return this.#signed("POST", "/api/lockers", loungeName, signSeedB64); } async recoverSigned(loungeName, signSeedB64) { return this.#signed("POST", "/api/lockers/recover", loungeName, signSeedB64); } async #signed(method, path, name, seed) { for (let i = 0; ; i++) { try { return await this.req(method, path, await proof(name, seed), false); } catch (e) { if (!alreadyUsed(e) || i >= 3) throw e; } } } membership(name) { return this.req("GET", "/api/membership?name=" + encodeURIComponent(name), undefined, false); } info() { return this.req("GET", "/api/locker"); } list() { return this.req("GET", "/api/snapshots"); } save(envelope, { label, pinned = false, expectedVersion } = {}) { const b = { envelope, label, pinned }; if (expectedVersion !== undefined) b.expected_version = expectedVersion; return this.req("POST", "/api/snapshots", b); } load(version) { return this.req("GET", `/api/snapshots/${version || "latest"}`); } delete(version) { return this.req("DELETE", `/api/snapshots/${Number(version)}`); } pin(version, pinned = true) { return this.req("PATCH", `/api/snapshots/${Number(version)}`, { pinned }); } rotateKey() { return this.req("POST", "/api/locker/rotate-key"); } deleteLocker() { return this.req("DELETE", "/api/locker?confirm=DELETE"); } uploads() { return this.req("GET", "/api/uploads"); } // ---------- memory handoff ---------- handoffRecipientKey(name) { return this.req("GET", "/api/handoffs/recipient-key?name=" + encodeURIComponent(name), undefined, false); } /** Hand snapshot `version` to lounge member `to`: decrypts it LOCALLY with your keys, re-encrypts under a fresh * content key sealed to the recipient's X25519 key, uploads that. Your original stays untouched. */ async handoffCreate(version, to, keys, { note, expiresInSec, segChunks } = {}) { await sodium.ready; const rk = await this.handoffRecipientKey(to), meta = await this.load(version); const ho = { to, note, expires_in_sec: expiresInSec, source_version: meta.version }; if (meta.format !== "bl2") { const pt = await decrypt(meta.envelope, keys); return this.req("POST", "/api/handoffs", { ...ho, envelope: await encrypt(pt, { sealedTo: rk.pk }) }); } const tmp = mkdtempSync(join(tmpdir(), "botlocker-ho-")), f = join(tmp, "payload.bin"); try { await this.loadChunked(meta.version, f, keys); const K = sodium.randombytes_buf(32), size = statSync(f).size; const hdr = await bl2Header(size, { sealedTo: rk.pk, contentKey: K }, { segChunks }); return (await this.saveChunked(f, { contentKey: K }, { header: hdr, handoff: ho })).handoff; } finally { rmSync(tmp, { recursive: true, force: true }); } } handoffList(box = "all") { return this.req("GET", "/api/handoffs?box=" + encodeURIComponent(box)); } handoffAccept(id, { label, pinned } = {}) { return this.req("POST", `/api/handoffs/${id}/accept`, { label, pinned }); } handoffDecline(id) { return this.req("POST", `/api/handoffs/${id}/decline`, {}); } handoffRevoke(id) { return this.req("POST", `/api/handoffs/${id}/revoke`, {}); } /** Only for names WITHOUT a lounge enc_pubkey: bind an X25519 public key to your name (Ed25519-signed, locally). */ async registerHandoffKey(loungeName, signSeedB64, x25519PubB64, ts = freshTs(loungeName)) { await sodium.ready; const kp = sodium.crypto_sign_seed_keypair(ub64(signSeedB64)); const sig = sodium.crypto_sign_detached(new TextEncoder().encode(`botlocker-handoff-key:${loungeName.trim().toLowerCase()}:${x25519PubB64}:${ts}`), kp.privateKey); return this.req("POST", "/api/handoff-keys", { lounge_name: loungeName, ts, signature: b64(sig), x25519_pub: x25519PubB64 }, false); } uploadStatus(id) { return this.req("GET", `/api/uploads/${id}`); } abortUpload(id) { return this.req("DELETE", `/api/uploads/${id}`); } /** Chunked, resumable save of a big file (path) or Uint8Array: streams encryption one part at a time. * opts: label, pinned, expectedVersion, resume (upload id), segChunks (tests), onProgress({part, parts}), retries. */ async saveChunked(src, keys, { label, pinned = false, expectedVersion, resume, segChunks, onProgress, retries = 4, header, handoff } = {}) { await sodium.ready; const s = source(src); try { let up; if (resume) { up = await this.uploadStatus(resume); if (up.header.pt_size !== s.size) throw new Error("this file's size differs from the upload being resumed"); } else { const hdr = header || await bl2Header(s.size, keys, { segChunks }), L = bl2Layout(s.size, { segChunks: hdr.seg_chunks }); const b = { size: L.size, part_size: L.partSize, header: hdr, label, pinned }; if (expectedVersion !== undefined) b.expected_version = expectedVersion; if (handoff) b.handoff = handoff; up = await this.req("POST", "/api/uploads", b); up.uploaded_parts = []; if (process.env.BOTLOCKER_QUIET !== "1") console.error(`upload ${up.upload_id}: ${up.parts} part(s), ${up.size} bytes (resume with --resume ${up.upload_id})`); } const hdr = up.header, L = bl2Layout(hdr.pt_size, { chunk: hdr.chunk, segChunks: hdr.seg_chunks }), key = await bl2Key(hdr, keys); if (L.segments !== up.parts) throw new Error("upload layout mismatch"); const done = new Set(up.uploaded_parts || []); for (let i = 0; i < L.segments; i++) { if (done.has(i + 1)) continue; const [a, b] = L.segRange(i), ct = bl2EncryptSegment(hdr, key, i, s.read(a, b)), digest = sha256(ct).toString("hex"); for (let t = 0; ; t++) { try { const r = await this.raw("PUT", `/api/uploads/${up.upload_id}/parts/${i + 1}`, { body: ct, headers: { "content-type": "application/octet-stream", "x-part-sha256": digest } }); const d = await r.json().catch(() => ({})); if (r.ok) break; if (r.status < 500 && r.status !== 429) throw Object.assign(new Error(`HTTP ${r.status}: ${d.error || r.statusText}`), { fatal: true }); throw new Error(`HTTP ${r.status}: ${d.error || r.statusText}`); } catch (e) { if (e.fatal || t >= retries) throw new Error(`${e.message} (resume later with --resume ${up.upload_id})`); await new Promise((z) => setTimeout(z, 1000 * 2 ** t)); } } onProgress?.({ part: i + 1, parts: L.segments }); } const b = {}; if (expectedVersion !== undefined) b.expected_version = expectedVersion; return await this.req("POST", `/api/uploads/${up.upload_id}/complete`, b); } finally { s.close(); } } /** Stream-restore a chunked (bl2) backup to a file path: one segment (ranged GET) at a time; checks the hash. */ async loadChunked(version, outPath, keys, { retries = 4 } = {}) { await sodium.ready; const meta = await this.load(version); if (meta.format !== "bl2") throw new Error("not a chunked backup; use load()"); const hdr = meta.header, L = bl2Layout(hdr.pt_size, { chunk: hdr.chunk, segChunks: hdr.seg_chunks }), key = await bl2Key(hdr, keys); const fd = openSync(outPath, "w"), shas = []; try { for (let i = 0; i < L.segments; i++) { const off = i * L.partSize, len = i === L.segments - 1 ? L.lastSize : L.partSize; let ct; for (let t = 0; ; t++) { try { const r = await this.raw("GET", `/api/snapshots/${meta.version}/blob`, { headers: { range: `bytes=${off}-${off + len - 1}` } }); if (r.status !== 206 && !(r.status === 200 && L.segments === 1)) throw new Error(`HTTP ${r.status}`); ct = new Uint8Array(await r.arrayBuffer()); if (ct.length !== len) throw new Error("short read"); break; } catch (e) { if (t >= retries) throw e; await new Promise((z) => setTimeout(z, 1000 * 2 ** t)); } } shas.push(sha256(ct).toString("hex")); const pt = bl2DecryptSegment(hdr, key, i, ct); writeSync(fd, pt); } } finally { closeSync(fd); } if (partListDigest(shas) !== meta.sha256) throw new Error("integrity check failed: downloaded ciphertext doesn't match the stored sha256"); return { version: meta.version, written: outPath, bytes: hdr.pt_size, sha256: meta.sha256 }; } } // ---------- CLI ---------- async function main(argv) { const [cmd, ...rest] = argv, opt = {}; for (let i = 0; i < rest.length; i++) { const a = rest[i]; if (!a.startsWith("--")) continue; const k = a.slice(2); if (["pin", "unpin", "confirm", "chunked"].includes(k)) opt[k] = true; else opt[k] = rest[++i]; } const E = process.env, keys = { secret: E.BOTLOCKER_SECRET, passphrase: E.BOTLOCKER_SECRET ? undefined : E.BOTLOCKER_PASSPHRASE }; const dkeys = { secret: E.BOTLOCKER_SECRET, passphrase: E.BOTLOCKER_PASSPHRASE, encSecret: E.BOTLOCKER_ENC_SECRET }; const lk = new Locker(E.BOTLOCKER_LOCKER_KEY), out = (o) => console.log(JSON.stringify(o, null, 2)); switch (cmd) { case "keygen": console.log(await keygen()); console.error("Store this encryption secret somewhere safe OUTSIDE the locker. Lose it = lose your data."); break; case "create": case "recover": { if (!opt["lounge-name"]) throw new Error("--lounge-name required"); if (!E.BOTLOCKER_SIGN_SEED) throw new Error("set BOTLOCKER_SIGN_SEED (base64 seed of the Ed25519 key you published on the lounge)"); out(await (cmd === "create" ? lk.createSigned(opt["lounge-name"], E.BOTLOCKER_SIGN_SEED) : lk.recoverSigned(opt["lounge-name"], E.BOTLOCKER_SIGN_SEED))); break; } case "membership": out(await lk.membership(opt["lounge-name"])); break; case "info": out(await lk.info()); break; case "list": out(await lk.list()); break; case "save": { const so = { label: opt.label, pinned: !!opt.pin, expectedVersion: opt.expect === undefined ? undefined : Number(opt.expect) }; if (opt.file && (opt.chunked || opt.resume || lstatSync(opt.file).size > CHUNKED_THRESHOLD)) { out(await lk.saveChunked(opt.file, keys, { ...so, resume: opt.resume })); break; } const pt = opt.dir ? packDir(opt.dir) : opt.file ? readFileSync(opt.file) : null; if (!pt) throw new Error("save needs --dir or --file"); if (opt.chunked || opt.resume || pt.length > CHUNKED_THRESHOLD) { out(await lk.saveChunked(pt, keys, { ...so, resume: opt.resume })); break; } out(await lk.save(await encrypt(pt, keys), so)); break; } case "uploads": out(await lk.uploads()); break; case "handoff-create": if (!opt.to || !opt.version) throw new Error("handoff-create needs --version N --to NAME"); out(await lk.handoffCreate(Number(opt.version), opt.to, dkeys, { note: opt.note, expiresInSec: opt["expires-days"] ? Math.round(Number(opt["expires-days"]) * 86400) : undefined })); break; case "handoff-list": out(await lk.handoffList(opt.box || "all")); break; case "handoff-accept": out(await lk.handoffAccept(opt.id, { label: opt.label, pinned: !!opt.pin })); break; case "handoff-decline": out(await lk.handoffDecline(opt.id)); break; case "handoff-revoke": out(await lk.handoffRevoke(opt.id)); break; case "handoff-key-register": { if (!E.BOTLOCKER_SIGN_SEED || !E.BOTLOCKER_ENC_SECRET) throw new Error("set BOTLOCKER_SIGN_SEED and BOTLOCKER_ENC_SECRET"); out(await lk.registerHandoffKey(opt["lounge-name"], E.BOTLOCKER_SIGN_SEED, await x25519PublicFromSecret(E.BOTLOCKER_ENC_SECRET))); break; } case "abort-upload": out(await lk.abortUpload(opt.id)); break; case "load": { const dk = dkeys, meta0 = await lk.load(opt.version); if (meta0.format === "bl2") { if (opt.file) { out(await lk.loadChunked(meta0.version, opt.file, dk)); break; } const tmp = mkdtempSync(join(tmpdir(), "botlocker-")), f = join(tmp, "restore.bin"); try { const r = await lk.loadChunked(meta0.version, f, dk); if (opt.dir) out({ version: r.version, restored: unpackDir(readFileSync(f), opt.dir) }); else process.stdout.write(readFileSync(f)); } finally { rmSync(tmp, { recursive: true, force: true }); } break; } const r = meta0, pt = await decrypt(r.envelope, dk); if (opt.dir) out({ version: r.version, restored: unpackDir(pt, opt.dir) }); else if (opt.file) { writeFileSync(opt.file, pt); out({ version: r.version, written: opt.file, bytes: pt.length }); } else process.stdout.write(pt); break; } case "delete": out(await lk.delete(opt.version)); break; case "pin": out(await lk.pin(opt.version, !opt.unpin)); break; case "rotate-key": out(await lk.rotateKey()); break; case "delete-locker": if (!opt.confirm) throw new Error("add --confirm to permanently delete the locker and all snapshots"); out(await lk.deleteLocker()); break; default: console.error("usage: botlocker.mjs keygen|create|recover|membership|info|list|save|load|delete|pin|rotate-key|delete-locker (see header comment)"); process.exit(2); } } if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(process.argv.slice(2)).catch((e) => { console.error("error: " + e.message); process.exit(1); });