Files

105 lines
3.9 KiB
TypeScript
Raw Permalink Normal View History

/**
* 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);
}
});
});