pithos/core/sia-share.js
Local Dev e4e4b3b587 Pithos: share links work inside Theseus (ChaCha20-Poly1305 in plain JS)
Creating a public link in the Theseus add-on failed with 'Unknown cipher':
Electron's BoringSSL has neither chacha20 nor chacha20-poly1305 in
createCipheriv, and the tests only ran under Node's OpenSSL. Opening Sia's
32-byte data key now uses RFC 8439 ChaCha20 and Poly1305 written out in
JS; the Go x/crypto vectors pass under Node and inside Electron 44.
2026-10-07 19:34:58 +02:00

157 lines
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;
}
// 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: <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 };
}