release(v4.0.0): Shade GA — V3.x consolidation + audit prep
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>
This commit is contained in:
2026-05-03 18:35:35 +02:00
parent 8b055912b7
commit e6fdf31b49
298 changed files with 37909 additions and 256 deletions

View File

@@ -0,0 +1,260 @@
import { describe, test, expect } from 'bun:test';
import {
createInboxServer,
MemoryInboxStore,
computeMsgId,
InboxPruneTask,
} from '../src/index.js';
import { signPayload } from '@shade/server';
import { SubtleCryptoProvider } from '@shade/crypto-web';
import { generateIdentityKeyPair, toBase64, fromBase64 } 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('Inbox lifecycle', () => {
test('100 messages delivered without online overlap', async () => {
const store = new MemoryInboxStore();
const app = createInboxServer({ crypto, store, disableRateLimit: true });
const bob = await makeIdentity();
const alice = await makeIdentity();
// Bob registers, then goes "offline".
const reg = await signPayload(crypto, bob.signingPrivateKey, {
address: 'bob',
signingKey: toBase64(bob.signingPublicKey),
});
await app.request('/v1/inbox/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reg),
});
// Alice puts 100 unique blobs while Bob is offline.
const sentMsgIds = new Set<string>();
for (let i = 0; i < 100; i++) {
const ct = randBytes(64 + (i % 8));
const msgId = await computeMsgId(ct);
sentMsgIds.add(msgId);
const body = await signPayload(crypto, alice.signingPrivateKey, {
senderSigningKey: toBase64(alice.signingPublicKey),
msgId,
ciphertext: toBase64(ct),
});
const r = await app.request(`/v1/inbox/bob`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
expect(r.status).toBe(200);
}
// Bob comes online and pulls everything in pages.
const seen = new Set<string>();
let cursor = 0;
let safety = 0;
while (safety++ < 50) {
const fetchBody = await signPayload(crypto, bob.signingPrivateKey, {
address: 'bob',
sinceCursor: cursor,
});
const r = await app.request(`/v1/inbox/bob/fetch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fetchBody),
});
const j: any = await r.json();
for (const b of j.blobs) seen.add(b.msgId);
cursor = j.cursor;
if (!j.hasMore) break;
}
expect(seen.size).toBe(100);
for (const msgId of sentMsgIds) expect(seen.has(msgId)).toBe(true);
});
test('persistence across "restart" — same store, fresh app object', async () => {
const store = new MemoryInboxStore();
const bob = await makeIdentity();
const alice = await makeIdentity();
// Stage 1: register + put 5 blobs.
{
const app = createInboxServer({ crypto, store, disableRateLimit: true });
const reg = await signPayload(crypto, bob.signingPrivateKey, {
address: 'bob',
signingKey: toBase64(bob.signingPublicKey),
});
await app.request('/v1/inbox/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reg),
});
for (let i = 0; i < 5; i++) {
const ct = randBytes(48 + i);
const msgId = await computeMsgId(ct);
const body = await signPayload(crypto, alice.signingPrivateKey, {
senderSigningKey: toBase64(alice.signingPublicKey),
msgId,
ciphertext: toBase64(ct),
});
const r = await app.request(`/v1/inbox/bob`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
expect(r.status).toBe(200);
}
}
// Stage 2: simulate a restart by building a brand-new Hono app on top
// of the same persistent store, then verify fetches still see the data.
{
const app = createInboxServer({ crypto, store, disableRateLimit: true });
const fetchBody = await signPayload(crypto, bob.signingPrivateKey, {
address: 'bob',
sinceCursor: 0,
});
const r = await app.request('/v1/inbox/bob/fetch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fetchBody),
});
const j: any = await r.json();
expect(j.blobs.length).toBe(5);
}
});
test('prune removes expired blobs but keeps live ones', async () => {
const store = new MemoryInboxStore();
const app = createInboxServer({ crypto, store, disableRateLimit: true });
const bob = await makeIdentity();
const alice = await makeIdentity();
const reg = await signPayload(crypto, bob.signingPrivateKey, {
address: 'bob',
signingKey: toBase64(bob.signingPublicKey),
});
await app.request('/v1/inbox/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reg),
});
// One blob with min TTL, one with default TTL (well in future).
const shortCt = randBytes(64);
const shortMsgId = await computeMsgId(shortCt);
const shortBody = await signPayload(crypto, alice.signingPrivateKey, {
senderSigningKey: toBase64(alice.signingPublicKey),
msgId: shortMsgId,
ciphertext: toBase64(shortCt),
ttlSeconds: 60,
});
await app.request('/v1/inbox/bob', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(shortBody),
});
const longCt = randBytes(64);
const longMsgId = await computeMsgId(longCt);
const longBody = await signPayload(crypto, alice.signingPrivateKey, {
senderSigningKey: toBase64(alice.signingPublicKey),
msgId: longMsgId,
ciphertext: toBase64(longCt),
});
await app.request('/v1/inbox/bob', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(longBody),
});
// Force-expire the short blob by mutating expires_at.
const list: any = (store as any).blobs.get('bob');
list[0].expiresAt = Date.now() - 1000;
const prune = new InboxPruneTask(store, { intervalMinutes: 60 });
const removed = await prune.runOnce();
expect(removed).toBe(1);
const fetchBody = await signPayload(crypto, bob.signingPrivateKey, {
address: 'bob',
sinceCursor: 0,
});
const r = await app.request('/v1/inbox/bob/fetch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fetchBody),
});
const j: any = await r.json();
expect(j.blobs.length).toBe(1);
expect(j.blobs[0].msgId).toBe(longMsgId);
});
});
describe('Tamper resistance', () => {
test('bit-flip on stored ciphertext is reported as decode/decrypt failure on the client', async () => {
const store = new MemoryInboxStore();
const app = createInboxServer({ crypto, store, disableRateLimit: true });
const bob = await makeIdentity();
const alice = await makeIdentity();
const reg = await signPayload(crypto, bob.signingPrivateKey, {
address: 'bob',
signingKey: toBase64(bob.signingPublicKey),
});
await app.request('/v1/inbox/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reg),
});
const ct = randBytes(64);
const msgId = await computeMsgId(ct);
const body = await signPayload(crypto, alice.signingPrivateKey, {
senderSigningKey: toBase64(alice.signingPublicKey),
msgId,
ciphertext: toBase64(ct),
});
const putRes = await app.request('/v1/inbox/bob', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
expect(putRes.status).toBe(200);
// Tamper directly in the store.
const blobs: any = (store as any).blobs.get('bob');
blobs[0].ciphertext[5] ^= 0x01;
// Fetch returns the tampered blob — server is oblivious to integrity.
const fetchBody = await signPayload(crypto, bob.signingPrivateKey, {
address: 'bob',
sinceCursor: 0,
});
const r = await app.request('/v1/inbox/bob/fetch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fetchBody),
});
const j: any = await r.json();
expect(j.blobs.length).toBe(1);
// Recipient recomputes msgId; tampered ciphertext now hashes to a
// value different from the stored msgId — that's the client-side
// canary the V3.6 spec requires.
const tampered = fromBase64(j.blobs[0].ciphertext);
const recomputed = await computeMsgId(tampered);
expect(recomputed).not.toBe(msgId);
});
});

View File

@@ -0,0 +1,383 @@
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);
});
});
});