pithos/hosted/test/gateway.test.js
Local Dev bd4e9ecff1 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.
2026-10-04 01:34:25 +02:00

146 lines
9 KiB
JavaScript

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