feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
import type { CryptoProvider } from './crypto.js';
|
|
|
|
|
import type { KeyPair, SessionState, ChainState, RatchetMessage } from './types.js';
|
|
|
|
|
import { MAX_SKIP, MAX_CACHED_SKIPPED_KEYS } from './types.js';
|
|
|
|
|
import { kdfRootKey, kdfChainKey } from './keys.js';
|
feat(files): @shade/files 0.3.0 — E2EE filesystem RPC primitive
M-Files-1..6 land the full files-RPC layer + everything 0.3.0 needs to
ship. Apps keep their own UI; this layer ships the typed RPC, the
streams bridge for content I/O, and production hooks (rate limit,
retention, fingerprint gate, metrics).
@shade/files (NEW)
- Standard ops: list/stat/mkdir/delete/move/read/write/getThumbnail with
Zod-validated wire schemas + clean user-handler types.
- Custom ops: typed via TypeScript declaration merging on CustomOpsMap
+ per-op Zod schemas; client.custom('app.foo', {...}) is fully typed.
- Content I/O: inline (≤ 256 KiB plaintext) base64-in-RPC; streams
(> 256 KiB) ride @shade/transfer via userMetadata.shadeFilesWriteId
/ shadeFilesReadStreamId correlation. Server-side TransformStream
bridges accept inbound transfers immediately (engine rejects chunks
that arrive before accept) and park the readable for the matching
RPC.
- Directory ops: walk(path, opts) async-iterable depth-first walker;
uploadDirectory()/downloadDirectory() with bounded concurrency pool
(default 4, cap 16), aggregated progress, abort.
- Production hooks (callback-based, vendor-neutral): rate-limit (op +
byte), idempotency cache (LRU + TTL + in-flight de-dupe), path
policy (traversal + percent-decode hardening), fingerprint gate
(required/optional/reject), pluggable Ed25519 sig verification with
±5 min replay window, onMetric sink (standard names).
- React hooks (subpath @shade/files/react): ShadeFilesProvider,
useShadeFiles, useFileList, useFileTransfer/Upload/Download.
- Shade.files.serve(handler) + Shade.files.client(peer) high-level
entrypoint in @shade/sdk; lazy + memoized; one handler per Shade.
Wire format bump
- @shade/proto wire VERSION 0x01 → 0x02. Length prefixes changed from
u16 to u32. The previous u16 silently truncated payloads above
64 KiB — a hard correctness ceiling that blocked inline file ops
up to 256 KiB. Wire-incompatible with 0.2.x peers; new sessions
only. Cross-platform Kotlin port (android/shade-android) updated to
match; test-vectors/wire-format.json regenerated.
Concurrency safety
- ShadeSessionManager.encrypt/.decrypt now run under per-peer mutex.
Concurrent decryptions of the same peer raced ratchet state
(manifested as sporadic "Failed to decrypt — wrong key or tampered
data" under load — surfaced once concurrent uploadDirectory pumped
many writes in flight). Encrypt was already serialized via
Shade.send's encryptChains; decrypt is now serialized at the
manager layer too.
@shade/streams extension
- StreamMetadata.userMetadata?: Record<string, string> for
application-level key/value pairs that round-trip verbatim through
stream-init plaintext. Used by @shade/files for write/read
correlation; available to any consumer.
@shade/sdk extension
- Shade.files getter (lazy + memoized).
- BackgroundHooks.onPruneFiles + periodic timer (default 5 min) +
BackgroundTasks.setHook(name, fn) for runtime hook registration.
Bundles in-flight 0.2.0 work
- packages/shade-streams/, packages/shade-transfer/, related
shade-sdk streams-bridge + shade-widgets transfer hooks were
uncommitted prior to this session. Including them keeps the
workspace consistent at 0.3.0 since @shade/files depends on them.
Tests
- 74 new tests in @shade/files (572 → 646 workspace pass; 0 fail;
3× stable). Coverage spans unit (inline-threshold + concurrency),
integration (read-write inline + streams up to 1 MiB, walk +
upload/download directory, custom-op, metrics, SDK namespace
end-to-end), and security (tampered-envelope sig verification,
replay window, fingerprint gate, rate-limit + quota).
Release artifacts
- All packages bumped to 0.3.0 via scripts/bump-version.ts.
- scripts/publish-all.ts PACKAGES updated with shade-files in
topological order (after shade-transfer, before shade-sdk).
- bun run publish:dry clean (14 packed, 0 failed).
- examples/08-files-browser/ — three-process CLI demo (prekey + Bob
server + Alice CLI) covering list/stat/mkdir/delete/upload/download.
- docs/files.md — full API + design doc.
- CHANGELOG.md 0.3.0 entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:00:01 +02:00
|
|
|
import { DecryptionError, MaxSkipExceededError } from './errors.js';
|
feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Double Ratchet — per-message forward secrecy and post-compromise recovery.
|
|
|
|
|
*
|
|
|
|
|
* Combines a symmetric-key ratchet (chain keys → message keys) with a
|
|
|
|
|
* Diffie-Hellman ratchet (new DH keypair per conversation turn).
|
|
|
|
|
*
|
|
|
|
|
* Reference: https://signal.org/docs/specifications/doubleratchet/
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
// ─── Utility ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function toBase64(buf: Uint8Array): string {
|
|
|
|
|
// Use a simple hex encoding for map keys (avoids btoa issues with Uint8Array)
|
|
|
|
|
return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function skippedKeyId(dhPublicKey: Uint8Array, counter: number): string {
|
|
|
|
|
return `${toBase64(dhPublicKey)}:${counter}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Encode a RatchetMessage header as bytes for use as AES-GCM AAD */
|
|
|
|
|
function encodeHeader(msg: Pick<RatchetMessage, 'dhPublicKey' | 'previousCounter' | 'counter'>): Uint8Array {
|
|
|
|
|
// dhPublicKey (32) + previousCounter (4, big-endian) + counter (4, big-endian)
|
|
|
|
|
const buf = new Uint8Array(40);
|
|
|
|
|
buf.set(msg.dhPublicKey, 0);
|
|
|
|
|
new DataView(buf.buffer).setUint32(32, msg.previousCounter, false);
|
|
|
|
|
new DataView(buf.buffer).setUint32(36, msg.counter, false);
|
|
|
|
|
return buf;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Session Initialization ──────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initialize a session as the sender (Alice, after X3DH).
|
|
|
|
|
*
|
|
|
|
|
* Alice knows the root key and Bob's signed prekey (initial remote DH key).
|
|
|
|
|
* She generates her first DH ratchet keypair and performs the first DH ratchet step.
|
|
|
|
|
*/
|
|
|
|
|
export async function initSenderSession(
|
|
|
|
|
crypto: CryptoProvider,
|
|
|
|
|
rootKey: Uint8Array,
|
|
|
|
|
remoteIdentityKey: Uint8Array,
|
|
|
|
|
remoteDHPublicKey: Uint8Array,
|
|
|
|
|
): Promise<SessionState> {
|
|
|
|
|
// Generate first DH ratchet keypair
|
|
|
|
|
const dhSend = await crypto.generateX25519KeyPair();
|
|
|
|
|
|
|
|
|
|
// First DH ratchet step
|
|
|
|
|
const dhOutput = await crypto.x25519(dhSend.privateKey, remoteDHPublicKey);
|
|
|
|
|
const { newRootKey, chainKey } = await kdfRootKey(crypto, rootKey, dhOutput);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
remoteIdentityKey,
|
|
|
|
|
rootKey: newRootKey,
|
|
|
|
|
sendChain: { chainKey, counter: 0 },
|
|
|
|
|
receiveChain: null,
|
|
|
|
|
dhSend,
|
|
|
|
|
dhReceive: remoteDHPublicKey,
|
|
|
|
|
previousSendCounter: 0,
|
|
|
|
|
skippedKeys: new Map(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initialize a session as the receiver (Bob, after X3DH).
|
|
|
|
|
*
|
|
|
|
|
* Bob knows the root key and his own signed prekey (which was used as
|
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.
Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
non-realtime stack.
Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
version 4.0.0 on /health.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00
|
|
|
* the initial DH ratchet keypair). The keypair is COPIED into the
|
|
|
|
|
* session — the receiving side's DH ratchet will eventually rotate
|
|
|
|
|
* `dhSend` and zeroize the previous private key, and that scratch
|
|
|
|
|
* buffer must NOT be the same memory as the persisted signed prekey
|
|
|
|
|
* (which is shared with future X3DH establishments from other senders).
|
feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
*/
|
|
|
|
|
export function initReceiverSession(
|
|
|
|
|
rootKey: Uint8Array,
|
|
|
|
|
remoteIdentityKey: Uint8Array,
|
|
|
|
|
localDHKeyPair: KeyPair,
|
|
|
|
|
): SessionState {
|
|
|
|
|
return {
|
|
|
|
|
remoteIdentityKey,
|
|
|
|
|
rootKey,
|
|
|
|
|
sendChain: { chainKey: new Uint8Array(32), counter: 0 },
|
|
|
|
|
receiveChain: null,
|
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.
Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
non-realtime stack.
Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
version 4.0.0 on /health.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00
|
|
|
dhSend: {
|
|
|
|
|
publicKey: new Uint8Array(localDHKeyPair.publicKey),
|
|
|
|
|
privateKey: new Uint8Array(localDHKeyPair.privateKey),
|
|
|
|
|
},
|
feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
dhReceive: null,
|
|
|
|
|
previousSendCounter: 0,
|
|
|
|
|
skippedKeys: new Map(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Encrypt ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Encrypt a plaintext message using the Double Ratchet.
|
|
|
|
|
*
|
|
|
|
|
* Advances the sending chain by one step, derives a message key,
|
|
|
|
|
* encrypts the plaintext with AES-256-GCM, and returns a RatchetMessage.
|
|
|
|
|
*/
|
|
|
|
|
export async function ratchetEncrypt(
|
|
|
|
|
crypto: CryptoProvider,
|
|
|
|
|
session: SessionState,
|
|
|
|
|
plaintext: Uint8Array,
|
|
|
|
|
): Promise<RatchetMessage> {
|
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>
2026-04-10 17:45:34 +02:00
|
|
|
// Advance sending chain — old chain key is replaced, zero the old one
|
|
|
|
|
const oldChainKey = session.sendChain.chainKey;
|
|
|
|
|
const { newChainKey, messageKey } = await kdfChainKey(crypto, oldChainKey);
|
|
|
|
|
crypto.zeroize(oldChainKey);
|
feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
const counter = session.sendChain.counter;
|
|
|
|
|
|
|
|
|
|
// Build header for AAD
|
|
|
|
|
const header: Pick<RatchetMessage, 'dhPublicKey' | 'previousCounter' | 'counter'> = {
|
|
|
|
|
dhPublicKey: session.dhSend.publicKey,
|
|
|
|
|
previousCounter: session.previousSendCounter,
|
|
|
|
|
counter,
|
|
|
|
|
};
|
|
|
|
|
const aad = encodeHeader(header);
|
|
|
|
|
|
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>
2026-04-10 17:45:34 +02:00
|
|
|
// Encrypt, then zero the message key (used once)
|
feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
const { ciphertext, nonce } = await crypto.aesGcmEncrypt(messageKey, plaintext, aad);
|
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>
2026-04-10 17:45:34 +02:00
|
|
|
crypto.zeroize(messageKey);
|
feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
|
|
|
|
|
// Update session state
|
|
|
|
|
session.sendChain.chainKey = newChainKey;
|
|
|
|
|
session.sendChain.counter = counter + 1;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
dhPublicKey: session.dhSend.publicKey,
|
|
|
|
|
previousCounter: session.previousSendCounter,
|
|
|
|
|
counter,
|
|
|
|
|
ciphertext,
|
|
|
|
|
nonce,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Decrypt ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Decrypt a RatchetMessage using the Double Ratchet.
|
|
|
|
|
*
|
|
|
|
|
* Handles three cases:
|
|
|
|
|
* 1. Message from a skipped key (out-of-order delivery)
|
|
|
|
|
* 2. Message from the current receiving chain
|
|
|
|
|
* 3. Message with a new DH key (triggers a DH ratchet step)
|
|
|
|
|
*/
|
|
|
|
|
export async function ratchetDecrypt(
|
|
|
|
|
crypto: CryptoProvider,
|
|
|
|
|
session: SessionState,
|
|
|
|
|
message: RatchetMessage,
|
|
|
|
|
): Promise<Uint8Array> {
|
|
|
|
|
// Case 1: Try skipped keys first
|
|
|
|
|
const skipId = skippedKeyId(message.dhPublicKey, message.counter);
|
|
|
|
|
const skippedKey = session.skippedKeys.get(skipId);
|
|
|
|
|
if (skippedKey) {
|
|
|
|
|
session.skippedKeys.delete(skipId);
|
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>
2026-04-10 17:45:34 +02:00
|
|
|
try {
|
|
|
|
|
return await decryptWithKey(crypto, skippedKey, message);
|
|
|
|
|
} finally {
|
|
|
|
|
crypto.zeroize(skippedKey);
|
|
|
|
|
}
|
feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Case 2 or 3: Check if this is a new DH ratchet
|
|
|
|
|
const isNewRatchet = !session.dhReceive || !arraysEqual(message.dhPublicKey, session.dhReceive);
|
|
|
|
|
|
|
|
|
|
if (isNewRatchet) {
|
|
|
|
|
// Skip any remaining messages in the current receiving chain
|
|
|
|
|
if (session.receiveChain && session.dhReceive) {
|
|
|
|
|
await skipMessageKeys(crypto, session, session.dhReceive, session.receiveChain, message.previousCounter);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Perform DH ratchet step
|
|
|
|
|
await performDHRatchetStep(crypto, session, message.dhPublicKey);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Skip to the message's counter in the current receiving chain
|
|
|
|
|
if (!session.receiveChain) {
|
|
|
|
|
throw new DecryptionError('No receiving chain available');
|
|
|
|
|
}
|
2026-05-04 22:58:26 +02:00
|
|
|
// Defense-in-depth: a same-DH message whose counter is already
|
|
|
|
|
// behind the chain — and that did NOT match a cached skipped key —
|
|
|
|
|
// is either a duplicate we already decrypted (skipped key was
|
|
|
|
|
// consumed) or one whose key was evicted under cache pressure.
|
|
|
|
|
// Falling through would call kdfChainKey on the *current* (ahead)
|
|
|
|
|
// chainKey and then rewind `chain.counter = message.counter + 1`,
|
|
|
|
|
// permanently desyncing the chain so every subsequent decrypt
|
|
|
|
|
// returns wrong-key. Reject without mutating state instead.
|
|
|
|
|
if (
|
|
|
|
|
!isNewRatchet &&
|
|
|
|
|
message.counter < session.receiveChain.counter
|
|
|
|
|
) {
|
|
|
|
|
throw new DecryptionError(
|
|
|
|
|
'Failed to decrypt message — wrong key or tampered data',
|
|
|
|
|
);
|
|
|
|
|
}
|
feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
await skipMessageKeys(crypto, session, message.dhPublicKey, session.receiveChain, message.counter);
|
|
|
|
|
|
|
|
|
|
// Advance the receiving chain one more step to get this message's key
|
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>
2026-04-10 17:45:34 +02:00
|
|
|
const oldChainKey = session.receiveChain.chainKey;
|
|
|
|
|
const { newChainKey, messageKey } = await kdfChainKey(crypto, oldChainKey);
|
|
|
|
|
crypto.zeroize(oldChainKey);
|
feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
session.receiveChain.chainKey = newChainKey;
|
|
|
|
|
session.receiveChain.counter = message.counter + 1;
|
|
|
|
|
|
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>
2026-04-10 17:45:34 +02:00
|
|
|
try {
|
|
|
|
|
return await decryptWithKey(crypto, messageKey, message);
|
|
|
|
|
} finally {
|
|
|
|
|
crypto.zeroize(messageKey);
|
|
|
|
|
}
|
feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── DH Ratchet Step ─────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Perform a DH ratchet step when receiving a message with a new DH public key.
|
|
|
|
|
*
|
|
|
|
|
* 1. DH(current send key, new remote key) → advance root key, get new receiving chain
|
|
|
|
|
* 2. Generate new DH keypair
|
|
|
|
|
* 3. DH(new keypair, remote key) → advance root key, get new sending chain
|
|
|
|
|
*/
|
|
|
|
|
async function performDHRatchetStep(
|
|
|
|
|
crypto: CryptoProvider,
|
|
|
|
|
session: SessionState,
|
|
|
|
|
remoteDHKey: Uint8Array,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
// Save previous send counter
|
|
|
|
|
session.previousSendCounter = session.sendChain.counter;
|
|
|
|
|
|
|
|
|
|
// Update remote DH key
|
|
|
|
|
session.dhReceive = remoteDHKey;
|
|
|
|
|
|
|
|
|
|
// DH with current send key → new receiving chain
|
|
|
|
|
const dh1 = await crypto.x25519(session.dhSend.privateKey, remoteDHKey);
|
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>
2026-04-10 17:45:34 +02:00
|
|
|
const oldRootKey1 = session.rootKey;
|
|
|
|
|
const recv = await kdfRootKey(crypto, oldRootKey1, dh1);
|
|
|
|
|
crypto.zeroize(oldRootKey1);
|
|
|
|
|
crypto.zeroize(dh1);
|
feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
session.rootKey = recv.newRootKey;
|
|
|
|
|
session.receiveChain = { chainKey: recv.chainKey, counter: 0 };
|
|
|
|
|
|
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>
2026-04-10 17:45:34 +02:00
|
|
|
// Generate new DH keypair, zero the old one's private key
|
|
|
|
|
const oldDhPrivate = session.dhSend.privateKey;
|
feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
session.dhSend = await crypto.generateX25519KeyPair();
|
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>
2026-04-10 17:45:34 +02:00
|
|
|
crypto.zeroize(oldDhPrivate);
|
feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
|
|
|
|
|
// DH with new send key → new sending chain
|
|
|
|
|
const dh2 = await crypto.x25519(session.dhSend.privateKey, remoteDHKey);
|
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>
2026-04-10 17:45:34 +02:00
|
|
|
const oldRootKey2 = session.rootKey;
|
|
|
|
|
const send = await kdfRootKey(crypto, oldRootKey2, dh2);
|
|
|
|
|
crypto.zeroize(oldRootKey2);
|
|
|
|
|
crypto.zeroize(dh2);
|
feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
session.rootKey = send.newRootKey;
|
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>
2026-04-10 17:45:34 +02:00
|
|
|
// Zero the old send chain key if present
|
|
|
|
|
if (session.sendChain.chainKey.length > 0) {
|
|
|
|
|
crypto.zeroize(session.sendChain.chainKey);
|
|
|
|
|
}
|
feat: Shade E2EE library — M1-M3 complete
Signal Protocol implementation with full X3DH + Double Ratchet:
- M1: Core types, CryptoProvider interface, KDF chain functions,
SubtleCrypto+noble/curves provider, MemoryStorage
- M2: X3DH key agreement (identity keys, signed prekeys, one-time
prekeys, bundle processing for both initiator and responder)
- M3: Double Ratchet (symmetric-key ratchet, DH ratchet, skipped
message key cache, out-of-order delivery, AAD-bound headers)
68 tests, 0 failures — including full integration test of
X3DH handshake → Double Ratchet conversation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:19 +02:00
|
|
|
session.sendChain = { chainKey: send.chainKey, counter: 0 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Skip Message Keys ──────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Advance a chain, caching skipped message keys for out-of-order decryption.
|
|
|
|
|
*/
|
|
|
|
|
async function skipMessageKeys(
|
|
|
|
|
crypto: CryptoProvider,
|
|
|
|
|
session: SessionState,
|
|
|
|
|
dhPublicKey: Uint8Array,
|
|
|
|
|
chain: ChainState,
|
|
|
|
|
untilCounter: number,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const toSkip = untilCounter - chain.counter;
|
|
|
|
|
if (toSkip < 0) return; // already past this point
|
|
|
|
|
if (toSkip > MAX_SKIP) {
|
|
|
|
|
throw new MaxSkipExceededError(toSkip, MAX_SKIP);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (let i = chain.counter; i < untilCounter; i++) {
|
|
|
|
|
const { newChainKey, messageKey } = await kdfChainKey(crypto, chain.chainKey);
|
|
|
|
|
const id = skippedKeyId(dhPublicKey, i);
|
|
|
|
|
session.skippedKeys.set(id, messageKey);
|
|
|
|
|
chain.chainKey = newChainKey;
|
|
|
|
|
chain.counter = i + 1;
|
|
|
|
|
|
|
|
|
|
// Evict oldest if we have too many cached keys
|
|
|
|
|
if (session.skippedKeys.size > MAX_CACHED_SKIPPED_KEYS) {
|
|
|
|
|
const firstKey = session.skippedKeys.keys().next().value;
|
|
|
|
|
if (firstKey) session.skippedKeys.delete(firstKey);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async function decryptWithKey(
|
|
|
|
|
crypto: CryptoProvider,
|
|
|
|
|
messageKey: Uint8Array,
|
|
|
|
|
message: RatchetMessage,
|
|
|
|
|
): Promise<Uint8Array> {
|
|
|
|
|
const aad = encodeHeader(message);
|
|
|
|
|
try {
|
|
|
|
|
return await crypto.aesGcmDecrypt(messageKey, message.ciphertext, message.nonce, aad);
|
|
|
|
|
} catch {
|
|
|
|
|
throw new DecryptionError('Failed to decrypt message — wrong key or tampered data');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function arraysEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
|
|
|
if (a.length !== b.length) return false;
|
|
|
|
|
for (let i = 0; i < a.length; i++) {
|
|
|
|
|
if (a[i] !== b[i]) return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|