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