// 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; } 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); const d = crypto.createDecipheriv('chacha20-poly1305', sub, nonce12, { authTagLength: 16 }); d.setAuthTag(ct.subarray(ct.length - 16)); return Buffer.concat([d.update(ct.subarray(0, ct.length - 16)), d.final()]); } // ---- 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 }; }