Initial Commit
This commit is contained in:
20
packages/networking/network/network-plugin-auth/README.md
Normal file
20
packages/networking/network/network-plugin-auth/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# @suaveplan/network-plugin-auth - Mesh membership authentication and cross-mesh relay authorization: join verifier, capability-token issuer, cross-mesh trust validator
|
||||
|
||||
## Summary
|
||||
|
||||
Mesh membership authentication and cross-mesh relay authorization. Bridges `@suaveplan/auth` into the mesh: peer join verification, capability-tier-bound tokens, cross-mesh trust store.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [issuer](./src/issuer/capability-token-issuer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [capability-token-issuer](./src/issuer/capability-token-issuer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [trust](./src/trust/cross-mesh-trust-validator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cross-mesh-trust-validator](./src/trust/cross-mesh-trust-validator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [verifier](./src/verifier/mesh-membership-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [mesh-membership-verifier](./src/verifier/mesh-membership-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the mesh-auth plugin. Five codes cover the full surface: `JOIN_REJECTED` when a peer…
|
||||
<!-- end:auto-docs --> - [capability-token-issuer](./src/issuer/capability-token-issuer.md) — <!-- begin:auto-docs --> _(no submodules)_- [trust](./src/trust/cross-mesh-trust-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [cross-mesh-trust-validator](./src/trust/cross-mesh-trust-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [verifier](./src/verifier/mesh-membership-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [mesh-membership-verifier](./src/verifier/mesh-membership-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the mesh-auth plugin. Five codes cover the full surface: `JOIN_REJECTED` when a peer…
|
||||
- [capability-token-issuer](./src/issuer/capability-token-issuer.md) — <!-- begin:auto-docs --> _(no submodules)_- [trust](./src/trust/cross-mesh-trust-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [cross-mesh-trust-validator](./src/trust/cross-mesh-trust-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [verifier](./src/verifier/mesh-membership-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [mesh-membership-verifier](./src/verifier/mesh-membership-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the mesh-auth plugin. Five codes cover the full surface: `JOIN_REJECTED` when a peer…
|
||||
- [capability-token-issuer](./src/issuer/capability-token-issuer.md) — <!-- begin:auto-docs --> _(no submodules)_- [trust](./src/trust/cross-mesh-trust-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [cross-mesh-trust-validator](./src/trust/cross-mesh-trust-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [verifier](./src/verifier/mesh-membership-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [mesh-membership-verifier](./src/verifier/mesh-membership-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the mesh-auth plugin. Five codes cover the full surface: `JOIN_REJECTED` when a peer…
|
||||
- [capability-token-issuer](./src/issuer/capability-token-issuer.md) — <!-- begin:auto-docs --> _(no submodules)_- [trust](./src/trust/cross-mesh-trust-validator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cross-mesh-trust-validator](./src/trust/cross-mesh-trust-validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [verifier](./src/verifier/mesh-membership-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_ - [mesh-membership-verifier](./src/verifier/mesh-membership-verifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the mesh-auth plugin. Five codes cover the full surface: `JOIN_REJECTED` when a peer…
|
||||
49
packages/networking/network/network-plugin-auth/package.json
Normal file
49
packages/networking/network/network-plugin-auth/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@suaveplan/network-plugin-auth",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Mesh membership authentication and cross-mesh relay authorization: join verifier, capability-token issuer, cross-mesh trust validator",
|
||||
"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/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,61 @@
|
||||
# MESH_AUTH_CODES
|
||||
|
||||
## Purpose
|
||||
|
||||
Frozen registry of error codes for the mesh-auth plugin. Five codes cover the full surface: `JOIN_REJECTED` when a peer fails membership verification, `TOKEN_EXPIRED` when a capability token's `expiresAt` is past, `TOKEN_INVALID_SIGNATURE` when the server signature does not verify, `CAPABILITY_DENIED` when a peer requests a capability outside its issued set, and `TRUST_NOT_ESTABLISHED` when cross-mesh relay traffic crosses an unknown boundary. The companion `MeshAuthError` class extends the cross-package `SuaveplanError` so consumers can pattern-match on `code` rather than parse messages.
|
||||
|
||||
## Features
|
||||
|
||||
- Five frozen codes registered under the `NET_AUTH` namespace.
|
||||
- `MeshAuthError extends SuaveplanError<MeshAuthErrorCode>`.
|
||||
- `name: "MeshAuthError"` on every instance — useful for log filters and dashboard alerts.
|
||||
- `cause` and `context` propagation via `SuaveplanErrorOptions`.
|
||||
- Codes are stable across versions — once registered they never disappear, only new codes are appended.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MESH_AUTH_CODES,
|
||||
MeshAuthError,
|
||||
} from "@suaveplan/network-plugin-auth";
|
||||
|
||||
throw new MeshAuthError(
|
||||
MESH_AUTH_CODES.JOIN_REJECTED,
|
||||
"auth.join-rejected",
|
||||
{ context: { peerId: "alice", meshId: "m" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MESH_AUTH_CODES,
|
||||
MeshAuthError,
|
||||
} from "@suaveplan/network-plugin-auth";
|
||||
|
||||
try {
|
||||
await issuer.validate(token);
|
||||
} catch (err) {
|
||||
if (err instanceof MeshAuthError) {
|
||||
if (err.code === MESH_AUTH_CODES.TOKEN_EXPIRED) {
|
||||
return await issuer.renew(token);
|
||||
}
|
||||
if (err.code === MESH_AUTH_CODES.TOKEN_INVALID_SIGNATURE) {
|
||||
await reportSecurityIncident(err.context);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `MESH_AUTH_CODES` — frozen `{ JOIN_REJECTED, TOKEN_EXPIRED, TOKEN_INVALID_SIGNATURE, CAPABILITY_DENIED, TRUST_NOT_ESTABLISHED }`.
|
||||
- `type MeshAuthErrorCode` — union of registered code values.
|
||||
- `class MeshAuthError extends SuaveplanError<MeshAuthErrorCode>` — typed throwable for every plugin failure mode.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`TOKEN_INVALID_SIGNATURE` is treated as a security-relevant signal — callers SHOULD log it loudly because a valid client never produces it. The other four codes are recoverable: `JOIN_REJECTED` is a clean refusal, `TOKEN_EXPIRED` is fixable by renewal, `CAPABILITY_DENIED` is a permission boundary, and `TRUST_NOT_ESTABLISHED` should trigger an out-of-band trust handshake. The package barrel side-effect-imports `./error-codes.js` so subpath consumers register the codes too.
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MESH_AUTH_CODES, MeshAuthError } from "./error-codes.js";
|
||||
|
||||
describe("MESH_AUTH_CODES", () => {
|
||||
it("registers under NET_AUTH", () => {
|
||||
for (const value of Object.values(MESH_AUTH_CODES)) {
|
||||
expect(value).toMatch(/^NET_AUTH\./);
|
||||
}
|
||||
});
|
||||
|
||||
it("is frozen", () => {
|
||||
expect(Object.isFrozen(MESH_AUTH_CODES)).toBe(true);
|
||||
});
|
||||
|
||||
it("contains the documented codes", () => {
|
||||
for (const key of [
|
||||
"JOIN_REJECTED",
|
||||
"TOKEN_EXPIRED",
|
||||
"TOKEN_INVALID_SIGNATURE",
|
||||
"CAPABILITY_DENIED",
|
||||
"TRUST_NOT_ESTABLISHED",
|
||||
] as const) {
|
||||
expect(MESH_AUTH_CODES).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("MeshAuthError", () => {
|
||||
it("extends SuaveplanError", () => {
|
||||
const err = new MeshAuthError(
|
||||
MESH_AUTH_CODES.JOIN_REJECTED,
|
||||
"join-rejected"
|
||||
);
|
||||
expect(err.name).toBe("MeshAuthError");
|
||||
expect(err.code).toBe(MESH_AUTH_CODES.JOIN_REJECTED);
|
||||
});
|
||||
|
||||
it("preserves cause", () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new MeshAuthError(
|
||||
MESH_AUTH_CODES.TOKEN_INVALID_SIGNATURE,
|
||||
"with-cause",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
export const MESH_AUTH_CODES = defineCodes("NET_AUTH", [
|
||||
"JOIN_REJECTED",
|
||||
"TOKEN_EXPIRED",
|
||||
"TOKEN_INVALID_SIGNATURE",
|
||||
"CAPABILITY_DENIED",
|
||||
"TRUST_NOT_ESTABLISHED",
|
||||
] as const);
|
||||
|
||||
export type MeshAuthErrorCode =
|
||||
(typeof MESH_AUTH_CODES)[keyof typeof MESH_AUTH_CODES];
|
||||
|
||||
export class MeshAuthError extends SuaveplanError<MeshAuthErrorCode> {
|
||||
public constructor(
|
||||
code: MeshAuthErrorCode,
|
||||
messageKey: string,
|
||||
options?: import("@suaveplan/error").SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "MeshAuthError";
|
||||
}
|
||||
}
|
||||
32
packages/networking/network/network-plugin-auth/src/index.ts
Normal file
32
packages/networking/network/network-plugin-auth/src/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Error codes (side-effect registration)
|
||||
import "./error-codes.js";
|
||||
|
||||
// Errors
|
||||
export {
|
||||
MESH_AUTH_CODES,
|
||||
MeshAuthError,
|
||||
type MeshAuthErrorCode,
|
||||
} from "./error-codes.js";
|
||||
// Issuer
|
||||
export {
|
||||
type CapabilityToken,
|
||||
CapabilityTokenIssuer,
|
||||
type CapabilityTokenIssuerConfig,
|
||||
type TokenSigner,
|
||||
} from "./issuer/capability-token-issuer.js";
|
||||
// Trust
|
||||
export {
|
||||
CrossMeshTrustValidator,
|
||||
type CrossMeshTrustValidatorConfig,
|
||||
type TrustDirection,
|
||||
type TrustRelation,
|
||||
} from "./trust/cross-mesh-trust-validator.js";
|
||||
// Verifier
|
||||
export {
|
||||
type AuthProvider,
|
||||
type Capability,
|
||||
type JoinRequest,
|
||||
type JoinResult,
|
||||
MeshMembershipVerifier,
|
||||
type MeshMembershipVerifierConfig,
|
||||
} from "./verifier/mesh-membership-verifier.js";
|
||||
@@ -0,0 +1,79 @@
|
||||
# CapabilityTokenIssuer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Issues server-signed capability tokens for mesh peers and validates incoming tokens. The token shape pins the peer's `tier`, allowed `capabilities`, and a `serverSignatureHex` covering a deterministic JSON canonicalisation. Decoupling the signer via `TokenSigner` lets consumers wire Ed25519 (production), HMAC (tests), or a hardware-backed signer without changes to the issuer surface.
|
||||
|
||||
## Features
|
||||
|
||||
- Deterministic JSON canonicalisation with sorted `capabilities`.
|
||||
- Configurable `defaultTtlMs` (default 1 hour); per-call `ttlMs` overrides.
|
||||
- Injectable `clock` for deterministic tests.
|
||||
- `validate` throws `TOKEN_EXPIRED` then `TOKEN_INVALID_SIGNATURE` in that order so callers can pattern-match.
|
||||
- `renew` re-issues without re-verifying the previous signature — callers that want pre-renewal checks must call `validate` first.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
CapabilityTokenIssuer,
|
||||
type TokenSigner,
|
||||
} from "@suaveplan/network-plugin-auth";
|
||||
|
||||
const signer: TokenSigner = {
|
||||
sign: async (bytes) => "deadbeef",
|
||||
verify: async () => true,
|
||||
};
|
||||
const issuer = new CapabilityTokenIssuer({ signer });
|
||||
|
||||
const token = await issuer.issue("alice", "mesh-A", 0, ["host"]);
|
||||
await issuer.validate(token);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
CapabilityTokenIssuer,
|
||||
MESH_AUTH_CODES,
|
||||
MeshAuthError,
|
||||
} from "@suaveplan/network-plugin-auth";
|
||||
|
||||
let now = 1_000_000;
|
||||
const issuer = new CapabilityTokenIssuer({
|
||||
signer,
|
||||
defaultTtlMs: 60_000,
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
const token = await issuer.issue("alice", "mesh-A", 1, ["subhost"]);
|
||||
now += 70_000; // past expiry
|
||||
|
||||
try {
|
||||
await issuer.validate(token);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof MeshAuthError &&
|
||||
err.code === MESH_AUTH_CODES.TOKEN_EXPIRED
|
||||
) {
|
||||
const renewed = await issuer.renew(token);
|
||||
// renewed.expiresAt > now
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface CapabilityToken { peerId, meshId, tier, capabilities, issuedAt, expiresAt, serverSignatureHex }`.
|
||||
- `interface TokenSigner { sign, verify }`.
|
||||
- `interface CapabilityTokenIssuerConfig { signer, defaultTtlMs?, clock? }`.
|
||||
- `issue(peerId, meshId, tier, capabilities, ttlMs?): Promise<CapabilityToken>`.
|
||||
- `validate(token): Promise<void>` — throws `TOKEN_EXPIRED` / `TOKEN_INVALID_SIGNATURE`.
|
||||
- `renew(token, ttlMs?): Promise<CapabilityToken>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The canonicalisation sorts `capabilities` alphabetically before JSON-stringify so a permutation of the same set produces the same signature input. We use JSON rather than a custom binary frame because (a) the token is small, (b) consumers can debug it visually, and (c) `JSON.stringify` is fast and well-specified for our scalar/string-only field set. Renewal explicitly does NOT verify the prior signature — that would require the renewer to also be the verifier, which is not always true (e.g. a hardware HSM signs but the issuer doesn't have the verify key locally). Callers that want defense-in-depth should call `validate` before `renew`.
|
||||
@@ -0,0 +1,125 @@
|
||||
import { MESH_AUTH_CODES, MeshAuthError } from "../error-codes.js";
|
||||
import type { Capability } from "../verifier/mesh-membership-verifier.js";
|
||||
|
||||
/**
|
||||
* Capability token issued by the mesh server.
|
||||
*/
|
||||
export interface CapabilityToken {
|
||||
readonly peerId: string;
|
||||
readonly meshId: string;
|
||||
readonly tier: number;
|
||||
readonly capabilities: readonly Capability[];
|
||||
readonly issuedAt: number;
|
||||
readonly expiresAt: number;
|
||||
readonly serverSignatureHex: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluggable signer + verifier (Ed25519 via `@suaveplan/crypto-curve`,
|
||||
* mock signer for tests, etc.).
|
||||
*/
|
||||
export interface TokenSigner {
|
||||
sign(canonicalBytes: Uint8Array): string | Promise<string>;
|
||||
verify(
|
||||
canonicalBytes: Uint8Array,
|
||||
signatureHex: string
|
||||
): boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for `CapabilityTokenIssuer`.
|
||||
*/
|
||||
export interface CapabilityTokenIssuerConfig {
|
||||
readonly signer: TokenSigner;
|
||||
readonly defaultTtlMs?: number;
|
||||
readonly clock?: () => number;
|
||||
}
|
||||
|
||||
function canonicalize(
|
||||
token: Omit<CapabilityToken, "serverSignatureHex">
|
||||
): Uint8Array {
|
||||
const json = JSON.stringify({
|
||||
peerId: token.peerId,
|
||||
meshId: token.meshId,
|
||||
tier: token.tier,
|
||||
capabilities: [...token.capabilities].sort(),
|
||||
issuedAt: token.issuedAt,
|
||||
expiresAt: token.expiresAt,
|
||||
});
|
||||
return new TextEncoder().encode(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Issues server-signed capability tokens for mesh peers and validates
|
||||
* incoming tokens. The signer is injected so consumers can use Ed25519
|
||||
* via `@suaveplan/crypto-curve`, a hash-based MAC for tests, etc.
|
||||
*/
|
||||
export class CapabilityTokenIssuer {
|
||||
private readonly signer: TokenSigner;
|
||||
private readonly defaultTtlMs: number;
|
||||
private readonly clock: () => number;
|
||||
|
||||
public constructor(config: CapabilityTokenIssuerConfig) {
|
||||
this.signer = config.signer;
|
||||
this.defaultTtlMs = config.defaultTtlMs ?? 3_600_000;
|
||||
this.clock = config.clock ?? (() => Date.now());
|
||||
}
|
||||
|
||||
public async issue(
|
||||
peerId: string,
|
||||
meshId: string,
|
||||
tier: number,
|
||||
capabilities: readonly Capability[],
|
||||
ttlMs?: number
|
||||
): Promise<CapabilityToken> {
|
||||
const issuedAt = this.clock();
|
||||
const expiresAt = issuedAt + (ttlMs ?? this.defaultTtlMs);
|
||||
const unsigned = {
|
||||
peerId,
|
||||
meshId,
|
||||
tier,
|
||||
capabilities,
|
||||
issuedAt,
|
||||
expiresAt,
|
||||
};
|
||||
const sig = await this.signer.sign(canonicalize(unsigned));
|
||||
return { ...unsigned, serverSignatureHex: sig };
|
||||
}
|
||||
|
||||
public async validate(token: CapabilityToken): Promise<void> {
|
||||
if (token.expiresAt <= this.clock()) {
|
||||
throw new MeshAuthError(
|
||||
MESH_AUTH_CODES.TOKEN_EXPIRED,
|
||||
"auth.token-expired",
|
||||
{ context: { peerId: token.peerId } }
|
||||
);
|
||||
}
|
||||
const ok = await this.signer.verify(
|
||||
canonicalize({ ...token }),
|
||||
token.serverSignatureHex
|
||||
);
|
||||
if (!ok) {
|
||||
throw new MeshAuthError(
|
||||
MESH_AUTH_CODES.TOKEN_INVALID_SIGNATURE,
|
||||
"auth.token-bad-sig",
|
||||
{ context: { peerId: token.peerId } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async renew(
|
||||
token: CapabilityToken,
|
||||
ttlMs?: number
|
||||
): Promise<CapabilityToken> {
|
||||
// Renewal does NOT re-validate signature — the server is the issuer
|
||||
// and can refresh tokens that have expired. Callers that want
|
||||
// signature checks before renewal should call `validate` first.
|
||||
return this.issue(
|
||||
token.peerId,
|
||||
token.meshId,
|
||||
token.tier,
|
||||
token.capabilities,
|
||||
ttlMs
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
CapabilityTokenIssuer,
|
||||
type TokenSigner,
|
||||
} from "./capability-token-issuer.js";
|
||||
|
||||
function makeSigner(): TokenSigner & { signed: number; verified: number } {
|
||||
let signed = 0;
|
||||
let verified = 0;
|
||||
return {
|
||||
get signed() {
|
||||
return signed;
|
||||
},
|
||||
get verified() {
|
||||
return verified;
|
||||
},
|
||||
sign: async () => {
|
||||
signed += 1;
|
||||
return "sig";
|
||||
},
|
||||
verify: async (_bytes, signatureHex) => {
|
||||
verified += 1;
|
||||
return signatureHex === "sig";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("CapabilityTokenIssuer", () => {
|
||||
it("issues a signed token", async () => {
|
||||
const now = 1_000;
|
||||
const issuer = new CapabilityTokenIssuer({
|
||||
signer: makeSigner(),
|
||||
defaultTtlMs: 5_000,
|
||||
clock: () => now,
|
||||
});
|
||||
const token = await issuer.issue("alice", "mesh-A", 0, ["host"]);
|
||||
expect(token.peerId).toBe("alice");
|
||||
expect(token.tier).toBe(0);
|
||||
expect(token.capabilities).toEqual(["host"]);
|
||||
expect(token.issuedAt).toBe(1_000);
|
||||
expect(token.expiresAt).toBe(6_000);
|
||||
expect(token.serverSignatureHex).toBe("sig");
|
||||
});
|
||||
|
||||
it("respects per-call ttlMs over the default", async () => {
|
||||
const issuer = new CapabilityTokenIssuer({
|
||||
signer: makeSigner(),
|
||||
defaultTtlMs: 5_000,
|
||||
clock: () => 0,
|
||||
});
|
||||
const token = await issuer.issue("alice", "m", 0, ["host"], 100);
|
||||
expect(token.expiresAt).toBe(100);
|
||||
});
|
||||
|
||||
it("validates a fresh signature", async () => {
|
||||
const issuer = new CapabilityTokenIssuer({
|
||||
signer: makeSigner(),
|
||||
clock: () => 0,
|
||||
});
|
||||
const token = await issuer.issue("alice", "m", 0, ["host"]);
|
||||
await issuer.validate(token);
|
||||
});
|
||||
|
||||
it("throws TOKEN_EXPIRED when past expiry", async () => {
|
||||
let now = 0;
|
||||
const issuer = new CapabilityTokenIssuer({
|
||||
signer: makeSigner(),
|
||||
defaultTtlMs: 100,
|
||||
clock: () => now,
|
||||
});
|
||||
const token = await issuer.issue("alice", "m", 0, ["host"]);
|
||||
now = 200;
|
||||
await expect(issuer.validate(token)).rejects.toThrow(
|
||||
"auth.token-expired"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws TOKEN_INVALID_SIGNATURE on bad signature", async () => {
|
||||
const issuer = new CapabilityTokenIssuer({
|
||||
signer: makeSigner(),
|
||||
clock: () => 0,
|
||||
});
|
||||
const token = await issuer.issue("alice", "m", 0, ["host"]);
|
||||
const tampered = { ...token, serverSignatureHex: "wrong" };
|
||||
await expect(issuer.validate(tampered)).rejects.toThrow(
|
||||
"auth.token-bad-sig"
|
||||
);
|
||||
});
|
||||
|
||||
it("renew returns a fresh token with new expiry", async () => {
|
||||
let now = 0;
|
||||
const issuer = new CapabilityTokenIssuer({
|
||||
signer: makeSigner(),
|
||||
defaultTtlMs: 100,
|
||||
clock: () => now,
|
||||
});
|
||||
const original = await issuer.issue("alice", "m", 0, ["host"]);
|
||||
now = 500;
|
||||
const renewed = await issuer.renew(original);
|
||||
expect(renewed.issuedAt).toBe(500);
|
||||
expect(renewed.expiresAt).toBe(600);
|
||||
});
|
||||
|
||||
it("uses Date.now-based clock when none injected", async () => {
|
||||
const issuer = new CapabilityTokenIssuer({ signer: makeSigner() });
|
||||
const token = await issuer.issue("alice", "m", 0, ["host"]);
|
||||
expect(token.issuedAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("canonicalises capabilities sorted alphabetically", async () => {
|
||||
const sigInputs: string[] = [];
|
||||
const signer: TokenSigner = {
|
||||
sign: async (b) => {
|
||||
sigInputs.push(new TextDecoder().decode(b));
|
||||
return "sig";
|
||||
},
|
||||
verify: async () => true,
|
||||
};
|
||||
const issuer = new CapabilityTokenIssuer({ signer, clock: () => 0 });
|
||||
await issuer.issue("alice", "m", 0, ["relay", "host", "audit"]);
|
||||
expect(sigInputs[0]).toContain('"audit"');
|
||||
// Should appear in sorted order
|
||||
const idxAudit = sigInputs[0]!.indexOf("audit");
|
||||
const idxHost = sigInputs[0]!.indexOf("host");
|
||||
const idxRelay = sigInputs[0]!.indexOf("relay");
|
||||
expect(idxAudit).toBeLessThan(idxHost);
|
||||
expect(idxHost).toBeLessThan(idxRelay);
|
||||
});
|
||||
|
||||
it("renew accepts a custom ttlMs", async () => {
|
||||
const now = 0;
|
||||
const issuer = new CapabilityTokenIssuer({
|
||||
signer: makeSigner(),
|
||||
defaultTtlMs: 100,
|
||||
clock: () => now,
|
||||
});
|
||||
const original = await issuer.issue("alice", "m", 0, ["host"]);
|
||||
const renewed = await issuer.renew(original, 9_999);
|
||||
expect(renewed.expiresAt - renewed.issuedAt).toBe(9_999);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
# CrossMeshTrustValidator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Tracks bidirectional or directional trust between mesh ids and rejects cross-mesh relay/backbone traffic that would cross an unknown boundary. Centralising trust state in a single validator means relay and backbone pipelines query one source of truth, eliminating the class of bugs where one path enforces trust and another path bypasses it.
|
||||
|
||||
## Features
|
||||
|
||||
- `TrustRelation` shape pins `fromMeshId`, `toMeshId`, allowed `directions` (`relay` / `backbone`), `establishedAt`, optional `expiresAt`.
|
||||
- `add` / `remove` for explicit lifecycle.
|
||||
- `assertTrusts(fromMeshId, toMeshId, direction)` throws `TRUST_NOT_ESTABLISHED` for unknown, expired, or direction-disallowed pairs.
|
||||
- Initial set seedable via constructor `initial` array.
|
||||
- Injectable `clock` for deterministic tests.
|
||||
- `list()` exposed for observability dashboards.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CrossMeshTrustValidator } from "@suaveplan/network-plugin-auth";
|
||||
|
||||
const trust = new CrossMeshTrustValidator({
|
||||
initial: [
|
||||
{
|
||||
fromMeshId: "mesh-A",
|
||||
toMeshId: "mesh-B",
|
||||
directions: ["relay", "backbone"],
|
||||
establishedAt: Date.now(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
trust.assertTrusts("mesh-A", "mesh-B", "relay");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
CrossMeshTrustValidator,
|
||||
MESH_AUTH_CODES,
|
||||
MeshAuthError,
|
||||
} from "@suaveplan/network-plugin-auth";
|
||||
|
||||
const trust = new CrossMeshTrustValidator({
|
||||
clock: () => Date.now(),
|
||||
});
|
||||
|
||||
trust.add({
|
||||
fromMeshId: "mesh-A",
|
||||
toMeshId: "mesh-C",
|
||||
directions: ["relay"], // backbone explicitly disallowed
|
||||
establishedAt: Date.now(),
|
||||
expiresAt: Date.now() + 86_400_000,
|
||||
});
|
||||
|
||||
try {
|
||||
trust.assertTrusts("mesh-A", "mesh-C", "backbone");
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof MeshAuthError &&
|
||||
err.code === MESH_AUTH_CODES.TRUST_NOT_ESTABLISHED
|
||||
) {
|
||||
// operator must establish backbone trust before this works
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface TrustRelation { fromMeshId, toMeshId, directions, establishedAt, expiresAt? }`.
|
||||
- `type TrustDirection = "relay" | "backbone"`.
|
||||
- `interface CrossMeshTrustValidatorConfig { clock?, initial? }`.
|
||||
- `add(relation)`, `remove(fromMeshId, toMeshId): boolean`.
|
||||
- `assertTrusts(fromMeshId, toMeshId, direction)` — throws `TRUST_NOT_ESTABLISHED` on any failure.
|
||||
- `list(): readonly TrustRelation[]`.
|
||||
- `clear()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Trust is intentionally directional — `(A, B)` and `(B, A)` are distinct keys. Bidirectional trust requires two `add` calls. This avoids the class of bugs where one mesh trusts another but the reverse direction was assumed without being explicit. The `directions` field further restricts what kinds of cross-mesh traffic are allowed; a relay-only pairing cannot host backbone traffic until an operator explicitly upgrades the relation. `assertTrusts` checks expiry before direction so an expired relation surfaces as expired regardless of which direction was requested.
|
||||
@@ -0,0 +1,93 @@
|
||||
import { MESH_AUTH_CODES, MeshAuthError } from "../error-codes.js";
|
||||
|
||||
/**
|
||||
* One trust relationship in the cross-mesh trust store.
|
||||
*/
|
||||
export interface TrustRelation {
|
||||
readonly fromMeshId: string;
|
||||
readonly toMeshId: string;
|
||||
readonly directions: readonly TrustDirection[];
|
||||
readonly establishedAt: number;
|
||||
readonly expiresAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Directions in which trust flows.
|
||||
*/
|
||||
export type TrustDirection = "relay" | "backbone";
|
||||
|
||||
/**
|
||||
* Configuration for `CrossMeshTrustValidator`.
|
||||
*/
|
||||
export interface CrossMeshTrustValidatorConfig {
|
||||
readonly clock?: () => number;
|
||||
readonly initial?: readonly TrustRelation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks bidirectional or directional trust between mesh ids and rejects
|
||||
* cross-mesh relay/backbone messages that would cross an unknown
|
||||
* boundary. A common store of trust relations means relay and backbone
|
||||
* pipelines consult one source of truth.
|
||||
*/
|
||||
export class CrossMeshTrustValidator {
|
||||
private readonly relations = new Map<string, TrustRelation>();
|
||||
private readonly clock: () => number;
|
||||
|
||||
public constructor(config: CrossMeshTrustValidatorConfig = {}) {
|
||||
this.clock = config.clock ?? (() => Date.now());
|
||||
for (const r of config.initial ?? []) {
|
||||
this.add(r);
|
||||
}
|
||||
}
|
||||
|
||||
public add(relation: TrustRelation): void {
|
||||
const key = this.key(relation.fromMeshId, relation.toMeshId);
|
||||
this.relations.set(key, relation);
|
||||
}
|
||||
|
||||
public remove(fromMeshId: string, toMeshId: string): boolean {
|
||||
return this.relations.delete(this.key(fromMeshId, toMeshId));
|
||||
}
|
||||
|
||||
public assertTrusts(
|
||||
fromMeshId: string,
|
||||
toMeshId: string,
|
||||
direction: TrustDirection
|
||||
): void {
|
||||
const rel = this.relations.get(this.key(fromMeshId, toMeshId));
|
||||
if (rel === undefined) {
|
||||
throw new MeshAuthError(
|
||||
MESH_AUTH_CODES.TRUST_NOT_ESTABLISHED,
|
||||
"trust.not-established",
|
||||
{ context: { fromMeshId, toMeshId, direction } }
|
||||
);
|
||||
}
|
||||
if (rel.expiresAt !== undefined && rel.expiresAt <= this.clock()) {
|
||||
throw new MeshAuthError(
|
||||
MESH_AUTH_CODES.TRUST_NOT_ESTABLISHED,
|
||||
"trust.expired",
|
||||
{ context: { fromMeshId, toMeshId, direction } }
|
||||
);
|
||||
}
|
||||
if (!rel.directions.includes(direction)) {
|
||||
throw new MeshAuthError(
|
||||
MESH_AUTH_CODES.TRUST_NOT_ESTABLISHED,
|
||||
"trust.direction-not-allowed",
|
||||
{ context: { fromMeshId, toMeshId, direction } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public list(): readonly TrustRelation[] {
|
||||
return [...this.relations.values()];
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.relations.clear();
|
||||
}
|
||||
|
||||
private key(fromMeshId: string, toMeshId: string): string {
|
||||
return `${fromMeshId}::${toMeshId}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CrossMeshTrustValidator } from "./cross-mesh-trust-validator.js";
|
||||
|
||||
describe("CrossMeshTrustValidator", () => {
|
||||
it("starts empty unless seeded", () => {
|
||||
const v = new CrossMeshTrustValidator();
|
||||
expect(v.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it("seeds initial relations from config", () => {
|
||||
const v = new CrossMeshTrustValidator({
|
||||
initial: [
|
||||
{
|
||||
fromMeshId: "A",
|
||||
toMeshId: "B",
|
||||
directions: ["relay"],
|
||||
establishedAt: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(v.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("asserts trust for known pairs and directions", () => {
|
||||
const v = new CrossMeshTrustValidator();
|
||||
v.add({
|
||||
fromMeshId: "A",
|
||||
toMeshId: "B",
|
||||
directions: ["relay"],
|
||||
establishedAt: 0,
|
||||
});
|
||||
expect(() => v.assertTrusts("A", "B", "relay")).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects unknown pairs", () => {
|
||||
const v = new CrossMeshTrustValidator();
|
||||
expect(() => v.assertTrusts("A", "B", "relay")).toThrow(
|
||||
"trust.not-established"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects expired relations", () => {
|
||||
const now = 100;
|
||||
const v = new CrossMeshTrustValidator({ clock: () => now });
|
||||
v.add({
|
||||
fromMeshId: "A",
|
||||
toMeshId: "B",
|
||||
directions: ["relay"],
|
||||
establishedAt: 0,
|
||||
expiresAt: 50,
|
||||
});
|
||||
expect(() => v.assertTrusts("A", "B", "relay")).toThrow(
|
||||
"trust.expired"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects disallowed directions", () => {
|
||||
const v = new CrossMeshTrustValidator();
|
||||
v.add({
|
||||
fromMeshId: "A",
|
||||
toMeshId: "B",
|
||||
directions: ["relay"],
|
||||
establishedAt: 0,
|
||||
});
|
||||
expect(() => v.assertTrusts("A", "B", "backbone")).toThrow(
|
||||
"trust.direction-not-allowed"
|
||||
);
|
||||
});
|
||||
|
||||
it("treats trust as directional", () => {
|
||||
const v = new CrossMeshTrustValidator();
|
||||
v.add({
|
||||
fromMeshId: "A",
|
||||
toMeshId: "B",
|
||||
directions: ["relay"],
|
||||
establishedAt: 0,
|
||||
});
|
||||
expect(() => v.assertTrusts("B", "A", "relay")).toThrow(
|
||||
"trust.not-established"
|
||||
);
|
||||
});
|
||||
|
||||
it("remove deletes a relation", () => {
|
||||
const v = new CrossMeshTrustValidator();
|
||||
v.add({
|
||||
fromMeshId: "A",
|
||||
toMeshId: "B",
|
||||
directions: ["relay"],
|
||||
establishedAt: 0,
|
||||
});
|
||||
expect(v.remove("A", "B")).toBe(true);
|
||||
expect(v.remove("A", "B")).toBe(false);
|
||||
});
|
||||
|
||||
it("clear empties every relation", () => {
|
||||
const v = new CrossMeshTrustValidator();
|
||||
v.add({
|
||||
fromMeshId: "A",
|
||||
toMeshId: "B",
|
||||
directions: ["relay"],
|
||||
establishedAt: 0,
|
||||
});
|
||||
v.clear();
|
||||
expect(v.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses Date.now-based clock when none injected", () => {
|
||||
const v = new CrossMeshTrustValidator();
|
||||
v.add({
|
||||
fromMeshId: "A",
|
||||
toMeshId: "B",
|
||||
directions: ["relay"],
|
||||
establishedAt: 0,
|
||||
expiresAt: Date.now() + 100_000,
|
||||
});
|
||||
expect(() => v.assertTrusts("A", "B", "relay")).not.toThrow();
|
||||
});
|
||||
|
||||
it("respects no-expiry relations forever", () => {
|
||||
const v = new CrossMeshTrustValidator({ clock: () => 1_000_000_000 });
|
||||
v.add({
|
||||
fromMeshId: "A",
|
||||
toMeshId: "B",
|
||||
directions: ["relay"],
|
||||
establishedAt: 0,
|
||||
});
|
||||
expect(() => v.assertTrusts("A", "B", "relay")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
# MeshMembershipVerifier
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Walks a `JoinRequest` through a four-step verification: token validation via the injected `AuthProvider`, public-key match, mesh id match, capability allow-list. Returns a `JoinResult` with the assigned tier on success or throws `MeshAuthError` on the first failure. Decoupling auth via the `AuthProvider` interface keeps this plugin from depending directly on `@suaveplan/auth` while still being easy to wire up.
|
||||
|
||||
## Features
|
||||
|
||||
- Lowest-tier-wins capability mapping: `host` → 0, `subhost`/`bridge` → 1, others → 2.
|
||||
- Mesh-id mismatch raises `JOIN_REJECTED`.
|
||||
- Public-key mismatch raises `JOIN_REJECTED` (when the auth provider returns a public key).
|
||||
- Disallowed capability raises `CAPABILITY_DENIED` with the offending name in `context`.
|
||||
- Empty `claimedCapabilities` defaults to tier 2 (most restrictive) so a join with no capabilities still produces a deterministic result.
|
||||
- `AuthProvider` is structural — any object with `verifyToken(token)` can plug in.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MeshMembershipVerifier,
|
||||
type AuthProvider,
|
||||
} from "@suaveplan/network-plugin-auth";
|
||||
|
||||
const authProvider: AuthProvider = {
|
||||
verifyToken: async (t) => ({ identity: "alice" }),
|
||||
};
|
||||
const verifier = new MeshMembershipVerifier({
|
||||
authProvider,
|
||||
meshId: "mesh-A",
|
||||
allowedCapabilities: ["host", "subhost", "relay"],
|
||||
});
|
||||
|
||||
const result = await verifier.verifyJoinRequest({
|
||||
peerId: "alice",
|
||||
meshId: "mesh-A",
|
||||
authToken: "...",
|
||||
publicKeyHex: "...",
|
||||
claimedCapabilities: ["host"],
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MESH_AUTH_CODES,
|
||||
MeshAuthError,
|
||||
MeshMembershipVerifier,
|
||||
} from "@suaveplan/network-plugin-auth";
|
||||
|
||||
const verifier = new MeshMembershipVerifier({
|
||||
authProvider,
|
||||
meshId: "mesh-A",
|
||||
allowedCapabilities: ["relay", "audit"],
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await verifier.verifyJoinRequest(request);
|
||||
promotePeer(result);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof MeshAuthError &&
|
||||
err.code === MESH_AUTH_CODES.CAPABILITY_DENIED
|
||||
) {
|
||||
await retryWithReducedCapabilities(request);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type Capability = "host" | "subhost" | "relay" | "bridge" | "audit" | "create_session" | "join_session"`.
|
||||
- `interface AuthProvider { verifyToken(token): Promise<{ identity, publicKeyHex? }> }`.
|
||||
- `interface JoinRequest { peerId, meshId, authToken, publicKeyHex, claimedCapabilities }`.
|
||||
- `interface JoinResult { accepted, peerId, assignedTier, capabilities, reason? }`.
|
||||
- `interface MeshMembershipVerifierConfig { authProvider, meshId, allowedCapabilities }`.
|
||||
- `verifyJoinRequest(request): Promise<JoinResult>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The verifier intentionally treats every failure as a thrown exception rather than a `{ accepted: false, reason }` result so callers cannot accidentally proceed past a security check by ignoring a return value. The cost is one branch per call site, which is the correct shape for security-critical paths. The `publicKeyHex` mismatch check is gated on the auth provider returning a key; providers that only verify identity (no key binding) skip the check, matching the principle that we don't fabricate a verification we don't actually perform.
|
||||
@@ -0,0 +1,136 @@
|
||||
import { MESH_AUTH_CODES, MeshAuthError } from "../error-codes.js";
|
||||
|
||||
/**
|
||||
* Capabilities a peer can request when joining a mesh.
|
||||
*/
|
||||
export type Capability =
|
||||
| "host"
|
||||
| "subhost"
|
||||
| "relay"
|
||||
| "bridge"
|
||||
| "audit"
|
||||
| "create_session"
|
||||
| "join_session";
|
||||
|
||||
/**
|
||||
* Authentication provider — `@suaveplan/auth` can implement this, but
|
||||
* the plugin doesn't import it directly so consumers can plug in any
|
||||
* structurally-compatible provider.
|
||||
*/
|
||||
export interface AuthProvider {
|
||||
verifyToken(token: string): Promise<{
|
||||
readonly identity: string;
|
||||
readonly publicKeyHex?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbound join request.
|
||||
*/
|
||||
export interface JoinRequest {
|
||||
readonly peerId: string;
|
||||
readonly meshId: string;
|
||||
readonly authToken: string;
|
||||
readonly publicKeyHex: string;
|
||||
readonly claimedCapabilities: readonly Capability[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of a join verification.
|
||||
*/
|
||||
export interface JoinResult {
|
||||
readonly accepted: boolean;
|
||||
readonly peerId: string;
|
||||
readonly assignedTier: number;
|
||||
readonly capabilities: readonly Capability[];
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for `MeshMembershipVerifier`.
|
||||
*/
|
||||
export interface MeshMembershipVerifierConfig {
|
||||
readonly authProvider: AuthProvider;
|
||||
readonly meshId: string;
|
||||
readonly allowedCapabilities: readonly Capability[];
|
||||
}
|
||||
|
||||
const TIER_FOR_CAPABILITY: Record<Capability, number> = {
|
||||
host: 0,
|
||||
subhost: 1,
|
||||
bridge: 1,
|
||||
relay: 2,
|
||||
audit: 2,
|
||||
create_session: 2,
|
||||
join_session: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Walks a `JoinRequest` through the four-step verification:
|
||||
*
|
||||
* 1. Token validates against `AuthProvider`.
|
||||
* 2. Provider's `publicKeyHex` (when present) matches the request.
|
||||
* 3. Mesh id matches the verifier's configured mesh.
|
||||
* 4. Every claimed capability is in the verifier's allow-list.
|
||||
*
|
||||
* The assigned tier is the lowest tier number across the granted
|
||||
* capabilities (lower number = higher rank).
|
||||
*/
|
||||
export class MeshMembershipVerifier {
|
||||
private readonly authProvider: AuthProvider;
|
||||
private readonly meshId: string;
|
||||
private readonly allowedCapabilities: ReadonlySet<Capability>;
|
||||
|
||||
public constructor(config: MeshMembershipVerifierConfig) {
|
||||
this.authProvider = config.authProvider;
|
||||
this.meshId = config.meshId;
|
||||
this.allowedCapabilities = new Set(config.allowedCapabilities);
|
||||
}
|
||||
|
||||
public async verifyJoinRequest(request: JoinRequest): Promise<JoinResult> {
|
||||
if (request.meshId !== this.meshId) {
|
||||
throw new MeshAuthError(
|
||||
MESH_AUTH_CODES.JOIN_REJECTED,
|
||||
"auth.mesh-mismatch",
|
||||
{ context: { expected: this.meshId, got: request.meshId } }
|
||||
);
|
||||
}
|
||||
const claim = await this.authProvider.verifyToken(request.authToken);
|
||||
if (
|
||||
claim.publicKeyHex !== undefined &&
|
||||
claim.publicKeyHex !== request.publicKeyHex
|
||||
) {
|
||||
throw new MeshAuthError(
|
||||
MESH_AUTH_CODES.JOIN_REJECTED,
|
||||
"auth.public-key-mismatch",
|
||||
{ context: { peerId: request.peerId } }
|
||||
);
|
||||
}
|
||||
const granted: Capability[] = [];
|
||||
for (const cap of request.claimedCapabilities) {
|
||||
if (!this.allowedCapabilities.has(cap)) {
|
||||
throw new MeshAuthError(
|
||||
MESH_AUTH_CODES.CAPABILITY_DENIED,
|
||||
"auth.capability-denied",
|
||||
{
|
||||
context: {
|
||||
peerId: request.peerId,
|
||||
capability: cap,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
granted.push(cap);
|
||||
}
|
||||
const tier =
|
||||
granted.length === 0
|
||||
? 2
|
||||
: Math.min(...granted.map((c) => TIER_FOR_CAPABILITY[c]));
|
||||
return {
|
||||
accepted: true,
|
||||
peerId: request.peerId,
|
||||
assignedTier: tier,
|
||||
capabilities: granted,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
type AuthProvider,
|
||||
type JoinRequest,
|
||||
MeshMembershipVerifier,
|
||||
} from "./mesh-membership-verifier.js";
|
||||
|
||||
const okProvider: AuthProvider = {
|
||||
verifyToken: async () => ({ identity: "alice" }),
|
||||
};
|
||||
const keyBoundProvider: AuthProvider = {
|
||||
verifyToken: async () => ({
|
||||
identity: "alice",
|
||||
publicKeyHex: "deadbeef",
|
||||
}),
|
||||
};
|
||||
|
||||
const baseRequest: JoinRequest = {
|
||||
peerId: "alice",
|
||||
meshId: "mesh-A",
|
||||
authToken: "tok",
|
||||
publicKeyHex: "deadbeef",
|
||||
claimedCapabilities: ["host"],
|
||||
};
|
||||
|
||||
describe("MeshMembershipVerifier", () => {
|
||||
it("accepts a valid join request and assigns tier 0 for host", async () => {
|
||||
const v = new MeshMembershipVerifier({
|
||||
authProvider: okProvider,
|
||||
meshId: "mesh-A",
|
||||
allowedCapabilities: ["host", "subhost", "relay"],
|
||||
});
|
||||
const result = await v.verifyJoinRequest(baseRequest);
|
||||
expect(result.accepted).toBe(true);
|
||||
expect(result.assignedTier).toBe(0);
|
||||
expect(result.capabilities).toEqual(["host"]);
|
||||
});
|
||||
|
||||
it("rejects mesh-id mismatch", async () => {
|
||||
const v = new MeshMembershipVerifier({
|
||||
authProvider: okProvider,
|
||||
meshId: "mesh-A",
|
||||
allowedCapabilities: ["host"],
|
||||
});
|
||||
await expect(
|
||||
v.verifyJoinRequest({ ...baseRequest, meshId: "mesh-Z" })
|
||||
).rejects.toThrow("auth.mesh-mismatch");
|
||||
});
|
||||
|
||||
it("rejects public-key mismatch when provider returns a key", async () => {
|
||||
const v = new MeshMembershipVerifier({
|
||||
authProvider: keyBoundProvider,
|
||||
meshId: "mesh-A",
|
||||
allowedCapabilities: ["host"],
|
||||
});
|
||||
await expect(
|
||||
v.verifyJoinRequest({ ...baseRequest, publicKeyHex: "cafebabe" })
|
||||
).rejects.toThrow("auth.public-key-mismatch");
|
||||
});
|
||||
|
||||
it("accepts when provider returns no key (no key binding to enforce)", async () => {
|
||||
const v = new MeshMembershipVerifier({
|
||||
authProvider: okProvider,
|
||||
meshId: "mesh-A",
|
||||
allowedCapabilities: ["host"],
|
||||
});
|
||||
const result = await v.verifyJoinRequest({
|
||||
...baseRequest,
|
||||
publicKeyHex: "anything",
|
||||
});
|
||||
expect(result.accepted).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects disallowed capabilities", async () => {
|
||||
const v = new MeshMembershipVerifier({
|
||||
authProvider: okProvider,
|
||||
meshId: "mesh-A",
|
||||
allowedCapabilities: ["relay"], // host disallowed
|
||||
});
|
||||
await expect(
|
||||
v.verifyJoinRequest({
|
||||
...baseRequest,
|
||||
claimedCapabilities: ["host"],
|
||||
})
|
||||
).rejects.toThrow("auth.capability-denied");
|
||||
});
|
||||
|
||||
it("assigns tier 2 for empty capability requests", async () => {
|
||||
const v = new MeshMembershipVerifier({
|
||||
authProvider: okProvider,
|
||||
meshId: "mesh-A",
|
||||
allowedCapabilities: ["host"],
|
||||
});
|
||||
const result = await v.verifyJoinRequest({
|
||||
...baseRequest,
|
||||
claimedCapabilities: [],
|
||||
});
|
||||
expect(result.assignedTier).toBe(2);
|
||||
expect(result.capabilities).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses lowest tier across granted capabilities", async () => {
|
||||
const v = new MeshMembershipVerifier({
|
||||
authProvider: okProvider,
|
||||
meshId: "mesh-A",
|
||||
allowedCapabilities: ["host", "subhost", "relay"],
|
||||
});
|
||||
const result = await v.verifyJoinRequest({
|
||||
...baseRequest,
|
||||
claimedCapabilities: ["relay", "subhost"],
|
||||
});
|
||||
expect(result.assignedTier).toBe(1); // subhost wins over relay
|
||||
});
|
||||
|
||||
it("matches public key when provider returns a matching key", async () => {
|
||||
const v = new MeshMembershipVerifier({
|
||||
authProvider: keyBoundProvider,
|
||||
meshId: "mesh-A",
|
||||
allowedCapabilities: ["host"],
|
||||
});
|
||||
const result = await v.verifyJoinRequest(baseRequest);
|
||||
expect(result.accepted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("network-plugin-auth dep-graph", () => {
|
||||
it("loads index.js (acyclic static imports)", async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
expect(mod.MeshMembershipVerifier).toBeDefined();
|
||||
expect(mod.CapabilityTokenIssuer).toBeDefined();
|
||||
expect(mod.CrossMeshTrustValidator).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
type AuthProvider,
|
||||
CapabilityTokenIssuer,
|
||||
CrossMeshTrustValidator,
|
||||
MeshMembershipVerifier,
|
||||
type TokenSigner,
|
||||
} from "../../src/index.js";
|
||||
|
||||
describe("auth end-to-end", () => {
|
||||
it("verifies join, issues token, validates trust across meshes", async () => {
|
||||
const authProvider: AuthProvider = {
|
||||
verifyToken: async () => ({
|
||||
identity: "alice",
|
||||
publicKeyHex: "deadbeef",
|
||||
}),
|
||||
};
|
||||
const verifier = new MeshMembershipVerifier({
|
||||
authProvider,
|
||||
meshId: "mesh-A",
|
||||
allowedCapabilities: ["host", "relay", "bridge"],
|
||||
});
|
||||
const signer: TokenSigner = {
|
||||
sign: async () => "sig",
|
||||
verify: async () => true,
|
||||
};
|
||||
const issuer = new CapabilityTokenIssuer({ signer, clock: () => 0 });
|
||||
|
||||
const result = await verifier.verifyJoinRequest({
|
||||
peerId: "alice",
|
||||
meshId: "mesh-A",
|
||||
authToken: "tok",
|
||||
publicKeyHex: "deadbeef",
|
||||
claimedCapabilities: ["host", "bridge"],
|
||||
});
|
||||
expect(result.assignedTier).toBe(0);
|
||||
|
||||
const token = await issuer.issue(
|
||||
result.peerId,
|
||||
"mesh-A",
|
||||
result.assignedTier,
|
||||
result.capabilities
|
||||
);
|
||||
await issuer.validate(token);
|
||||
|
||||
const trust = new CrossMeshTrustValidator();
|
||||
trust.add({
|
||||
fromMeshId: "mesh-A",
|
||||
toMeshId: "mesh-B",
|
||||
directions: ["relay", "backbone"],
|
||||
establishedAt: 0,
|
||||
});
|
||||
trust.assertTrusts("mesh-A", "mesh-B", "backbone");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
|
||||
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,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/network-plugin-auth",
|
||||
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/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