261 lines
8.7 KiB
TypeScript
261 lines
8.7 KiB
TypeScript
|
|
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);
|
||
|
|
});
|
||
|
|
});
|