theseus/bundled-addons/pithos/core/sia-share.js

108 lines
5.8 KiB
JavaScript
Raw Normal View History

// 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 <drive>/<name> to the object's Sia id;
// 2. the indexer returns the sealed object (signed GET /objects/<id>);
// 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/<id>/shared, valid until a date,
// with #encryption_key=<data 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: <drive>/<name> -> 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 };
}