feat(hardening): M-Hard 1-5 — crypto, auth, rate limit, fingerprints, rotation
M-Hard 1: Cryptographic Hardening - constantTimeEqual, zeroize, randomUint32 on CryptoProvider - Fix Math.random() → crypto.randomUint32() for registrationId - Zero message keys and chain keys after use in ratchet.ts - Constant-time trust comparison in MemoryStorage + SQLiteStorage - Timing variance test catches early-exit regressions M-Hard 2: Self-Authenticated Prekey Server - Ed25519 signature verification on all write routes - signPayload/verifyPayload with canonical JSON, ±5 min replay window - Address validation (NFKC, alphanumeric + :_-.) - Global ShadeError → HTTP status mapping - ShadeFetchTransport signs requests when signingPrivateKey provided - Anonymous bundle fetches still allowed (read-only) M-Hard 3: Rate Limiting + DoS Protection - Token bucket rate limiter with pluggable store - Per-route limits: register 5/h/IP, fetch 60/min/IP, replenish 10/min/id - 64KB body size limit on POST - Retry-After header on 429 responses M-Hard 4: Auto-replenish + Fingerprints + Session Reset - Safety numbers (12 groups × 5 digits, Signal-style) - ensurePreKeyStock, resetSession, acceptIdentityChange - verifyRemoteIdentity for out-of-band comparison M-Hard 5: Identity Rotation with Grace Period - rotateIdentity archives old identity, generates fresh signed prekey - RetiredIdentity storage with addRetired/getRetired/pruneRetired - 7-day default grace period for decrypting old sessions - pruneExpiredIdentities for cleanup M-Hard 8: Error Hierarchy - New error types: Network, Storage, Validation, Timeout, RateLimit, Configuration, Unauthorized, Replay, IdentityRotation - All errors have stable SHADE_* codes - errorToHttpStatus for consistent HTTP mapping - toJSON() for network serialization 188 tests passing, zero failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,5 +7,8 @@
|
||||
"dependencies": {
|
||||
"@shade/core": "workspace:*",
|
||||
"hono": "^4.12.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@shade/crypto-web": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
133
packages/shade-server/src/auth.ts
Normal file
133
packages/shade-server/src/auth.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { CryptoProvider } from '@shade/core';
|
||||
import { fromBase64, UnauthorizedError, ValidationError, ReplayError } from '@shade/core';
|
||||
|
||||
/**
|
||||
* Self-authenticated prekey server.
|
||||
*
|
||||
* Each write request must include a signature over a canonical
|
||||
* representation of the body (excluding the signature field itself)
|
||||
* using the identity's Ed25519 signing key.
|
||||
*
|
||||
* On register: the signing key is extracted from the body itself
|
||||
* (TOFU — first registration establishes the identity).
|
||||
*
|
||||
* On replenish/delete: the signing key is looked up from the stored
|
||||
* identity record for the address.
|
||||
*/
|
||||
|
||||
/** Maximum age of a signed request, in milliseconds */
|
||||
const MAX_SIGNATURE_AGE_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/** Maximum clock skew tolerated (future timestamps) */
|
||||
const MAX_FUTURE_SKEW_MS = 1 * 60 * 1000; // 1 minute
|
||||
|
||||
interface SignedPayload {
|
||||
signedAt: number;
|
||||
signature: string; // base64
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize a payload for signing by omitting the `signature` field
|
||||
* and using deterministic JSON key ordering.
|
||||
*
|
||||
* Both signer and verifier must produce identical bytes.
|
||||
*/
|
||||
export function canonicalizePayload(payload: SignedPayload): Uint8Array {
|
||||
const { signature, ...rest } = payload;
|
||||
// Sort keys to be deterministic
|
||||
const sorted = Object.keys(rest)
|
||||
.sort()
|
||||
.reduce<Record<string, unknown>>((acc, key) => {
|
||||
acc[key] = (rest as Record<string, unknown>)[key];
|
||||
return acc;
|
||||
}, {});
|
||||
return new TextEncoder().encode(JSON.stringify(sorted));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a payload with an Ed25519 signing private key.
|
||||
* Returns the payload with `signature` and `signedAt` set.
|
||||
*/
|
||||
export async function signPayload<T extends Record<string, unknown>>(
|
||||
crypto: CryptoProvider,
|
||||
signingPrivateKey: Uint8Array,
|
||||
payload: T,
|
||||
): Promise<T & { signedAt: number; signature: string }> {
|
||||
const signedAt = Date.now();
|
||||
const bytes = canonicalizePayload({ ...payload, signedAt, signature: '' });
|
||||
const sig = await crypto.sign(signingPrivateKey, bytes);
|
||||
return {
|
||||
...payload,
|
||||
signedAt,
|
||||
signature: Buffer.from(sig).toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a signed payload against a given signing public key.
|
||||
*
|
||||
* Throws:
|
||||
* - ValidationError if signedAt or signature fields are missing/malformed
|
||||
* - ReplayError if signedAt is outside the allowed window
|
||||
* - UnauthorizedError if the signature does not verify
|
||||
*/
|
||||
export async function verifyPayload(
|
||||
crypto: CryptoProvider,
|
||||
signingPublicKey: Uint8Array,
|
||||
payload: unknown,
|
||||
): Promise<void> {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
throw new ValidationError('Payload must be an object');
|
||||
}
|
||||
const p = payload as SignedPayload;
|
||||
|
||||
if (typeof p.signedAt !== 'number') {
|
||||
throw new ValidationError('Missing or invalid signedAt', 'signedAt');
|
||||
}
|
||||
if (typeof p.signature !== 'string') {
|
||||
throw new ValidationError('Missing or invalid signature', 'signature');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const age = now - p.signedAt;
|
||||
if (age > MAX_SIGNATURE_AGE_MS) {
|
||||
throw new ReplayError(`Signature too old: ${age}ms`);
|
||||
}
|
||||
if (age < -MAX_FUTURE_SKEW_MS) {
|
||||
throw new ReplayError(`Signature in the future: ${-age}ms ahead`);
|
||||
}
|
||||
|
||||
let sigBytes: Uint8Array;
|
||||
try {
|
||||
sigBytes = fromBase64(p.signature);
|
||||
} catch {
|
||||
throw new ValidationError('Signature is not valid base64', 'signature');
|
||||
}
|
||||
|
||||
const canonical = canonicalizePayload(p);
|
||||
const valid = await crypto.verify(signingPublicKey, canonical, sigBytes);
|
||||
if (!valid) {
|
||||
throw new UnauthorizedError('Invalid signature');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Address validation ─────────────────────────────────────
|
||||
|
||||
const ADDRESS_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9:_\-.]{0,255}$/;
|
||||
|
||||
/**
|
||||
* Validate a peer address string.
|
||||
* Allows alphanumeric, :, _, -, . with max 256 chars and must start with alphanumeric.
|
||||
* Rejects path traversal, unicode tricks, and empty strings.
|
||||
*/
|
||||
export function validateAddress(address: unknown): string {
|
||||
if (typeof address !== 'string') {
|
||||
throw new ValidationError('Address must be a string', 'address');
|
||||
}
|
||||
const normalized = address.normalize('NFKC');
|
||||
if (!ADDRESS_REGEX.test(normalized)) {
|
||||
throw new ValidationError('Invalid address format', 'address');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Hono } from 'hono';
|
||||
import type { CryptoProvider } from '@shade/core';
|
||||
import { createPrekeyRoutes } from './routes.js';
|
||||
import { MemoryPrekeyStore } from './memory-store.js';
|
||||
import type { PrekeyStore } from './store.js';
|
||||
@@ -6,23 +7,30 @@ import type { PrekeyStore } from './store.js';
|
||||
export { createPrekeyRoutes } from './routes.js';
|
||||
export { MemoryPrekeyStore } from './memory-store.js';
|
||||
export type { PrekeyStore } from './store.js';
|
||||
export { verifyPayload, signPayload, canonicalizePayload, validateAddress } from './auth.js';
|
||||
|
||||
/**
|
||||
* Create a standalone Shade Prekey Server.
|
||||
*
|
||||
* Can be used standalone (Docker) or embedded in another Hono app.
|
||||
* Requires a CryptoProvider for signature verification on write routes.
|
||||
*
|
||||
* Standalone:
|
||||
* const server = createPrekeyServer();
|
||||
* const crypto = new SubtleCryptoProvider();
|
||||
* const server = createPrekeyServer({ crypto });
|
||||
* export default { port: 3900, fetch: server.fetch };
|
||||
*
|
||||
* Embedded:
|
||||
* Embedded in another Hono app:
|
||||
* const app = new Hono();
|
||||
* app.route('/shade', createPrekeyServer());
|
||||
* app.route('/shade', createPrekeyServer({ crypto }));
|
||||
*/
|
||||
export function createPrekeyServer(options?: {
|
||||
export function createPrekeyServer(options: {
|
||||
crypto: CryptoProvider;
|
||||
store?: PrekeyStore;
|
||||
disableRateLimit?: boolean;
|
||||
}): Hono {
|
||||
const store = options?.store ?? new MemoryPrekeyStore();
|
||||
return createPrekeyRoutes(store);
|
||||
const store = options.store ?? new MemoryPrekeyStore();
|
||||
return createPrekeyRoutes(store, options.crypto, { disableRateLimit: options.disableRateLimit });
|
||||
}
|
||||
|
||||
export { RateLimiter, MemoryRateLimitStore } from './rate-limit.js';
|
||||
export type { RateLimitStore, RateLimitConfig } from './rate-limit.js';
|
||||
|
||||
103
packages/shade-server/src/rate-limit.ts
Normal file
103
packages/shade-server/src/rate-limit.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { RateLimitError } from '@shade/core';
|
||||
|
||||
/**
|
||||
* Simple token-bucket rate limiter with pluggable storage.
|
||||
*
|
||||
* Default storage is in-memory (Map). For distributed deployments,
|
||||
* swap in a Redis-backed RateLimitStore implementation.
|
||||
*/
|
||||
|
||||
export interface RateLimitStore {
|
||||
/** Get the current token count and last refill time for a key */
|
||||
get(key: string): Promise<{ tokens: number; lastRefill: number } | null>;
|
||||
/** Store the current token count and last refill time */
|
||||
set(key: string, tokens: number, lastRefill: number): Promise<void>;
|
||||
}
|
||||
|
||||
export class MemoryRateLimitStore implements RateLimitStore {
|
||||
private entries = new Map<string, { tokens: number; lastRefill: number }>();
|
||||
|
||||
async get(key: string) {
|
||||
return this.entries.get(key) ?? null;
|
||||
}
|
||||
|
||||
async set(key: string, tokens: number, lastRefill: number) {
|
||||
this.entries.set(key, { tokens, lastRefill });
|
||||
}
|
||||
|
||||
/** Periodic cleanup: drop entries older than `maxAge` ms */
|
||||
cleanup(maxAge: number) {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.entries) {
|
||||
if (now - entry.lastRefill > maxAge) {
|
||||
this.entries.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface RateLimitConfig {
|
||||
/** Maximum tokens in the bucket (burst capacity) */
|
||||
capacity: number;
|
||||
/** Tokens added per second */
|
||||
refillPerSecond: number;
|
||||
}
|
||||
|
||||
export class RateLimiter {
|
||||
constructor(
|
||||
private readonly store: RateLimitStore,
|
||||
private readonly config: RateLimitConfig,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Attempt to consume one token for the given key.
|
||||
* Throws RateLimitError if no tokens available.
|
||||
*/
|
||||
async consume(key: string, tokens = 1): Promise<void> {
|
||||
const now = Date.now();
|
||||
const entry = await this.store.get(key);
|
||||
|
||||
let currentTokens: number;
|
||||
if (!entry) {
|
||||
currentTokens = this.config.capacity;
|
||||
} else {
|
||||
const elapsed = (now - entry.lastRefill) / 1000;
|
||||
const refilled = elapsed * this.config.refillPerSecond;
|
||||
currentTokens = Math.min(this.config.capacity, entry.tokens + refilled);
|
||||
}
|
||||
|
||||
if (currentTokens < tokens) {
|
||||
const needed = tokens - currentTokens;
|
||||
const retryAfter = Math.ceil(needed / this.config.refillPerSecond);
|
||||
throw new RateLimitError(`Rate limit exceeded for ${key}`, retryAfter);
|
||||
}
|
||||
|
||||
await this.store.set(key, currentTokens - tokens, now);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Preset configurations ──────────────────────────────────
|
||||
|
||||
/** Register: 5 per hour (prevent identity flooding) */
|
||||
export const REGISTER_LIMIT: RateLimitConfig = {
|
||||
capacity: 5,
|
||||
refillPerSecond: 5 / 3600, // 5 per hour
|
||||
};
|
||||
|
||||
/** Fetch bundle: 60 per minute (anonymous, high limit) */
|
||||
export const FETCH_LIMIT: RateLimitConfig = {
|
||||
capacity: 60,
|
||||
refillPerSecond: 1,
|
||||
};
|
||||
|
||||
/** Replenish: 10 per minute per identity */
|
||||
export const REPLENISH_LIMIT: RateLimitConfig = {
|
||||
capacity: 10,
|
||||
refillPerSecond: 10 / 60,
|
||||
};
|
||||
|
||||
/** Delete: 5 per hour per identity */
|
||||
export const DELETE_LIMIT: RateLimitConfig = {
|
||||
capacity: 5,
|
||||
refillPerSecond: 5 / 3600,
|
||||
};
|
||||
@@ -1,35 +1,93 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { CryptoProvider } from '@shade/core';
|
||||
import { fromBase64, errorToHttpStatus, ShadeError, ValidationError } from '@shade/core';
|
||||
import type { PrekeyStore } from './store.js';
|
||||
import { verifyPayload, validateAddress } from './auth.js';
|
||||
import { RateLimiter, MemoryRateLimitStore, REGISTER_LIMIT, FETCH_LIMIT, REPLENISH_LIMIT, DELETE_LIMIT } from './rate-limit.js';
|
||||
|
||||
/** Max POST body size in bytes (64KB) */
|
||||
const MAX_BODY_SIZE = 64 * 1024;
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* POST /v1/keys/register — Register identity + upload prekey bundle (SIGNED)
|
||||
* GET /v1/keys/bundle/:address — Fetch a prekey bundle (anonymous)
|
||||
* POST /v1/keys/replenish — Upload additional one-time prekeys (SIGNED)
|
||||
* GET /v1/keys/count/:address — Get remaining one-time prekey count (anonymous)
|
||||
* DELETE /v1/keys/:address — Unregister (SIGNED)
|
||||
*
|
||||
* Write routes require a valid Ed25519 signature. See auth.ts.
|
||||
*/
|
||||
export function createPrekeyRoutes(store: PrekeyStore): Hono {
|
||||
export interface PrekeyRoutesOptions {
|
||||
/** Disable rate limiting (for tests). Default: enabled. */
|
||||
disableRateLimit?: boolean;
|
||||
}
|
||||
|
||||
export function createPrekeyRoutes(
|
||||
store: PrekeyStore,
|
||||
crypto: CryptoProvider,
|
||||
options: PrekeyRoutesOptions = {},
|
||||
): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
// Rate limiters (one per route, per IP or per identity)
|
||||
const rlStore = new MemoryRateLimitStore();
|
||||
const registerRL = new RateLimiter(rlStore, REGISTER_LIMIT);
|
||||
const fetchRL = new RateLimiter(rlStore, FETCH_LIMIT);
|
||||
const replenishRL = new RateLimiter(rlStore, REPLENISH_LIMIT);
|
||||
const deleteRL = new RateLimiter(rlStore, DELETE_LIMIT);
|
||||
const rateLimitEnabled = !options.disableRateLimit;
|
||||
|
||||
// Helper: extract client IP from request headers
|
||||
const getClientIp = (c: any): string => {
|
||||
return (
|
||||
c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ??
|
||||
c.req.header('x-real-ip') ??
|
||||
'unknown'
|
||||
);
|
||||
};
|
||||
|
||||
// Global error handler — maps ShadeError to HTTP status
|
||||
app.onError((err, c) => {
|
||||
if (err instanceof ShadeError) {
|
||||
const status = errorToHttpStatus(err);
|
||||
const body: any = err.toJSON();
|
||||
if ((err as any).retryAfterSeconds) {
|
||||
c.header('Retry-After', String((err as any).retryAfterSeconds));
|
||||
}
|
||||
return c.json(body, status as any);
|
||||
}
|
||||
console.error('[Shade] Unhandled error:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
});
|
||||
|
||||
// ─── Register ──────────────────────────────────────────────
|
||||
app.post('/v1/keys/register', async (c) => {
|
||||
const body = await c.req.json();
|
||||
if (rateLimitEnabled) await registerRL.consume(`register:${getClientIp(c)}`);
|
||||
|
||||
const rawBody = await c.req.text();
|
||||
if (rawBody.length > MAX_BODY_SIZE) {
|
||||
throw new ValidationError(`Request body too large (max ${MAX_BODY_SIZE} bytes)`);
|
||||
}
|
||||
const body = JSON.parse(rawBody);
|
||||
const { address, identitySigningKey, identityDHKey, signedPreKey, oneTimePreKeys } = body;
|
||||
|
||||
if (!address || !identitySigningKey || !identityDHKey || !signedPreKey) {
|
||||
return c.json({ error: 'Missing required fields' }, 400);
|
||||
const addr = validateAddress(address);
|
||||
if (!identitySigningKey || !identityDHKey || !signedPreKey) {
|
||||
throw new ValidationError('Missing required fields');
|
||||
}
|
||||
|
||||
// Decode base64 keys
|
||||
const signingKey = b64ToBytes(identitySigningKey);
|
||||
const dhKey = b64ToBytes(identityDHKey);
|
||||
|
||||
await store.saveIdentity(address, signingKey, dhKey);
|
||||
// Verify signature against the identity's own signing key (TOFU)
|
||||
await verifyPayload(crypto, signingKey, body);
|
||||
|
||||
await store.saveIdentity(addr, signingKey, dhKey);
|
||||
await store.saveSignedPreKey(
|
||||
address,
|
||||
addr,
|
||||
signedPreKey.keyId,
|
||||
b64ToBytes(signedPreKey.publicKey),
|
||||
b64ToBytes(signedPreKey.signature),
|
||||
@@ -40,27 +98,27 @@ export function createPrekeyRoutes(store: PrekeyStore): Hono {
|
||||
keyId: k.keyId,
|
||||
publicKey: b64ToBytes(k.publicKey),
|
||||
}));
|
||||
await store.saveOneTimePreKeys(address, keys);
|
||||
await store.saveOneTimePreKeys(addr, keys);
|
||||
}
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Fetch Bundle ──────────────────────────────────────────
|
||||
// ─── Fetch Bundle (anonymous) ──────────────────────────────
|
||||
app.get('/v1/keys/bundle/:address', async (c) => {
|
||||
const address = c.req.param('address');
|
||||
if (rateLimitEnabled) await fetchRL.consume(`fetch:${getClientIp(c)}`);
|
||||
const address = validateAddress(c.req.param('address'));
|
||||
|
||||
const identity = await store.getIdentity(address);
|
||||
if (!identity) {
|
||||
return c.json({ error: 'Address not found' }, 404);
|
||||
return c.json({ error: 'Address not found', code: 'SHADE_NOT_FOUND' }, 404);
|
||||
}
|
||||
|
||||
const signedPreKey = await store.getSignedPreKey(address);
|
||||
if (!signedPreKey) {
|
||||
return c.json({ error: 'No signed prekey' }, 404);
|
||||
return c.json({ error: 'No signed prekey', code: 'SHADE_NOT_FOUND' }, 404);
|
||||
}
|
||||
|
||||
// Consume one one-time prekey (if available)
|
||||
const oneTimePreKey = await store.consumeOneTimePreKey(address);
|
||||
|
||||
const bundle: any = {
|
||||
@@ -83,35 +141,59 @@ export function createPrekeyRoutes(store: PrekeyStore): Hono {
|
||||
return c.json(bundle);
|
||||
});
|
||||
|
||||
// ─── Replenish One-Time Prekeys ────────────────────────────
|
||||
// ─── Replenish One-Time Prekeys (signed) ───────────────────
|
||||
app.post('/v1/keys/replenish', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const rawBody = await c.req.text();
|
||||
if (rawBody.length > MAX_BODY_SIZE) {
|
||||
throw new ValidationError(`Request body too large (max ${MAX_BODY_SIZE} bytes)`);
|
||||
}
|
||||
const body = JSON.parse(rawBody);
|
||||
const { address, oneTimePreKeys } = body;
|
||||
|
||||
if (!address || !oneTimePreKeys || !Array.isArray(oneTimePreKeys)) {
|
||||
return c.json({ error: 'Missing address or oneTimePreKeys' }, 400);
|
||||
const addr = validateAddress(address);
|
||||
if (rateLimitEnabled) await replenishRL.consume(`replenish:${addr}`);
|
||||
if (!oneTimePreKeys || !Array.isArray(oneTimePreKeys)) {
|
||||
throw new ValidationError('Missing oneTimePreKeys');
|
||||
}
|
||||
|
||||
// Look up the stored identity and verify the signature against it
|
||||
const identity = await store.getIdentity(addr);
|
||||
if (!identity) {
|
||||
return c.json({ error: 'Address not registered', code: 'SHADE_NOT_FOUND' }, 404);
|
||||
}
|
||||
await verifyPayload(crypto, identity.identitySigningKey, body);
|
||||
|
||||
const keys = oneTimePreKeys.map((k: any) => ({
|
||||
keyId: k.keyId,
|
||||
publicKey: b64ToBytes(k.publicKey),
|
||||
}));
|
||||
await store.saveOneTimePreKeys(address, keys);
|
||||
await store.saveOneTimePreKeys(addr, keys);
|
||||
|
||||
const count = await store.getOneTimePreKeyCount(address);
|
||||
const count = await store.getOneTimePreKeyCount(addr);
|
||||
return c.json({ ok: true, remaining: count });
|
||||
});
|
||||
|
||||
// ─── Get Count ─────────────────────────────────────────────
|
||||
// ─── Get Count (anonymous) ─────────────────────────────────
|
||||
app.get('/v1/keys/count/:address', async (c) => {
|
||||
const address = c.req.param('address');
|
||||
const address = validateAddress(c.req.param('address'));
|
||||
const count = await store.getOneTimePreKeyCount(address);
|
||||
return c.json({ count });
|
||||
});
|
||||
|
||||
// ─── Delete ────────────────────────────────────────────────
|
||||
// ─── Delete (signed) ───────────────────────────────────────
|
||||
app.delete('/v1/keys/:address', async (c) => {
|
||||
const address = c.req.param('address');
|
||||
const address = validateAddress(c.req.param('address'));
|
||||
if (rateLimitEnabled) await deleteRL.consume(`delete:${address}`);
|
||||
const body = await c.req.json();
|
||||
|
||||
// Look up the stored identity and verify the signature
|
||||
const identity = await store.getIdentity(address);
|
||||
if (!identity) {
|
||||
return c.json({ error: 'Address not registered', code: 'SHADE_NOT_FOUND' }, 404);
|
||||
}
|
||||
// Include address in the signed payload
|
||||
await verifyPayload(crypto, identity.identitySigningKey, { ...body, address });
|
||||
|
||||
await store.deleteAll(address);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import { createPrekeyRoutes } from './routes.js';
|
||||
import type { PrekeyStore } from './store.js';
|
||||
|
||||
@@ -13,8 +14,9 @@ async function createStore(): Promise<PrekeyStore> {
|
||||
return new MemoryPrekeyStore();
|
||||
}
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
const store = await createStore();
|
||||
const server = createPrekeyRoutes(store);
|
||||
const server = createPrekeyRoutes(store, crypto);
|
||||
const port = Number(process.env.PORT ?? 3900);
|
||||
|
||||
export default { port, fetch: server.fetch };
|
||||
|
||||
149
packages/shade-server/tests/rate-limit.test.ts
Normal file
149
packages/shade-server/tests/rate-limit.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { RateLimiter, MemoryRateLimitStore, createPrekeyServer, MemoryPrekeyStore, signPayload } from '../src/index.js';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import { generateIdentityKeyPair, RateLimitError } from '@shade/core';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
|
||||
describe('RateLimiter', () => {
|
||||
test('allows requests up to capacity', async () => {
|
||||
const store = new MemoryRateLimitStore();
|
||||
const rl = new RateLimiter(store, { capacity: 5, refillPerSecond: 1 });
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await rl.consume('user:1');
|
||||
}
|
||||
});
|
||||
|
||||
test('throws RateLimitError when exhausted', async () => {
|
||||
const store = new MemoryRateLimitStore();
|
||||
const rl = new RateLimiter(store, { capacity: 3, refillPerSecond: 0.1 });
|
||||
|
||||
for (let i = 0; i < 3; i++) await rl.consume('user:1');
|
||||
expect(rl.consume('user:1')).rejects.toThrow(RateLimitError);
|
||||
});
|
||||
|
||||
test('different keys have independent limits', async () => {
|
||||
const store = new MemoryRateLimitStore();
|
||||
const rl = new RateLimiter(store, { capacity: 2, refillPerSecond: 0.1 });
|
||||
|
||||
await rl.consume('user:1');
|
||||
await rl.consume('user:1');
|
||||
// user:1 exhausted
|
||||
expect(rl.consume('user:1')).rejects.toThrow(RateLimitError);
|
||||
|
||||
// user:2 still has full capacity
|
||||
await rl.consume('user:2');
|
||||
await rl.consume('user:2');
|
||||
});
|
||||
|
||||
test('refills over time', async () => {
|
||||
const store = new MemoryRateLimitStore();
|
||||
const rl = new RateLimiter(store, { capacity: 2, refillPerSecond: 100 }); // fast refill for test
|
||||
|
||||
await rl.consume('user:1');
|
||||
await rl.consume('user:1');
|
||||
|
||||
// Wait a bit for refill (100 tokens/sec → 2 tokens in 20ms)
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
// Should be refilled
|
||||
await rl.consume('user:1');
|
||||
});
|
||||
|
||||
test('RateLimitError has retryAfterSeconds', async () => {
|
||||
const store = new MemoryRateLimitStore();
|
||||
const rl = new RateLimiter(store, { capacity: 1, refillPerSecond: 0.5 });
|
||||
|
||||
await rl.consume('user:1');
|
||||
try {
|
||||
await rl.consume('user:1');
|
||||
expect.unreachable();
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(RateLimitError);
|
||||
expect((err as RateLimitError).retryAfterSeconds).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate limiting integration with routes', () => {
|
||||
test('register endpoint rate-limits per IP', async () => {
|
||||
const app = createPrekeyServer({ crypto, store: new MemoryPrekeyStore() });
|
||||
|
||||
async function doRegister(addressSuffix: number) {
|
||||
const identity = await generateIdentityKeyPair(crypto);
|
||||
const body: any = {
|
||||
address: `user${addressSuffix}`,
|
||||
identitySigningKey: Buffer.from(identity.signingPublicKey).toString('base64'),
|
||||
identityDHKey: Buffer.from(identity.dhPublicKey).toString('base64'),
|
||||
signedPreKey: {
|
||||
keyId: 1,
|
||||
publicKey: Buffer.from(crypto.randomBytes(32)).toString('base64'),
|
||||
signature: Buffer.from(crypto.randomBytes(64)).toString('base64'),
|
||||
},
|
||||
};
|
||||
const signed = await signPayload(crypto, identity.signingPrivateKey, body);
|
||||
return app.request('/v1/keys/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '203.0.113.1' },
|
||||
body: JSON.stringify(signed),
|
||||
});
|
||||
}
|
||||
|
||||
// Register limit is 5/hour, so after 5 successful, the 6th should fail
|
||||
const results: number[] = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const res = await doRegister(i);
|
||||
results.push(res.status);
|
||||
}
|
||||
|
||||
// First 5 should succeed (200), rest should be rate-limited (429)
|
||||
expect(results.filter((s) => s === 200).length).toBeGreaterThanOrEqual(5);
|
||||
expect(results.filter((s) => s === 429).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('rate limit returns Retry-After header', async () => {
|
||||
const app = createPrekeyServer({ crypto, store: new MemoryPrekeyStore() });
|
||||
|
||||
// Burn through the delete limit (5/hour)
|
||||
const identity = await generateIdentityKeyPair(crypto);
|
||||
|
||||
// First register
|
||||
const regBody: any = {
|
||||
address: 'ratelimit',
|
||||
identitySigningKey: Buffer.from(identity.signingPublicKey).toString('base64'),
|
||||
identityDHKey: Buffer.from(identity.dhPublicKey).toString('base64'),
|
||||
signedPreKey: {
|
||||
keyId: 1,
|
||||
publicKey: Buffer.from(crypto.randomBytes(32)).toString('base64'),
|
||||
signature: Buffer.from(crypto.randomBytes(64)).toString('base64'),
|
||||
},
|
||||
};
|
||||
const signedReg = await signPayload(crypto, identity.signingPrivateKey, regBody);
|
||||
await app.request('/v1/keys/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '203.0.113.2' },
|
||||
body: JSON.stringify(signedReg),
|
||||
});
|
||||
|
||||
// Burn through the delete limit
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const delBody = await signPayload(crypto, identity.signingPrivateKey, { address: 'ratelimit' });
|
||||
await app.request('/v1/keys/ratelimit', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(delBody),
|
||||
});
|
||||
}
|
||||
|
||||
// 6th should be rate-limited
|
||||
const delBody = await signPayload(crypto, identity.signingPrivateKey, { address: 'ratelimit' });
|
||||
const res = await app.request('/v1/keys/ratelimit', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(delBody),
|
||||
});
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.headers.get('Retry-After')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import { createPrekeyServer, MemoryPrekeyStore } from '../src/index.js';
|
||||
import { createPrekeyServer, MemoryPrekeyStore, signPayload } from '../src/index.js';
|
||||
import type { PrekeyStore } from '../src/index.js';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import { generateIdentityKeyPair } from '@shade/core';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
|
||||
function b64(bytes: Uint8Array): string {
|
||||
return Buffer.from(bytes).toString('base64');
|
||||
@@ -8,167 +12,164 @@ function b64(bytes: Uint8Array): string {
|
||||
|
||||
function randBytes(n: number): Uint8Array {
|
||||
const buf = new Uint8Array(n);
|
||||
crypto.getRandomValues(buf);
|
||||
globalThis.crypto.getRandomValues(buf);
|
||||
return buf;
|
||||
}
|
||||
|
||||
async function makeIdentity() {
|
||||
return generateIdentityKeyPair(crypto);
|
||||
}
|
||||
|
||||
describe('Shade Prekey Server', () => {
|
||||
let store: PrekeyStore;
|
||||
let app: ReturnType<typeof createPrekeyServer>;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new MemoryPrekeyStore();
|
||||
app = createPrekeyServer({ store });
|
||||
app = createPrekeyServer({ crypto, store, disableRateLimit: true });
|
||||
});
|
||||
|
||||
function req(method: string, path: string, body?: any) {
|
||||
const init: RequestInit = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
if (body) init.body = JSON.stringify(body);
|
||||
if (body !== undefined) init.body = JSON.stringify(body);
|
||||
return app.request(path, init);
|
||||
}
|
||||
|
||||
/** Helper: build a signed registration body for a given identity */
|
||||
async function signedRegisterBody(identity: Awaited<ReturnType<typeof makeIdentity>>, address: string, withOTPKs = true) {
|
||||
const body: any = {
|
||||
address,
|
||||
identitySigningKey: b64(identity.signingPublicKey),
|
||||
identityDHKey: b64(identity.dhPublicKey),
|
||||
signedPreKey: {
|
||||
keyId: 1,
|
||||
publicKey: b64(randBytes(32)),
|
||||
signature: b64(randBytes(64)),
|
||||
},
|
||||
};
|
||||
if (withOTPKs) {
|
||||
body.oneTimePreKeys = [
|
||||
{ keyId: 100, publicKey: b64(randBytes(32)) },
|
||||
{ keyId: 101, publicKey: b64(randBytes(32)) },
|
||||
];
|
||||
}
|
||||
return signPayload(crypto, identity.signingPrivateKey, body);
|
||||
}
|
||||
|
||||
// ─── 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)),
|
||||
},
|
||||
});
|
||||
test('accepts valid signed registration', async () => {
|
||||
const alice = await makeIdentity();
|
||||
const body = await signedRegisterBody(alice, 'alice');
|
||||
const res = await req('POST', '/v1/keys/register', body);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
test('registers with one-time prekeys', async () => {
|
||||
test('rejects unsigned registration', async () => {
|
||||
const alice = await makeIdentity();
|
||||
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)) },
|
||||
],
|
||||
identitySigningKey: b64(alice.signingPublicKey),
|
||||
identityDHKey: b64(alice.dhPublicKey),
|
||||
signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) },
|
||||
});
|
||||
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' });
|
||||
// Missing signature/signedAt → validation error
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects registration with wrong signing key', async () => {
|
||||
const alice = await makeIdentity();
|
||||
const bob = await makeIdentity();
|
||||
// Sign with bob's key but claim alice's public key
|
||||
const body: any = {
|
||||
address: 'alice',
|
||||
identitySigningKey: b64(alice.signingPublicKey), // mismatch
|
||||
identityDHKey: b64(alice.dhPublicKey),
|
||||
signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) },
|
||||
};
|
||||
const signed = await signPayload(crypto, bob.signingPrivateKey, body);
|
||||
const res = await req('POST', '/v1/keys/register', signed);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test('rejects registration with stale signedAt', async () => {
|
||||
const alice = await makeIdentity();
|
||||
const body = await signedRegisterBody(alice, 'alice');
|
||||
// Tamper with signedAt to be old
|
||||
body.signedAt = Date.now() - 10 * 60 * 1000; // 10 minutes ago
|
||||
const res = await req('POST', '/v1/keys/register', body);
|
||||
expect(res.status).toBe(409); // ReplayError
|
||||
});
|
||||
|
||||
test('rejects invalid address format', async () => {
|
||||
const alice = await makeIdentity();
|
||||
const body = await signedRegisterBody(alice, '../evil');
|
||||
const res = await req('POST', '/v1/keys/register', body);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('accepts registration with one-time prekeys', async () => {
|
||||
const alice = await makeIdentity();
|
||||
const body = await signedRegisterBody(alice, 'alice');
|
||||
await req('POST', '/v1/keys/register', body);
|
||||
|
||||
const countRes = await req('GET', '/v1/keys/count/alice');
|
||||
expect((await countRes.json()).count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fetch Bundle ──────────────────────────────────────────
|
||||
// ─── Fetch Bundle (anonymous) ──────────────────────────────
|
||||
|
||||
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 }],
|
||||
});
|
||||
test('returns bundle for registered address', async () => {
|
||||
const bob = await makeIdentity();
|
||||
const body = await signedRegisterBody(bob, 'bob');
|
||||
await req('POST', '/v1/keys/register', body);
|
||||
|
||||
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.identitySigningKey).toBe(b64(bob.signingPublicKey));
|
||||
expect(bundle.identityDHKey).toBe(b64(bob.dhPublicKey));
|
||||
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)) },
|
||||
});
|
||||
test('consumes one-time prekey on each fetch (FIFO)', async () => {
|
||||
const bob = await makeIdentity();
|
||||
await req('POST', '/v1/keys/register', await signedRegisterBody(bob, 'bob'));
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test('rejects invalid address in URL', async () => {
|
||||
const res = await req('GET', '/v1/keys/bundle/..evil');
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Replenish ─────────────────────────────────────────────
|
||||
// ─── Replenish (signed) ────────────────────────────────────
|
||||
|
||||
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)) }],
|
||||
});
|
||||
test('accepts signed replenishment from registered identity', async () => {
|
||||
const bob = await makeIdentity();
|
||||
await req('POST', '/v1/keys/register', await signedRegisterBody(bob, 'bob'));
|
||||
|
||||
const res = await req('POST', '/v1/keys/replenish', {
|
||||
const replenishBody = await signPayload(crypto, bob.signingPrivateKey, {
|
||||
address: 'bob',
|
||||
oneTimePreKeys: [
|
||||
{ keyId: 200, publicKey: b64(randBytes(32)) },
|
||||
@@ -176,52 +177,83 @@ describe('Shade Prekey Server', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const res = await req('POST', '/v1/keys/replenish', replenishBody);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.remaining).toBe(3); // 1 original + 2 new
|
||||
expect(body.remaining).toBe(4); // 2 original + 2 new
|
||||
});
|
||||
|
||||
test('rejects replenishment signed by wrong identity', async () => {
|
||||
const bob = await makeIdentity();
|
||||
const eve = await makeIdentity();
|
||||
await req('POST', '/v1/keys/register', await signedRegisterBody(bob, 'bob'));
|
||||
|
||||
const evilBody = await signPayload(crypto, eve.signingPrivateKey, {
|
||||
address: 'bob',
|
||||
oneTimePreKeys: [{ keyId: 200, publicKey: b64(randBytes(32)) }],
|
||||
});
|
||||
|
||||
const res = await req('POST', '/v1/keys/replenish', evilBody);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test('rejects replenishment for unknown address', async () => {
|
||||
const bob = await makeIdentity();
|
||||
const body = await signPayload(crypto, bob.signingPrivateKey, {
|
||||
address: 'nobody',
|
||||
oneTimePreKeys: [{ keyId: 200, publicKey: b64(randBytes(32)) }],
|
||||
});
|
||||
const res = await req('POST', '/v1/keys/replenish', body);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Delete ────────────────────────────────────────────────
|
||||
// ─── Delete (signed) ───────────────────────────────────────
|
||||
|
||||
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)) }],
|
||||
});
|
||||
test('accepts signed delete from registered identity', async () => {
|
||||
const bob = await makeIdentity();
|
||||
await req('POST', '/v1/keys/register', await signedRegisterBody(bob, 'bob'));
|
||||
|
||||
const delRes = await req('DELETE', '/v1/keys/bob');
|
||||
expect(delRes.status).toBe(200);
|
||||
const delBody = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob' });
|
||||
const res = await req('DELETE', '/v1/keys/bob', delBody);
|
||||
expect(res.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);
|
||||
test('rejects delete signed by wrong identity', async () => {
|
||||
const bob = await makeIdentity();
|
||||
const eve = await makeIdentity();
|
||||
await req('POST', '/v1/keys/register', await signedRegisterBody(bob, 'bob'));
|
||||
|
||||
const evilBody = await signPayload(crypto, eve.signingPrivateKey, { address: 'bob' });
|
||||
const res = await req('DELETE', '/v1/keys/bob', evilBody);
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
// Should still exist
|
||||
const bundleRes = await req('GET', '/v1/keys/bundle/bob');
|
||||
expect(bundleRes.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Multiple Addresses ────────────────────────────────────
|
||||
// ─── Multi-address isolation ───────────────────────────────
|
||||
|
||||
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)) }],
|
||||
});
|
||||
}
|
||||
const alice = await makeIdentity();
|
||||
const bob = await makeIdentity();
|
||||
const charlie = await makeIdentity();
|
||||
|
||||
// Delete bob, others remain
|
||||
await req('DELETE', '/v1/keys/bob');
|
||||
await req('POST', '/v1/keys/register', await signedRegisterBody(alice, 'alice'));
|
||||
await req('POST', '/v1/keys/register', await signedRegisterBody(bob, 'bob'));
|
||||
await req('POST', '/v1/keys/register', await signedRegisterBody(charlie, 'charlie'));
|
||||
|
||||
// Delete bob with his own signature
|
||||
const delBody = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob' });
|
||||
await req('DELETE', '/v1/keys/bob', delBody);
|
||||
|
||||
expect((await req('GET', '/v1/keys/bundle/alice')).status).toBe(200);
|
||||
expect((await req('GET', '/v1/keys/bundle/bob')).status).toBe(404);
|
||||
|
||||
Reference in New Issue
Block a user