theseus/bundled-addons/pithos/core/sia-share.js
Local Dev 6225e79d9b Pithos 0.3.16: public links at navigate.st/sia, and the Pithos name opens Overview
Files in your own Sia account can now be shared with anyone through a
navigate.st/sia link that keeps working with this computer off; the
navigate.st service is live, so the buttons can ship.
2026-10-04 18:27:05 +02:00

107 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 };
}