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:
2
bun.lock
2
bun.lock
@@ -40,7 +40,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@shade/core": "workspace:*",
|
||||
"hono": "^4.0.0",
|
||||
"hono": "^4.12.12",
|
||||
},
|
||||
},
|
||||
"packages/shade-transport": {
|
||||
|
||||
1
packages/shade-proto/src/index.ts
Normal file
1
packages/shade-proto/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { encodeEnvelope, decodeEnvelope, encodePreKeyMessage, encodeRatchetMessage } from './wire.js';
|
||||
172
packages/shade-proto/src/wire.ts
Normal file
172
packages/shade-proto/src/wire.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Shade Wire Format — compact binary encoding for protocol messages.
|
||||
*
|
||||
* Format: [version:1][type:1][payload...]
|
||||
*
|
||||
* Types:
|
||||
* 0x01 = PreKeyMessage
|
||||
* 0x02 = RatchetMessage
|
||||
*
|
||||
* All multi-byte integers are big-endian.
|
||||
* All byte arrays are length-prefixed (2-byte length + data).
|
||||
*/
|
||||
|
||||
import type { PreKeyMessage, RatchetMessage, ShadeEnvelope } from '@shade/core';
|
||||
|
||||
const VERSION = 0x01;
|
||||
|
||||
const TYPE_PREKEY = 0x01;
|
||||
const TYPE_RATCHET = 0x02;
|
||||
|
||||
// ─── Encode ──────────────────────────────────────────────────
|
||||
|
||||
export function encodeEnvelope(envelope: ShadeEnvelope): Uint8Array {
|
||||
if (envelope.type === 'prekey') {
|
||||
return encodePreKeyMessage(envelope.content as PreKeyMessage);
|
||||
}
|
||||
return encodeRatchetMessage(envelope.content as RatchetMessage);
|
||||
}
|
||||
|
||||
export function encodePreKeyMessage(msg: PreKeyMessage): Uint8Array {
|
||||
const ratchetBytes = encodeRatchetMessageInner(msg.message);
|
||||
const parts: Uint8Array[] = [];
|
||||
|
||||
// Header
|
||||
parts.push(new Uint8Array([VERSION, TYPE_PREKEY]));
|
||||
|
||||
// registrationId (4 bytes)
|
||||
parts.push(uint32(msg.registrationId));
|
||||
|
||||
// preKeyId (4 bytes, 0xFFFFFFFF = none)
|
||||
parts.push(uint32(msg.preKeyId ?? 0xFFFFFFFF));
|
||||
|
||||
// signedPreKeyId (4 bytes)
|
||||
parts.push(uint32(msg.signedPreKeyId));
|
||||
|
||||
// ephemeralKey (length-prefixed)
|
||||
parts.push(lpBytes(msg.ephemeralKey));
|
||||
|
||||
// identityDHKey (length-prefixed)
|
||||
parts.push(lpBytes(msg.identityDHKey));
|
||||
|
||||
// embedded ratchet message (length-prefixed)
|
||||
parts.push(lpBytes(ratchetBytes));
|
||||
|
||||
return concat(parts);
|
||||
}
|
||||
|
||||
export function encodeRatchetMessage(msg: RatchetMessage): Uint8Array {
|
||||
const parts: Uint8Array[] = [];
|
||||
parts.push(new Uint8Array([VERSION, TYPE_RATCHET]));
|
||||
parts.push(encodeRatchetMessageInner(msg));
|
||||
return concat(parts);
|
||||
}
|
||||
|
||||
function encodeRatchetMessageInner(msg: RatchetMessage): Uint8Array {
|
||||
const parts: Uint8Array[] = [];
|
||||
parts.push(lpBytes(msg.dhPublicKey));
|
||||
parts.push(uint32(msg.previousCounter));
|
||||
parts.push(uint32(msg.counter));
|
||||
parts.push(lpBytes(msg.ciphertext));
|
||||
parts.push(lpBytes(msg.nonce));
|
||||
return concat(parts);
|
||||
}
|
||||
|
||||
// ─── Decode ──────────────────────────────────────────────────
|
||||
|
||||
export function decodeEnvelope(data: Uint8Array): ShadeEnvelope {
|
||||
if (data.length < 2) throw new Error('Too short');
|
||||
const version = data[0];
|
||||
if (version !== VERSION) throw new Error(`Unknown version: ${version}`);
|
||||
|
||||
const type = data[1];
|
||||
const payload = data.slice(2);
|
||||
|
||||
if (type === TYPE_PREKEY) {
|
||||
const msg = decodePreKeyMessageInner(payload);
|
||||
return { type: 'prekey', content: msg, timestamp: 0, senderAddress: '' };
|
||||
}
|
||||
if (type === TYPE_RATCHET) {
|
||||
const msg = decodeRatchetMessageInner(payload, 0).value;
|
||||
return { type: 'ratchet', content: msg, timestamp: 0, senderAddress: '' };
|
||||
}
|
||||
throw new Error(`Unknown type: ${type}`);
|
||||
}
|
||||
|
||||
function decodePreKeyMessageInner(data: Uint8Array): PreKeyMessage {
|
||||
let offset = 0;
|
||||
|
||||
const registrationId = readUint32(data, offset); offset += 4;
|
||||
const preKeyIdRaw = readUint32(data, offset); offset += 4;
|
||||
const preKeyId = preKeyIdRaw === 0xFFFFFFFF ? undefined : preKeyIdRaw;
|
||||
const signedPreKeyId = readUint32(data, offset); offset += 4;
|
||||
|
||||
const ephemeral = readLP(data, offset); offset = ephemeral.end;
|
||||
const identityDH = readLP(data, offset); offset = identityDH.end;
|
||||
const ratchetData = readLP(data, offset); offset = ratchetData.end;
|
||||
|
||||
const ratchet = decodeRatchetMessageInner(ratchetData.value, 0);
|
||||
|
||||
return {
|
||||
registrationId,
|
||||
preKeyId,
|
||||
signedPreKeyId,
|
||||
ephemeralKey: ephemeral.value,
|
||||
identityDHKey: identityDH.value,
|
||||
message: ratchet.value,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeRatchetMessageInner(data: Uint8Array, offset: number): { value: RatchetMessage; end: number } {
|
||||
const dhPub = readLP(data, offset); offset = dhPub.end;
|
||||
const prevCounter = readUint32(data, offset); offset += 4;
|
||||
const counter = readUint32(data, offset); offset += 4;
|
||||
const ciphertext = readLP(data, offset); offset = ciphertext.end;
|
||||
const nonce = readLP(data, offset); offset = nonce.end;
|
||||
|
||||
return {
|
||||
value: {
|
||||
dhPublicKey: dhPub.value,
|
||||
previousCounter: prevCounter,
|
||||
counter,
|
||||
ciphertext: ciphertext.value,
|
||||
nonce: nonce.value,
|
||||
},
|
||||
end: offset,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
function uint32(n: number): Uint8Array {
|
||||
const buf = new Uint8Array(4);
|
||||
new DataView(buf.buffer).setUint32(0, n, false);
|
||||
return buf;
|
||||
}
|
||||
|
||||
function lpBytes(data: Uint8Array): Uint8Array {
|
||||
const len = new Uint8Array(2);
|
||||
new DataView(len.buffer).setUint16(0, data.length, false);
|
||||
return concat([len, data]);
|
||||
}
|
||||
|
||||
function readUint32(data: Uint8Array, offset: number): number {
|
||||
return new DataView(data.buffer, data.byteOffset + offset).getUint32(0, false);
|
||||
}
|
||||
|
||||
function readLP(data: Uint8Array, offset: number): { value: Uint8Array; end: number } {
|
||||
const len = new DataView(data.buffer, data.byteOffset + offset).getUint16(0, false);
|
||||
const value = data.slice(offset + 2, offset + 2 + len);
|
||||
return { value, end: offset + 2 + len };
|
||||
}
|
||||
|
||||
function concat(parts: Uint8Array[]): Uint8Array {
|
||||
const total = parts.reduce((sum, p) => sum + p.length, 0);
|
||||
const result = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const p of parts) {
|
||||
result.set(p, offset);
|
||||
offset += p.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
185
packages/shade-proto/tests/wire.test.ts
Normal file
185
packages/shade-proto/tests/wire.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { encodeEnvelope, decodeEnvelope, encodePreKeyMessage, encodeRatchetMessage } from '../src/index.js';
|
||||
import type { PreKeyMessage, RatchetMessage, ShadeEnvelope } from '@shade/core';
|
||||
|
||||
function randBytes(n: number): Uint8Array {
|
||||
const buf = new Uint8Array(n);
|
||||
crypto.getRandomValues(buf);
|
||||
return buf;
|
||||
}
|
||||
|
||||
function makeRatchetMessage(): RatchetMessage {
|
||||
return {
|
||||
dhPublicKey: randBytes(32),
|
||||
previousCounter: 42,
|
||||
counter: 7,
|
||||
ciphertext: randBytes(64),
|
||||
nonce: randBytes(12),
|
||||
};
|
||||
}
|
||||
|
||||
function makePreKeyMessage(): PreKeyMessage {
|
||||
return {
|
||||
registrationId: 12345,
|
||||
preKeyId: 100,
|
||||
signedPreKeyId: 1,
|
||||
ephemeralKey: randBytes(32),
|
||||
identityDHKey: randBytes(32),
|
||||
message: makeRatchetMessage(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('Wire Format', () => {
|
||||
// ─── RatchetMessage ────────────────────────────────────────
|
||||
|
||||
describe('RatchetMessage', () => {
|
||||
test('encode/decode roundtrip', () => {
|
||||
const msg = makeRatchetMessage();
|
||||
const envelope: ShadeEnvelope = {
|
||||
type: 'ratchet',
|
||||
content: msg,
|
||||
timestamp: Date.now(),
|
||||
senderAddress: 'alice',
|
||||
};
|
||||
|
||||
const encoded = encodeEnvelope(envelope);
|
||||
const decoded = decodeEnvelope(encoded);
|
||||
|
||||
expect(decoded.type).toBe('ratchet');
|
||||
const rm = decoded.content as RatchetMessage;
|
||||
expect(rm.dhPublicKey).toEqual(msg.dhPublicKey);
|
||||
expect(rm.previousCounter).toBe(42);
|
||||
expect(rm.counter).toBe(7);
|
||||
expect(rm.ciphertext).toEqual(msg.ciphertext);
|
||||
expect(rm.nonce).toEqual(msg.nonce);
|
||||
});
|
||||
|
||||
test('compact size (smaller than JSON)', () => {
|
||||
const msg = makeRatchetMessage();
|
||||
const envelope: ShadeEnvelope = {
|
||||
type: 'ratchet',
|
||||
content: msg,
|
||||
timestamp: 0,
|
||||
senderAddress: '',
|
||||
};
|
||||
|
||||
const binary = encodeEnvelope(envelope);
|
||||
const json = new TextEncoder().encode(JSON.stringify(envelope));
|
||||
|
||||
// Binary should be significantly smaller
|
||||
expect(binary.length).toBeLessThan(json.length);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PreKeyMessage ─────────────────────────────────────────
|
||||
|
||||
describe('PreKeyMessage', () => {
|
||||
test('encode/decode roundtrip with preKeyId', () => {
|
||||
const msg = makePreKeyMessage();
|
||||
const envelope: ShadeEnvelope = {
|
||||
type: 'prekey',
|
||||
content: msg,
|
||||
timestamp: Date.now(),
|
||||
senderAddress: 'alice',
|
||||
};
|
||||
|
||||
const encoded = encodeEnvelope(envelope);
|
||||
const decoded = decodeEnvelope(encoded);
|
||||
|
||||
expect(decoded.type).toBe('prekey');
|
||||
const pm = decoded.content as PreKeyMessage;
|
||||
expect(pm.registrationId).toBe(12345);
|
||||
expect(pm.preKeyId).toBe(100);
|
||||
expect(pm.signedPreKeyId).toBe(1);
|
||||
expect(pm.ephemeralKey).toEqual(msg.ephemeralKey);
|
||||
expect(pm.identityDHKey).toEqual(msg.identityDHKey);
|
||||
|
||||
// Nested ratchet message
|
||||
expect(pm.message.dhPublicKey).toEqual(msg.message.dhPublicKey);
|
||||
expect(pm.message.counter).toBe(msg.message.counter);
|
||||
expect(pm.message.ciphertext).toEqual(msg.message.ciphertext);
|
||||
expect(pm.message.nonce).toEqual(msg.message.nonce);
|
||||
});
|
||||
|
||||
test('encode/decode roundtrip without preKeyId', () => {
|
||||
const msg = makePreKeyMessage();
|
||||
msg.preKeyId = undefined;
|
||||
|
||||
const envelope: ShadeEnvelope = {
|
||||
type: 'prekey',
|
||||
content: msg,
|
||||
timestamp: 0,
|
||||
senderAddress: '',
|
||||
};
|
||||
|
||||
const encoded = encodeEnvelope(envelope);
|
||||
const decoded = decodeEnvelope(encoded);
|
||||
|
||||
const pm = decoded.content as PreKeyMessage;
|
||||
expect(pm.preKeyId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Edge Cases ────────────────────────────────────────────
|
||||
|
||||
describe('edge cases', () => {
|
||||
test('empty ciphertext', () => {
|
||||
const msg: RatchetMessage = {
|
||||
dhPublicKey: randBytes(32),
|
||||
previousCounter: 0,
|
||||
counter: 0,
|
||||
ciphertext: new Uint8Array(0),
|
||||
nonce: randBytes(12),
|
||||
};
|
||||
|
||||
const encoded = encodeRatchetMessage(msg);
|
||||
const decoded = decodeEnvelope(encoded);
|
||||
expect((decoded.content as RatchetMessage).ciphertext.length).toBe(0);
|
||||
});
|
||||
|
||||
test('large ciphertext (10KB)', () => {
|
||||
const msg: RatchetMessage = {
|
||||
dhPublicKey: randBytes(32),
|
||||
previousCounter: 0,
|
||||
counter: 0,
|
||||
ciphertext: randBytes(10240),
|
||||
nonce: randBytes(12),
|
||||
};
|
||||
|
||||
const encoded = encodeRatchetMessage(msg);
|
||||
const decoded = decodeEnvelope(encoded);
|
||||
expect((decoded.content as RatchetMessage).ciphertext).toEqual(msg.ciphertext);
|
||||
});
|
||||
|
||||
test('max counter values', () => {
|
||||
const msg: RatchetMessage = {
|
||||
dhPublicKey: randBytes(32),
|
||||
previousCounter: 0xFFFFFFFF - 1,
|
||||
counter: 0xFFFFFFFF - 1,
|
||||
ciphertext: randBytes(16),
|
||||
nonce: randBytes(12),
|
||||
};
|
||||
|
||||
const encoded = encodeRatchetMessage(msg);
|
||||
const decoded = decodeEnvelope(encoded);
|
||||
const rm = decoded.content as RatchetMessage;
|
||||
expect(rm.previousCounter).toBe(0xFFFFFFFF - 1);
|
||||
expect(rm.counter).toBe(0xFFFFFFFF - 1);
|
||||
});
|
||||
|
||||
test('rejects unknown version', () => {
|
||||
const data = new Uint8Array([0xFF, 0x01]);
|
||||
expect(() => decodeEnvelope(data)).toThrow('Unknown version');
|
||||
});
|
||||
|
||||
test('rejects unknown type', () => {
|
||||
const data = new Uint8Array([0x01, 0xFF]);
|
||||
expect(() => decodeEnvelope(data)).toThrow('Unknown type');
|
||||
});
|
||||
|
||||
test('rejects too-short data', () => {
|
||||
expect(() => decodeEnvelope(new Uint8Array([0x01]))).toThrow('Too short');
|
||||
expect(() => decodeEnvelope(new Uint8Array([]))).toThrow('Too short');
|
||||
});
|
||||
});
|
||||
});
|
||||
8
packages/shade-proto/tsconfig.json
Normal file
8
packages/shade-proto/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
11
packages/shade-server/Dockerfile
Normal file
11
packages/shade-server/Dockerfile
Normal 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"]
|
||||
@@ -6,6 +6,6 @@
|
||||
"types": "src/index.ts",
|
||||
"dependencies": {
|
||||
"@shade/core": "workspace:*",
|
||||
"hono": "^4.0.0"
|
||||
"hono": "^4.12.12"
|
||||
}
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
231
packages/shade-server/tests/server.test.ts
Normal file
231
packages/shade-server/tests/server.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
8
packages/shade-server/tsconfig.json
Normal file
8
packages/shade-server/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user