Initial Commit

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

View File

@@ -0,0 +1,86 @@
# `SecurityMiddleware`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Applies sign / encrypt on outbound messages and verifies signatures on inbound messages, gated by a per-`MessageType` or per-tier policy. Crypto-implementation-agnostic: callers supply `sign`, `encrypt`, and `verify` callbacks so the middleware works with any crypto module today (and will plug into `@suaveplan/network-plugin-crypto-messages` once that package ships).
## Features
- Two-key lookup: policy is checked first by `MessageType`, then by tier name resolved from a caller-supplied `tierFor(peerId)` lookup. The first match wins.
- `requireSignature: true` rejects unsigned/malformed inbound messages with `SECURITY_REJECTED`.
- Outbound `sign` runs before `encrypt` so the verifier sees the signature inside the encrypted envelope.
- Misconfiguration (policy demands signing without supplying a `sign` callback) is detected at dispatch time, not runtime, so deployments fail loudly.
- Lives in CRITICAL band by default (`priority: 80`) so security runs immediately after validation.
## Usage: Basic Example
```ts
import {
SecurityMiddleware,
MiddlewareManager,
} from "@suaveplan/network-plugin-middleware";
const manager = new MiddlewareManager();
manager.register(
new SecurityMiddleware({
policy: {
"registry:add": { sign: true, requireSignature: true },
},
sign: async (env) => ({
...env,
headers: { ...env.headers, "x-signature": "sig-stub" },
}),
verify: async (env) => env.headers["x-signature"] === "sig-stub",
})
);
```
## Usage: Advanced Example
```ts
import {
SecurityMiddleware,
type SecurityPolicy,
type TierName,
} from "@suaveplan/network-plugin-middleware";
const policy: SecurityPolicy = {
"tier-0": { sign: true, encrypt: true, requireSignature: true },
"tier-1": { sign: true, requireSignature: true },
"tier-2": { sign: true },
"registry:add": { sign: true, requireSignature: true },
};
const security = new SecurityMiddleware({
id: "tier-aware-security",
priority: 90,
policy,
tierFor: (peerId): TierName | undefined =>
peerId.startsWith("hub-") ? "tier-0" : "tier-2",
sign: signWithEd25519,
encrypt: encryptWithChacha,
verify: verifyEd25519,
});
```
## API Reference
- `class SecurityMiddleware implements Middleware`
- `constructor(config?: SecurityMiddlewareConfig)`.
- `apply(envelope, context): Promise<MessageEnvelope | null>`.
- `interface SecurityMiddlewareConfig` — `{ id?, priority?, direction?, tierFor?, policy?, sign?, encrypt?, verify? }`.
- `interface SecurityPolicyEntry` — `{ sign?, encrypt?, requireSignature? }`.
- `type SecurityPolicy` — `Readonly<Record<string, SecurityPolicyEntry | undefined>>` keyed by `MessageType` or `TierName`.
- `type TierName` — `"tier-0" | "tier-1" | "tier-2" | "tier-3"`.
- `type SignFn` / `EncryptFn` / `VerifyFn` — callback signatures.
## Implementation Notes
- The `MessageType` lookup wins over the tier lookup so deployments can opt specific message types out of (or into) more restrictive policies without rewriting the tier table.
- `tierFor` resolves against `envelope.targetId ?? envelope.senderId` so outbound messages key off the recipient and inbound messages key off the originator. Callers that need different lookup semantics can compose two SecurityMiddleware instances under different ids.
- Crypto callbacks MAY be sync or async; the middleware always awaits, so adding async-only flows (e.g. WebCrypto) does not require recompiling sync paths.
- The error code is deliberately generic (`SECURITY_REJECTED`) — distinguishing missing-callback vs invalid-signature is left to the `messageKey` so callers can branch on the key string when richer feedback is required.
- Policy entries default to "no action" — `sign: false` and `requireSignature: false` are equivalent to omitting the keys, matching the proposal's "additive only" stance.

View File

@@ -0,0 +1,164 @@
import type { MessageEnvelope } from "@suaveplan/network";
import { NETWORK_MW_CODES, NetworkMwError } from "../error-codes.js";
import type {
Middleware,
MiddlewareContext,
} from "../types/middleware.types.js";
export type TierName = "tier-0" | "tier-1" | "tier-2" | "tier-3";
export interface SecurityPolicyEntry {
/** Whether outbound messages of this type/tier should be signed. */
readonly sign?: boolean;
/** Whether outbound messages of this type/tier should be encrypted. */
readonly encrypt?: boolean;
/** Whether inbound messages MUST be signed; reject otherwise. */
readonly requireSignature?: boolean;
}
export type SecurityPolicy = Readonly<
Record<string, SecurityPolicyEntry | undefined>
>;
export type SignFn = (
envelope: MessageEnvelope,
context: MiddlewareContext
) => MessageEnvelope | Promise<MessageEnvelope>;
export type EncryptFn = SignFn;
export type VerifyFn = (
envelope: MessageEnvelope,
context: MiddlewareContext
) => boolean | Promise<boolean>;
export interface SecurityMiddlewareConfig {
readonly id?: string;
readonly priority?: number;
readonly direction?: MiddlewareContext["direction"];
/**
* Lookup helper that maps a `peerId` to the tier name used in
* `policy`. Returning `undefined` means "no tier-specific entry".
*/
readonly tierFor?: (peerId: string) => TierName | undefined;
/**
* Policy keyed by either `MessageType` or `TierName` — the
* middleware checks both, message type taking precedence.
*/
readonly policy?: SecurityPolicy;
/** Required when any policy entry sets `sign: true`. */
readonly sign?: SignFn;
/** Required when any policy entry sets `encrypt: true`. */
readonly encrypt?: EncryptFn;
/** Required when any policy entry sets `requireSignature: true`. */
readonly verify?: VerifyFn;
}
/**
* Applies sign / encrypt / verify per policy. The middleware is
* crypto-implementation-agnostic: callers supply `sign`, `encrypt`,
* and `verify` callbacks (typically backed by
* `@suaveplan/network-plugin-crypto-messages` once shipped, or by an
* in-house crypto module today).
*/
export class SecurityMiddleware implements Middleware {
public readonly id: string;
public readonly priority: number;
public readonly direction: MiddlewareContext["direction"] | undefined;
public enabled = true;
private readonly policy: SecurityPolicy;
private readonly tierFor:
| ((peerId: string) => TierName | undefined)
| undefined;
private readonly signFn: SignFn | undefined;
private readonly encryptFn: EncryptFn | undefined;
private readonly verifyFn: VerifyFn | undefined;
public constructor(config: SecurityMiddlewareConfig = {}) {
this.id = config.id ?? "security";
this.priority = config.priority ?? 80;
this.direction = config.direction;
this.policy = config.policy ?? {};
this.tierFor = config.tierFor;
this.signFn = config.sign;
this.encryptFn = config.encrypt;
this.verifyFn = config.verify;
}
public async apply(
envelope: MessageEnvelope,
context: MiddlewareContext
): Promise<MessageEnvelope | null> {
const entry = this.entryFor(envelope);
if (entry === undefined) return envelope;
if (context.direction === "outgoing") {
return await this.handleOutgoing(envelope, context, entry);
}
return await this.handleIncoming(envelope, context, entry);
}
private async handleOutgoing(
envelope: MessageEnvelope,
context: MiddlewareContext,
entry: SecurityPolicyEntry
): Promise<MessageEnvelope> {
let current = envelope;
if (entry.sign === true) {
if (this.signFn === undefined) {
throw this.misconfigured("security.sign-fn-missing");
}
current = await this.signFn(current, context);
}
if (entry.encrypt === true) {
if (this.encryptFn === undefined) {
throw this.misconfigured("security.encrypt-fn-missing");
}
current = await this.encryptFn(current, context);
}
return current;
}
private async handleIncoming(
envelope: MessageEnvelope,
context: MiddlewareContext,
entry: SecurityPolicyEntry
): Promise<MessageEnvelope> {
if (entry.requireSignature !== true) return envelope;
if (this.verifyFn === undefined) {
throw this.misconfigured("security.verify-fn-missing");
}
const ok = await this.verifyFn(envelope, context);
if (!ok) {
throw new NetworkMwError(
NETWORK_MW_CODES.SECURITY_REJECTED,
"security.signature-invalid",
{
context: {
messageId: envelope.id,
messageType: envelope.type,
senderId: envelope.senderId,
},
}
);
}
return envelope;
}
private entryFor(
envelope: MessageEnvelope
): SecurityPolicyEntry | undefined {
const byType = this.policy[envelope.type];
if (byType !== undefined) return byType;
const peerId = envelope.targetId ?? envelope.senderId;
const tier = this.tierFor?.(peerId);
if (tier === undefined) return undefined;
return this.policy[tier];
}
private misconfigured(messageKey: string): NetworkMwError {
return new NetworkMwError(
NETWORK_MW_CODES.SECURITY_REJECTED,
messageKey
);
}
}

View File

@@ -0,0 +1,181 @@
import { createEnvelope } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_MW_CODES, type NetworkMwError } from "../error-codes.js";
import { SecurityMiddleware } from "./security-middleware.js";
const env = (overrides: Partial<Parameters<typeof createEnvelope>[0]> = {}) =>
createEnvelope({
type: "secure:test",
senderId: "peer-1",
payload: {},
...overrides,
});
describe("SecurityMiddleware: per-policy sign / encrypt / verify", () => {
it("signs outbound when policy says so", async () => {
const s = new SecurityMiddleware({
policy: { "secure:test": { sign: true } },
sign: async (e) => ({
...e,
headers: { ...e.headers, "x-signature": "sig" },
}),
});
const result = await s.apply(env(), {
direction: "outgoing",
state: {},
});
expect(result?.headers["x-signature"]).toBe("sig");
});
it("throws SECURITY_REJECTED when sign callback is missing", async () => {
const s = new SecurityMiddleware({
policy: { "secure:test": { sign: true } },
});
try {
await s.apply(env(), { direction: "outgoing", state: {} });
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMwError).code).toBe(
NETWORK_MW_CODES.SECURITY_REJECTED
);
}
});
it("encrypts after signing on outbound", async () => {
const order: string[] = [];
const s = new SecurityMiddleware({
policy: { "secure:test": { sign: true, encrypt: true } },
sign: async (e) => {
order.push("sign");
return e;
},
encrypt: async (e) => {
order.push("encrypt");
return e;
},
});
await s.apply(env(), { direction: "outgoing", state: {} });
expect(order).toEqual(["sign", "encrypt"]);
});
it("rejects unsigned inbound when requireSignature", async () => {
const s = new SecurityMiddleware({
policy: { "secure:test": { requireSignature: true } },
verify: async () => false,
});
try {
await s.apply(env(), { direction: "incoming", state: {} });
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMwError).code).toBe(
NETWORK_MW_CODES.SECURITY_REJECTED
);
}
});
it("requires verify callback when requireSignature is set", async () => {
const s = new SecurityMiddleware({
policy: { "secure:test": { requireSignature: true } },
});
try {
await s.apply(env(), { direction: "incoming", state: {} });
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMwError).code).toBe(
NETWORK_MW_CODES.SECURITY_REJECTED
);
}
});
it("forwards inbound when verify returns true", async () => {
const s = new SecurityMiddleware({
policy: { "secure:test": { requireSignature: true } },
verify: async () => true,
});
const e = env();
expect(await s.apply(e, { direction: "incoming", state: {} })).toBe(e);
});
it("encrypt callback missing throws SECURITY_REJECTED", async () => {
const s = new SecurityMiddleware({
policy: { "secure:test": { sign: true, encrypt: true } },
sign: async (e) => e,
});
try {
await s.apply(env(), { direction: "outgoing", state: {} });
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMwError).code).toBe(
NETWORK_MW_CODES.SECURITY_REJECTED
);
}
});
it("falls back to tier policy when no per-type entry matches", async () => {
const s = new SecurityMiddleware({
policy: { "tier-0": { sign: true } },
tierFor: (peerId) => (peerId === "hub-A" ? "tier-0" : undefined),
sign: async (e) => ({
...e,
headers: { ...e.headers, "x-tier-signed": "yes" },
}),
});
const result = await s.apply(
env({ targetId: "hub-A", type: "anything" }),
{ direction: "outgoing", state: {} }
);
expect(result?.headers["x-tier-signed"]).toBe("yes");
});
it("returns the envelope when no policy entry matches", async () => {
const s = new SecurityMiddleware({});
const e = env();
expect(await s.apply(e, { direction: "outgoing", state: {} })).toBe(e);
});
it("inbound without requireSignature is forwarded", async () => {
const s = new SecurityMiddleware({
policy: { "secure:test": { sign: true } },
sign: async (e) => e,
});
const e = env();
expect(await s.apply(e, { direction: "incoming", state: {} })).toBe(e);
});
it("falls back to senderId when targetId is absent for tier lookup", async () => {
const s = new SecurityMiddleware({
policy: { "tier-1": { sign: true } },
tierFor: (peerId) => (peerId === "peer-X" ? "tier-1" : undefined),
sign: async (e) => ({
...e,
headers: { ...e.headers, "x-from-sender": "yes" },
}),
});
const result = await s.apply(
env({ senderId: "peer-X", type: "anything" }),
{ direction: "outgoing", state: {} }
);
expect(result?.headers["x-from-sender"]).toBe("yes");
});
it("default-config constructor (no args) is usable", async () => {
const s = new SecurityMiddleware();
const e = env();
expect(await s.apply(e, { direction: "outgoing", state: {} })).toBe(e);
});
it("encrypt-only outbound (no sign) still encrypts", async () => {
const s = new SecurityMiddleware({
policy: { "secure:test": { encrypt: true } },
encrypt: async (e) => ({
...e,
headers: { ...e.headers, "x-encrypted": "yes" },
}),
});
const result = await s.apply(env(), {
direction: "outgoing",
state: {},
});
expect(result?.headers["x-encrypted"]).toBe("yes");
});
});