// 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/); }); 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); });