Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user