// Sia share links for files in your own Sia account. A shared object URL lets // anyone read ONE object (its data key travels in the URL fragment) and // nothing else in the account. It is made the way the Sia SDK makes it // (siastorage CreateSharedObjectURL, indexd api/app/client.go): // 1. s3d's database maps / to the object's Sia id; // 2. the indexer returns the sealed object (signed GET /objects/); // 3. its data key is opened with a key derived from s3d's app key: // XChaCha20-Poly1305 under HKDF-BLAKE2b-256(appKey, salt = id, "dataKey"); // 4. the link is a signed GET /objects//shared, valid until a date, // with #encryption_key= (padded URL-safe base64). // The app key is read from s3d's database for this and never leaves. import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { blake2b256 } from './blake2b.js'; import { readConnection, signRequest } from './sia-account.js'; // ---- HMAC / HKDF over BLAKE2b-256 (block size 128) -------------------------------- export function hmacBlake2b256(key, data) { let k = Buffer.from(key); if (k.length > 128) k = blake2b256(k); const block = Buffer.alloc(128); k.copy(block); const ipad = Buffer.from(block.map((b) => b ^ 0x36)); const opad = Buffer.from(block.map((b) => b ^ 0x5c)); return blake2b256(Buffer.concat([opad, blake2b256(Buffer.concat([ipad, Buffer.from(data)]))])); } export function hkdfBlake2b256(ikm, salt, info, length) { const prk = hmacBlake2b256(salt && salt.length ? salt : Buffer.alloc(32), ikm); const out = []; let t = Buffer.alloc(0); for (let i = 1; Buffer.concat(out).length < length; i++) { t = hmacBlake2b256(prk, Buffer.concat([t, Buffer.from(info || []), Buffer.from([i])])); out.push(t); } return Buffer.concat(out).subarray(0, length); } // ---- XChaCha20-Poly1305 (open only) ------------------------------------------------ const rotl = (v, n) => ((v << n) | (v >>> (32 - n))) >>> 0; function quarter(s, a, b, c, d) { s[a] = (s[a] + s[b]) >>> 0; s[d] = rotl(s[d] ^ s[a], 16); s[c] = (s[c] + s[d]) >>> 0; s[b] = rotl(s[b] ^ s[c], 12); s[a] = (s[a] + s[b]) >>> 0; s[d] = rotl(s[d] ^ s[a], 8); s[c] = (s[c] + s[d]) >>> 0; s[b] = rotl(s[b] ^ s[c], 7); } export function hchacha20(key, nonce16) { const s = new Uint32Array(16); s.set([0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]); for (let i = 0; i < 8; i++) s[4 + i] = key.readUInt32LE(i * 4); for (let i = 0; i < 4; i++) s[12 + i] = nonce16.readUInt32LE(i * 4); for (let r = 0; r < 10; r++) { quarter(s, 0, 4, 8, 12); quarter(s, 1, 5, 9, 13); quarter(s, 2, 6, 10, 14); quarter(s, 3, 7, 11, 15); quarter(s, 0, 5, 10, 15); quarter(s, 1, 6, 11, 12); quarter(s, 2, 7, 8, 13); quarter(s, 3, 4, 9, 14); } const out = Buffer.alloc(32); [0, 1, 2, 3, 12, 13, 14, 15].forEach((w, i) => out.writeUInt32LE(s[w], i * 4)); return out; } // ChaCha20 and Poly1305 in plain JS (RFC 8439): Electron's BoringSSL has no // chacha20 or chacha20-poly1305 in createCipheriv ("Unknown cipher" in the // Theseus add-on), and all this ever opens is a 32-byte key. function chachaBlock(key, counter, nonce12) { const s = new Uint32Array(16); s.set([0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]); for (let i = 0; i < 8; i++) s[4 + i] = key.readUInt32LE(i * 4); s[12] = counter >>> 0; for (let i = 0; i < 3; i++) s[13 + i] = nonce12.readUInt32LE(i * 4); const w = Uint32Array.from(s); for (let r = 0; r < 10; r++) { quarter(w, 0, 4, 8, 12); quarter(w, 1, 5, 9, 13); quarter(w, 2, 6, 10, 14); quarter(w, 3, 7, 11, 15); quarter(w, 0, 5, 10, 15); quarter(w, 1, 6, 11, 12); quarter(w, 2, 7, 8, 13); quarter(w, 3, 4, 9, 14); } const out = Buffer.alloc(64); for (let i = 0; i < 16; i++) out.writeUInt32LE((w[i] + s[i]) >>> 0, i * 4); return out; } function chachaXor(key, counter, nonce12, data) { const out = Buffer.alloc(data.length); for (let off = 0, c = counter; off < data.length; off += 64, c++) { const ks = chachaBlock(key, c, nonce12); for (let i = 0; i < 64 && off + i < data.length; i++) out[off + i] = data[off + i] ^ ks[i]; } return out; } function poly1305(key32, msg) { const le = (b) => b.reduceRight((acc, x) => (acc << 8n) | BigInt(x), 0n); const r = le(key32.subarray(0, 16)) & 0x0ffffffc0ffffffc0ffffffc0fffffffn; const s = le(key32.subarray(16, 32)); const p = (1n << 130n) - 5n; let acc = 0n; for (let off = 0; off < msg.length; off += 16) { const block = msg.subarray(off, off + 16); acc = ((acc + le(block) + (1n << BigInt(8 * block.length))) * r) % p; } acc = (acc + s) & ((1n << 128n) - 1n); const tag = Buffer.alloc(16); for (let i = 0; i < 16; i++) { tag[i] = Number(acc & 0xffn); acc >>= 8n; } return tag; } const pad16 = (n) => Buffer.alloc((16 - (n % 16)) % 16); const le64 = (n) => { const b = Buffer.alloc(8); b.writeBigUInt64LE(BigInt(n)); return b; }; export function xchachaOpen(key, nonce24, sealed) { const sub = hchacha20(Buffer.from(key), Buffer.from(nonce24).subarray(0, 16)); const nonce12 = Buffer.concat([Buffer.alloc(4), Buffer.from(nonce24).subarray(16, 24)]); const ct = Buffer.from(sealed); if (ct.length < 16) throw new Error('sealed data is too short'); const body = ct.subarray(0, ct.length - 16); const polyKey = chachaBlock(sub, 0, nonce12).subarray(0, 32); const want = poly1305(polyKey, Buffer.concat([body, pad16(body.length), le64(0), le64(body.length)])); if (!crypto.timingSafeEqual(want, ct.subarray(ct.length - 16))) throw new Error('unable to authenticate data'); return chachaXor(sub, 1, nonce12, body); } // ---- s3d's database: / -> Sia object id ------------------------------- export async function siaObjectId(dataDir, bucket, key) { const file = path.join(dataDir, 's3d.db'); if (!fs.existsSync(file)) throw new Error('s3d has no database yet'); const { DatabaseSync } = await import('node:sqlite'); const db = new DatabaseSync(file, { readOnly: true }); try { const row = db.prepare(`SELECT o.sia_object_id AS id, o.size AS size FROM objects o JOIN buckets b ON b.id = o.bucket_id WHERE b.name = ? AND o.name = ? AND o.is_latest = 1 AND o.is_delete_marker = 0`).get(bucket, key); if (!row) return null; return { id: row.id ? Buffer.from(row.id) : null, size: Number(row.size) }; } finally { db.close(); } } const b64urlPadded = (b) => Buffer.from(b).toString('base64').replace(/\+/g, '-').replace(/\//g, '_'); // The shared-object URL for one file, valid until validUntil (unix seconds). export async function createShareUrl(dataDir, bucket, key, validUntil, { fetchImpl = fetch } = {}) { const conn = await readConnection(dataDir); if (!conn?.appKey || !conn.indexerUrl) throw Object.assign(new Error('s3d is not connected to a Sia account'), { status: 409 }); const obj = await siaObjectId(dataDir, bucket, key); if (!obj) throw Object.assign(new Error('no such file'), { status: 404 }); if (!obj.id) throw Object.assign(new Error('This file has not reached Sia yet. Upload it now (Overview › Upload now) and try again.'), { status: 409, code: 'pending' }); const base = conn.indexerUrl.replace(/\/+$/, ''); const idHex = obj.id.toString('hex'); const res = await fetchImpl(signRequest(conn.appKey, 'GET', `${base}/objects/${idHex}`, Math.floor(Date.now() / 1000) + 120), { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(20_000) }); if (!res.ok) throw new Error(`indexer answered ${res.status}: ${(await res.text()).slice(0, 160)}`); const sealed = await res.json(); const enc = Buffer.from(sealed.encryptedDataKey || '', 'base64'); if (enc.length < 24 + 16) throw new Error('the indexer returned no data key'); const kek = hkdfBlake2b256(conn.appKey, obj.id, Buffer.from('dataKey'), 32); const dataKey = xchachaOpen(kek, enc.subarray(0, 24), enc.subarray(24)); kek.fill(0); const u = signRequest(conn.appKey, 'GET', `${base}/objects/${idHex}/shared`, validUntil); u.hash = `encryption_key=${b64urlPadded(dataKey)}`; dataKey.fill(0); return { url: u.toString(), size: obj.size, objectId: idHex }; }