147 lines
9 KiB
JavaScript
147 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/);
|
||
|
|
});
|