Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers byte-for-byte. The version bump is semantic: audit-cycle complete, opt-in surface fully exposed, threat model refreshed for every new surface. Highlights: - All 24 @shade/* packages bumped to 4.0.0 in lockstep. - CHANGELOG 4.0.0 section is the canonical manifest of what landed. - THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12 Web-Worker boundary) + residual-risks table refreshed. - OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox, bridge, observer, /metrics, /healthz, /ready. - MIGRATION 0.3.x → 4.0 documented + smoke-tested against shade migrate-storage on a real SQLite DB. - docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer. - scripts/soak.ts harness for the GA-stable 2-week soak window. - All V*.md plans archived under docs/archive/ with Status: Done. - Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen non-realtime stack. Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green. Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports version 4.0.0 on /health. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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);
|
|
}
|
|
});
|
|
});
|