From bd4e9ecff159befa5dc6a2a64337d443b688e684 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 01:34:25 +0200 Subject: [PATCH] pithos-hosted: tests for signatures, rules and the sign-up gate The gateway is the only thing standing between a shared key and the rest of an owner's drive, so its SigV4 check, grant decisions and the BNS/deposit proofs get tests that need no s3d to run. --- hosted/package.json | 3 +- hosted/test/gateway.test.js | 146 ++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 hosted/test/gateway.test.js diff --git a/hosted/package.json b/hosted/package.json index 75896c6..344bfd6 100644 --- a/hosted/package.json +++ b/hosted/package.json @@ -7,7 +7,8 @@ "type": "module", "main": "main.js", "scripts": { - "start": "node main.js --config /etc/pithos-hosted.json" + "start": "node main.js --config /etc/pithos-hosted.json", + "test": "node --test test/*.test.js" }, "engines": { "node": ">=20" diff --git a/hosted/test/gateway.test.js b/hosted/test/gateway.test.js new file mode 100644 index 0000000..7d1bf54 --- /dev/null +++ b/hosted/test/gateway.test.js @@ -0,0 +1,146 @@ +// 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/); +});