pithos/core/sia-share.js
Local Dev 24709d582d Pithos: public links at navigate.st/sia for files in your own Sia account
A share link from s3d only opened where s3d is reachable, which on a
default setup is this computer. Pithos now makes a Sia shared-object URL
for the file the way the Sia SDK does (the indexer's sealed object, its
data key opened with a key derived from s3d's app key) and registers it
at navigate.st, which streams the file from Sia hosts for anyone, with
seeking, even with this computer off. A shared-object URL reads that one
object and nothing else in the account. Files still waiting on this
computer are uploaded first. A folder gets a page with its files and a
player. Settings lists the links, each one can be switched off.

sia-share is the navigate.st side, in Go on the Sia SDK: it keeps short
ids, checks every URL against an allowed indexer, serves files sandboxed
so a shared page cannot run script on navigate.st, and caps sign-ups per
address and downloads per link per day. Host reads are paid by its own
app on Silent Mode's indexer account.
2026-10-04 16:45:07 +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 };
}