S3 has no rename, so files, folders and drives kept the name they were made with. s3d copies objects in its own database (the copy points at the same data on Sia), so a rename is a copy to the new name and a delete of the old: quick, and it uses no extra storage. A drive rename makes a new bucket, moves every file, rebuilds public access for the new name (a policy names its bucket) and removes the old one. Names that already exist are refused. Drives on Silent Mode encrypt with keys tied to each name, so their client gets no rename until it can re-encrypt. The drives page and Overview now show how much of the Sia account is used and how much is free. s3d does not report it; the indexer's GET /account does, signed with the app key s3d registered (read from its database for this one request, BLAKE2b-256 + Ed25519 as indexd expects).
109 lines
5.6 KiB
JavaScript
109 lines
5.6 KiB
JavaScript
// Storage left on the Sia account: the indexer's GET /account, signed with
|
|
// the app key s3d registered at login (it lives in s3d's own database, and
|
|
// is only read here to sign this one request; it is never sent anywhere).
|
|
// The signature scheme is indexd's (api/app/auth.go requestHash + client.go
|
|
// sign): BLAKE2b-256 over method, host, path and the expiry, Ed25519-signed,
|
|
// passed as ?sv=<unix expiry>&sc=<pubkey>&ss=<signature> (URL-safe base64).
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
// ---- BLAKE2b-256 (RFC 7693) ----------------------------------------------
|
|
// Node only ships blake2b512, and Sia hashes are the 256-bit variant, which
|
|
// is a different parameter block, not a truncation. Inputs here are tiny,
|
|
// so plain BigInt arithmetic is fine.
|
|
const M64 = (1n << 64n) - 1n;
|
|
const IV = [
|
|
0x6a09e667f3bcc908n, 0xbb67ae8584caa73bn, 0x3c6ef372fe94f82bn, 0xa54ff53a5f1d36f1n,
|
|
0x510e527fade682d1n, 0x9b05688c2b3e6c1fn, 0x1f83d9abfb41bd6bn, 0x5be0cd19137e2179n,
|
|
];
|
|
const SIGMA = [
|
|
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], [14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3],
|
|
[11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4], [7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8],
|
|
[9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13], [2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9],
|
|
[12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11], [13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10],
|
|
[6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5], [10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0],
|
|
];
|
|
const rotr = (x, n) => ((x >> BigInt(n)) | (x << BigInt(64 - n))) & M64;
|
|
|
|
export function blake2b256(data) {
|
|
const outLen = 32;
|
|
const h = IV.slice();
|
|
h[0] ^= 0x01010000n ^ BigInt(outLen);
|
|
const buf = Buffer.from(data);
|
|
const blocks = Math.max(1, Math.ceil(buf.length / 128));
|
|
for (let b = 0; b < blocks; b++) {
|
|
const last = b === blocks - 1;
|
|
const block = Buffer.alloc(128);
|
|
buf.copy(block, 0, b * 128, Math.min(buf.length, (b + 1) * 128));
|
|
const m = Array.from({ length: 16 }, (_, i) => block.readBigUInt64LE(i * 8));
|
|
const t = BigInt(last ? buf.length : (b + 1) * 128);
|
|
const v = [...h, ...IV];
|
|
v[12] ^= t & M64;
|
|
v[13] ^= t >> 64n;
|
|
if (last) v[14] ^= M64;
|
|
const G = (a, bb, c, d, x, y) => {
|
|
v[a] = (v[a] + v[bb] + x) & M64; v[d] = rotr(v[d] ^ v[a], 32);
|
|
v[c] = (v[c] + v[d]) & M64; v[bb] = rotr(v[bb] ^ v[c], 24);
|
|
v[a] = (v[a] + v[bb] + y) & M64; v[d] = rotr(v[d] ^ v[a], 16);
|
|
v[c] = (v[c] + v[d]) & M64; v[bb] = rotr(v[bb] ^ v[c], 63);
|
|
};
|
|
for (let r = 0; r < 12; r++) {
|
|
const s = SIGMA[r % 10];
|
|
G(0, 4, 8, 12, m[s[0]], m[s[1]]); G(1, 5, 9, 13, m[s[2]], m[s[3]]);
|
|
G(2, 6, 10, 14, m[s[4]], m[s[5]]); G(3, 7, 11, 15, m[s[6]], m[s[7]]);
|
|
G(0, 5, 10, 15, m[s[8]], m[s[9]]); G(1, 6, 11, 12, m[s[10]], m[s[11]]);
|
|
G(2, 7, 8, 13, m[s[12]], m[s[13]]); G(3, 4, 9, 14, m[s[14]], m[s[15]]);
|
|
}
|
|
for (let i = 0; i < 8; i++) h[i] ^= v[i] ^ v[i + 8];
|
|
}
|
|
const out = Buffer.alloc(64);
|
|
h.forEach((x, i) => out.writeBigUInt64LE(x, i * 8));
|
|
return out.subarray(0, outLen);
|
|
}
|
|
|
|
// ---- indexd request signing ---------------------------------------------------
|
|
const PKCS8_ED25519 = Buffer.from('302e020100300506032b657004220420', 'hex');
|
|
|
|
export function signIndexerUrl(appKey, method, endpointUrl, validUntilSec) {
|
|
const u = new URL(endpointUrl);
|
|
const until = Buffer.alloc(8);
|
|
until.writeBigUInt64LE(BigInt(validUntilSec));
|
|
const hash = blake2b256(Buffer.concat([Buffer.from(method), Buffer.from(u.host), Buffer.from(u.pathname), until]));
|
|
const seed = Buffer.from(appKey).subarray(0, 32); // a Sia private key is seed || public key
|
|
const key = crypto.createPrivateKey({ key: Buffer.concat([PKCS8_ED25519, seed]), format: 'der', type: 'pkcs8' });
|
|
const pub = crypto.createPublicKey(key).export({ format: 'der', type: 'spki' }).subarray(-32);
|
|
const sig = crypto.sign(null, hash, key);
|
|
const b64 = (b) => Buffer.from(b).toString('base64').replace(/\+/g, '-').replace(/\//g, '_');
|
|
u.searchParams.set('sv', String(validUntilSec));
|
|
u.searchParams.set('sc', b64(pub));
|
|
u.searchParams.set('ss', b64(sig));
|
|
return u;
|
|
}
|
|
|
|
// ---- s3d's app key -------------------------------------------------------------
|
|
export async function readAppKey(dataDir) {
|
|
const file = path.join(dataDir, 's3d.db');
|
|
if (!fs.existsSync(file)) return null;
|
|
let DatabaseSync;
|
|
try { ({ DatabaseSync } = await import('node:sqlite')); } catch { return null; }
|
|
const db = new DatabaseSync(file, { readOnly: true });
|
|
try {
|
|
const row = db.prepare('SELECT app_key, indexer_url FROM global_settings LIMIT 1').get();
|
|
if (!row || !row.app_key) return null;
|
|
return { appKey: Buffer.from(row.app_key), indexerUrl: String(row.indexer_url || '') };
|
|
} finally { db.close(); }
|
|
}
|
|
|
|
// { maxPinnedData, pinnedData, remainingStorage } in bytes, from the indexer.
|
|
export async function accountSpace(dataDir, { fetchImpl = fetch } = {}) {
|
|
const k = await readAppKey(dataDir);
|
|
if (!k || !k.indexerUrl) return null;
|
|
const base = k.indexerUrl.replace(/\/+$/, '');
|
|
const url = signIndexerUrl(k.appKey, 'GET', `${base}/account`, Math.floor(Date.now() / 1000) + 120);
|
|
k.appKey.fill(0);
|
|
const res = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(15_000) });
|
|
if (!res.ok) throw new Error(`indexer answered ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
const j = await res.json();
|
|
return { maxPinnedData: Number(j.maxPinnedData), pinnedData: Number(j.pinnedData), remainingStorage: Number(j.remainingStorage), indexer: new URL(base).host };
|
|
}
|