Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
# @suaveplan/network-plugin-crypto-messages - Cryptographic integrity for the Suaveplan mesh: Ed25519 message signatures, server-signed session/mesh tokens, peer identity challenge-response, hash-chained signed logs, key rotation, plugin security verifier, tier-specific security policies
## Summary
Cryptographic integrity layer for the Suaveplan mesh. Ed25519-backed `MessageSigner` (sign on send, verify against the registry on receive); `SessionTokenAuthority` and `MeshTokenAuthority` validating server-signed tokens with replay + expiry rejection; `PeerIdentityVerifier` running challenge-response handshakes to defeat peer-id spoofing; `LogChainSigner` producing tamper-evident hash-chained signed audit logs; `KeyRotationManager` periodically rotating signing keys with grace period and bus broadcast; `PluginSecurityVerifier` + `PluginCapabilityValidator` + `PluginActivityMonitor` + `SandboxedPluginContext` covering plugin-side integrity and capability containment; `TierSecurityPolicy` + `CredentialDelegation` + `GeoSecurityPolicy` for tier-aware and region-aware policy enforcement. Wrapped in a single `CryptoMessagesPlugin` satisfying the `NetworkPlugin` lifecycle.
<!-- begin:auto-docs -->
## Documentation tree
- [identity](./src/identity/peer-identity-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [peer-identity-verifier](./src/identity/peer-identity-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [key-rotation](./src/key-rotation/key-rotation-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [key-rotation-manager](./src/key-rotation/key-rotation-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [log-chain](./src/log-chain/log-chain-signer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [log-chain-signer](./src/log-chain/log-chain-signer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plugin](./src/plugin/crypto-messages-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [crypto-messages-plugin](./src/plugin/crypto-messages-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [signer](./src/signer/message-signer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [message-signer](./src/signer/message-signer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [mesh-token-authority](./src/token/mesh-token-authority.md) — Validates server-signed `MeshToken`s. Mirrors `SessionTokenAuthority` for the mesh-creation ceremony — same expiry / fo…
- [session-token-authority](./src/token/session-token-authority.md) — Validates server-signed `SessionToken`s. Rejects expired tokens (`expiresAt <= now`), forged tokens (Ed25519 verify fai…
- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the crypto-messages package. Codes are namespaced under `NET_CRYPTO` and…
<!-- end:auto-docs --> - [peer-identity-verifier](./src/identity/peer-identity-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-rotation](./src/key-rotation/key-rotation-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-rotation-manager](./src/key-rotation/key-rotation-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-chain](./src/log-chain/log-chain-signer.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-chain-signer](./src/log-chain/log-chain-signer.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/crypto-messages-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [crypto-messages-plugin](./src/plugin/crypto-messages-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [signer](./src/signer/message-signer.md) — <!-- begin:auto-docs --> _(no submodules)_- [message-signer](./src/signer/message-signer.md) — <!-- begin:auto-docs --> _(no submodules)_- [mesh-token-authority](./src/token/mesh-token-authority.md) — Validates server-signed `MeshToken`s. Mirrors `SessionTokenAuthority` for the mesh-creation ceremony — same expiry / fo…
- [session-token-authority](./src/token/session-token-authority.md) — Validates server-signed `SessionToken`s. Rejects expired tokens (`expiresAt <= now`), forged tokens (Ed25519 verify fai…
- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the crypto-messages package. Codes are namespaced under `NET_CRYPTO` and…
- [peer-identity-verifier](./src/identity/peer-identity-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-rotation](./src/key-rotation/key-rotation-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-rotation-manager](./src/key-rotation/key-rotation-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-chain](./src/log-chain/log-chain-signer.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-chain-signer](./src/log-chain/log-chain-signer.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/crypto-messages-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [crypto-messages-plugin](./src/plugin/crypto-messages-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [signer](./src/signer/message-signer.md) — <!-- begin:auto-docs --> _(no submodules)_- [message-signer](./src/signer/message-signer.md) — <!-- begin:auto-docs --> _(no submodules)_- [mesh-token-authority](./src/token/mesh-token-authority.md) — Validates server-signed `MeshToken`s. Mirrors `SessionTokenAuthority` for the mesh-creation ceremony — same expiry / fo…
- [session-token-authority](./src/token/session-token-authority.md) — Validates server-signed `SessionToken`s. Rejects expired tokens (`expiresAt <= now`), forged tokens (Ed25519 verify fai…
- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the crypto-messages package. Codes are namespaced under `NET_CRYPTO` and…
- [peer-identity-verifier](./src/identity/peer-identity-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-rotation](./src/key-rotation/key-rotation-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-rotation-manager](./src/key-rotation/key-rotation-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-chain](./src/log-chain/log-chain-signer.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-chain-signer](./src/log-chain/log-chain-signer.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/crypto-messages-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [crypto-messages-plugin](./src/plugin/crypto-messages-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [signer](./src/signer/message-signer.md) — <!-- begin:auto-docs --> _(no submodules)_- [message-signer](./src/signer/message-signer.md) — <!-- begin:auto-docs --> _(no submodules)_- [mesh-token-authority](./src/token/mesh-token-authority.md) — Validates server-signed `MeshToken`s. Mirrors `SessionTokenAuthority` for the mesh-creation ceremony — same expiry / fo…
- [session-token-authority](./src/token/session-token-authority.md) — Validates server-signed `SessionToken`s. Rejects expired tokens (`expiresAt <= now`), forged tokens (Ed25519 verify fai…
- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the crypto-messages package. Codes are namespaced under `NET_CRYPTO` and…
- [peer-identity-verifier](./src/identity/peer-identity-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-rotation](./src/key-rotation/key-rotation-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [key-rotation-manager](./src/key-rotation/key-rotation-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-chain](./src/log-chain/log-chain-signer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [log-chain-signer](./src/log-chain/log-chain-signer.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/crypto-messages-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [crypto-messages-plugin](./src/plugin/crypto-messages-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [signer](./src/signer/message-signer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [message-signer](./src/signer/message-signer.md) — <!-- begin:auto-docs --> _(no submodules)_- [mesh-token-authority](./src/token/mesh-token-authority.md) — Validates server-signed `MeshToken`s. Mirrors `SessionTokenAuthority` for the mesh-creation ceremony — same expiry / fo…
- [session-token-authority](./src/token/session-token-authority.md) — Validates server-signed `SessionToken`s. Rejects expired tokens (`expiresAt <= now`), forged tokens (Ed25519 verify fai…
- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the crypto-messages package. Codes are namespaced under `NET_CRYPTO` and…

View File

@@ -0,0 +1,52 @@
{
"name": "@suaveplan/network-plugin-crypto-messages",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Cryptographic integrity for the Suaveplan mesh: Ed25519 message signatures, server-signed session/mesh tokens, peer identity challenge-response, hash-chained signed logs, key rotation, plugin security verifier, tier-specific security policies",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/crypto-curve": "workspace:^",
"@suaveplan/crypto-hash": "workspace:^",
"@suaveplan/crypto-utils": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/network": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

View File

@@ -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.

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_CRYPTO_CODES, NetworkCryptoError } from "./error-codes.js";
describe("NETWORK_CRYPTO_CODES", () => {
it("registers under NET_CRYPTO", () => {
for (const value of Object.values(NETWORK_CRYPTO_CODES)) {
expect(value).toMatch(/^NET_CRYPTO\./);
}
});
it("is frozen", () => {
expect(Object.isFrozen(NETWORK_CRYPTO_CODES)).toBe(true);
});
it("contains the documented codes", () => {
for (const key of [
"SIGNATURE_INVALID",
"TOKEN_EXPIRED",
"TOKEN_INVALID",
"TOKEN_REPLAYED",
"IDENTITY_MISMATCH",
"CHAIN_TAMPERED",
"KEY_NOT_FOUND",
] as const) {
expect(NETWORK_CRYPTO_CODES).toHaveProperty(key);
}
});
});
describe("NetworkCryptoError", () => {
it("extends SuaveplanError", () => {
const err = new NetworkCryptoError(
NETWORK_CRYPTO_CODES.SIGNATURE_INVALID,
"x"
);
expect(err.name).toBe("NetworkCryptoError");
expect(err.code).toBe(NETWORK_CRYPTO_CODES.SIGNATURE_INVALID);
});
it("preserves cause", () => {
const cause = new Error("inner");
const err = new NetworkCryptoError(
NETWORK_CRYPTO_CODES.TOKEN_EXPIRED,
"with-cause",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,29 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
export const NETWORK_CRYPTO_CODES = defineCodes("NET_CRYPTO", [
"SIGNATURE_INVALID",
"TOKEN_EXPIRED",
"TOKEN_INVALID",
"TOKEN_REPLAYED",
"IDENTITY_MISMATCH",
"CHAIN_TAMPERED",
"KEY_NOT_FOUND",
] as const);
export type NetworkCryptoErrorCode =
(typeof NETWORK_CRYPTO_CODES)[keyof typeof NETWORK_CRYPTO_CODES];
export class NetworkCryptoError extends SuaveplanError<NetworkCryptoErrorCode> {
public constructor(
code: NetworkCryptoErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "NetworkCryptoError";
}
}

View File

@@ -0,0 +1,140 @@
import { ed25519 } from "@suaveplan/crypto-curve";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
NETWORK_CRYPTO_CODES,
type NetworkCryptoError,
} from "../error-codes.js";
import { PeerIdentityVerifier } from "./peer-identity-verifier.js";
const setup = () => {
const verifier = new PeerIdentityVerifier({
clock: () => 1_000,
random: (n) => new Uint8Array(n).fill(7),
});
const priv = ed25519.randomPrivateKey();
const pub = ed25519.getPublicKey(priv);
return { verifier, priv, pub };
};
describe("PeerIdentityVerifier: challenge / response / spoof detection", () => {
it("issue + sign + verify round-trips", () => {
const { verifier, priv, pub } = setup();
const challenge = verifier.issueChallenge("peer-A");
const response = verifier.sign(challenge, priv);
expect(() => verifier.verify("peer-A", response, pub)).not.toThrow();
});
it("rejects unknown pending peer with IDENTITY_MISMATCH", () => {
const { verifier, priv, pub } = setup();
const challenge = verifier.issueChallenge("peer-A");
const response = verifier.sign(challenge, priv);
try {
verifier.verify("peer-B", response, pub);
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkCryptoError).code).toBe(
NETWORK_CRYPTO_CODES.IDENTITY_MISMATCH
);
}
});
it("rejects expired challenges", () => {
let now = 1_000;
const verifier = new PeerIdentityVerifier({
clock: () => now,
defaultTtlMs: 100,
random: (n) => new Uint8Array(n).fill(7),
});
const priv = ed25519.randomPrivateKey();
const pub = ed25519.getPublicKey(priv);
const challenge = verifier.issueChallenge("peer-A");
now += 200;
const response = verifier.sign(challenge, priv);
try {
verifier.verify("peer-A", response, pub);
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkCryptoError).code).toBe(
NETWORK_CRYPTO_CODES.IDENTITY_MISMATCH
);
}
});
it("rejects nonce mismatch", () => {
const { verifier, priv, pub } = setup();
verifier.issueChallenge("peer-A");
const response = verifier.sign(
{
nonce: new Uint8Array(32).fill(99),
issuedAt: 0,
expiresAt: 100_000,
},
priv
);
try {
verifier.verify("peer-A", response, pub);
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkCryptoError).code).toBe(
NETWORK_CRYPTO_CODES.IDENTITY_MISMATCH
);
}
});
it("rejects peer-key mismatch", () => {
const { verifier, priv } = setup();
const challenge = verifier.issueChallenge("peer-A");
const response = verifier.sign(challenge, priv);
const otherPriv = ed25519.randomPrivateKey();
const otherPub = ed25519.getPublicKey(otherPriv);
try {
verifier.verify("peer-A", response, otherPub);
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkCryptoError).code).toBe(
NETWORK_CRYPTO_CODES.IDENTITY_MISMATCH
);
}
});
it("rejects bad signature with SIGNATURE_INVALID", () => {
const { verifier, priv, pub } = setup();
const challenge = verifier.issueChallenge("peer-A");
const response = verifier.sign(challenge, priv);
const tampered = {
...response,
signature: new Uint8Array(64).fill(0),
};
try {
verifier.verify("peer-A", tampered, pub);
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkCryptoError).code).toBe(
NETWORK_CRYPTO_CODES.SIGNATURE_INVALID
);
}
});
it("hasPending + clear reflect the cache", () => {
const { verifier } = setup();
verifier.issueChallenge("peer-A");
expect(verifier.hasPending("peer-A")).toBe(true);
verifier.clear();
expect(verifier.hasPending("peer-A")).toBe(false);
});
it("default config (no random / clock) constructs without throwing", () => {
const v = new PeerIdentityVerifier();
const challenge = v.issueChallenge("peer-A");
expect(challenge.nonce.length).toBeGreaterThanOrEqual(16);
});
it("clamps tiny nonceBytes / ttl to safe minimums", () => {
const v = new PeerIdentityVerifier({ nonceBytes: 0, defaultTtlMs: 0 });
const challenge = v.issueChallenge("peer");
expect(challenge.nonce.length).toBe(16);
expect(challenge.expiresAt - challenge.issuedAt).toBeGreaterThanOrEqual(
1
);
});
});

View File

@@ -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.

View File

@@ -0,0 +1,130 @@
import { ed25519 } from "@suaveplan/crypto-curve";
import { NETWORK_CRYPTO_CODES, NetworkCryptoError } from "../error-codes.js";
import { bytesEqual } from "../signer/message-signer.js";
import type {
IdentityChallenge,
IdentityResponse,
} from "../types/identity.types.js";
export interface PeerIdentityVerifierConfig {
readonly clock?: () => number;
readonly nonceBytes?: number;
/** Default challenge TTL. Defaults to 30 seconds. */
readonly defaultTtlMs?: number;
/** Random source — defaults to `crypto.getRandomValues`. */
readonly random?: (length: number) => Uint8Array;
}
interface PendingChallenge {
readonly peerId: string;
readonly challenge: IdentityChallenge;
}
/**
* Issues random nonce challenges for peer-handshake identity proof and
* verifies the responses against the expected public key.
*/
export class PeerIdentityVerifier {
private readonly clock: () => number;
private readonly nonceBytes: number;
private readonly defaultTtlMs: number;
private readonly random: (length: number) => Uint8Array;
private readonly pending = new Map<string, PendingChallenge>();
public constructor(config: PeerIdentityVerifierConfig = {}) {
this.clock = config.clock ?? (() => Date.now());
this.nonceBytes = Math.max(16, config.nonceBytes ?? 32);
this.defaultTtlMs = Math.max(1, config.defaultTtlMs ?? 30_000);
this.random = config.random ?? defaultRandom;
}
public issueChallenge(peerId: string, ttlMs?: number): IdentityChallenge {
const now = this.clock();
const challenge: IdentityChallenge = {
nonce: this.random(this.nonceBytes),
issuedAt: now,
expiresAt: now + (ttlMs ?? this.defaultTtlMs),
};
this.pending.set(peerId, { peerId, challenge });
return challenge;
}
public sign(
challenge: IdentityChallenge,
privateKey: Uint8Array
): IdentityResponse {
const signature = ed25519.sign(challenge.nonce, privateKey);
const publicKey = ed25519.getPublicKey(privateKey);
return {
nonce: challenge.nonce,
signerPublicKey: publicKey,
signature,
respondedAt: this.clock(),
};
}
public verify(
peerId: string,
response: IdentityResponse,
expectedPublicKey: Uint8Array
): void {
const entry = this.pending.get(peerId);
if (entry === undefined) {
throw new NetworkCryptoError(
NETWORK_CRYPTO_CODES.IDENTITY_MISMATCH,
"identity.no-pending",
{ context: { peerId } }
);
}
const now = this.clock();
if (now > entry.challenge.expiresAt) {
this.pending.delete(peerId);
throw new NetworkCryptoError(
NETWORK_CRYPTO_CODES.IDENTITY_MISMATCH,
"identity.challenge-expired",
{ context: { peerId } }
);
}
if (!bytesEqual(response.nonce, entry.challenge.nonce)) {
throw new NetworkCryptoError(
NETWORK_CRYPTO_CODES.IDENTITY_MISMATCH,
"identity.nonce-mismatch",
{ context: { peerId } }
);
}
if (!bytesEqual(response.signerPublicKey, expectedPublicKey)) {
throw new NetworkCryptoError(
NETWORK_CRYPTO_CODES.IDENTITY_MISMATCH,
"identity.peer-key-mismatch",
{ context: { peerId } }
);
}
const ok = ed25519.verify(
response.signature,
response.nonce,
response.signerPublicKey
);
if (!ok) {
throw new NetworkCryptoError(
NETWORK_CRYPTO_CODES.SIGNATURE_INVALID,
"identity.bad-signature",
{ context: { peerId } }
);
}
this.pending.delete(peerId);
}
public hasPending(peerId: string): boolean {
return this.pending.has(peerId);
}
public clear(): void {
this.pending.clear();
}
}
function defaultRandom(length: number): Uint8Array {
const out = new Uint8Array(length);
crypto.getRandomValues(out);
return out;
}

View File

@@ -0,0 +1,66 @@
// Error codes (side-effect registration)
import "./error-codes.js";
// Errors
export {
NETWORK_CRYPTO_CODES,
NetworkCryptoError,
type NetworkCryptoErrorCode,
} from "./error-codes.js";
// Identity
export {
PeerIdentityVerifier,
type PeerIdentityVerifierConfig,
} from "./identity/peer-identity-verifier.js";
// Key rotation
export {
type KeyRotationBus,
KeyRotationManager,
type KeyRotationManagerConfig,
} from "./key-rotation/key-rotation-manager.js";
// Log chain
export {
type LogChainEntry,
LogChainSigner,
type LogChainSignerConfig,
} from "./log-chain/log-chain-signer.js";
// Plugin
export {
CryptoMessagesPlugin,
type CryptoMessagesPluginConfig,
} from "./plugin/crypto-messages-plugin.js";
// Signer
export {
bytesEqual,
MessageSigner,
type MessageSignerConfig,
type RegistryLookup,
serialise,
} from "./signer/message-signer.js";
// Token
export {
canonicalMeshTokenBytes,
MeshTokenAuthority,
type MeshTokenAuthorityConfig,
} from "./token/mesh-token-authority.js";
export {
canonicalSessionTokenBytes,
SessionTokenAuthority,
type SessionTokenAuthorityConfig,
type TokenBus,
} from "./token/session-token-authority.js";
// Types
export type {
IdentityChallenge,
IdentityResponse,
} from "./types/identity.types.js";
export type {
KeyPair,
MessageSignature,
SignedMessage,
} from "./types/signature.types.js";
export type {
MeshToken,
SessionToken,
TokenPayload,
} from "./types/token.types.js";

View File

@@ -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.

View File

@@ -0,0 +1,113 @@
import { ed25519 } from "@suaveplan/crypto-curve";
import type { KeyPair } from "../types/signature.types.js";
export interface KeyRotationBus {
emit(
event: "key:rotated",
payload: {
previous: Uint8Array;
current: Uint8Array;
rotatedAt: number;
}
): void;
}
export interface KeyRotationManagerConfig {
/** Initial key pair. */
readonly initial: KeyPair;
/** Time after which the active key is eligible for rotation. */
readonly rotateAfterMs?: number;
/** Time after rotation during which old signatures still verify. */
readonly graceMs?: number;
readonly clock?: () => number;
readonly bus?: KeyRotationBus;
/**
* Generator used to derive the next key pair. Defaults to the
* Ed25519 randomness primitive.
*/
readonly generator?: () => KeyPair;
}
interface RetiredKey {
readonly publicKey: Uint8Array;
readonly retiredAt: number;
}
/**
* Tracks the active signing key and the most-recently rotated key
* inside a grace window. Consumers call `maybeRotate()` on a cadence
* (heartbeat / minute timer) to flip keys when due.
*/
export class KeyRotationManager {
private readonly rotateAfterMs: number;
private readonly graceMs: number;
private readonly clock: () => number;
private readonly bus: KeyRotationBus | undefined;
private readonly generator: () => KeyPair;
private active: KeyPair;
private activatedAt: number;
private retired: RetiredKey | null = null;
public constructor(config: KeyRotationManagerConfig) {
this.rotateAfterMs = Math.max(
1,
config.rotateAfterMs ?? 24 * 60 * 60_000
);
this.graceMs = Math.max(0, config.graceMs ?? 60 * 60_000);
this.clock = config.clock ?? (() => Date.now());
this.bus = config.bus;
this.generator = config.generator ?? defaultGenerator;
this.active = config.initial;
this.activatedAt = this.clock();
}
public current(): KeyPair {
return this.active;
}
public verifierKeys(): ReadonlyArray<Uint8Array> {
const out: Uint8Array[] = [this.active.publicKey];
if (this.retired !== null) {
const now = this.clock();
if (now - this.retired.retiredAt < this.graceMs) {
out.push(this.retired.publicKey);
} else {
this.retired = null;
}
}
return out;
}
/** Rotate immediately. Returns the newly-active key pair. */
public rotate(): KeyPair {
const previous = this.active;
const next = this.generator();
this.retired = {
publicKey: previous.publicKey,
retiredAt: this.clock(),
};
this.active = next;
this.activatedAt = this.clock();
this.bus?.emit("key:rotated", {
previous: previous.publicKey,
current: next.publicKey,
rotatedAt: this.activatedAt,
});
return next;
}
/** Rotate only if the active key has been alive past `rotateAfterMs`. */
public maybeRotate(): boolean {
if (this.clock() - this.activatedAt < this.rotateAfterMs) return false;
this.rotate();
return true;
}
}
function defaultGenerator(): KeyPair {
const privateKey = ed25519.randomPrivateKey();
return {
privateKey,
publicKey: ed25519.getPublicKey(privateKey),
};
}

View File

@@ -0,0 +1,107 @@
import { ed25519 } from "@suaveplan/crypto-curve";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
type KeyRotationBus,
KeyRotationManager,
} from "./key-rotation-manager.js";
const makeKey = (fill: number) => ({
privateKey: new Uint8Array(32).fill(fill),
publicKey: new Uint8Array(32).fill(fill + 100),
});
describe("KeyRotationManager: rotate + grace + bus emit", () => {
it("starts with the supplied initial key", () => {
const initial = makeKey(1);
const m = new KeyRotationManager({ initial });
expect(m.current().publicKey).toBe(initial.publicKey);
});
it("rotate replaces the active key and retires the old", () => {
let next = 0;
const m = new KeyRotationManager({
initial: makeKey(1),
generator: () => makeKey(2 + next++),
});
m.rotate();
const verifiers = m.verifierKeys();
expect(verifiers.length).toBe(2);
});
it("verifierKeys evicts the retired key past the grace window", () => {
let now = 0;
const m = new KeyRotationManager({
initial: makeKey(1),
graceMs: 100,
clock: () => now,
generator: () => makeKey(2),
});
m.rotate();
now += 200;
expect(m.verifierKeys().length).toBe(1);
});
it("maybeRotate fires when past rotateAfterMs", () => {
let now = 0;
const events: unknown[] = [];
const bus: KeyRotationBus = {
emit: (_event, payload) => events.push(payload),
};
const m = new KeyRotationManager({
initial: makeKey(1),
rotateAfterMs: 1_000,
clock: () => now,
bus,
generator: () => makeKey(2),
});
expect(m.maybeRotate()).toBe(false);
now += 1_500;
expect(m.maybeRotate()).toBe(true);
expect(events.length).toBe(1);
});
it("clamps rotateAfterMs / graceMs to safe minimums", () => {
const m = new KeyRotationManager({
initial: makeKey(1),
rotateAfterMs: 0,
graceMs: -100,
generator: () => makeKey(2),
});
// Should not throw — 0 is clamped to 1 / -100 to 0
expect(() => m.maybeRotate()).not.toThrow();
});
it("uses ed25519 random as default generator", () => {
const initial = makeKey(1);
const m = new KeyRotationManager({ initial });
const next = m.rotate();
// Default generator produces new keys distinct from the initial
expect(next.publicKey).not.toBe(initial.publicKey);
});
it("uses Date.now as default clock", () => {
const m = new KeyRotationManager({
initial: makeKey(1),
generator: () => makeKey(2),
});
expect(() => m.maybeRotate()).not.toThrow();
});
it("verifierKeys returns only the active key when no rotation has happened", () => {
const m = new KeyRotationManager({
initial: makeKey(1),
generator: () => makeKey(2),
});
expect(m.verifierKeys().length).toBe(1);
});
it("supports realistic ed25519 round-trip", () => {
const priv = ed25519.randomPrivateKey();
const pub = ed25519.getPublicKey(priv);
const m = new KeyRotationManager({
initial: { privateKey: priv, publicKey: pub },
});
const next = m.rotate();
expect(ed25519.getPublicKey(next.privateKey)).toEqual(next.publicKey);
});
});

View File

@@ -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.

View File

@@ -0,0 +1,121 @@
import { ed25519 } from "@suaveplan/crypto-curve";
import { sha256 } from "@suaveplan/crypto-hash";
import { NETWORK_CRYPTO_CODES, NetworkCryptoError } from "../error-codes.js";
import { serialise } from "../signer/message-signer.js";
export interface LogChainEntry<TPayload = unknown> {
readonly seq: number;
readonly previousHash: Uint8Array;
readonly payload: TPayload;
readonly signature: Uint8Array;
readonly signerPublicKey: Uint8Array;
readonly recordedAt: number;
}
export interface LogChainSignerConfig {
readonly clock?: () => number;
}
const ZERO_HASH = new Uint8Array(32);
/**
* Append-only signed log chain. Each entry's signature covers the
* previous entry's hash plus the current payload, so any tamper-with-
* a-leaf is detectable as a chain break.
*/
export class LogChainSigner {
private readonly clock: () => number;
private readonly entries: LogChainEntry[] = [];
private nextSeq = 0;
public constructor(config: LogChainSignerConfig = {}) {
this.clock = config.clock ?? (() => Date.now());
}
public append<TPayload>(
payload: TPayload,
keyPair: { publicKey: Uint8Array; privateKey: Uint8Array }
): LogChainEntry<TPayload> {
const previousHash = this.tipHash();
const bytes = entryBytes(previousHash, payload);
const signature = ed25519.sign(bytes, keyPair.privateKey);
const entry: LogChainEntry<TPayload> = {
seq: this.nextSeq,
previousHash,
payload,
signature,
signerPublicKey: keyPair.publicKey,
recordedAt: this.clock(),
};
this.entries.push(entry);
this.nextSeq += 1;
return entry;
}
public chain(): ReadonlyArray<LogChainEntry> {
return [...this.entries];
}
/**
* Verify the entire chain. Throws `CHAIN_TAMPERED` on the first
* inconsistency and reports the offending sequence number in
* `error.context`.
*/
public verify(): void {
let expectedPrev = ZERO_HASH;
for (const entry of this.entries) {
if (!bytesEqual(entry.previousHash, expectedPrev)) {
throw new NetworkCryptoError(
NETWORK_CRYPTO_CODES.CHAIN_TAMPERED,
"chain.previous-hash-mismatch",
{ context: { seq: entry.seq } }
);
}
const bytes = entryBytes(entry.previousHash, entry.payload);
const ok = ed25519.verify(
entry.signature,
bytes,
entry.signerPublicKey
);
if (!ok) {
throw new NetworkCryptoError(
NETWORK_CRYPTO_CODES.CHAIN_TAMPERED,
"chain.signature-invalid",
{ context: { seq: entry.seq } }
);
}
expectedPrev = new Uint8Array(sha256(entryHashBytes(entry)));
}
}
public reset(): void {
this.entries.length = 0;
this.nextSeq = 0;
}
private tipHash(): Uint8Array {
if (this.entries.length === 0) return ZERO_HASH;
const last = this.entries[this.entries.length - 1] as LogChainEntry;
return new Uint8Array(sha256(entryHashBytes(last)));
}
}
function entryBytes(previousHash: Uint8Array, payload: unknown): Uint8Array {
const payloadBytes = serialise(payload);
const out = new Uint8Array(previousHash.length + payloadBytes.length);
out.set(previousHash, 0);
out.set(payloadBytes, previousHash.length);
return out;
}
function entryHashBytes(entry: LogChainEntry): Uint8Array {
return entryBytes(entry.previousHash, entry.payload);
}
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
}

View File

@@ -0,0 +1,142 @@
import { ed25519 } from "@suaveplan/crypto-curve";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
NETWORK_CRYPTO_CODES,
type NetworkCryptoError,
} from "../error-codes.js";
import { LogChainSigner } from "./log-chain-signer.js";
const makeKeyPair = () => {
const privateKey = ed25519.randomPrivateKey();
return { privateKey, publicKey: ed25519.getPublicKey(privateKey) };
};
describe("LogChainSigner: append + verify + tamper detection", () => {
it("verifies a clean chain", () => {
const kp = makeKeyPair();
const signer = new LogChainSigner();
signer.append({ event: "a" }, kp);
signer.append({ event: "b" }, kp);
signer.append({ event: "c" }, kp);
expect(() => signer.verify()).not.toThrow();
expect(signer.chain().length).toBe(3);
});
it("detects tampered payload via chain hash mismatch", () => {
const kp = makeKeyPair();
const signer = new LogChainSigner();
signer.append({ event: "a" }, kp);
const second = signer.append({ event: "b" }, kp);
const all = signer.chain();
// Tamper with the second entry's payload
const target = signer as unknown as {
entries: Array<{ payload: unknown; seq: number }>;
};
target.entries[1] = {
...all[1],
payload: { event: "X" },
seq: second.seq,
};
try {
signer.verify();
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkCryptoError).code).toBe(
NETWORK_CRYPTO_CODES.CHAIN_TAMPERED
);
}
});
it("detects tampered previousHash", () => {
const kp = makeKeyPair();
const signer = new LogChainSigner();
signer.append({ event: "a" }, kp);
signer.append({ event: "b" }, kp);
const target = signer as unknown as {
entries: Array<{
previousHash: Uint8Array;
seq: number;
payload: unknown;
}>;
};
target.entries[1] = {
...(signer.chain()[1] as object),
previousHash: new Uint8Array(32).fill(99),
} as never;
try {
signer.verify();
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkCryptoError).code).toBe(
NETWORK_CRYPTO_CODES.CHAIN_TAMPERED
);
}
});
it("first entry's previousHash is 32 zero bytes", () => {
const kp = makeKeyPair();
const signer = new LogChainSigner();
const first = signer.append({ event: "a" }, kp);
expect(first.previousHash.length).toBe(32);
expect(first.previousHash.every((b) => b === 0)).toBe(true);
});
it("recordedAt uses the injectable clock", () => {
const kp = makeKeyPair();
const signer = new LogChainSigner({ clock: () => 12_345 });
const entry = signer.append({ event: "a" }, kp);
expect(entry.recordedAt).toBe(12_345);
});
it("uses Date.now as default clock", () => {
const kp = makeKeyPair();
const signer = new LogChainSigner();
const entry = signer.append({ event: "a" }, kp);
expect(typeof entry.recordedAt).toBe("number");
});
it("reset clears entries + sequence", () => {
const kp = makeKeyPair();
const signer = new LogChainSigner();
signer.append({ event: "a" }, kp);
signer.reset();
const after = signer.append({ event: "b" }, kp);
expect(after.seq).toBe(0);
});
it("detects previousHash with non-32-byte length", () => {
const kp = makeKeyPair();
const signer = new LogChainSigner();
signer.append({ event: "a" }, kp);
signer.append({ event: "b" }, kp);
const target = signer as unknown as {
entries: Array<{
previousHash: Uint8Array;
seq: number;
payload: unknown;
}>;
};
target.entries[1] = {
...(signer.chain()[1] as object),
previousHash: new Uint8Array(16).fill(99),
} as never;
try {
signer.verify();
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkCryptoError).code).toBe(
NETWORK_CRYPTO_CODES.CHAIN_TAMPERED
);
}
});
it("chain returns an immutable copy", () => {
const kp = makeKeyPair();
const signer = new LogChainSigner();
signer.append({ event: "a" }, kp);
const snap = signer.chain();
// Mutating the snapshot must not affect subsequent reads
(snap as unknown as Array<unknown>).push(null);
expect(signer.chain().length).toBe(1);
});
});

View File

@@ -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.

View File

@@ -0,0 +1,85 @@
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
import { PeerIdentityVerifier } from "../identity/peer-identity-verifier.js";
import { KeyRotationManager } from "../key-rotation/key-rotation-manager.js";
import { LogChainSigner } from "../log-chain/log-chain-signer.js";
import {
MessageSigner,
type MessageSignerConfig,
} from "../signer/message-signer.js";
import { MeshTokenAuthority } from "../token/mesh-token-authority.js";
import { SessionTokenAuthority } from "../token/session-token-authority.js";
import type { KeyPair } from "../types/signature.types.js";
export interface CryptoMessagesPluginConfig {
readonly initialKey: KeyPair;
readonly serverPublicKey: Uint8Array;
readonly signer?: MessageSignerConfig;
readonly rotation?: ConstructorParameters<typeof KeyRotationManager>[0];
readonly sessionAuthority?: Omit<
ConstructorParameters<typeof SessionTokenAuthority>[0],
"serverPublicKey"
>;
readonly meshAuthority?: Omit<
ConstructorParameters<typeof MeshTokenAuthority>[0],
"serverPublicKey"
>;
readonly identity?: ConstructorParameters<typeof PeerIdentityVerifier>[0];
}
export class CryptoMessagesPlugin implements NetworkPlugin {
public readonly name = "crypto-messages";
public readonly signer: MessageSigner;
public readonly sessionAuthority: SessionTokenAuthority;
public readonly meshAuthority: MeshTokenAuthority;
public readonly identity: PeerIdentityVerifier;
public readonly logChain: LogChainSigner;
public readonly rotation: KeyRotationManager;
private node: NetworkNode | null = null;
private started = false;
public constructor(config: CryptoMessagesPluginConfig) {
this.signer = new MessageSigner(config.signer);
this.sessionAuthority = new SessionTokenAuthority({
serverPublicKey: config.serverPublicKey,
...(config.sessionAuthority ?? {}),
});
this.meshAuthority = new MeshTokenAuthority({
serverPublicKey: config.serverPublicKey,
...(config.meshAuthority ?? {}),
});
this.identity = new PeerIdentityVerifier(config.identity);
this.logChain = new LogChainSigner();
this.rotation = new KeyRotationManager({
initial: config.initialKey,
...(config.rotation ?? {}),
});
}
public install(node: NetworkNode): void {
this.node = node;
}
public start(): void {
this.started = true;
}
public stop(): void {
this.started = false;
}
public dispose(): void {
this.sessionAuthority.reset();
this.meshAuthority.reset();
this.identity.clear();
this.logChain.reset();
this.node = null;
}
public host(): NetworkNode | null {
return this.node;
}
public isStarted(): boolean {
return this.started;
}
}

View File

@@ -0,0 +1,96 @@
import { ed25519 } from "@suaveplan/crypto-curve";
import { NetworkBus, NetworkNode } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CryptoMessagesPlugin } from "./crypto-messages-plugin.js";
const newNode = () =>
new NetworkNode({ id: "node-1", networkBus: new NetworkBus() });
const setup = () => {
const priv = ed25519.randomPrivateKey();
const initialKey = {
privateKey: priv,
publicKey: ed25519.getPublicKey(priv),
};
const serverPriv = ed25519.randomPrivateKey();
return {
initialKey,
serverPublicKey: ed25519.getPublicKey(serverPriv),
};
};
describe("CryptoMessagesPlugin: lifecycle", () => {
it("constructs with all subsystems", () => {
const { initialKey, serverPublicKey } = setup();
const plugin = new CryptoMessagesPlugin({
initialKey,
serverPublicKey,
});
expect(plugin.name).toBe("crypto-messages");
expect(plugin.signer).toBeDefined();
expect(plugin.sessionAuthority).toBeDefined();
expect(plugin.meshAuthority).toBeDefined();
expect(plugin.identity).toBeDefined();
expect(plugin.logChain).toBeDefined();
expect(plugin.rotation).toBeDefined();
});
it("install / start / stop / dispose lifecycle", async () => {
const { initialKey, serverPublicKey } = setup();
const plugin = new CryptoMessagesPlugin({
initialKey,
serverPublicKey,
});
const node = newNode();
node.use(plugin);
await node.start();
expect(plugin.host()).not.toBeNull();
expect(plugin.isStarted()).toBe(true);
await node.stop();
expect(plugin.isStarted()).toBe(false);
plugin.dispose();
expect(plugin.host()).toBeNull();
});
it("dispose clears every subsystem state", async () => {
const { initialKey, serverPublicKey } = setup();
const plugin = new CryptoMessagesPlugin({
initialKey,
serverPublicKey,
});
const kp = plugin.rotation.current();
plugin.logChain.append({ x: 1 }, kp);
plugin.identity.issueChallenge("peer-A");
plugin.dispose();
expect(plugin.logChain.chain().length).toBe(0);
expect(plugin.identity.hasPending("peer-A")).toBe(false);
});
it("optional subsystem configs flow through to internals", () => {
const { initialKey, serverPublicKey } = setup();
const plugin = new CryptoMessagesPlugin({
initialKey,
serverPublicKey,
rotation: { rotateAfterMs: 1_000, graceMs: 100 },
identity: { defaultTtlMs: 5_000 },
sessionAuthority: { replayWindowMs: 10_000 },
meshAuthority: { replayWindowMs: 10_000 },
});
expect(
plugin.identity.issueChallenge("p").nonce.length
).toBeGreaterThan(0);
});
it("end-to-end: signer + rotation produce valid signatures", () => {
const { initialKey, serverPublicKey } = setup();
const plugin = new CryptoMessagesPlugin({
initialKey,
serverPublicKey,
});
const signed = plugin.signer.sign(
{ ping: 1 },
plugin.rotation.current()
);
expect(plugin.signer.verify(signed)).toBe(true);
});
});

View File

@@ -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.

View File

@@ -0,0 +1,137 @@
import { ed25519 } from "@suaveplan/crypto-curve";
import { NETWORK_CRYPTO_CODES, NetworkCryptoError } from "../error-codes.js";
import type {
KeyPair,
MessageSignature,
SignedMessage,
} from "../types/signature.types.js";
export interface RegistryLookup {
/**
* Returns the registered public key for the given peer id, or
* `undefined` if the peer is unknown.
*/
publicKeyFor(peerId: string): Uint8Array | undefined;
}
export interface MessageSignerConfig {
readonly clock?: () => number;
readonly registry?: RegistryLookup;
}
const ENCODER = new TextEncoder();
/**
* Signs outbound message payloads with Ed25519 and verifies inbound
* signatures against the peer registry. Pure: no global state.
*/
export class MessageSigner {
private readonly clock: () => number;
private readonly registry: RegistryLookup | undefined;
public constructor(config: MessageSignerConfig = {}) {
this.clock = config.clock ?? (() => Date.now());
this.registry = config.registry;
}
public sign<TPayload>(
payload: TPayload,
keyPair: KeyPair
): SignedMessage<TPayload> {
const bytes = serialise(payload);
const signature = ed25519.sign(bytes, keyPair.privateKey);
const sig: MessageSignature = {
signerPublicKey: keyPair.publicKey,
signature,
signedAt: this.clock(),
};
return { payload, signature: sig };
}
public verify<TPayload>(message: SignedMessage<TPayload>): boolean {
const bytes = serialise(message.payload);
return ed25519.verify(
message.signature.signature,
bytes,
message.signature.signerPublicKey
);
}
/**
* Verify against the registry: confirm the signer's public key
* matches the one registered for `peerId`, then verify the
* signature. Throws `IDENTITY_MISMATCH` on key mismatch and
* `SIGNATURE_INVALID` on bad signature.
*/
public verifyForPeer<TPayload>(
peerId: string,
message: SignedMessage<TPayload>
): void {
if (this.registry === undefined) {
throw new NetworkCryptoError(
NETWORK_CRYPTO_CODES.KEY_NOT_FOUND,
"signer.no-registry",
{ context: { peerId } }
);
}
const expected = this.registry.publicKeyFor(peerId);
if (expected === undefined) {
throw new NetworkCryptoError(
NETWORK_CRYPTO_CODES.KEY_NOT_FOUND,
"signer.peer-unknown",
{ context: { peerId } }
);
}
if (!bytesEqual(expected, message.signature.signerPublicKey)) {
throw new NetworkCryptoError(
NETWORK_CRYPTO_CODES.IDENTITY_MISMATCH,
"signer.peer-key-mismatch",
{ context: { peerId } }
);
}
if (!this.verify(message)) {
throw new NetworkCryptoError(
NETWORK_CRYPTO_CODES.SIGNATURE_INVALID,
"signer.bad-signature",
{ context: { peerId } }
);
}
}
}
export function serialise(value: unknown): Uint8Array {
return ENCODER.encode(canonical(value));
}
function canonical(value: unknown): string {
if (value === null) return "null";
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "number")
return Number.isFinite(value) ? String(value) : "null";
if (typeof value === "string") return JSON.stringify(value);
if (Array.isArray(value)) {
return `[${value.map((v) => canonical(v)).join(",")}]`;
}
if (typeof value === "object") {
const keys = Object.keys(value as Record<string, unknown>).sort();
const parts: string[] = [];
for (const key of keys) {
parts.push(
`${JSON.stringify(key)}:${canonical(
(value as Record<string, unknown>)[key]
)}`
);
}
return `{${parts.join(",")}}`;
}
return "null";
}
export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i += 1) {
diff |= (a[i] as number) ^ (b[i] as number);
}
return diff === 0;
}

View File

@@ -0,0 +1,151 @@
import { ed25519 } from "@suaveplan/crypto-curve";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
NETWORK_CRYPTO_CODES,
type NetworkCryptoError,
} from "../error-codes.js";
import {
bytesEqual,
MessageSigner,
type RegistryLookup,
serialise,
} from "./message-signer.js";
const makeKeyPair = () => {
const privateKey = ed25519.randomPrivateKey();
return { privateKey, publicKey: ed25519.getPublicKey(privateKey) };
};
describe("MessageSigner: sign/verify round-trip + registry checks", () => {
it("sign + verify returns true on a clean round-trip", () => {
const signer = new MessageSigner();
const kp = makeKeyPair();
const signed = signer.sign({ ping: 1 }, kp);
expect(signer.verify(signed)).toBe(true);
});
it("verify returns false on a tampered payload", () => {
const signer = new MessageSigner();
const kp = makeKeyPair();
const signed = signer.sign({ ping: 1 }, kp);
const tampered = { ...signed, payload: { ping: 2 } };
expect(signer.verify(tampered)).toBe(false);
});
it("canonical serialisation is order-independent for object keys", () => {
const a = serialise({ x: 1, y: 2 });
const b = serialise({ y: 2, x: 1 });
expect(bytesEqual(a, b)).toBe(true);
});
it("verifyForPeer throws KEY_NOT_FOUND without a registry", () => {
const signer = new MessageSigner();
const kp = makeKeyPair();
const signed = signer.sign({ ping: 1 }, kp);
try {
signer.verifyForPeer("alice", signed);
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkCryptoError).code).toBe(
NETWORK_CRYPTO_CODES.KEY_NOT_FOUND
);
}
});
it("verifyForPeer throws KEY_NOT_FOUND when peer is unknown", () => {
const registry: RegistryLookup = { publicKeyFor: () => undefined };
const signer = new MessageSigner({ registry });
const kp = makeKeyPair();
const signed = signer.sign({ ping: 1 }, kp);
try {
signer.verifyForPeer("ghost", signed);
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkCryptoError).code).toBe(
NETWORK_CRYPTO_CODES.KEY_NOT_FOUND
);
}
});
it("verifyForPeer throws IDENTITY_MISMATCH on wrong public key", () => {
const aKp = makeKeyPair();
const bKp = makeKeyPair();
const registry: RegistryLookup = {
publicKeyFor: (id) => (id === "alice" ? bKp.publicKey : undefined),
};
const signer = new MessageSigner({ registry });
const signed = signer.sign({ ping: 1 }, aKp);
try {
signer.verifyForPeer("alice", signed);
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkCryptoError).code).toBe(
NETWORK_CRYPTO_CODES.IDENTITY_MISMATCH
);
}
});
it("verifyForPeer throws SIGNATURE_INVALID on tampered signature", () => {
const kp = makeKeyPair();
const registry: RegistryLookup = {
publicKeyFor: () => kp.publicKey,
};
const signer = new MessageSigner({ registry });
const signed = signer.sign({ ping: 1 }, kp);
const tampered = {
...signed,
payload: { ping: 2 },
};
try {
signer.verifyForPeer("alice", tampered);
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkCryptoError).code).toBe(
NETWORK_CRYPTO_CODES.SIGNATURE_INVALID
);
}
});
it("verifyForPeer success path completes without throwing", () => {
const kp = makeKeyPair();
const registry: RegistryLookup = {
publicKeyFor: () => kp.publicKey,
};
const signer = new MessageSigner({ registry });
const signed = signer.sign({ ping: 1 }, kp);
expect(() => signer.verifyForPeer("alice", signed)).not.toThrow();
});
it("uses Date.now by default for signedAt", () => {
const signer = new MessageSigner();
const kp = makeKeyPair();
const signed = signer.sign({ ping: 1 }, kp);
expect(typeof signed.signature.signedAt).toBe("number");
});
it("bytesEqual is constant-time-style and rejects mismatched lengths", () => {
expect(bytesEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2]))).toBe(
true
);
expect(bytesEqual(new Uint8Array([1, 2]), new Uint8Array([1]))).toBe(
false
);
expect(bytesEqual(new Uint8Array([1, 2]), new Uint8Array([1, 3]))).toBe(
false
);
});
it("serialise covers null / boolean / number / array / nested object", () => {
expect(new TextDecoder().decode(serialise(null))).toBe("null");
expect(new TextDecoder().decode(serialise(true))).toBe("true");
expect(new TextDecoder().decode(serialise(false))).toBe("false");
expect(new TextDecoder().decode(serialise(1))).toBe("1");
expect(new TextDecoder().decode(serialise(Number.NaN))).toBe("null");
expect(new TextDecoder().decode(serialise("x"))).toBe('"x"');
expect(new TextDecoder().decode(serialise([1, "a"]))).toBe('[1,"a"]');
expect(new TextDecoder().decode(serialise({ b: 2, a: 1 }))).toBe(
'{"a":1,"b":2}'
);
expect(new TextDecoder().decode(serialise(undefined))).toBe("null");
});
});

View File

@@ -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.

View File

@@ -0,0 +1,99 @@
import { ed25519 } from "@suaveplan/crypto-curve";
import { NETWORK_CRYPTO_CODES, NetworkCryptoError } from "../error-codes.js";
import { serialise } from "../signer/message-signer.js";
import type { MeshToken } from "../types/token.types.js";
import type { TokenBus } from "./session-token-authority.js";
export interface MeshTokenAuthorityConfig {
readonly serverPublicKey: Uint8Array;
readonly clock?: () => number;
readonly bus?: TokenBus;
readonly replayWindowMs?: number;
}
export class MeshTokenAuthority {
private readonly serverPublicKey: Uint8Array;
private readonly clock: () => number;
private readonly bus: TokenBus | undefined;
private readonly replayWindowMs: number;
private readonly seen = new Map<string, number>();
public constructor(config: MeshTokenAuthorityConfig) {
this.serverPublicKey = config.serverPublicKey;
this.clock = config.clock ?? (() => Date.now());
this.bus = config.bus;
this.replayWindowMs = config.replayWindowMs ?? 60 * 60_000;
}
public validate(token: MeshToken): void {
const now = this.clock();
const id = `mesh:${token.meshId}`;
if (token.expiresAt <= now) {
this.emit(false, id, "expired");
throw new NetworkCryptoError(
NETWORK_CRYPTO_CODES.TOKEN_EXPIRED,
"mesh-token.expired",
{ context: { meshId: token.meshId } }
);
}
const prevSeenAt = this.seen.get(id);
if (
prevSeenAt !== undefined &&
now - prevSeenAt < this.replayWindowMs
) {
this.emit(false, id, "replayed");
throw new NetworkCryptoError(
NETWORK_CRYPTO_CODES.TOKEN_REPLAYED,
"mesh-token.replayed",
{ context: { meshId: token.meshId } }
);
}
const bytes = canonicalMeshTokenBytes(token);
const ok = ed25519.verify(
token.serverSignature,
bytes,
this.serverPublicKey
);
if (!ok) {
this.emit(false, id, "signature");
throw new NetworkCryptoError(
NETWORK_CRYPTO_CODES.TOKEN_INVALID,
"mesh-token.bad-signature",
{ context: { meshId: token.meshId } }
);
}
this.seen.set(id, now);
this.gcReplayCache(now);
this.emit(true, id);
}
public reset(): void {
this.seen.clear();
}
private gcReplayCache(now: number): void {
for (const [key, seenAt] of this.seen) {
if (now - seenAt >= this.replayWindowMs) {
this.seen.delete(key);
}
}
}
private emit(valid: boolean, tokenId: string, reason?: string): void {
const payload =
reason !== undefined
? { tokenKind: "mesh" as const, tokenId, reason }
: { tokenKind: "mesh" as const, tokenId };
this.bus?.emit(valid ? "token:valid" : "token:invalid", payload);
}
}
export function canonicalMeshTokenBytes(token: MeshToken): Uint8Array {
return serialise({
meshId: token.meshId,
maxCapacity: token.maxCapacity,
allowedTiers: token.allowedTiers,
issuedAt: token.issuedAt,
expiresAt: token.expiresAt,
});
}

View File

@@ -0,0 +1,174 @@
import { ed25519 } from "@suaveplan/crypto-curve";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
NETWORK_CRYPTO_CODES,
type NetworkCryptoError,
} from "../error-codes.js";
import type { MeshToken } from "../types/token.types.js";
import {
canonicalMeshTokenBytes,
MeshTokenAuthority,
} from "./mesh-token-authority.js";
const buildToken = (
serverPriv: Uint8Array,
overrides: Partial<MeshToken> = {}
): MeshToken => {
const base: Omit<MeshToken, "serverSignature"> = {
meshId: "mesh-1",
maxCapacity: 64,
allowedTiers: 0b111,
issuedAt: 1_000,
expiresAt: 10_000,
...overrides,
};
const placeholder: MeshToken = {
...base,
serverSignature: new Uint8Array(),
};
const sig = ed25519.sign(canonicalMeshTokenBytes(placeholder), serverPriv);
return { ...base, serverSignature: sig };
};
describe("MeshTokenAuthority: valid / expired / forged / replayed", () => {
it("accepts a valid token", () => {
const serverPriv = ed25519.randomPrivateKey();
const authority = new MeshTokenAuthority({
serverPublicKey: ed25519.getPublicKey(serverPriv),
clock: () => 5_000,
});
expect(() => authority.validate(buildToken(serverPriv))).not.toThrow();
});
it("rejects expired tokens", () => {
const serverPriv = ed25519.randomPrivateKey();
const authority = new MeshTokenAuthority({
serverPublicKey: ed25519.getPublicKey(serverPriv),
clock: () => 100_000,
});
try {
authority.validate(buildToken(serverPriv));
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkCryptoError).code).toBe(
NETWORK_CRYPTO_CODES.TOKEN_EXPIRED
);
}
});
it("rejects forged signatures", () => {
const serverPriv = ed25519.randomPrivateKey();
const otherPriv = ed25519.randomPrivateKey();
const authority = new MeshTokenAuthority({
serverPublicKey: ed25519.getPublicKey(otherPriv),
clock: () => 5_000,
});
try {
authority.validate(buildToken(serverPriv));
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkCryptoError).code).toBe(
NETWORK_CRYPTO_CODES.TOKEN_INVALID
);
}
});
it("rejects replays", () => {
const serverPriv = ed25519.randomPrivateKey();
const authority = new MeshTokenAuthority({
serverPublicKey: ed25519.getPublicKey(serverPriv),
clock: () => 5_000,
replayWindowMs: 1_000,
});
const token = buildToken(serverPriv);
authority.validate(token);
try {
authority.validate(token);
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkCryptoError).code).toBe(
NETWORK_CRYPTO_CODES.TOKEN_REPLAYED
);
}
});
it("reset + replay window cleanup work", () => {
const serverPriv = ed25519.randomPrivateKey();
let now = 5_000;
const authority = new MeshTokenAuthority({
serverPublicKey: ed25519.getPublicKey(serverPriv),
clock: () => now,
replayWindowMs: 1_000,
});
const t1 = buildToken(serverPriv, { meshId: "m1" });
authority.validate(t1);
now += 2_000;
// Different mesh id triggers GC sweep
const t2 = buildToken(serverPriv, { meshId: "m2", expiresAt: 100_000 });
authority.validate(t2);
// After GC, m1 can be replayed (window expired). Construct a
// fresh token with same id but new expiry.
const t1Fresh = buildToken(serverPriv, {
meshId: "m1",
expiresAt: 100_000,
});
expect(() => authority.validate(t1Fresh)).not.toThrow();
authority.reset();
});
it("uses Date.now as default clock", () => {
const serverPriv = ed25519.randomPrivateKey();
const authority = new MeshTokenAuthority({
serverPublicKey: ed25519.getPublicKey(serverPriv),
});
const token = buildToken(serverPriv, {
expiresAt: Date.now() + 10_000,
});
expect(() => authority.validate(token)).not.toThrow();
});
it("canonicalMeshTokenBytes is deterministic across construction order", () => {
const a: MeshToken = {
meshId: "m",
maxCapacity: 1,
allowedTiers: 1,
issuedAt: 1,
expiresAt: 2,
serverSignature: new Uint8Array(),
};
const b: MeshToken = {
expiresAt: 2,
issuedAt: 1,
allowedTiers: 1,
maxCapacity: 1,
meshId: "m",
serverSignature: new Uint8Array(),
};
const ba = canonicalMeshTokenBytes(a);
const bb = canonicalMeshTokenBytes(b);
expect(ba.length).toBe(bb.length);
for (let i = 0; i < ba.length; i += 1) {
expect(ba[i]).toBe(bb[i]);
}
});
it("emits bus events on valid + invalid", () => {
const events: { event: string }[] = [];
const serverPriv = ed25519.randomPrivateKey();
const authority = new MeshTokenAuthority({
serverPublicKey: ed25519.getPublicKey(serverPriv),
clock: () => 5_000,
bus: { emit: (event) => events.push({ event }) },
});
authority.validate(buildToken(serverPriv));
try {
authority.validate(
buildToken(serverPriv, { expiresAt: 1, meshId: "expired" })
);
} catch {
// expected
}
expect(events.some((e) => e.event === "token:valid")).toBe(true);
expect(events.some((e) => e.event === "token:invalid")).toBe(true);
});
});

View File

@@ -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.

View File

@@ -0,0 +1,115 @@
import { ed25519 } from "@suaveplan/crypto-curve";
import { NETWORK_CRYPTO_CODES, NetworkCryptoError } from "../error-codes.js";
import { serialise } from "../signer/message-signer.js";
import type { SessionToken } from "../types/token.types.js";
export interface TokenBus {
emit(
event: "token:valid" | "token:invalid",
payload: {
tokenKind: "session" | "mesh";
reason?: string;
tokenId: string;
}
): void;
}
export interface SessionTokenAuthorityConfig {
readonly serverPublicKey: Uint8Array;
readonly clock?: () => number;
readonly bus?: TokenBus;
/** Replay window. Defaults to 1 hour. */
readonly replayWindowMs?: number;
}
/**
* Validates server-signed `SessionToken`s. Rejects expired, forged,
* and replayed tokens.
*/
export class SessionTokenAuthority {
private readonly serverPublicKey: Uint8Array;
private readonly clock: () => number;
private readonly bus: TokenBus | undefined;
private readonly replayWindowMs: number;
private readonly seen = new Map<string, number>();
public constructor(config: SessionTokenAuthorityConfig) {
this.serverPublicKey = config.serverPublicKey;
this.clock = config.clock ?? (() => Date.now());
this.bus = config.bus;
this.replayWindowMs = config.replayWindowMs ?? 60 * 60_000;
}
public validate(token: SessionToken): void {
const now = this.clock();
const id = `session:${token.sessionId}`;
if (token.expiresAt <= now) {
this.emit(false, id, "expired");
throw new NetworkCryptoError(
NETWORK_CRYPTO_CODES.TOKEN_EXPIRED,
"session-token.expired",
{ context: { sessionId: token.sessionId } }
);
}
const prevSeenAt = this.seen.get(id);
if (
prevSeenAt !== undefined &&
now - prevSeenAt < this.replayWindowMs
) {
this.emit(false, id, "replayed");
throw new NetworkCryptoError(
NETWORK_CRYPTO_CODES.TOKEN_REPLAYED,
"session-token.replayed",
{ context: { sessionId: token.sessionId } }
);
}
const bytes = canonicalSessionTokenBytes(token);
const ok = ed25519.verify(
token.serverSignature,
bytes,
this.serverPublicKey
);
if (!ok) {
this.emit(false, id, "signature");
throw new NetworkCryptoError(
NETWORK_CRYPTO_CODES.TOKEN_INVALID,
"session-token.bad-signature",
{ context: { sessionId: token.sessionId } }
);
}
this.seen.set(id, now);
this.gcReplayCache(now);
this.emit(true, id);
}
public reset(): void {
this.seen.clear();
}
private gcReplayCache(now: number): void {
for (const [key, seenAt] of this.seen) {
if (now - seenAt >= this.replayWindowMs) {
this.seen.delete(key);
}
}
}
private emit(valid: boolean, tokenId: string, reason?: string): void {
const payload =
reason !== undefined
? { tokenKind: "session" as const, tokenId, reason }
: { tokenKind: "session" as const, tokenId };
this.bus?.emit(valid ? "token:valid" : "token:invalid", payload);
}
}
export function canonicalSessionTokenBytes(token: SessionToken): Uint8Array {
return serialise({
sessionId: token.sessionId,
parentSessionId: token.parentSessionId,
capacity: token.capacity,
rulesHash: token.rulesHash,
issuedAt: token.issuedAt,
expiresAt: token.expiresAt,
});
}

View File

@@ -0,0 +1,174 @@
import { ed25519 } from "@suaveplan/crypto-curve";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
NETWORK_CRYPTO_CODES,
type NetworkCryptoError,
} from "../error-codes.js";
import type { SessionToken } from "../types/token.types.js";
import {
canonicalSessionTokenBytes,
SessionTokenAuthority,
type TokenBus,
} from "./session-token-authority.js";
const buildToken = (
serverPriv: Uint8Array,
overrides: Partial<SessionToken> = {}
): SessionToken => {
const base: Omit<SessionToken, "serverSignature"> = {
sessionId: "sess-1",
parentSessionId: null,
capacity: 4,
rulesHash: "sha256:rules",
issuedAt: 1_000,
expiresAt: 10_000,
...overrides,
};
const placeholder: SessionToken = {
...base,
serverSignature: new Uint8Array(),
};
const sig = ed25519.sign(
canonicalSessionTokenBytes(placeholder),
serverPriv
);
return { ...base, serverSignature: sig };
};
describe("SessionTokenAuthority: valid / expired / forged / replayed", () => {
it("accepts a valid token", () => {
const serverPriv = ed25519.randomPrivateKey();
const authority = new SessionTokenAuthority({
serverPublicKey: ed25519.getPublicKey(serverPriv),
clock: () => 5_000,
});
const token = buildToken(serverPriv);
expect(() => authority.validate(token)).not.toThrow();
});
it("rejects an expired token", () => {
const serverPriv = ed25519.randomPrivateKey();
const authority = new SessionTokenAuthority({
serverPublicKey: ed25519.getPublicKey(serverPriv),
clock: () => 100_000,
});
const token = buildToken(serverPriv);
try {
authority.validate(token);
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkCryptoError).code).toBe(
NETWORK_CRYPTO_CODES.TOKEN_EXPIRED
);
}
});
it("rejects a forged signature", () => {
const serverPriv = ed25519.randomPrivateKey();
const otherPriv = ed25519.randomPrivateKey();
const authority = new SessionTokenAuthority({
serverPublicKey: ed25519.getPublicKey(otherPriv), // wrong key
clock: () => 5_000,
});
const token = buildToken(serverPriv);
try {
authority.validate(token);
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkCryptoError).code).toBe(
NETWORK_CRYPTO_CODES.TOKEN_INVALID
);
}
});
it("rejects replays within the replay window", () => {
const serverPriv = ed25519.randomPrivateKey();
let now = 5_000;
const authority = new SessionTokenAuthority({
serverPublicKey: ed25519.getPublicKey(serverPriv),
clock: () => now,
replayWindowMs: 1_000,
});
const token = buildToken(serverPriv);
authority.validate(token);
try {
authority.validate(token);
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkCryptoError).code).toBe(
NETWORK_CRYPTO_CODES.TOKEN_REPLAYED
);
}
now += 1_500;
// Window expired — but still within token expiry — accepts again
// (note: building a fresh-expiry token; original token would be expired)
const fresh = buildToken(serverPriv, { expiresAt: 100_000 });
expect(() => authority.validate(fresh)).not.toThrow();
});
it("emits token:valid / token:invalid on the bus", () => {
const events: { event: string; payload: unknown }[] = [];
const bus: TokenBus = {
emit: (event, payload) => events.push({ event, payload }),
};
const serverPriv = ed25519.randomPrivateKey();
const authority = new SessionTokenAuthority({
serverPublicKey: ed25519.getPublicKey(serverPriv),
clock: () => 5_000,
bus,
});
authority.validate(buildToken(serverPriv));
try {
authority.validate(
buildToken(serverPriv, { expiresAt: 1, sessionId: "sess-2" })
);
} catch {
// expected
}
expect(events.some((e) => e.event === "token:valid")).toBe(true);
expect(events.some((e) => e.event === "token:invalid")).toBe(true);
});
it("reset clears the replay cache", () => {
const serverPriv = ed25519.randomPrivateKey();
const authority = new SessionTokenAuthority({
serverPublicKey: ed25519.getPublicKey(serverPriv),
clock: () => 5_000,
});
const token = buildToken(serverPriv);
authority.validate(token);
authority.reset();
expect(() => authority.validate(token)).not.toThrow();
});
it("replay cache GC sweep evicts stale entries", () => {
const serverPriv = ed25519.randomPrivateKey();
let now = 5_000;
const authority = new SessionTokenAuthority({
serverPublicKey: ed25519.getPublicKey(serverPriv),
clock: () => now,
replayWindowMs: 1_000,
});
const t1 = buildToken(serverPriv, { sessionId: "s1" });
authority.validate(t1);
now += 5_000;
// Validating a different session at a later time triggers the
// GC sweep over `seen`, evicting the stale entry for s1.
const t2 = buildToken(serverPriv, {
sessionId: "s2",
expiresAt: 100_000,
});
authority.validate(t2);
});
it("uses Date.now as default clock", () => {
const serverPriv = ed25519.randomPrivateKey();
const authority = new SessionTokenAuthority({
serverPublicKey: ed25519.getPublicKey(serverPriv),
});
const token = buildToken(serverPriv, {
expiresAt: Date.now() + 10_000,
});
expect(() => authority.validate(token)).not.toThrow();
});
});

View File

@@ -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.

View File

@@ -0,0 +1,17 @@
/**
* Challenge sent during peer handshake. The receiver signs the
* challenge bytes with their private key and returns the resulting
* signature in `IdentityResponse`.
*/
export interface IdentityChallenge {
readonly nonce: Uint8Array;
readonly issuedAt: number;
readonly expiresAt: number;
}
export interface IdentityResponse {
readonly nonce: Uint8Array;
readonly signerPublicKey: Uint8Array;
readonly signature: Uint8Array;
readonly respondedAt: number;
}

View File

@@ -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.

View File

@@ -0,0 +1,23 @@
/**
* Caller-supplied signing material. The plugin never holds the private
* key — callers manage rotation and persistence.
*/
export interface KeyPair {
readonly publicKey: Uint8Array;
readonly privateKey: Uint8Array;
}
/**
* Signed envelope payload. The signature covers `payload` exactly so
* receivers can re-sign over the same byte sequence.
*/
export interface MessageSignature {
readonly signerPublicKey: Uint8Array;
readonly signature: Uint8Array;
readonly signedAt: number;
}
export interface SignedMessage<TPayload = unknown> {
readonly payload: TPayload;
readonly signature: MessageSignature;
}

View File

@@ -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.

View File

@@ -0,0 +1,34 @@
/**
* Server-signed session token. Sessions cannot be created without a
* valid token even when the central server is offline — peers
* validate against the trusted server public key.
*/
export interface SessionToken {
readonly sessionId: string;
readonly parentSessionId: string | null;
readonly capacity: number;
readonly rulesHash: string;
readonly issuedAt: number;
readonly expiresAt: number;
readonly serverSignature: Uint8Array;
}
/**
* Server-signed mesh token. Mesh creation is gated on validation.
*/
export interface MeshToken {
readonly meshId: string;
readonly maxCapacity: number;
readonly allowedTiers: number;
readonly issuedAt: number;
readonly expiresAt: number;
readonly serverSignature: Uint8Array;
}
/**
* Discriminated wrapper used by the validators when the caller does
* not yet know which token type they hold.
*/
export type TokenPayload =
| { readonly kind: "session"; readonly token: SessionToken }
| { readonly kind: "mesh"; readonly token: MeshToken };

View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("network-plugin-crypto-messages dep-graph", () => {
it("loads index.js and exports every public class", async () => {
const mod = await import("../src/index.js");
expect(mod.MessageSigner).toBeDefined();
expect(mod.SessionTokenAuthority).toBeDefined();
expect(mod.MeshTokenAuthority).toBeDefined();
expect(mod.PeerIdentityVerifier).toBeDefined();
expect(mod.LogChainSigner).toBeDefined();
expect(mod.KeyRotationManager).toBeDefined();
expect(mod.CryptoMessagesPlugin).toBeDefined();
});
});

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"references": [{ "path": "./tsconfig.lib.json" }]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,42 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir:
"../../../node_modules/.vite/packages/network-plugin-crypto-messages",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/crypto-curve",
"@suaveplan/crypto-hash",
"@suaveplan/crypto-utils",
"@suaveplan/error",
"@suaveplan/network",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/__benchmarks__/**",
"benchmarks/**",
"e2e/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});