108 lines
5.8 KiB
JavaScript
108 lines
5.8 KiB
JavaScript
|
|
// 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 };
|
|||
|
|
}
|