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>
120 lines
3.6 KiB
TypeScript
120 lines
3.6 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
import {
|
|
ShadeError,
|
|
InvalidSignatureError,
|
|
DecryptionError,
|
|
NoSessionError,
|
|
MaxSkipExceededError,
|
|
DuplicateMessageError,
|
|
UntrustedIdentityError,
|
|
PreKeyNotFoundError,
|
|
ReplayError,
|
|
IdentityRotationError,
|
|
NetworkError,
|
|
StorageError,
|
|
ValidationError,
|
|
TimeoutError,
|
|
RateLimitError,
|
|
ConfigurationError,
|
|
UnauthorizedError,
|
|
errorToHttpStatus,
|
|
} from '../src/errors.js';
|
|
|
|
describe('Error hierarchy', () => {
|
|
test('all errors extend ShadeError', () => {
|
|
const errors = [
|
|
new InvalidSignatureError(),
|
|
new DecryptionError(),
|
|
new NoSessionError('addr'),
|
|
new MaxSkipExceededError(1001, 1000),
|
|
new DuplicateMessageError(),
|
|
new UntrustedIdentityError('addr'),
|
|
new PreKeyNotFoundError(1, 'signed'),
|
|
new ReplayError(),
|
|
new IdentityRotationError(),
|
|
new NetworkError('net'),
|
|
new StorageError('storage'),
|
|
new ValidationError('val'),
|
|
new TimeoutError(),
|
|
new RateLimitError(),
|
|
new ConfigurationError('cfg'),
|
|
new UnauthorizedError(),
|
|
];
|
|
for (const e of errors) {
|
|
expect(e).toBeInstanceOf(ShadeError);
|
|
expect(e).toBeInstanceOf(Error);
|
|
expect(e.code).toMatch(/^SHADE_/);
|
|
expect(e.name).not.toBe('ShadeError');
|
|
}
|
|
});
|
|
|
|
test('toJSON produces serializable form', () => {
|
|
const e = new InvalidSignatureError('bad sig');
|
|
const json = e.toJSON();
|
|
expect(json.name).toBe('InvalidSignatureError');
|
|
expect(json.code).toBe('SHADE_INVALID_SIGNATURE');
|
|
expect(json.message).toBe('bad sig');
|
|
|
|
const str = JSON.stringify(e);
|
|
const parsed = JSON.parse(str);
|
|
expect(parsed.code).toBe('SHADE_INVALID_SIGNATURE');
|
|
});
|
|
|
|
test('NetworkError carries status code', () => {
|
|
const e = new NetworkError('server error', 503);
|
|
expect(e.statusCode).toBe(503);
|
|
});
|
|
|
|
test('RateLimitError carries retry-after', () => {
|
|
const e = new RateLimitError('too many', 60);
|
|
expect(e.retryAfterSeconds).toBe(60);
|
|
});
|
|
|
|
test('ValidationError carries field name', () => {
|
|
const e = new ValidationError('must be positive', 'count');
|
|
expect(e.field).toBe('count');
|
|
});
|
|
});
|
|
|
|
describe('errorToHttpStatus', () => {
|
|
test('unknown error → 500', () => {
|
|
expect(errorToHttpStatus(new Error('plain'))).toBe(500);
|
|
expect(errorToHttpStatus('string error')).toBe(500);
|
|
expect(errorToHttpStatus(null)).toBe(500);
|
|
});
|
|
|
|
test('auth errors → 401/403', () => {
|
|
expect(errorToHttpStatus(new InvalidSignatureError())).toBe(401);
|
|
expect(errorToHttpStatus(new UnauthorizedError())).toBe(401);
|
|
expect(errorToHttpStatus(new UntrustedIdentityError('x'))).toBe(403);
|
|
});
|
|
|
|
test('not-found errors → 404', () => {
|
|
expect(errorToHttpStatus(new NoSessionError('x'))).toBe(404);
|
|
expect(errorToHttpStatus(new PreKeyNotFoundError(1, 'signed'))).toBe(404);
|
|
});
|
|
|
|
test('validation → 400', () => {
|
|
expect(errorToHttpStatus(new ValidationError('bad'))).toBe(400);
|
|
});
|
|
|
|
test('replay/duplicate → 409', () => {
|
|
expect(errorToHttpStatus(new ReplayError())).toBe(409);
|
|
expect(errorToHttpStatus(new DuplicateMessageError())).toBe(409);
|
|
});
|
|
|
|
test('rate limit → 429', () => {
|
|
expect(errorToHttpStatus(new RateLimitError())).toBe(429);
|
|
});
|
|
|
|
test('infra errors → 503', () => {
|
|
expect(errorToHttpStatus(new NetworkError('x'))).toBe(503);
|
|
expect(errorToHttpStatus(new StorageError('x'))).toBe(503);
|
|
expect(errorToHttpStatus(new ConfigurationError('x'))).toBe(503);
|
|
});
|
|
|
|
test('timeout → 504', () => {
|
|
expect(errorToHttpStatus(new TimeoutError())).toBe(504);
|
|
});
|
|
});
|