feat: M5 Prekey Server + M7 Wire Format

M5: Shade Prekey Server (Hono)
- REST API: register, fetch bundle, replenish, count, delete
- MemoryPrekeyStore for testing/embedded use
- Standalone Docker deployment (Dockerfile + standalone.ts)
- One-time prekey consumption on bundle fetch

M7: Compact binary wire format
- Version-tagged envelopes (PreKeyMessage, RatchetMessage)
- Length-prefixed byte arrays, big-endian integers
- Significantly smaller than JSON (no base64 bloat)
- Roundtrip encode/decode for all message types

100 tests, 0 failures across M1-M5+M7.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 20:16:41 +02:00
parent a60ff9d6e8
commit 740a652d51
14 changed files with 898 additions and 2 deletions

View File

@@ -0,0 +1,11 @@
FROM oven/bun:1-alpine
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --production --frozen-lockfile
COPY src/ src/
EXPOSE 3900
CMD ["bun", "run", "src/standalone.ts"]

View File

@@ -6,6 +6,6 @@
"types": "src/index.ts",
"dependencies": {
"@shade/core": "workspace:*",
"hono": "^4.0.0"
"hono": "^4.12.12"
}
}

View File

@@ -0,0 +1,28 @@
import { Hono } from 'hono';
import { createPrekeyRoutes } from './routes.js';
import { MemoryPrekeyStore } from './memory-store.js';
import type { PrekeyStore } from './store.js';
export { createPrekeyRoutes } from './routes.js';
export { MemoryPrekeyStore } from './memory-store.js';
export type { PrekeyStore } from './store.js';
/**
* Create a standalone Shade Prekey Server.
*
* Can be used standalone (Docker) or embedded in another Hono app.
*
* Standalone:
* const server = createPrekeyServer();
* export default { port: 3900, fetch: server.fetch };
*
* Embedded:
* const app = new Hono();
* app.route('/shade', createPrekeyServer());
*/
export function createPrekeyServer(options?: {
store?: PrekeyStore;
}): Hono {
const store = options?.store ?? new MemoryPrekeyStore();
return createPrekeyRoutes(store);
}

View File

@@ -0,0 +1,64 @@
import type { PrekeyStore } from './store.js';
interface IdentityRecord {
identitySigningKey: Uint8Array;
identityDHKey: Uint8Array;
}
interface SignedPreKeyRecord {
keyId: number;
publicKey: Uint8Array;
signature: Uint8Array;
}
interface OneTimePreKeyRecord {
keyId: number;
publicKey: Uint8Array;
}
/**
* In-memory PrekeyStore for testing and embedded use.
*/
export class MemoryPrekeyStore implements PrekeyStore {
private identities = new Map<string, IdentityRecord>();
private signedPreKeys = new Map<string, SignedPreKeyRecord>();
private oneTimePreKeys = new Map<string, OneTimePreKeyRecord[]>();
async saveIdentity(address: string, identitySigningKey: Uint8Array, identityDHKey: Uint8Array): Promise<void> {
this.identities.set(address, { identitySigningKey, identityDHKey });
}
async getIdentity(address: string): Promise<IdentityRecord | null> {
return this.identities.get(address) ?? null;
}
async saveSignedPreKey(address: string, keyId: number, publicKey: Uint8Array, signature: Uint8Array): Promise<void> {
this.signedPreKeys.set(address, { keyId, publicKey, signature });
}
async getSignedPreKey(address: string): Promise<SignedPreKeyRecord | null> {
return this.signedPreKeys.get(address) ?? null;
}
async saveOneTimePreKeys(address: string, keys: Array<{ keyId: number; publicKey: Uint8Array }>): Promise<void> {
const existing = this.oneTimePreKeys.get(address) ?? [];
existing.push(...keys);
this.oneTimePreKeys.set(address, existing);
}
async consumeOneTimePreKey(address: string): Promise<OneTimePreKeyRecord | null> {
const keys = this.oneTimePreKeys.get(address);
if (!keys || keys.length === 0) return null;
return keys.shift()!;
}
async getOneTimePreKeyCount(address: string): Promise<number> {
return this.oneTimePreKeys.get(address)?.length ?? 0;
}
async deleteAll(address: string): Promise<void> {
this.identities.delete(address);
this.signedPreKeys.delete(address);
this.oneTimePreKeys.delete(address);
}
}

View File

@@ -0,0 +1,130 @@
import { Hono } from 'hono';
import type { PrekeyStore } from './store.js';
/**
* Create the Shade Prekey Server Hono app.
*
* Routes:
* POST /v1/keys/register — Register identity + upload prekey bundle
* GET /v1/keys/bundle/:address — Fetch a prekey bundle (consumes one OTP key)
* POST /v1/keys/replenish — Upload additional one-time prekeys
* GET /v1/keys/count/:address — Get remaining one-time prekey count
* DELETE /v1/keys/:address — Unregister (delete all keys)
*/
export function createPrekeyRoutes(store: PrekeyStore): Hono {
const app = new Hono();
// ─── Register ──────────────────────────────────────────────
app.post('/v1/keys/register', async (c) => {
const body = await c.req.json();
const { address, identitySigningKey, identityDHKey, signedPreKey, oneTimePreKeys } = body;
if (!address || !identitySigningKey || !identityDHKey || !signedPreKey) {
return c.json({ error: 'Missing required fields' }, 400);
}
// Decode base64 keys
const signingKey = b64ToBytes(identitySigningKey);
const dhKey = b64ToBytes(identityDHKey);
await store.saveIdentity(address, signingKey, dhKey);
await store.saveSignedPreKey(
address,
signedPreKey.keyId,
b64ToBytes(signedPreKey.publicKey),
b64ToBytes(signedPreKey.signature),
);
if (oneTimePreKeys && Array.isArray(oneTimePreKeys)) {
const keys = oneTimePreKeys.map((k: any) => ({
keyId: k.keyId,
publicKey: b64ToBytes(k.publicKey),
}));
await store.saveOneTimePreKeys(address, keys);
}
return c.json({ ok: true });
});
// ─── Fetch Bundle ──────────────────────────────────────────
app.get('/v1/keys/bundle/:address', async (c) => {
const address = c.req.param('address');
const identity = await store.getIdentity(address);
if (!identity) {
return c.json({ error: 'Address not found' }, 404);
}
const signedPreKey = await store.getSignedPreKey(address);
if (!signedPreKey) {
return c.json({ error: 'No signed prekey' }, 404);
}
// Consume one one-time prekey (if available)
const oneTimePreKey = await store.consumeOneTimePreKey(address);
const bundle: any = {
identitySigningKey: bytesToB64(identity.identitySigningKey),
identityDHKey: bytesToB64(identity.identityDHKey),
signedPreKey: {
keyId: signedPreKey.keyId,
publicKey: bytesToB64(signedPreKey.publicKey),
signature: bytesToB64(signedPreKey.signature),
},
};
if (oneTimePreKey) {
bundle.oneTimePreKey = {
keyId: oneTimePreKey.keyId,
publicKey: bytesToB64(oneTimePreKey.publicKey),
};
}
return c.json(bundle);
});
// ─── Replenish One-Time Prekeys ────────────────────────────
app.post('/v1/keys/replenish', async (c) => {
const body = await c.req.json();
const { address, oneTimePreKeys } = body;
if (!address || !oneTimePreKeys || !Array.isArray(oneTimePreKeys)) {
return c.json({ error: 'Missing address or oneTimePreKeys' }, 400);
}
const keys = oneTimePreKeys.map((k: any) => ({
keyId: k.keyId,
publicKey: b64ToBytes(k.publicKey),
}));
await store.saveOneTimePreKeys(address, keys);
const count = await store.getOneTimePreKeyCount(address);
return c.json({ ok: true, remaining: count });
});
// ─── Get Count ─────────────────────────────────────────────
app.get('/v1/keys/count/:address', async (c) => {
const address = c.req.param('address');
const count = await store.getOneTimePreKeyCount(address);
return c.json({ count });
});
// ─── Delete ────────────────────────────────────────────────
app.delete('/v1/keys/:address', async (c) => {
const address = c.req.param('address');
await store.deleteAll(address);
return c.json({ ok: true });
});
return app;
}
// ─── Base64 helpers ──────────────────────────────────────────
function bytesToB64(bytes: Uint8Array): string {
return Buffer.from(bytes).toString('base64');
}
function b64ToBytes(b64: string): Uint8Array {
return new Uint8Array(Buffer.from(b64, 'base64'));
}

View File

@@ -0,0 +1,10 @@
import { createPrekeyServer } from './index.js';
const server = createPrekeyServer();
export default {
port: Number(process.env.PORT ?? 3900),
fetch: server.fetch,
};
console.log(`Shade Prekey Server listening on port ${process.env.PORT ?? 3900}`);

View File

@@ -0,0 +1,48 @@
/**
* PrekeyStore — server-side storage interface for prekey bundles.
*
* The prekey server stores public keys only (never private keys).
*/
export interface PrekeyStore {
/** Save or update an identity for an address */
saveIdentity(
address: string,
identitySigningKey: Uint8Array,
identityDHKey: Uint8Array,
): Promise<void>;
/** Get an identity by address */
getIdentity(
address: string,
): Promise<{ identitySigningKey: Uint8Array; identityDHKey: Uint8Array } | null>;
/** Save or update a signed prekey for an address */
saveSignedPreKey(
address: string,
keyId: number,
publicKey: Uint8Array,
signature: Uint8Array,
): Promise<void>;
/** Get the current signed prekey for an address */
getSignedPreKey(
address: string,
): Promise<{ keyId: number; publicKey: Uint8Array; signature: Uint8Array } | null>;
/** Add one-time prekeys for an address */
saveOneTimePreKeys(
address: string,
keys: Array<{ keyId: number; publicKey: Uint8Array }>,
): Promise<void>;
/** Consume (pop) one one-time prekey for an address. Returns null if none left. */
consumeOneTimePreKey(
address: string,
): Promise<{ keyId: number; publicKey: Uint8Array } | null>;
/** Get remaining one-time prekey count for an address */
getOneTimePreKeyCount(address: string): Promise<number>;
/** Delete all keys for an address */
deleteAll(address: string): Promise<void>;
}

View File

@@ -0,0 +1,231 @@
import { describe, test, expect, beforeEach } from 'bun:test';
import { createPrekeyServer, MemoryPrekeyStore } from '../src/index.js';
import type { PrekeyStore } from '../src/index.js';
function b64(bytes: Uint8Array): string {
return Buffer.from(bytes).toString('base64');
}
function randBytes(n: number): Uint8Array {
const buf = new Uint8Array(n);
crypto.getRandomValues(buf);
return buf;
}
describe('Shade Prekey Server', () => {
let store: PrekeyStore;
let app: ReturnType<typeof createPrekeyServer>;
beforeEach(() => {
store = new MemoryPrekeyStore();
app = createPrekeyServer({ store });
});
function req(method: string, path: string, body?: any) {
const init: RequestInit = { method, headers: { 'Content-Type': 'application/json' } };
if (body) init.body = JSON.stringify(body);
return app.request(path, init);
}
// ─── Registration ──────────────────────────────────────────
describe('POST /v1/keys/register', () => {
test('registers identity and signed prekey', async () => {
const res = await req('POST', '/v1/keys/register', {
address: 'alice',
identitySigningKey: b64(randBytes(32)),
identityDHKey: b64(randBytes(32)),
signedPreKey: {
keyId: 1,
publicKey: b64(randBytes(32)),
signature: b64(randBytes(64)),
},
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true });
});
test('registers with one-time prekeys', async () => {
const res = await req('POST', '/v1/keys/register', {
address: 'alice',
identitySigningKey: b64(randBytes(32)),
identityDHKey: b64(randBytes(32)),
signedPreKey: {
keyId: 1,
publicKey: b64(randBytes(32)),
signature: b64(randBytes(64)),
},
oneTimePreKeys: [
{ keyId: 100, publicKey: b64(randBytes(32)) },
{ keyId: 101, publicKey: b64(randBytes(32)) },
{ keyId: 102, publicKey: b64(randBytes(32)) },
],
});
expect(res.status).toBe(200);
// Verify count
const countRes = await req('GET', '/v1/keys/count/alice');
expect((await countRes.json()).count).toBe(3);
});
test('rejects missing fields', async () => {
const res = await req('POST', '/v1/keys/register', { address: 'alice' });
expect(res.status).toBe(400);
});
});
// ─── Fetch Bundle ──────────────────────────────────────────
describe('GET /v1/keys/bundle/:address', () => {
test('returns bundle with one-time prekey', async () => {
// Register first
const sigKey = b64(randBytes(32));
const dhKey = b64(randBytes(32));
const spkPub = b64(randBytes(32));
const spkSig = b64(randBytes(64));
const otpkPub = b64(randBytes(32));
await req('POST', '/v1/keys/register', {
address: 'bob',
identitySigningKey: sigKey,
identityDHKey: dhKey,
signedPreKey: { keyId: 1, publicKey: spkPub, signature: spkSig },
oneTimePreKeys: [{ keyId: 100, publicKey: otpkPub }],
});
const res = await req('GET', '/v1/keys/bundle/bob');
expect(res.status).toBe(200);
const bundle = await res.json();
expect(bundle.identitySigningKey).toBe(sigKey);
expect(bundle.identityDHKey).toBe(dhKey);
expect(bundle.signedPreKey.keyId).toBe(1);
expect(bundle.signedPreKey.publicKey).toBe(spkPub);
expect(bundle.signedPreKey.signature).toBe(spkSig);
expect(bundle.oneTimePreKey.keyId).toBe(100);
expect(bundle.oneTimePreKey.publicKey).toBe(otpkPub);
});
test('returns bundle without one-time prekey when depleted', async () => {
await req('POST', '/v1/keys/register', {
address: 'bob',
identitySigningKey: b64(randBytes(32)),
identityDHKey: b64(randBytes(32)),
signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) },
});
const res = await req('GET', '/v1/keys/bundle/bob');
expect(res.status).toBe(200);
const bundle = await res.json();
expect(bundle.oneTimePreKey).toBeUndefined();
});
test('consumes one-time prekeys on each fetch', async () => {
await req('POST', '/v1/keys/register', {
address: 'bob',
identitySigningKey: b64(randBytes(32)),
identityDHKey: b64(randBytes(32)),
signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) },
oneTimePreKeys: [
{ keyId: 100, publicKey: b64(randBytes(32)) },
{ keyId: 101, publicKey: b64(randBytes(32)) },
],
});
// First fetch consumes key 100
const res1 = await req('GET', '/v1/keys/bundle/bob');
expect((await res1.json()).oneTimePreKey.keyId).toBe(100);
// Second fetch consumes key 101
const res2 = await req('GET', '/v1/keys/bundle/bob');
expect((await res2.json()).oneTimePreKey.keyId).toBe(101);
// Third fetch has none left
const res3 = await req('GET', '/v1/keys/bundle/bob');
expect((await res3.json()).oneTimePreKey).toBeUndefined();
// Count should be 0
const countRes = await req('GET', '/v1/keys/count/bob');
expect((await countRes.json()).count).toBe(0);
});
test('404 for unknown address', async () => {
const res = await req('GET', '/v1/keys/bundle/nobody');
expect(res.status).toBe(404);
});
});
// ─── Replenish ─────────────────────────────────────────────
describe('POST /v1/keys/replenish', () => {
test('adds more one-time prekeys', async () => {
await req('POST', '/v1/keys/register', {
address: 'bob',
identitySigningKey: b64(randBytes(32)),
identityDHKey: b64(randBytes(32)),
signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) },
oneTimePreKeys: [{ keyId: 100, publicKey: b64(randBytes(32)) }],
});
const res = await req('POST', '/v1/keys/replenish', {
address: 'bob',
oneTimePreKeys: [
{ keyId: 200, publicKey: b64(randBytes(32)) },
{ keyId: 201, publicKey: b64(randBytes(32)) },
],
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.remaining).toBe(3); // 1 original + 2 new
});
});
// ─── Delete ────────────────────────────────────────────────
describe('DELETE /v1/keys/:address', () => {
test('removes all keys for an address', async () => {
await req('POST', '/v1/keys/register', {
address: 'bob',
identitySigningKey: b64(randBytes(32)),
identityDHKey: b64(randBytes(32)),
signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) },
oneTimePreKeys: [{ keyId: 100, publicKey: b64(randBytes(32)) }],
});
const delRes = await req('DELETE', '/v1/keys/bob');
expect(delRes.status).toBe(200);
// Should be gone
const bundleRes = await req('GET', '/v1/keys/bundle/bob');
expect(bundleRes.status).toBe(404);
const countRes = await req('GET', '/v1/keys/count/bob');
expect((await countRes.json()).count).toBe(0);
});
});
// ─── Multiple Addresses ────────────────────────────────────
describe('multi-address isolation', () => {
test('different addresses are independent', async () => {
for (const addr of ['alice', 'bob', 'charlie']) {
await req('POST', '/v1/keys/register', {
address: addr,
identitySigningKey: b64(randBytes(32)),
identityDHKey: b64(randBytes(32)),
signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) },
oneTimePreKeys: [{ keyId: 1, publicKey: b64(randBytes(32)) }],
});
}
// Delete bob, others remain
await req('DELETE', '/v1/keys/bob');
expect((await req('GET', '/v1/keys/bundle/alice')).status).toBe(200);
expect((await req('GET', '/v1/keys/bundle/bob')).status).toBe(404);
expect((await req('GET', '/v1/keys/bundle/charlie')).status).toBe(200);
});
});
});

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}