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.
161 lines
9.8 KiB
JavaScript
161 lines
9.8 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/);
|
|
});
|
|
|
|
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);
|
|
});
|