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:
28
packages/shade-server/src/index.ts
Normal file
28
packages/shade-server/src/index.ts
Normal 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);
|
||||
}
|
||||
64
packages/shade-server/src/memory-store.ts
Normal file
64
packages/shade-server/src/memory-store.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
130
packages/shade-server/src/routes.ts
Normal file
130
packages/shade-server/src/routes.ts
Normal 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'));
|
||||
}
|
||||
10
packages/shade-server/src/standalone.ts
Normal file
10
packages/shade-server/src/standalone.ts
Normal 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}`);
|
||||
48
packages/shade-server/src/store.ts
Normal file
48
packages/shade-server/src/store.ts
Normal 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user