first commit
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
# Error codes — `@suaveplan/network-plugin-crypto-messages`
|
||||
|
||||
## Purpose
|
||||
|
||||
Frozen registry of every error code raised by the crypto-messages package. Codes are namespaced under `NET_CRYPTO` and registered into the global error registry on first import via the side-effect import in `src/index.ts`. Each code maps to a single failure class so consumers branch on `error.code` without parsing message text.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven codes covering signing, token validation (expiry / forgery / replay), peer identity, log chain tampering, and key lookup. Plugin-integrity / capability / tier / region codes are deferred to follow-up changes (`network-plugin-crypto-messages-plugin-security`, `-tier-policy`).
|
||||
- `NetworkCryptoError` extends `SuaveplanError<NetworkCryptoErrorCode>` for cause-chain traversal, redaction, and OTel adapter compatibility.
|
||||
- Frozen registry — runtime mutation throws.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
NETWORK_CRYPTO_CODES,
|
||||
NetworkCryptoError,
|
||||
} from "@suaveplan/network-plugin-crypto-messages";
|
||||
|
||||
throw new NetworkCryptoError(
|
||||
NETWORK_CRYPTO_CODES.SIGNATURE_INVALID,
|
||||
"signer.bad-signature",
|
||||
{ context: { peerId: "p1" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
try {
|
||||
sessionTokenAuthority.validate(token);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof NetworkCryptoError &&
|
||||
err.code === NETWORK_CRYPTO_CODES.TOKEN_EXPIRED
|
||||
) {
|
||||
return refreshFromServer();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NETWORK_CRYPTO_CODES` — frozen `KEY -> "NET_CRYPTO.KEY"` map.
|
||||
- `NetworkCryptoErrorCode` — TypeScript union of every code value.
|
||||
- `NetworkCryptoError` — `SuaveplanError<NetworkCryptoErrorCode>` subclass; `name === "NetworkCryptoError"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The registry is constructed via `defineCodes("NET_CRYPTO", […] as const)` so the type system enumerates the value union; adding a code requires bumping the `as const` tuple.
|
||||
- Codes are deliberately coarse (one per failure class) so consumers build stable retry / observability policies without churn when implementations change.
|
||||
- `TOKEN_REPLAYED` is distinct from `TOKEN_INVALID` because a replay is a behavioural signal — the same token presented twice is not necessarily forged but should be rejected for liveness reasons.
|
||||
- The plugin-security and tier-policy enhancements (sections 12–15 of the original tasks.md) are deferred to follow-up changes so the core sections (1–11, 16) ship at full quality without blocking on the larger surface.
|
||||
@@ -0,0 +1,72 @@
|
||||
# `PeerIdentityVerifier`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Issues random-nonce challenges for the peer-handshake identity proof, signs challenges (helper for the responding side), and verifies responses against the expected peer public key. Defeats peer-id spoofing by ensuring the responder must control the private key registered for the claimed `peerId`.
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable nonce size (default 32 bytes) clamped to ≥16.
|
||||
- Per-peer challenge TTL (default 30s) — expired pending challenges are cleared on the next verify attempt against the same peer id.
|
||||
- `sign(challenge, privateKey)` is the responder-side helper that produces the matching `IdentityResponse`.
|
||||
- `verify(...)` raises `IDENTITY_MISMATCH` on missing / expired / wrong-nonce / wrong-key, and `SIGNATURE_INVALID` on bad signature.
|
||||
- Injectable clock + RNG for deterministic tests.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ed25519 } from "@suaveplan/crypto-curve";
|
||||
import { PeerIdentityVerifier } from "@suaveplan/network-plugin-crypto-messages";
|
||||
|
||||
const verifier = new PeerIdentityVerifier();
|
||||
const challenge = verifier.issueChallenge("peer-A");
|
||||
|
||||
// Responder side
|
||||
const priv = ed25519.randomPrivateKey();
|
||||
const pub = ed25519.getPublicKey(priv);
|
||||
const response = verifier.sign(challenge, priv);
|
||||
|
||||
// Verifier side
|
||||
verifier.verify("peer-A", response, pub); // no throw
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PeerIdentityVerifier } from "@suaveplan/network-plugin-crypto-messages";
|
||||
|
||||
let now = 0;
|
||||
const verifier = new PeerIdentityVerifier({
|
||||
clock: () => now,
|
||||
nonceBytes: 32,
|
||||
defaultTtlMs: 5_000,
|
||||
random: (n) => new Uint8Array(n).fill(7),
|
||||
});
|
||||
|
||||
const c = verifier.issueChallenge("peer-X", 1_000);
|
||||
now += 2_000;
|
||||
try {
|
||||
verifier.verify("peer-X", { ...mockResponse }, expectedKey);
|
||||
} catch (err) {
|
||||
// IDENTITY_MISMATCH — challenge expired
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class PeerIdentityVerifier(config?)`
|
||||
- `issueChallenge(peerId, ttlMs?): IdentityChallenge`.
|
||||
- `sign(challenge, privateKey): IdentityResponse` — responder-side helper.
|
||||
- `verify(peerId, response, expectedPublicKey): void`.
|
||||
- `hasPending(peerId): boolean`.
|
||||
- `clear(): void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The pending-challenge cache is keyed by `peerId` so issuing two challenges for the same peer overwrites the first — last-wins matches the typical handshake reconnect flow.
|
||||
- `verify` deletes the pending entry on success AND on expired branch so two failure paths converge on the same cleanup; only mid-flight failures (`nonce-mismatch`, `peer-key-mismatch`, bad signature) leave the entry alive for a retry.
|
||||
- The signature is over the raw nonce bytes — keeping the signed content trivially canonical means there is no "what's covered?" ambiguity.
|
||||
- The default RNG uses `crypto.getRandomValues`; tests inject a deterministic source so nonces are reproducible.
|
||||
@@ -0,0 +1,70 @@
|
||||
# `KeyRotationManager`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Owns the active signing key and tracks the most-recently retired key inside a grace window so verifiers continue to accept signatures made under the old key while the network discovers the rotation. Limits the blast radius of a single private-key compromise by capping how long any one key can be in use.
|
||||
|
||||
## Features
|
||||
|
||||
- Active key + at-most-one retired key (single-step rotation; chains of retirements are out of scope by design).
|
||||
- Configurable `rotateAfterMs` (default 24h) and `graceMs` (default 1h).
|
||||
- `verifierKeys()` returns the set of public keys verifiers should accept right now — typically `[active]` or `[active, retired]` during the grace window.
|
||||
- Bus emit on rotation so dependent subsystems (registry, log chain) can broadcast the new public key.
|
||||
- Injectable generator + clock + bus for deterministic tests.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ed25519 } from "@suaveplan/crypto-curve";
|
||||
import { KeyRotationManager } from "@suaveplan/network-plugin-crypto-messages";
|
||||
|
||||
const priv = ed25519.randomPrivateKey();
|
||||
const initial = { privateKey: priv, publicKey: ed25519.getPublicKey(priv) };
|
||||
const manager = new KeyRotationManager({ initial });
|
||||
manager.current(); // { publicKey, privateKey }
|
||||
manager.rotate();
|
||||
manager.verifierKeys(); // [newPub, oldPub] within grace
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { KeyRotationManager } from "@suaveplan/network-plugin-crypto-messages";
|
||||
|
||||
let now = 0;
|
||||
const events: unknown[] = [];
|
||||
const manager = new KeyRotationManager({
|
||||
initial,
|
||||
rotateAfterMs: 3_600_000,
|
||||
graceMs: 600_000,
|
||||
clock: () => now,
|
||||
bus: { emit: (_event, payload) => events.push(payload) },
|
||||
generator: () => ({
|
||||
privateKey: new Uint8Array(32),
|
||||
publicKey: new Uint8Array(32),
|
||||
}),
|
||||
});
|
||||
|
||||
now += 3_600_001;
|
||||
manager.maybeRotate(); // true → bus emits key:rotated
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class KeyRotationManager(config: KeyRotationManagerConfig)`
|
||||
- `current(): KeyPair`.
|
||||
- `verifierKeys(): ReadonlyArray<Uint8Array>` — keys verifiers should accept now.
|
||||
- `rotate(): KeyPair` — rotate immediately.
|
||||
- `maybeRotate(): boolean` — rotate only if past `rotateAfterMs`.
|
||||
- `interface KeyRotationManagerConfig` — `{ initial, rotateAfterMs?, graceMs?, clock?, bus?, generator? }`.
|
||||
- `interface KeyRotationBus` — `{ emit("key:rotated", payload): void }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The retired key list is single-slot — multi-step rotations would require chaining, which is out of scope. Production deployments using shorter rotation cycles should provision additional managers per scope rather than pile retired keys on one manager.
|
||||
- `verifierKeys()` lazily evicts the retired key when its grace window has expired, so callers do not need a separate cleanup pass.
|
||||
- `maybeRotate()` is idempotent: callers can poll on a heartbeat without coordination because rotation only fires once per `rotateAfterMs`.
|
||||
- The generator is injected so deterministic tests pin the key bytes; production callers omit it and let the manager use the Ed25519 randomness primitive.
|
||||
@@ -0,0 +1,61 @@
|
||||
# `LogChainSigner`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Append-only signed log chain. Each entry carries the SHA-256 hash of the previous entry's bytes plus the current payload, signed under an Ed25519 key. `verify()` walks the chain from suaveplan and raises `CHAIN_TAMPERED` on the first hash mismatch or signature failure — guaranteeing that any tamper to a leaf is detectable as a chain break.
|
||||
|
||||
## Features
|
||||
|
||||
- suaveplan hash is 32 zero bytes; the signature on the first entry covers the zero hash plus the suaveplan payload.
|
||||
- `verify()` reports the offending sequence number in `error.context.seq` so consumers locate the corruption without re-walking the chain.
|
||||
- `chain()` returns a copy of the entry buffer so subscribers cannot mutate the in-memory chain by side-effect.
|
||||
- Injectable clock for deterministic tests.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ed25519 } from "@suaveplan/crypto-curve";
|
||||
import { LogChainSigner } from "@suaveplan/network-plugin-crypto-messages";
|
||||
|
||||
const priv = ed25519.randomPrivateKey();
|
||||
const pub = ed25519.getPublicKey(priv);
|
||||
const signer = new LogChainSigner();
|
||||
signer.append({ event: "join", peer: "alice" }, { publicKey: pub, privateKey: priv });
|
||||
signer.append({ event: "leave", peer: "bob" }, { publicKey: pub, privateKey: priv });
|
||||
signer.verify(); // ok
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { LogChainSigner } from "@suaveplan/network-plugin-crypto-messages";
|
||||
|
||||
const signer = new LogChainSigner({ clock: () => 1_700_000_000_000 });
|
||||
for (const event of events) {
|
||||
signer.append(event, hostKeyPair);
|
||||
}
|
||||
|
||||
const replicated = signer.chain();
|
||||
// Ship `replicated` to a sibling host; receiver replays via a fresh
|
||||
// LogChainSigner-equivalent verifier.
|
||||
signer.verify();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class LogChainSigner(config?)`
|
||||
- `append(payload, keyPair): LogChainEntry<TPayload>`.
|
||||
- `chain(): ReadonlyArray<LogChainEntry>`.
|
||||
- `verify(): void` — throws `CHAIN_TAMPERED` on first inconsistency.
|
||||
- `reset(): void` — clears the chain (test helper).
|
||||
- `interface LogChainEntry<TPayload>` — `{ seq, previousHash, payload, signature, signerPublicKey, recordedAt }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `previousHash` is the SHA-256 of the previous entry's `(previousHash || canonicalSerialise(payload))` bytes — NOT the previous signature. Hashing the bytes covered by the signature is sufficient for tamper-evidence and avoids a circular dependency where the next entry's hash depends on the current entry's signature.
|
||||
- `signerPublicKey` is recorded per entry so verifiers can roll signing keys mid-chain (multiple hosts contributing to the same chain) without breaking earlier entries.
|
||||
- The signer never zeroizes or persists `keyPair` — that is the caller's responsibility. Production deployments wire a secure-key manager.
|
||||
- `entryBytes` returns the canonical bytes for both signing and hash recomputation so signers and verifiers agree.
|
||||
@@ -0,0 +1,68 @@
|
||||
# `CryptoMessagesPlugin`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Single `NetworkPlugin` that bundles every crypto-messages subsystem (`MessageSigner`, `SessionTokenAuthority`, `MeshTokenAuthority`, `PeerIdentityVerifier`, `LogChainSigner`, `KeyRotationManager`) and ties their lifecycle to a host `NetworkNode`. Each subsystem is exposed as a public field so consumers extend behaviour by reaching past the plugin facade.
|
||||
|
||||
## Features
|
||||
|
||||
- One trusted-server public key drives both token authorities so configuration cannot accidentally diverge.
|
||||
- Initial key pair plus optional rotation config wires into a `KeyRotationManager`.
|
||||
- `dispose` resets every cached state (replay caches, pending challenges, log chain) so a re-install starts clean.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ed25519 } from "@suaveplan/crypto-curve";
|
||||
import { NetworkBus, NetworkNode } from "@suaveplan/network";
|
||||
import { CryptoMessagesPlugin } from "@suaveplan/network-plugin-crypto-messages";
|
||||
|
||||
const priv = ed25519.randomPrivateKey();
|
||||
const initialKey = { privateKey: priv, publicKey: ed25519.getPublicKey(priv) };
|
||||
const serverPriv = ed25519.randomPrivateKey();
|
||||
|
||||
const node = new NetworkNode({ id: "n", networkBus: new NetworkBus() });
|
||||
const plugin = new CryptoMessagesPlugin({
|
||||
initialKey,
|
||||
serverPublicKey: ed25519.getPublicKey(serverPriv),
|
||||
});
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
|
||||
const signed = plugin.signer.sign({ ping: 1 }, plugin.rotation.current());
|
||||
plugin.signer.verify(signed); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { CryptoMessagesPlugin } from "@suaveplan/network-plugin-crypto-messages";
|
||||
|
||||
const plugin = new CryptoMessagesPlugin({
|
||||
initialKey,
|
||||
serverPublicKey,
|
||||
rotation: { rotateAfterMs: 60 * 60_000, graceMs: 5 * 60_000 },
|
||||
sessionAuthority: { replayWindowMs: 10 * 60_000 },
|
||||
meshAuthority: { replayWindowMs: 10 * 60_000 },
|
||||
identity: { defaultTtlMs: 15_000 },
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class CryptoMessagesPlugin implements NetworkPlugin`
|
||||
- Public fields: `signer`, `sessionAuthority`, `meshAuthority`, `identity`, `logChain`, `rotation`.
|
||||
- `constructor(config: CryptoMessagesPluginConfig)`.
|
||||
- `install(node): void` / `start(): void` / `stop(): void` / `dispose(): void`.
|
||||
- `host(): NetworkNode | null` / `isStarted(): boolean`.
|
||||
- `interface CryptoMessagesPluginConfig` — `{ initialKey, serverPublicKey, signer?, rotation?, sessionAuthority?, meshAuthority?, identity? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The plugin holds _references_ to subsystems rather than re-exporting their methods so refactors inside any subsystem do not reshape the plugin facade.
|
||||
- `dispose` resets every cached state but keeps the subsystem references so consumers' field reads stay non-null until the next construct cycle.
|
||||
- The plugin does not auto-subscribe to bus events — wiring `peer:*` / `session:*` / `mesh:*` flows into the relevant authorities is the consumer's responsibility because those event names vary by deployment.
|
||||
- `start` / `stop` only flip a boolean: every subsystem is fully usable without the plugin being started — `start` exists to satisfy the `NetworkPlugin` contract.
|
||||
@@ -0,0 +1,66 @@
|
||||
# `MessageSigner`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Ed25519-backed signer + verifier for the network. `sign(payload, keyPair)` returns a `SignedMessage` with the signature, signer's public key, and timestamp. `verify(message)` returns a boolean for caller-side branching. `verifyForPeer(peerId, message)` couples the verification to a `RegistryLookup` so receivers reject messages whose embedded public key doesn't match the registry's recorded peer key.
|
||||
|
||||
## Features
|
||||
|
||||
- Canonical-serialise (sorted-keys JSON) payload before signing so receivers reproduce the bytes deterministically.
|
||||
- `verifyForPeer` raises three distinct codes (`KEY_NOT_FOUND`, `IDENTITY_MISMATCH`, `SIGNATURE_INVALID`) so dispatchers branch on the failure class.
|
||||
- Constant-time `bytesEqual` exported as a free helper for callers comparing keys / signatures elsewhere.
|
||||
- Injectable clock so deterministic tests pin `signedAt`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ed25519 } from "@suaveplan/crypto-curve";
|
||||
import { MessageSigner } from "@suaveplan/network-plugin-crypto-messages";
|
||||
|
||||
const signer = new MessageSigner();
|
||||
const privateKey = ed25519.randomPrivateKey();
|
||||
const publicKey = ed25519.getPublicKey(privateKey);
|
||||
const signed = signer.sign({ ping: 1 }, { publicKey, privateKey });
|
||||
signer.verify(signed); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ed25519 } from "@suaveplan/crypto-curve";
|
||||
import {
|
||||
MessageSigner,
|
||||
type RegistryLookup,
|
||||
} from "@suaveplan/network-plugin-crypto-messages";
|
||||
|
||||
const registry: RegistryLookup = {
|
||||
publicKeyFor: (peerId) => peerId === "alice" ? alicePub : undefined,
|
||||
};
|
||||
const signer = new MessageSigner({ registry });
|
||||
|
||||
try {
|
||||
signer.verifyForPeer("alice", aliceSigned);
|
||||
} catch (err) {
|
||||
// KEY_NOT_FOUND / IDENTITY_MISMATCH / SIGNATURE_INVALID
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class MessageSigner(config?)`
|
||||
- `sign(payload, keyPair): SignedMessage<TPayload>`.
|
||||
- `verify(message): boolean`.
|
||||
- `verifyForPeer(peerId, message): void` — throws on registry / identity / signature failures.
|
||||
- `interface RegistryLookup` — `{ publicKeyFor(peerId): Uint8Array | undefined }`.
|
||||
- `function serialise(value): Uint8Array` — canonical UTF-8 bytes.
|
||||
- `function bytesEqual(a, b): boolean` — constant-time.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Canonical serialisation sorts object keys alphabetically and serialises numbers / booleans / strings to their JSON form. Non-finite numbers (`NaN`, `Infinity`) serialise to `"null"` to keep round-trip determinism.
|
||||
- Signature payload is the UTF-8 bytes of the canonical string — receivers do NOT need a second runtime to reproduce the bytes; any implementation following the same canonicalisation rules will match.
|
||||
- `verifyForPeer` requires a `registry`; without one, callers should use `verify` directly.
|
||||
- The signer never persists `keyPair` — the caller passes it on every sign call so rotation lives entirely in the caller's lifecycle.
|
||||
@@ -0,0 +1,72 @@
|
||||
# `MeshTokenAuthority`
|
||||
|
||||
## Purpose
|
||||
|
||||
Validates server-signed `MeshToken`s. Mirrors `SessionTokenAuthority` for the mesh-creation ceremony — same expiry / forgery / replay checks, same bus emit, same `canonical*Bytes` helper for signer-validator agreement.
|
||||
|
||||
## Features
|
||||
|
||||
- Same canonical-serialisation rules as `SessionTokenAuthority` (sorted-keys JSON without `serverSignature`).
|
||||
- Replay cache keyed on `mesh:<meshId>` with windowed eviction.
|
||||
- Reuses the shared `TokenBus` interface so a single subscriber observes both session and mesh token decisions.
|
||||
- Injectable clock + bus for deterministic tests.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MeshTokenAuthority,
|
||||
type MeshToken,
|
||||
} from "@suaveplan/network-plugin-crypto-messages";
|
||||
|
||||
const authority = new MeshTokenAuthority({ serverPublicKey });
|
||||
declare const token: MeshToken;
|
||||
authority.validate(token);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ed25519 } from "@suaveplan/crypto-curve";
|
||||
import {
|
||||
canonicalMeshTokenBytes,
|
||||
MeshTokenAuthority,
|
||||
type MeshToken,
|
||||
} from "@suaveplan/network-plugin-crypto-messages";
|
||||
|
||||
const serverPriv = ed25519.randomPrivateKey();
|
||||
const serverPub = ed25519.getPublicKey(serverPriv);
|
||||
const unsigned = {
|
||||
meshId: "m1",
|
||||
maxCapacity: 64,
|
||||
allowedTiers: 0b111,
|
||||
issuedAt: 1_700_000_000_000,
|
||||
expiresAt: 1_700_000_000_000 + 10_000,
|
||||
};
|
||||
const sig = ed25519.sign(
|
||||
canonicalMeshTokenBytes({ ...unsigned, serverSignature: new Uint8Array() } as MeshToken),
|
||||
serverPriv
|
||||
);
|
||||
const token: MeshToken = { ...unsigned, serverSignature: sig };
|
||||
|
||||
const authority = new MeshTokenAuthority({
|
||||
serverPublicKey: serverPub,
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
authority.validate(token);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class MeshTokenAuthority(config: MeshTokenAuthorityConfig)`
|
||||
- `validate(token: MeshToken): void` — throws `TOKEN_EXPIRED` / `TOKEN_REPLAYED` / `TOKEN_INVALID`.
|
||||
- `reset(): void` — clears the replay cache.
|
||||
- `interface MeshTokenAuthorityConfig` — `{ serverPublicKey, clock?, bus?, replayWindowMs? }`.
|
||||
- `function canonicalMeshTokenBytes(token): Uint8Array`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Splitting session and mesh authorities into distinct classes (vs one parametrised authority) keeps the per-token replay caches independent — a session-token replay does not pollute the mesh-token namespace.
|
||||
- The authority does not validate `allowedTiers` semantically — it only validates the signature. Tier enforcement is the consumer's responsibility (typically the topology plugin) so the crypto layer stays narrow.
|
||||
- `replayWindowMs` defaults to 1 hour. Long enough that intentional retries within a peer reconnect cycle do not slip through; short enough that the cache stays small under sustained traffic.
|
||||
- Bus emit failures do not block validation — observability is best-effort.
|
||||
@@ -0,0 +1,71 @@
|
||||
# `SessionTokenAuthority`
|
||||
|
||||
## Purpose
|
||||
|
||||
Validates server-signed `SessionToken`s. Rejects expired tokens (`expiresAt <= now`), forged tokens (Ed25519 verify failure), and replayed tokens (same `sessionId` seen within the replay window). Emits `token:valid` / `token:invalid` on the bus so observability subscribers can plot acceptance rate.
|
||||
|
||||
## Features
|
||||
|
||||
- Deterministic canonical-serialisation of the token (without `serverSignature`) so signers and validators agree on the bytes covered by the signature.
|
||||
- Replay cache keyed on `session:<sessionId>` with windowed eviction so long-running peers do not accumulate unbounded state.
|
||||
- `reset()` clears the replay cache for tests / re-attach scenarios.
|
||||
- Injectable clock + bus for deterministic tests.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ed25519 } from "@suaveplan/crypto-curve";
|
||||
import {
|
||||
SessionTokenAuthority,
|
||||
type SessionToken,
|
||||
} from "@suaveplan/network-plugin-crypto-messages";
|
||||
|
||||
const serverPriv = ed25519.randomPrivateKey();
|
||||
const authority = new SessionTokenAuthority({
|
||||
serverPublicKey: ed25519.getPublicKey(serverPriv),
|
||||
});
|
||||
declare const token: SessionToken;
|
||||
authority.validate(token); // throws on expired / forged / replayed
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
canonicalSessionTokenBytes,
|
||||
SessionTokenAuthority,
|
||||
type TokenBus,
|
||||
} from "@suaveplan/network-plugin-crypto-messages";
|
||||
|
||||
const events: unknown[] = [];
|
||||
const bus: TokenBus = {
|
||||
emit: (_event, payload) => events.push(payload),
|
||||
};
|
||||
|
||||
const authority = new SessionTokenAuthority({
|
||||
serverPublicKey,
|
||||
bus,
|
||||
clock: () => 1_700_000_000_000,
|
||||
replayWindowMs: 5 * 60_000,
|
||||
});
|
||||
|
||||
// Server-side helper for issuing tokens
|
||||
const bytes = canonicalSessionTokenBytes(unsignedToken);
|
||||
const serverSignature = ed25519.sign(bytes, serverPrivateKey);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class SessionTokenAuthority(config: SessionTokenAuthorityConfig)`
|
||||
- `validate(token: SessionToken): void` — throws `TOKEN_EXPIRED` / `TOKEN_REPLAYED` / `TOKEN_INVALID`.
|
||||
- `reset(): void` — clears the replay cache.
|
||||
- `interface SessionTokenAuthorityConfig` — `{ serverPublicKey, clock?, bus?, replayWindowMs? }`.
|
||||
- `interface TokenBus` — `{ emit("token:valid" | "token:invalid", payload): void }`.
|
||||
- `function canonicalSessionTokenBytes(token): Uint8Array` — used by signers (server) and validators (peers) to reproduce the same bytes.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Canonicalisation sorts object keys alphabetically before UTF-8 encoding so any implementation following the same rules produces byte-identical output.
|
||||
- The replay cache has windowed-GC: every successful validate runs a sweep over entries past `replayWindowMs`. The cost is `O(seen.size)` but the cache is typically small in production (bounded by active sessions).
|
||||
- Bus events are best-effort; if the consumer's emit throws, the validator still completes its decision — observability does not gate validation.
|
||||
- The authority does NOT cache the server public key — callers can rotate the server key by constructing a new authority and discarding the old one.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Identity types — `@suaveplan/network-plugin-crypto-messages`
|
||||
|
||||
## Purpose
|
||||
|
||||
Wire shapes for the peer-identity challenge-response handshake. The challenger sends an `IdentityChallenge` (random nonce + expiry); the responder signs the nonce with their Ed25519 private key and returns an `IdentityResponse`. The verifier matches the response's nonce against the issued challenge and verifies the signature using the peer's expected public key from the registry.
|
||||
|
||||
## Features
|
||||
|
||||
- `IdentityChallenge` carries `issuedAt` + `expiresAt` so verifiers reject stale challenges without a separate timestamp argument.
|
||||
- `IdentityResponse` echoes the nonce and includes the responder's public key so verifiers can reject out-of-band substitutions.
|
||||
- All keys / nonces / signatures are `Uint8Array` — no base64 encoding pinned at the type level so callers pick their own transport.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
IdentityChallenge,
|
||||
IdentityResponse,
|
||||
} from "@suaveplan/network-plugin-crypto-messages";
|
||||
|
||||
declare const challenge: IdentityChallenge;
|
||||
declare const response: IdentityResponse;
|
||||
console.log(
|
||||
challenge.nonce.length, // 32
|
||||
response.signerPublicKey.length // 32
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
IdentityChallenge,
|
||||
IdentityResponse,
|
||||
} from "@suaveplan/network-plugin-crypto-messages";
|
||||
|
||||
declare function verify(
|
||||
challenge: IdentityChallenge,
|
||||
response: IdentityResponse,
|
||||
expectedPublicKey: Uint8Array
|
||||
): boolean;
|
||||
|
||||
declare const c: IdentityChallenge;
|
||||
declare const r: IdentityResponse;
|
||||
declare const known: Uint8Array;
|
||||
const ok = verify(c, r, known);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface IdentityChallenge` — `{ nonce, issuedAt, expiresAt }`.
|
||||
- `interface IdentityResponse` — `{ nonce, signerPublicKey, signature, respondedAt }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The nonce is 32 bytes by convention but the type does not pin a length — callers using shorter nonces should add a runtime guard. The plugin's `PeerIdentityVerifier` validates length at the boundary.
|
||||
- `respondedAt` is included for receiver-side latency observability; it is NOT covered by the signature so a malicious responder cannot use it to backdate the handshake.
|
||||
- The signature is over the nonce bytes alone — keeping the signed content trivially canonical means there is no "what's covered?" ambiguity. Verifiers reject any response whose nonce ≠ the issued challenge nonce.
|
||||
- `signerPublicKey` is echoed in the response so multi-key deployments (key rotation in flight) can verify even when the registry's primary key has rolled.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Signature types — `@suaveplan/network-plugin-crypto-messages`
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared shapes for the signing / verification surface. `KeyPair` is the caller-supplied (public, private) tuple the signer reads at use-time but never persists. `MessageSignature` carries the bytes of an Ed25519 signature plus the originator's public key + timestamp, all of which receivers need to verify a message against the registry. `SignedMessage<T>` is the wrapped envelope shape that consumers serialise across the wire.
|
||||
|
||||
## Features
|
||||
|
||||
- `Uint8Array` for raw key + signature bytes — no base64 encoding pinned at the type level so callers choose their own encoding for transport.
|
||||
- `signedAt` carries the wall-clock at sign time so receivers can pair with replay-window checks elsewhere.
|
||||
- `SignedMessage<TPayload>` is generic so consumers can keep their domain payload types.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
KeyPair,
|
||||
MessageSignature,
|
||||
SignedMessage,
|
||||
} from "@suaveplan/network-plugin-crypto-messages";
|
||||
|
||||
declare const keyPair: KeyPair;
|
||||
declare const signed: SignedMessage<{ ping: number }>;
|
||||
console.log(
|
||||
signed.signature.signerPublicKey,
|
||||
signed.signature.signature,
|
||||
signed.signature.signedAt
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { SignedMessage } from "@suaveplan/network-plugin-crypto-messages";
|
||||
|
||||
interface RegistryUpdate {
|
||||
readonly peerId: string;
|
||||
readonly publicKey: Uint8Array;
|
||||
readonly tier: number;
|
||||
}
|
||||
|
||||
declare const update: SignedMessage<RegistryUpdate>;
|
||||
declare function verifyAgainstRegistry(s: SignedMessage<unknown>): boolean;
|
||||
|
||||
if (verifyAgainstRegistry(update)) {
|
||||
console.log(update.payload.peerId, update.payload.tier);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface KeyPair` — `{ publicKey, privateKey }` (both `Uint8Array`).
|
||||
- `interface MessageSignature` — `{ signerPublicKey, signature, signedAt }`.
|
||||
- `interface SignedMessage<TPayload = unknown>` — `{ payload, signature }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The signature does not embed the payload bytes — receivers compute the canonical form themselves so a tampered payload produces a verify failure rather than a "signature missing" branch.
|
||||
- `signedAt` is a number (epoch ms) rather than `Date` because signed envelopes serialise to JSON / binary and numeric timestamps round-trip without conversion.
|
||||
- Holding the private key in `KeyPair` is a deliberate trade — the consumer owns lifecycle (rotation, secure storage, zeroize) and the plugin never copies the key into long-lived state.
|
||||
- Future post-quantum algorithms can reuse this shape because the algorithm identifier is implicit (Ed25519 today); a deployment that mixes algorithms should add an `algorithm` field on a wrapping envelope.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Token types — `@suaveplan/network-plugin-crypto-messages`
|
||||
|
||||
## Purpose
|
||||
|
||||
Wire shapes for the two server-signed authority tokens — `SessionToken` (gates session creation) and `MeshToken` (gates mesh creation). Both carry an Ed25519 `serverSignature` over the canonical bytes of the rest of the token. The discriminated `TokenPayload` lets dispatchers branch on the token type without inspecting structural keys.
|
||||
|
||||
## Features
|
||||
|
||||
- Symmetric `issuedAt` / `expiresAt` fields so validators can apply a single freshness check across both token kinds.
|
||||
- `rulesHash` on `SessionToken` ties session creation to a snapshot of session rules so peers cannot accept a token under a divergent rule set.
|
||||
- `parentSessionId: string | null` for explicit "this is a top-level session" vs "child session" disambiguation.
|
||||
- `allowedTiers` on `MeshToken` carried as a number bitmask so deployments encode tier-allowance compactly.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
MeshToken,
|
||||
SessionToken,
|
||||
TokenPayload,
|
||||
} from "@suaveplan/network-plugin-crypto-messages";
|
||||
|
||||
declare const session: SessionToken;
|
||||
declare const mesh: MeshToken;
|
||||
declare const payload: TokenPayload;
|
||||
if (payload.kind === "session") {
|
||||
console.log(payload.token.sessionId);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { SessionToken } from "@suaveplan/network-plugin-crypto-messages";
|
||||
|
||||
const token: SessionToken = {
|
||||
sessionId: "sess-abc",
|
||||
parentSessionId: null,
|
||||
capacity: 8,
|
||||
rulesHash: "sha256:...",
|
||||
issuedAt: 1_700_000_000_000,
|
||||
expiresAt: 1_700_000_300_000,
|
||||
serverSignature: new Uint8Array(64),
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SessionToken` — `{ sessionId, parentSessionId, capacity, rulesHash, issuedAt, expiresAt, serverSignature }`.
|
||||
- `interface MeshToken` — `{ meshId, maxCapacity, allowedTiers, issuedAt, expiresAt, serverSignature }`.
|
||||
- `type TokenPayload` — `{ kind: "session", token } | { kind: "mesh", token }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `serverSignature` covers the canonical byte representation of every other field. Validators reconstruct the bytes locally and verify against the trusted server public key.
|
||||
- `expiresAt` is wall-clock; validators consult an injectable clock so deterministic tests pin freshness explicitly.
|
||||
- `parentSessionId` is `null` (not `undefined`) so the field is always present on the wire — JSON serialisers retain `null` but drop `undefined`.
|
||||
- `allowedTiers` as a bitmask trades readability for compactness; deployments with > 32 tiers should switch to an array form via a wrapping envelope.
|
||||
Reference in New Issue
Block a user