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>
384 lines
14 KiB
TypeScript
384 lines
14 KiB
TypeScript
import { describe, test, expect, beforeEach } from 'bun:test';
|
|
import { Hono } from 'hono';
|
|
import {
|
|
createInboxServer,
|
|
MemoryInboxStore,
|
|
computeMsgId,
|
|
type InboxStore,
|
|
} from '../src/index.js';
|
|
import { signPayload } from '@shade/server';
|
|
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
|
import { generateIdentityKeyPair, toBase64 } from '@shade/core';
|
|
|
|
const crypto = new SubtleCryptoProvider();
|
|
|
|
async function makeIdentity() {
|
|
return generateIdentityKeyPair(crypto);
|
|
}
|
|
|
|
function randBytes(n: number): Uint8Array {
|
|
const buf = new Uint8Array(n);
|
|
globalThis.crypto.getRandomValues(buf);
|
|
return buf;
|
|
}
|
|
|
|
describe('Shade Inbox Server', () => {
|
|
let store: InboxStore;
|
|
let app: Hono;
|
|
|
|
beforeEach(() => {
|
|
store = new MemoryInboxStore();
|
|
app = createInboxServer({ crypto, store, disableRateLimit: true });
|
|
});
|
|
|
|
function req(method: string, path: string, body?: any) {
|
|
const init: RequestInit = { method, headers: { 'Content-Type': 'application/json' } };
|
|
if (body !== undefined) init.body = JSON.stringify(body);
|
|
return app.request(path, init);
|
|
}
|
|
|
|
async function registerBob(address = 'bob') {
|
|
const bob = await makeIdentity();
|
|
const body = await signPayload(crypto, bob.signingPrivateKey, {
|
|
address,
|
|
signingKey: toBase64(bob.signingPublicKey),
|
|
});
|
|
const res = await req('POST', '/v1/inbox/register', body);
|
|
expect(res.status).toBe(200);
|
|
return bob;
|
|
}
|
|
|
|
async function putMsg(args: {
|
|
sender: Awaited<ReturnType<typeof makeIdentity>>;
|
|
recipient: string;
|
|
ciphertext: Uint8Array;
|
|
ttlSeconds?: number;
|
|
}) {
|
|
const msgId = await computeMsgId(args.ciphertext);
|
|
const body: Record<string, unknown> = {
|
|
senderSigningKey: toBase64(args.sender.signingPublicKey),
|
|
msgId,
|
|
ciphertext: toBase64(args.ciphertext),
|
|
};
|
|
if (args.ttlSeconds !== undefined) body.ttlSeconds = args.ttlSeconds;
|
|
const signed = await signPayload(crypto, args.sender.signingPrivateKey, body);
|
|
const res = await req('POST', `/v1/inbox/${args.recipient}`, signed);
|
|
return { res, msgId };
|
|
}
|
|
|
|
// ─── Health ─────────────────────────────────────────────────
|
|
|
|
test('health endpoint responds', async () => {
|
|
const res = await req('GET', '/health');
|
|
expect(res.status).toBe(200);
|
|
const json = await res.json();
|
|
expect(json.service).toBe('shade-inbox-server');
|
|
});
|
|
|
|
// ─── Registration (TOFU) ────────────────────────────────────
|
|
|
|
describe('POST /v1/inbox/register', () => {
|
|
test('accepts valid registration', async () => {
|
|
await registerBob();
|
|
});
|
|
|
|
test('idempotent re-register with same key', async () => {
|
|
const bob = await registerBob('bob');
|
|
const body = await signPayload(crypto, bob.signingPrivateKey, {
|
|
address: 'bob',
|
|
signingKey: toBase64(bob.signingPublicKey),
|
|
});
|
|
const res = await req('POST', '/v1/inbox/register', body);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
test('rejects different key claiming same address', async () => {
|
|
await registerBob('bob');
|
|
const eve = await makeIdentity();
|
|
const body = await signPayload(crypto, eve.signingPrivateKey, {
|
|
address: 'bob',
|
|
signingKey: toBase64(eve.signingPublicKey),
|
|
});
|
|
const res = await req('POST', '/v1/inbox/register', body);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
test('rejects unsigned body', async () => {
|
|
const bob = await makeIdentity();
|
|
const res = await req('POST', '/v1/inbox/register', {
|
|
address: 'bob',
|
|
signingKey: toBase64(bob.signingPublicKey),
|
|
});
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
test('rejects bad signature', async () => {
|
|
const bob = await makeIdentity();
|
|
const res = await req('POST', '/v1/inbox/register', {
|
|
address: 'bob',
|
|
signingKey: toBase64(bob.signingPublicKey),
|
|
signedAt: Date.now(),
|
|
signature: toBase64(randBytes(64)),
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
// ─── PUT blob ───────────────────────────────────────────────
|
|
|
|
describe('POST /v1/inbox/:address (PUT blob)', () => {
|
|
test('stores a signed blob from sender', async () => {
|
|
await registerBob();
|
|
const alice = await makeIdentity();
|
|
const ct = randBytes(128);
|
|
const { res, msgId } = await putMsg({ sender: alice, recipient: 'bob', ciphertext: ct });
|
|
expect(res.status).toBe(200);
|
|
const json = await res.json();
|
|
expect(json.msgId).toBe(msgId);
|
|
expect(json.idempotent).toBe(false);
|
|
});
|
|
|
|
test('idempotent on duplicate ciphertext', async () => {
|
|
await registerBob();
|
|
const alice = await makeIdentity();
|
|
const ct = randBytes(64);
|
|
const first = await putMsg({ sender: alice, recipient: 'bob', ciphertext: ct });
|
|
expect(first.res.status).toBe(200);
|
|
const second = await putMsg({ sender: alice, recipient: 'bob', ciphertext: ct });
|
|
expect(second.res.status).toBe(200);
|
|
const j2 = await second.res.json();
|
|
expect(j2.idempotent).toBe(true);
|
|
expect(j2.msgId).toBe(first.msgId);
|
|
});
|
|
|
|
test('rejects mismatched msgId', async () => {
|
|
await registerBob();
|
|
const alice = await makeIdentity();
|
|
const ct = randBytes(64);
|
|
const wrongId = '0'.repeat(64);
|
|
const body = await signPayload(crypto, alice.signingPrivateKey, {
|
|
senderSigningKey: toBase64(alice.signingPublicKey),
|
|
msgId: wrongId,
|
|
ciphertext: toBase64(ct),
|
|
});
|
|
const res = await req('POST', '/v1/inbox/bob', body);
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
test('rejects PUT to unregistered address', async () => {
|
|
const alice = await makeIdentity();
|
|
const ct = randBytes(64);
|
|
const { res } = await putMsg({ sender: alice, recipient: 'nobody', ciphertext: ct });
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
test('rejects bad sender signature', async () => {
|
|
await registerBob();
|
|
const alice = await makeIdentity();
|
|
const eve = await makeIdentity();
|
|
const ct = randBytes(64);
|
|
const msgId = await computeMsgId(ct);
|
|
// Sign with Eve, claim Alice's key.
|
|
const body = await signPayload(crypto, eve.signingPrivateKey, {
|
|
senderSigningKey: toBase64(alice.signingPublicKey),
|
|
msgId,
|
|
ciphertext: toBase64(ct),
|
|
});
|
|
const res = await req('POST', '/v1/inbox/bob', body);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
test('rejects ciphertext > maxBlobBytes', async () => {
|
|
const small = createInboxServer({
|
|
crypto,
|
|
store: new MemoryInboxStore(),
|
|
disableRateLimit: true,
|
|
quota: { maxBlobBytes: 256 },
|
|
});
|
|
// Register bob in this fresh app.
|
|
const bob = await makeIdentity();
|
|
const reg = await signPayload(crypto, bob.signingPrivateKey, {
|
|
address: 'bob',
|
|
signingKey: toBase64(bob.signingPublicKey),
|
|
});
|
|
await small.request('/v1/inbox/register', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(reg),
|
|
});
|
|
const alice = await makeIdentity();
|
|
const ct = randBytes(257);
|
|
const msgId = await computeMsgId(ct);
|
|
const body = await signPayload(crypto, alice.signingPrivateKey, {
|
|
senderSigningKey: toBase64(alice.signingPublicKey),
|
|
msgId,
|
|
ciphertext: toBase64(ct),
|
|
});
|
|
const res = await small.request('/v1/inbox/bob', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
test('rejects stale signature (replay window)', async () => {
|
|
await registerBob();
|
|
const alice = await makeIdentity();
|
|
const ct = randBytes(64);
|
|
const msgId = await computeMsgId(ct);
|
|
// Hand-craft: sign normally, then mutate signedAt to 10 minutes ago.
|
|
const signed = await signPayload(crypto, alice.signingPrivateKey, {
|
|
senderSigningKey: toBase64(alice.signingPublicKey),
|
|
msgId,
|
|
ciphertext: toBase64(ct),
|
|
});
|
|
(signed as any).signedAt = Date.now() - 10 * 60 * 1000;
|
|
const res = await req('POST', '/v1/inbox/bob', signed);
|
|
// signedAt mutated → signature invalid → 401, OR replay → 409.
|
|
expect([401, 409]).toContain(res.status);
|
|
});
|
|
|
|
test('enforces per-address quota', async () => {
|
|
const small = createInboxServer({
|
|
crypto,
|
|
store: new MemoryInboxStore(),
|
|
disableRateLimit: true,
|
|
quota: { maxBlobsPerAddress: 2 },
|
|
});
|
|
const bob = await makeIdentity();
|
|
const reg = await signPayload(crypto, bob.signingPrivateKey, {
|
|
address: 'bob',
|
|
signingKey: toBase64(bob.signingPublicKey),
|
|
});
|
|
await small.request('/v1/inbox/register', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(reg),
|
|
});
|
|
|
|
const alice = await makeIdentity();
|
|
for (let i = 0; i < 2; i++) {
|
|
const ct = randBytes(32 + i);
|
|
const msgId = await computeMsgId(ct);
|
|
const body = await signPayload(crypto, alice.signingPrivateKey, {
|
|
senderSigningKey: toBase64(alice.signingPublicKey),
|
|
msgId,
|
|
ciphertext: toBase64(ct),
|
|
});
|
|
const r = await small.request('/v1/inbox/bob', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
expect(r.status).toBe(200);
|
|
}
|
|
// Third should be quota-rejected.
|
|
const ct = randBytes(99);
|
|
const msgId = await computeMsgId(ct);
|
|
const body = await signPayload(crypto, alice.signingPrivateKey, {
|
|
senderSigningKey: toBase64(alice.signingPublicKey),
|
|
msgId,
|
|
ciphertext: toBase64(ct),
|
|
});
|
|
const r = await small.request('/v1/inbox/bob', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
expect(r.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
// ─── FETCH ──────────────────────────────────────────────────
|
|
|
|
describe('POST /v1/inbox/:address/fetch', () => {
|
|
test('returns blobs after registration', async () => {
|
|
const bob = await registerBob();
|
|
const alice = await makeIdentity();
|
|
const ct1 = randBytes(64);
|
|
const ct2 = randBytes(80);
|
|
await putMsg({ sender: alice, recipient: 'bob', ciphertext: ct1 });
|
|
await putMsg({ sender: alice, recipient: 'bob', ciphertext: ct2 });
|
|
|
|
const fetchBody = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', sinceCursor: 0 });
|
|
const res = await req('POST', '/v1/inbox/bob/fetch', fetchBody);
|
|
expect(res.status).toBe(200);
|
|
const json = await res.json();
|
|
expect(json.blobs.length).toBe(2);
|
|
expect(typeof json.cursor).toBe('number');
|
|
});
|
|
|
|
test('cursor pagination skips already-seen blobs', async () => {
|
|
const bob = await registerBob();
|
|
const alice = await makeIdentity();
|
|
await putMsg({ sender: alice, recipient: 'bob', ciphertext: randBytes(20) });
|
|
const firstFetch = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', sinceCursor: 0 });
|
|
const r1 = await req('POST', '/v1/inbox/bob/fetch', firstFetch);
|
|
const j1 = await r1.json();
|
|
const cursor = j1.cursor;
|
|
expect(j1.blobs.length).toBe(1);
|
|
// Add a second blob.
|
|
await putMsg({ sender: alice, recipient: 'bob', ciphertext: randBytes(30) });
|
|
const secondFetch = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', sinceCursor: cursor });
|
|
const r2 = await req('POST', '/v1/inbox/bob/fetch', secondFetch);
|
|
const j2 = await r2.json();
|
|
expect(j2.blobs.length).toBe(1);
|
|
});
|
|
|
|
test('rejects fetch from a different signing key', async () => {
|
|
await registerBob();
|
|
const eve = await makeIdentity();
|
|
const fetchBody = await signPayload(crypto, eve.signingPrivateKey, { address: 'bob', sinceCursor: 0 });
|
|
const res = await req('POST', '/v1/inbox/bob/fetch', fetchBody);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
test('rejects fetch on unregistered address', async () => {
|
|
const eve = await makeIdentity();
|
|
const fetchBody = await signPayload(crypto, eve.signingPrivateKey, { address: 'nobody', sinceCursor: 0 });
|
|
const res = await req('POST', '/v1/inbox/nobody/fetch', fetchBody);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
// ─── DELETE / ack ───────────────────────────────────────────
|
|
|
|
describe('DELETE /v1/inbox/:address/:msgId', () => {
|
|
test('removes a blob after ack', async () => {
|
|
const bob = await registerBob();
|
|
const alice = await makeIdentity();
|
|
const ct = randBytes(64);
|
|
const { msgId } = await putMsg({ sender: alice, recipient: 'bob', ciphertext: ct });
|
|
|
|
const ackBody = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', msgId });
|
|
const res = await req('DELETE', `/v1/inbox/bob/${msgId}`, ackBody);
|
|
expect(res.status).toBe(200);
|
|
const j = await res.json();
|
|
expect(j.ok).toBe(true);
|
|
|
|
// Subsequent fetch should return zero.
|
|
const fetchBody = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', sinceCursor: 0 });
|
|
const r2 = await req('POST', '/v1/inbox/bob/fetch', fetchBody);
|
|
const j2 = await r2.json();
|
|
expect(j2.blobs.length).toBe(0);
|
|
});
|
|
|
|
test('rejects ack from a different signing key', async () => {
|
|
const bob = await registerBob();
|
|
const alice = await makeIdentity();
|
|
const eve = await makeIdentity();
|
|
const ct = randBytes(64);
|
|
const { msgId } = await putMsg({ sender: alice, recipient: 'bob', ciphertext: ct });
|
|
const ackBody = await signPayload(crypto, eve.signingPrivateKey, { address: 'bob', msgId });
|
|
const res = await req('DELETE', `/v1/inbox/bob/${msgId}`, ackBody);
|
|
expect(res.status).toBe(401);
|
|
// and the blob must still be there
|
|
const fetchBody = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', sinceCursor: 0 });
|
|
const r2 = await req('POST', '/v1/inbox/bob/fetch', fetchBody);
|
|
const j2 = await r2.json();
|
|
expect(j2.blobs.length).toBe(1);
|
|
});
|
|
});
|
|
});
|