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
// 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 ;
}
2026-10-07 19:34:58 +02:00
// 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 << 8 n ) | BigInt ( x ) , 0 n ) ;
const r = le ( key32 . subarray ( 0 , 16 ) ) & 0x0ffffffc0ffffffc0ffffffc0fffffff n ;
const s = le ( key32 . subarray ( 16 , 32 ) ) ;
const p = ( 1 n << 130 n ) - 5 n ;
let acc = 0 n ;
for ( let off = 0 ; off < msg . length ; off += 16 ) {
const block = msg . subarray ( off , off + 16 ) ;
acc = ( ( acc + le ( block ) + ( 1 n << BigInt ( 8 * block . length ) ) ) * r ) % p ;
}
acc = ( acc + s ) & ( ( 1 n << 128 n ) - 1 n ) ;
const tag = Buffer . alloc ( 16 ) ;
for ( let i = 0 ; i < 16 ; i ++ ) { tag [ i ] = Number ( acc & 0xff n ) ; acc >>= 8 n ; }
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 ; } ;
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
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 ) ;
2026-10-07 19:34:58 +02:00
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 ) ;
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
}
// ---- 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 } ;
}