116 lines
4.6 KiB
TypeScript
116 lines
4.6 KiB
TypeScript
|
|
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);
|
||
|
|
});
|
||
|
|
});
|