105 lines
3.9 KiB
TypeScript
105 lines
3.9 KiB
TypeScript
|
|
/**
|
||
|
|
* End-to-end PII guard test.
|
||
|
|
*
|
||
|
|
* Exercises real Shade entry points (session encrypt/decrypt, transfer
|
||
|
|
* upload + receive, prekey HTTP routes, files RPC) with a recorder hook
|
||
|
|
* and asserts that NONE of the recorded span attributes echo the
|
||
|
|
* sensitive plaintext we deliberately fed in (peer address, message
|
||
|
|
* content, exact byte counts).
|
||
|
|
*/
|
||
|
|
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||
|
|
import { ShadeSessionManager, type StorageProvider } from '@shade/core';
|
||
|
|
import { MemoryStorage, SubtleCryptoProvider } from '@shade/crypto-web';
|
||
|
|
import { createRecorder } from '../src/index.ts';
|
||
|
|
import { createPrekeyRoutes, MemoryPrekeyStore } from '@shade/server';
|
||
|
|
|
||
|
|
const DANGER_FRAGMENTS = [
|
||
|
|
// Peer addresses we feed into APIs:
|
||
|
|
'alice@danger.test',
|
||
|
|
'bob@danger.test',
|
||
|
|
'device:hot-secret-12345',
|
||
|
|
// Plaintext message bodies:
|
||
|
|
'sekret-payload-XYZ',
|
||
|
|
'CLASSIFIED-7777',
|
||
|
|
// Exact byte counts that we'd never want leaked:
|
||
|
|
'1048577',
|
||
|
|
];
|
||
|
|
|
||
|
|
describe('observability — PII guard for ShadeSessionManager', () => {
|
||
|
|
test('encrypt/decrypt spans never echo address or plaintext', async () => {
|
||
|
|
const rec = createRecorder();
|
||
|
|
const crypto = new SubtleCryptoProvider();
|
||
|
|
|
||
|
|
const aliceStorage: StorageProvider = new MemoryStorage();
|
||
|
|
const bobStorage: StorageProvider = new MemoryStorage();
|
||
|
|
const alice = new ShadeSessionManager(crypto, aliceStorage, { observability: rec });
|
||
|
|
const bob = new ShadeSessionManager(crypto, bobStorage, { observability: rec });
|
||
|
|
await alice.initialize();
|
||
|
|
await bob.initialize();
|
||
|
|
|
||
|
|
// Alice -> Bob handshake (X3DH)
|
||
|
|
const bobBundle = await bob.createPreKeyBundle();
|
||
|
|
await alice.initSessionFromBundle('bob@danger.test', bobBundle);
|
||
|
|
|
||
|
|
const env1 = await alice.encrypt('bob@danger.test', 'sekret-payload-XYZ');
|
||
|
|
await bob.decrypt('alice@danger.test', env1);
|
||
|
|
|
||
|
|
// Round-trip a second time so a steady-state ratchet step also runs.
|
||
|
|
const env2 = await bob.encrypt('alice@danger.test', 'CLASSIFIED-7777');
|
||
|
|
await alice.decrypt('bob@danger.test', env2);
|
||
|
|
|
||
|
|
expect(rec.spans.length).toBeGreaterThan(0);
|
||
|
|
const hits = rec.scanForPII(DANGER_FRAGMENTS);
|
||
|
|
if (hits.length > 0) {
|
||
|
|
throw new Error(`PII leak in spans: ${JSON.stringify(hits, null, 2)}`);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('observability — PII guard for prekey routes', () => {
|
||
|
|
let port: number;
|
||
|
|
let server: ReturnType<typeof Bun.serve>;
|
||
|
|
let rec: ReturnType<typeof createRecorder>;
|
||
|
|
|
||
|
|
beforeAll(async () => {
|
||
|
|
rec = createRecorder();
|
||
|
|
const crypto = new SubtleCryptoProvider();
|
||
|
|
const store = new MemoryPrekeyStore();
|
||
|
|
const app = createPrekeyRoutes(store, crypto, {
|
||
|
|
observability: rec,
|
||
|
|
disableRateLimit: true,
|
||
|
|
});
|
||
|
|
server = Bun.serve({
|
||
|
|
fetch: app.fetch,
|
||
|
|
port: 0,
|
||
|
|
});
|
||
|
|
port = (server as unknown as { port: number }).port;
|
||
|
|
});
|
||
|
|
afterAll(async () => {
|
||
|
|
await server.stop();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('GET /v1/keys/bundle/<address> never logs the address verbatim', async () => {
|
||
|
|
const addr = 'device:hot-secret-12345';
|
||
|
|
// Anonymous fetch — bundle endpoint will 404 since we never registered,
|
||
|
|
// but the route still emits a span with the route template (not the
|
||
|
|
// raw address path).
|
||
|
|
await fetch(`http://localhost:${port}/v1/keys/bundle/${encodeURIComponent(addr)}`);
|
||
|
|
expect(rec.spans.length).toBeGreaterThan(0);
|
||
|
|
const hits = rec.scanForPII([addr, 'hot-secret']);
|
||
|
|
if (hits.length > 0) {
|
||
|
|
throw new Error(`PII leak in prekey-route spans: ${JSON.stringify(hits, null, 2)}`);
|
||
|
|
}
|
||
|
|
// The span name should reference the route TEMPLATE, not the raw path.
|
||
|
|
const seenRoutes = rec.spans.flatMap((s) => {
|
||
|
|
const r = s.attributes['shade.route'];
|
||
|
|
return typeof r === 'string' ? [r] : [];
|
||
|
|
});
|
||
|
|
// Route should be `/v1/keys/bundle/:address` (or empty if Hono didn't
|
||
|
|
// resolve it; what we MUST NOT see is the literal device:... value).
|
||
|
|
for (const r of seenRoutes) {
|
||
|
|
expect(r.includes('hot-secret')).toBe(false);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|