2026-10-04 01:34:25 +02:00
// pithos-hosted pieces that need no s3d: SigV4 verification, access rules,
// the sign-up gate. Run: node --test hosted/test/
import test from 'node:test' ;
import assert from 'node:assert/strict' ;
import os from 'node:os' ;
import fs from 'node:fs' ;
import path from 'node:path' ;
import crypto from 'node:crypto' ;
import {
secp256k1 , hash160 , sha256 , utf8ToBin , binToBase64 , lockingBytecodeToCashAddress , cashAddressToLockingBytecode , binToHex ,
} from '@bitauth/libauth' ;
import { S3Client } from '../../core/s3.js' ;
import { parseAuth , verify } from '../sigv4.js' ;
import { validateRule , classify , decideGrant , publicRuleFor } from '../rules.js' ;
import { createGate , bnsMessage , depositMemo , signedByAddress } from '../gate.js' ;
import { accountId , challengeText , verifyEd25519 } from '../auth.js' ;
// A request object as the gateway would see it, from what S3Client signs.
function signed ( client , method , objPath , query = { } , extra = { } ) {
const headers = client . sign ( method , objPath , query , extra ) ;
const qs = Object . keys ( query ) . sort ( ) . map ( ( k ) => ` ${ encodeURIComponent ( k ) } = ${ encodeURIComponent ( query [ k ] ) } ` ) . join ( '&' ) ;
const reqUrl = objPath + ( qs ? ` ? ${ qs } ` : '' ) ;
const req = { method , url : reqUrl , headers : { ... headers , host : client . endpoint . host } } ;
return { req , url : new URL ( reqUrl , 'http://' + client . endpoint . host ) } ;
}
test ( 'SigV4: header-signed requests verify, tampering does not' , ( ) => {
const c = new S3Client ( { endpoint : 'https://s3.example.test' , accessKeyId : 'PGTEST' , secretKey : 'sekrit' } ) ;
const { req , url } = signed ( c , 'GET' , '/drive/a%20b/%E2%9C%93.txt' , { 'list-type' : '2' , prefix : 'x y/' } ) ;
const auth = parseAuth ( req , url ) ;
assert . equal ( auth . akid , 'PGTEST' ) ;
verify ( req , url , auth , 'sekrit' ) ;
assert . throws ( ( ) => verify ( req , url , auth , 'other' ) , /signature/ ) ;
const moved = { ... req , url : req . url . replace ( 'drive' , 'drivf' ) } ;
assert . throws ( ( ) => verify ( moved , new URL ( moved . url , url ) , auth , 'sekrit' ) , /signature/ ) ;
const old = { ... req , headers : { ... req . headers } } ;
assert . throws ( ( ) => verify ( old , url , auth , 'sekrit' , Date . now ( ) + 3_600_000 ) , /time/ ) ;
} ) ;
test ( 'SigV4: presigned URLs verify until they expire' , ( ) => {
const c = new S3Client ( { endpoint : 'https://s3.example.test' , accessKeyId : 'PGTEST' , secretKey : 'sekrit' } ) ;
const u = new URL ( c . presign ( 'drive' , 'folder/file name.txt' , 60 ) ) ;
const req = { method : 'GET' , url : u . pathname + u . search , headers : { host : u . host } } ;
const auth = parseAuth ( req , u ) ;
assert . equal ( auth . presigned , true ) ;
verify ( req , u , auth , 'sekrit' ) ;
assert . throws ( ( ) => verify ( req , u , auth , 'sekrit' , Date . now ( ) + 120_000 ) , /expired/ ) ;
} ) ;
test ( 'rules: validation and grant decisions' , ( ) => {
assert . throws ( ( ) => validateRule ( { type : 'public' , bucket : 'd' , prefix : 'no-slash' } , [ 'd' ] ) , /end it with/ ) ;
assert . throws ( ( ) => validateRule ( { type : 'link' , bucket : 'd' , prefix : 'f.txt' } , [ 'd' ] ) , /expiry/ ) ;
assert . throws ( ( ) => validateRule ( { type : 'grant' , bucket : 'nope' , prefix : '' } , [ 'd' ] ) , /no drive/ ) ;
assert . throws ( ( ) => validateRule ( { type : 'grant' , bucket : 'd' , rights : 'admin' } , [ 'd' ] ) , /allows/ ) ;
const add = validateRule ( { type : 'grant' , bucket : 'd' , prefix : 'in/' , rights : 'add' } , [ 'd' ] ) ;
assert . match ( add . accessKeyId , /^PG[0-9A-F]{18}$/ ) ;
const op = ( m , key , q = { } ) => classify ( m , q , key ) . op ;
assert . deepEqual ( decideGrant ( add , 'd' , op ( 'PUT' , 'in/a' ) , 'in/a' ) , { allow : true , needsAbsent : true } ) ;
assert . equal ( decideGrant ( add , 'd' , op ( 'DELETE' , 'in/a' ) , 'in/a' ) . allow , false ) ;
assert . equal ( decideGrant ( add , 'd' , op ( 'GET' , 'out/a' ) , 'out/a' ) . allow , false ) ;
assert . equal ( decideGrant ( add , 'e' , op ( 'GET' , 'in/a' ) , 'in/a' ) . allow , false ) ;
assert . equal ( decideGrant ( add , 'd' , 'list' , null , 'in/sub/' ) . allow , true ) ;
assert . equal ( decideGrant ( add , 'd' , 'list' , null , '' ) . allow , false ) ;
assert . equal ( op ( 'PUT' , null , { policy : '' } ) , 'bucket-admin' ) ;
assert . equal ( classify ( 'PUT' , { } , 'in/a' , { 'x-amz-copy-source' : '/d/x' } ) . op , 'copy' ) ;
const edit = validateRule ( { type : 'grant' , bucket : 'd' , prefix : '' , rights : 'edit' } , [ 'd' ] ) ;
assert . equal ( decideGrant ( edit , 'd' , 'delete' , 'any/thing' ) . allow , true ) ;
const expired = { ... edit , expires : Date . now ( ) - 1 } ;
assert . equal ( decideGrant ( expired , 'd' , 'read' , 'x' ) . allow , false ) ;
const rules = [
validateRule ( { type : 'public' , bucket : 'd' , prefix : 'site/' } , [ 'd' ] ) ,
validateRule ( { type : 'public' , bucket : 'd' , prefix : 'site/docs/' , rights : 'read-list' } , [ 'd' ] ) ,
] ;
assert . equal ( publicRuleFor ( rules , 'd' , 'site/docs/a.html' ) . rights , 'read-list' ) ;
assert . equal ( publicRuleFor ( rules , 'd' , 'site/a.html' ) . rights , 'read' ) ;
assert . equal ( publicRuleFor ( rules , 'd' , 'private/a.html' ) , null ) ;
} ) ;
test ( 'sign-in: Ed25519 challenge' , ( ) => {
const { publicKey , privateKey } = crypto . generateKeyPairSync ( 'ed25519' ) ;
const pub = publicKey . export ( { format : 'der' , type : 'spki' } ) . subarray ( - 32 ) . toString ( 'hex' ) ;
const msg = challengeText ( 'https://s3.example.test' , 'n0nce' ) ;
const sig = crypto . sign ( null , Buffer . from ( msg ) , privateKey ) . toString ( 'base64' ) ;
assert . equal ( verifyEd25519 ( pub , msg , sig ) , true ) ;
assert . equal ( verifyEd25519 ( pub , challengeText ( 'https://evil.test' , 'n0nce' ) , sig ) , false ) ;
assert . match ( accountId ( pub ) , /^[0-9a-f]{32}$/ ) ;
} ) ;
// A BCH key, its address, and a BIP-137 signature over a message.
function bchKey ( prefix = 'bchtest' ) {
const priv = crypto . randomBytes ( 32 ) ;
const pub = secp256k1 . derivePublicKeyCompressed ( priv ) ;
const address = lockingBytecodeToCashAddress ( { bytecode : Uint8Array . from ( [ 0x76 , 0xa9 , 0x14 , ... hash160 ( pub ) , 0x88 , 0xac ] ) , prefix , tokenSupport : false } ) . address ;
const sign = ( message ) => {
const v = ( b ) => Uint8Array . from ( [ b . length , ... b ] ) ;
const digest = sha256 . hash ( sha256 . hash ( Uint8Array . from ( [ ... v ( utf8ToBin ( 'Bitcoin Signed Message:\n' ) ) , ... v ( utf8ToBin ( message ) ) ] ) ) ) ;
const s = secp256k1 . signMessageHashRecoverableCompact ( priv , digest ) ;
return binToBase64 ( Uint8Array . from ( [ 31 + s . recoveryId , ... s . signature ] ) ) ;
} ;
return { address , sign } ;
}
const tmp = ( ) => fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'pithos-gate-' ) ) ;
test ( 'gate: BNS name owner signature, one slot per name, per-IP limit' , async ( ) => {
const owner = bchKey ( ) ;
const stranger = bchKey ( ) ;
const names = { 'alice.bch' : { registered : true , owner : owner . address } } ;
const fetchImpl = async ( u ) => {
const n = decodeURIComponent ( u . split ( '/api/name/' ) [ 1 ] ) ;
return { ok : true , json : async ( ) => names [ n ] || { registered : false } } ;
} ;
const gate = createGate ( tmp ( ) , { bns : { api : 'https://bns.test' } , perIpPerDay : 2 } , { fetchImpl } ) ;
const id = 'a' . repeat ( 32 ) ;
assert . equal ( signedByAddress ( bnsMessage ( id ) , owner . sign ( bnsMessage ( id ) ) , owner . address ) , true ) ;
await assert . rejects ( gate . admit ( { type : 'bns' , name : 'alice.bch' , signature : stranger . sign ( bnsMessage ( id ) ) } , id , '1.1.1.1' ) , /not from the wallet/ ) ;
await assert . rejects ( gate . admit ( { type : 'bns' , name : 'alice.bch' , signature : owner . sign ( bnsMessage ( 'b' . repeat ( 32 ) ) ) } , id , '1.1.1.1' ) , /not from the wallet/ , 'signature for another account' ) ;
await assert . rejects ( gate . admit ( { type : 'bns' , name : 'nobody.bch' , signature : 'x' } , id , '1.1.1.1' ) , /not a registered/ ) ;
const r = await gate . admit ( { type : 'bns' , name : 'Alice.bch' , signature : owner . sign ( bnsMessage ( id ) ) } , id , '1.1.1.1' ) ;
assert . equal ( r . name , 'alice.bch' ) ;
const id2 = 'c' . repeat ( 32 ) ;
await assert . rejects ( gate . admit ( { type : 'bns' , name : 'alice.bch' , signature : owner . sign ( bnsMessage ( id2 ) ) } , id2 , '1.1.1.2' ) , /already opened/ ) ;
await assert . rejects ( gate . admit ( { type : 'invite' , code : 'x' } , id2 , '1.1.1.1' ) . then ( ( ) => gate . admit ( { type : 'invite' , code : 'x' } , id2 , '1.1.1.1' ) ) , /not valid|too many/ ) ;
} ) ;
test ( 'gate: deposit needs amount, memo and a confirmation' , async ( ) => {
const dep = bchKey ( 'bitcoincash' ) ;
const hex = binToHex ( cashAddressToLockingBytecode ( dep . address ) . bytecode ) ;
const id = 'd' . repeat ( 32 ) ;
const memo = Buffer . from ( depositMemo ( id ) ) . toString ( 'hex' ) ;
const txs = {
[ '1' . repeat ( 64 ) ] : { confirmations : 2 , vout : [ { value : 0.0001 , scriptPubKey : { hex } } , { value : 0 , scriptPubKey : { hex : ` 6a ${ ( memo . length / 2 ) . toString ( 16 ) } ${ memo } ` } } ] } ,
[ '2' . repeat ( 64 ) ] : { confirmations : 2 , vout : [ { value : 0.0001 , scriptPubKey : { hex } } ] } ,
[ '3' . repeat ( 64 ) ] : { confirmations : 0 , vout : [ ] } ,
[ '4' . repeat ( 64 ) ] : { confirmations : 3 , vout : [ { value : 0.00000500 , scriptPubKey : { hex } } , { value : 0 , scriptPubKey : { hex : ` 6a ${ memo } ` } } ] } ,
} ;
const electrum = async ( _ , method , [ txid ] ) => txs [ txid ] ;
const gate = createGate ( tmp ( ) , { deposit : { address : dep . address , minSats : 10000 , electrum : { } } , perIpPerDay : 10 } , { electrum } ) ;
await assert . rejects ( gate . admit ( { type : 'deposit' , txid : '2' . repeat ( 64 ) } , id , 'ip' ) , /memo/ ) ;
await assert . rejects ( gate . admit ( { type : 'deposit' , txid : '3' . repeat ( 64 ) } , id , 'ip' ) , /confirmation/ ) ;
await assert . rejects ( gate . admit ( { type : 'deposit' , txid : '4' . repeat ( 64 ) } , id , 'ip' ) , /at least/ ) ;
const r = await gate . admit ( { type : 'deposit' , txid : '1' . repeat ( 64 ) } , id , 'ip' ) ;
assert . equal ( r . sats , 10000 ) ;
await assert . rejects ( gate . admit ( { type : 'deposit' , txid : '1' . repeat ( 64 ) } , 'e' . repeat ( 32 ) , 'ip' ) , /already opened/ ) ;
} ) ;
Pithos hosting: fixes from an independent review
A server-sent file list could name paths outside the s3d folder (or the key
file in it), and a failed move back deleted every listed path. Transfers now
accept only s3d data paths, check all of them before writing, never
overwrite, and clean up only what they wrote.
A lost reply could lose or fork the drives: move-up and move-down now ask the
server who holds them before touching the local copy, and once the server is
told to release, the local copy is never removed (the release is retried).
The gateway no longer lets one account route another's access key id, refuses
. and .. in shared-key paths, strips presign parameters before re-signing,
counts upload size on disk, stops s3d before it can be restarted mid-move, and
claims sign-up proofs before checking them. The key file is never replaced
when unreadable, a local edit removes the older encrypted copy, listing pages
no longer repeat, and data hashes stream instead of loading whole files.
2026-10-04 03:15:42 +02:00
test ( 'gate: parallel sign-ups cannot share one proof' , async ( ) => {
let release ;
const slow = new Promise ( ( r ) => { release = r ; } ) ;
const owner = bchKey ( ) ;
const fetchImpl = async ( ) => { await slow ; return { ok : true , json : async ( ) => ( { registered : true , owner : owner . address } ) } ; } ;
const gate = createGate ( tmp ( ) , { bns : { api : 'https://bns.test' } , perIpPerDay : 9 } , { fetchImpl } ) ;
const id1 = '1' . repeat ( 32 ) ;
const id2 = '2' . repeat ( 32 ) ;
const a = gate . admit ( { type : 'bns' , name : 'bob.bch' , signature : owner . sign ( bnsMessage ( id1 ) ) } , id1 , 'ip' ) ;
const b = gate . admit ( { type : 'bns' , name : 'bob.bch' , signature : owner . sign ( bnsMessage ( id2 ) ) } , id2 , 'ip' ) ;
release ( ) ;
const results = await Promise . allSettled ( [ a , b ] ) ;
assert . equal ( results . filter ( ( r ) => r . status === 'fulfilled' ) . length , 1 ) ;
} ) ;