import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { ed25519 } from '@noble/curves/ed25519.js'; import { setupFileRig, type FileTestRig } from '../integration/helpers/rig.js'; import { bytesToBase64, canonicalRpcBytes, hashArgs, InvalidSignatureError, type FileEntry, NotFoundError, } from '../../src/index.js'; /** * The dispatcher's `verifySender` callback gets the canonical bytes the * client claims they signed. By plugging a real Ed25519 verify in tests, * we can demonstrate that: * - A valid sig over the canonical bytes is accepted. * - Tampering ANY bound field (kind, args, signedAt, sender) breaks * verification → InvalidSignatureError. */ describe('Tampered envelope — Ed25519 sig verification', () => { let rig: FileTestRig; // Generate a stable Ed25519 keypair for Alice. Bob will pin it. const alicePriv = ed25519.utils.randomSecretKey(); const alicePub = ed25519.getPublicKey(alicePriv); beforeAll(async () => { rig = await setupFileRig({ verifySender: (sender, canonical, sigBase64) => { // We only know Alice's key for this test. Bob's pub key would be // looked up similarly in a real app. if (sender !== 'alice') return false; // Decode base64 sig try { const bin = atob(sigBase64); const sigBytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) sigBytes[i] = bin.charCodeAt(i); return ed25519.verify(sigBytes, canonical, alicePub); } catch { return false; } }, stat: async (ctx) => { if (ctx.path !== '/exists.txt') throw new NotFoundError(ctx.path); const e: FileEntry = { name: 'exists.txt', kind: 'file', size: 1, mtime: 0, metadata: {} }; return e; }, }); // Re-create the client with a real signRequest hook. // (Rig's default fs has signRequest=undefined; we replace it.) const { createFileClient, ShadeFileRpcChannel, PendingRpcRegistry, attachClientRouting } = await import('../../src/index.js'); const aliceChannel = new ShadeFileRpcChannel(rig.alice); const alicePending = new PendingRpcRegistry(); attachClientRouting(aliceChannel, alicePending); rig.fs = createFileClient(rig.alice, aliceChannel, alicePending, 'bob', { defaultTimeoutMs: 5000, signRequest: async (canonical) => { const sig = ed25519.sign(canonical, alicePriv); return bytesToBase64(sig); }, }); }); afterAll(async () => { await rig.teardown(); }); test('valid signature → request succeeds', async () => { const result = await rig.fs.stat('/exists.txt'); expect(result.name).toBe('exists.txt'); }); test('tampered args → InvalidSignatureError', async () => { // Craft a request manually: sign over '/a' but ship '/b'. const { createFileClient, ShadeFileRpcChannel, PendingRpcRegistry, attachClientRouting } = await import('../../src/index.js'); const aliceChannel = new ShadeFileRpcChannel(rig.alice); const alicePending = new PendingRpcRegistry(); attachClientRouting(aliceChannel, alicePending); const tamperedFs = createFileClient(rig.alice, aliceChannel, alicePending, 'bob', { defaultTimeoutMs: 3000, signRequest: async (_canonical) => { // Sign over a DIFFERENT canonical (different argsHash), so the // server's recomputation won't match. const fake = canonicalRpcBytes({ address: 'alice', signedAt: 0, kind: 'shade.fs.list/v1', id: 'AAAAAAAAAAAAAAAAAAAAAA', argsHash: hashArgs({ tampered: true }), }); const sig = ed25519.sign(fake, alicePriv); return bytesToBase64(sig); }, }); await expect(tamperedFs.stat('/exists.txt')).rejects.toBeInstanceOf(InvalidSignatureError); }); test('valid signature from unknown signer → InvalidSignatureError', async () => { const { createFileClient, ShadeFileRpcChannel, PendingRpcRegistry, attachClientRouting } = await import('../../src/index.js'); const aliceChannel = new ShadeFileRpcChannel(rig.alice); const alicePending = new PendingRpcRegistry(); attachClientRouting(aliceChannel, alicePending); const otherPriv = ed25519.utils.randomSecretKey(); const wrongFs = createFileClient(rig.alice, aliceChannel, alicePending, 'bob', { defaultTimeoutMs: 3000, signRequest: async (canonical) => { const sig = ed25519.sign(canonical, otherPriv); return bytesToBase64(sig); }, }); await expect(wrongFs.stat('/exists.txt')).rejects.toBeInstanceOf(InvalidSignatureError); }); });