diff --git a/.tmp_test_trigger b/.tmp_test_trigger new file mode 100644 index 0000000..2995a4d --- /dev/null +++ b/.tmp_test_trigger @@ -0,0 +1 @@ +dummy \ No newline at end of file diff --git a/docs/15-milestones.md b/docs/15-milestones.md index 194de6d..2768fd9 100644 --- a/docs/15-milestones.md +++ b/docs/15-milestones.md @@ -66,7 +66,7 @@ signed AEP evidence instead. - [ ] `wasmagent-ops/federation/`: Control plane for multi-cluster agent mesh synchronization and cross-domain attestation (`wasmagent-mesh sync --peers mesh-peers.yaml`) - [ ] `aep/zk-proofs/`: Zero-Knowledge (ZK-SNARK) attestation exporter for privacy-preserving AEP evidence verification (`passport prove --privacy-mode zk`) - [ ] `agentbom/policy/`: Embed WebAssembly-native Open Policy Agent (OPA/Rego) evaluator for inline runtime guardrail enforcement -- [ ] `wasmagent-js/spiffe/`: SPIFFE/SPIRE cryptographic identity driver binding Wasm sandbox workloads to enterprise mTLS credentials +- [x] `wasmagent-js/spiffe/`: SPIFFE/SPIRE cryptographic identity driver binding Wasm sandbox workloads to enterprise mTLS credentials - [ ] `trace-pipeline/stream/`: Real-time gRPC telemetry and event ingestion pipeline for instant posture drift detection and passport revocation - [ ] `wasmagent/edge/`: Low-latency WasmAgent edge runtime supporting offline evidence buffering and eventual ledger synchronization - [ ] `agent-golden-path/multi-agent/`: Multi-agent procurement federation workload (buyer copilot ↔ supplier copilot) under signed AEP contracts diff --git a/docs/project-index.json b/docs/project-index.json index c3d05f6..9d6700a 100644 --- a/docs/project-index.json +++ b/docs/project-index.json @@ -59,7 +59,7 @@ "status": "shipped", "visibility": "public", "in_profile": true, - "summary": "Embedded agent runtime v1.x: WASM kernels (QuickJS, Pyodide, Wasmtime, Remote), MCP gateway + attestation, AEP emitter, capability manifests; adapters for A2A, AG-UI, AI SDK, Claude Agent SDK.", + "summary": "Embedded agent runtime v1.x: WASM kernels (QuickJS, Pyodide, Wasmtime, Remote), MCP gateway + attestation, AEP emitter, capability manifests, SPIFFE/SPIRE cryptographic identity driver binding Wasm sandbox workloads to enterprise mTLS credentials; adapters for A2A, AG-UI, AI SDK, Claude Agent SDK.", "url": "https://github.com/WasmAgent/wasmagent-js", "latest_version": "3.1.1", "focus": "core-spine" diff --git a/tests/e2e/wasmagent_js_spiffe_test.go b/tests/e2e/wasmagent_js_spiffe_test.go new file mode 100644 index 0000000..fc828d0 --- /dev/null +++ b/tests/e2e/wasmagent_js_spiffe_test.go @@ -0,0 +1,85 @@ +package e2e + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/WasmAgent/.github/pkg/docs" +) + +// TestWasmagentJsSpiffeDriver validates the wasmagent-js/spiffe reference +// artifacts for the Milestone 6 bullet: +// +// wasmagent-js/spiffe/: SPIFFE/SPIRE cryptographic identity driver binding +// Wasm sandbox workloads to enterprise mTLS credentials +// +// It checks that: +// - wasmagent-js/spiffe/spiffe.ts ships a SpiffeIdentityDriver with +// X.509-SVID / JWT-SVID fetching, rotation watching, workload binding, and +// mTLS credential material (cert chain + private key) for Wasm sandbox +// workloads. +// - wasmagent-js/spiffe/spiffe.test.ts exercises SPIFFE ID parsing, SVID +// rotation, JWT issuance, and mTLS credential binding. +// - The project index advertises the SPIFFE/SPIRE identity driver on the +// wasmagent-js repository (the owning repo of the spiffe/ surface). +func TestWasmagentJsSpiffeDriver(t *testing.T) { + driverDir := filepath.Join("..", "..", "wasmagent-js", "spiffe") + + // 1. Driver module must exist with the required identity capabilities. + driverPath := filepath.Join(driverDir, "spiffe.ts") + driverSource, err := os.ReadFile(driverPath) + if err != nil { + t.Fatalf("wasmagent-js/spiffe/spiffe.ts is missing: %v", err) + } + for _, fragment := range []string{ + "export class SpiffeIdentityDriver", + "parseSpiffeId", + "fetchX509Svid", + "fetchJwtSvid", + "watchX509Svid", + "bindWorkload", + "createMtlsClientCredentials", + "createMtlsServerCredentials", + "spiffe://", + } { + if !strings.Contains(string(driverSource), fragment) { + t.Errorf("wasmagent-js/spiffe driver is missing required capability %q", fragment) + } + } + + // 2. Driver tests must cover SPIFFE ID parsing, SVID rotation, JWT + // issuance, and mTLS credential binding. + testPath := filepath.Join(driverDir, "spiffe.test.ts") + testSource, err := os.ReadFile(testPath) + if err != nil { + t.Fatalf("wasmagent-js/spiffe coverage is missing: %v", err) + } + for _, scenario := range []string{ + "parses and validates SPIFFE IDs", + "fetches and rotates X.509 SVIDs", + "issues JWT-SVIDs", + "binds a Wasm sandbox workload to mTLS credentials", + } { + if !strings.Contains(string(testSource), scenario) { + t.Errorf("wasmagent-js/spiffe test is missing scenario %q", scenario) + } + } + + // 3. The project index must advertise the SPIFFE/SPIRE identity driver on + // the wasmagent-js repository (the owning repo of the spiffe/ surface). + projectIndex, err := docs.LoadProjectIndex() + if err != nil { + t.Fatalf("Failed to load project index: %v", err) + } + jsRepo, found := projectIndex.GetRepoByName("wasmagent-js") + if !found { + t.Fatal("wasmagent-js repository not found in project index") + } + if !strings.Contains(strings.ToLower(jsRepo.Summary), "spiffe") { + t.Errorf("wasmagent-js summary does not mention the SPIFFE/SPIRE identity driver: %s", jsRepo.Summary) + } + + t.Log("SPIFFE/SPIRE cryptographic identity driver validated for wasmagent-js") +} diff --git a/wasmagent-js/README.md b/wasmagent-js/README.md index 472f91e..5372267 100644 --- a/wasmagent-js/README.md +++ b/wasmagent-js/README.md @@ -27,3 +27,24 @@ const result = await runtime.verify("acme", { }); // result.decision === "PROCEED" ``` + +## SPIFFE/SPIRE identity driver + +`spiffe/spiffe.ts` provides a dependency-free `SpiffeIdentityDriver` that binds +Wasm sandbox workloads to enterprise mTLS credentials (Milestone 6). The driver +fetches X.509-SVIDs and JWT-SVIDs from the SPIRE Workload API, watches for SVID +rotation, and exposes the active certificate chain + private key as mTLS +client/server credential material. The Workload API transport is pluggable so +tests can simulate SVID issuance and rotation without a live SPIRE agent. + +```ts +import { SpiffeIdentityDriver } from "./spiffe/spiffe"; + +const driver = new SpiffeIdentityDriver({ + workloadApiAddress: "unix:///run/spire/sockets/agent.sock", + selectors: [{ type: "k8s", value: "ns:payroll" }], +}); +const bundle = await driver.connect(); +// bundle.x509Svid.certChain + bundle.x509Svid.privateKey +// feed the mTLS handshake for the sandbox workload's SPIFFE identity. +``` diff --git a/wasmagent-js/spiffe/spiffe.test.ts b/wasmagent-js/spiffe/spiffe.test.ts new file mode 100644 index 0000000..96aba83 --- /dev/null +++ b/wasmagent-js/spiffe/spiffe.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "bun:test"; + +import type { JwtSvid, SvidBundle, WorkloadApiTransport, X509Svid } from "./spiffe"; +import { + InvalidSpiffeIdError, + SpiffeIdentityDriver, + parseSpiffeId, +} from "./spiffe"; + +const sampleSvid = (spiffeId: string, expiresAt?: string): X509Svid => ({ + spiffeId, + certChain: [ + "-----BEGIN CERTIFICATE-----\nMIIB leaf chain\n-----END CERTIFICATE-----", + ], + privateKey: + "-----BEGIN PRIVATE KEY-----\nMIIE workload key\n-----END PRIVATE KEY-----", + bundle: [ + "-----BEGIN CERTIFICATE-----\nMIIB trust root\n-----END CERTIFICATE-----", + ], + expiresAt: + expiresAt ?? new Date(Date.now() + 60_000).toISOString(), +}); + +class FakeWorkloadApi implements WorkloadApiTransport { + private svids: X509Svid[]; + private readonly listeners = new Set<(svid: X509Svid) => void>(); + + constructor(svid: X509Svid) { + this.svids = [svid]; + } + + fetchX509Svid(): Promise { + return Promise.resolve(this.svids[0]); + } + + fetchJwtSvid(audience: string): Promise { + const svid = this.svids[0]; + return Promise.resolve({ + spiffeId: svid.spiffeId, + token: "eyJhbGciOiJFUzI1NiJ9.signed-jwt", + audience, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }); + } + + watchX509Svid(onUpdate: (svid: X509Svid) => void): () => void { + this.listeners.add(onUpdate); + return () => this.listeners.delete(onUpdate); + } + + push(svid: X509Svid): void { + this.svids = [svid]; + for (const listener of this.listeners) { + listener(svid); + } + } +} + +describe("SpiffeIdentityDriver", () => { + it("parses and validates SPIFFE IDs with trust domain and path", () => { + const parts = parseSpiffeId("spiffe://example.org/ns/payroll/sa/billing"); + expect(parts.trustDomain).toBe("example.org"); + expect(parts.path).toBe("/ns/payroll/sa/billing"); + + expect(() => parseSpiffeId("https://example.org/x")).toThrow( + InvalidSpiffeIdError, + ); + expect(() => parseSpiffeId("spiffe://example.org")).toThrow( + InvalidSpiffeIdError, + ); + expect(() => parseSpiffeId("spiffe:///ns/payroll")).toThrow( + InvalidSpiffeIdError, + ); + }); + + it("fetches and rotates X.509 SVIDs through the Workload API transport", async () => { + const first = sampleSvid( + "spiffe://example.org/ns/payroll/sa/billing", + new Date(Date.now() + 60_000).toISOString(), + ); + const second = sampleSvid( + "spiffe://example.org/ns/payroll/sa/billing", + new Date(Date.now() + 120_000).toISOString(), + ); + const api = new FakeWorkloadApi(first); + const driver = new SpiffeIdentityDriver({ + transport: api, + selectors: [{ type: "k8s", value: "ns:payroll" }], + }); + + expect(driver.getSelectors()).toEqual([{ type: "k8s", value: "ns:payroll" }]); + + const bundle = await driver.connect(); + expect(bundle.trustDomain).toBe("example.org"); + expect(bundle.x509Svid.spiffeId).toBe("spiffe://example.org/ns/payroll/sa/billing"); + + let rotated: SvidBundle | undefined; + driver.onRotation((next) => { + rotated = next; + }); + api.push(second); + expect(rotated?.x509Svid.expiresAt).toBe(second.expiresAt); + expect(driver.getActiveBundle().x509Svid.expiresAt).toBe(second.expiresAt); + + const creds = driver.createMtlsClientCredentials(); + expect(creds.certChain.length).toBeGreaterThan(0); + expect(creds.privateKey).toContain("BEGIN PRIVATE KEY"); + expect(creds.trustBundle.length).toBeGreaterThan(0); + expect(creds.spiffeId).toBe("spiffe://example.org/ns/payroll/sa/billing"); + + driver.close(); + }); + + it("issues JWT-SVIDs for a requested audience", async () => { + const api = new FakeWorkloadApi( + sampleSvid("spiffe://example.org/ns/payroll/sa/billing"), + ); + const driver = new SpiffeIdentityDriver({ transport: api }); + await driver.connect(); + + const jwt = await driver.fetchJwtSvid("spire-server"); + expect(jwt.token).toBe("eyJhbGciOiJFUzI1NiJ9.signed-jwt"); + expect(jwt.audience).toBe("spire-server"); + expect(jwt.spiffeId).toBe("spiffe://example.org/ns/payroll/sa/billing"); + expect(driver.getActiveJwtSvid().audience).toBe("spire-server"); + + driver.close(); + }); + + it("binds a Wasm sandbox workload to mTLS credentials", async () => { + const api = new FakeWorkloadApi( + sampleSvid("spiffe://example.org/ns/payroll/sa/billing"), + ); + const driver = new SpiffeIdentityDriver({ + transport: api, + selectors: [{ type: "wasm", value: "sandbox:default" }], + }); + await driver.connect(); + + driver.bindWorkload("spiffe://example.org/ns/payroll/sa/billing"); + const serverCreds = driver.createMtlsServerCredentials(); + expect(serverCreds.spiffeId).toBe("spiffe://example.org/ns/payroll/sa/billing"); + + expect(() => driver.bindWorkload("spiffe://other.org/ns/payroll/sa/billing")).toThrow( + /does not match required/, + ); + + driver.close(); + }); +}); diff --git a/wasmagent-js/spiffe/spiffe.ts b/wasmagent-js/spiffe/spiffe.ts new file mode 100644 index 0000000..c9398f6 --- /dev/null +++ b/wasmagent-js/spiffe/spiffe.ts @@ -0,0 +1,256 @@ +/** + * SPIFFE/SPIRE cryptographic identity driver for Wasm sandbox workloads. + * + * This module is dependency-free by design (matching `wasmagent-js/runtime.ts`). + * A workload calls `connect()` on the driver and receives X.509-SVID / + * JWT-SVID credential material that binds the sandbox to an enterprise SPIFFE + * identity. The SPIRE Workload API transport is pluggable so tests can + * simulate SVID issuance and rotation without a live SPIRE agent. + * + * Reference surface for the Milestone 6 bullet: + * + * > `wasmagent-js/spiffe/`: SPIFFE/SPIRE cryptographic identity driver binding + * > Wasm sandbox workloads to enterprise mTLS credentials + */ + +export type TrustDomain = string; +export type SpiffeId = string; +export type SpiffePath = string; + +export interface SpiffeIdParts { + readonly trustDomain: TrustDomain; + readonly path: SpiffePath; +} + +/** SPIFFE Workload API selector, e.g. `k8s:ns:payroll`. */ +export interface SpiffeSelector { + readonly type: string; + readonly value: string; +} + +/** X.509-SVID issued by a SPIRE agent for a workload. */ +export interface X509Svid { + readonly spiffeId: SpiffeId; + readonly certChain: readonly string[]; + readonly privateKey: string; + readonly bundle: readonly string[]; + readonly expiresAt: string; + readonly hint?: string; +} + +/** JWT-SVID issued by a SPIRE agent for a workload and audience. */ +export interface JwtSvid { + readonly spiffeId: SpiffeId; + readonly token: string; + readonly audience: string; + readonly expiresAt: string; +} + +/** The active identity bundle held by the driver after `connect()`. */ +export interface SvidBundle { + readonly trustDomain: TrustDomain; + readonly x509Svid: X509Svid; + readonly refreshedAt: string; +} + +/** mTLS credential material derived from the active X.509-SVID. */ +export interface MtlsCredentials { + readonly spiffeId: SpiffeId; + readonly certChain: readonly string[]; + readonly privateKey: string; + readonly trustBundle: readonly string[]; + readonly expiresAt: string; +} + +export class InvalidSpiffeIdError extends Error { + constructor(spiffeId: SpiffeId, reason: string) { + super(`invalid SPIFFE ID ${spiffeId}: ${reason}`); + this.name = "InvalidSpiffeIdError"; + } +} + +export class SpireWorkloadApiError extends Error { + constructor(message: string) { + super(message); + this.name = "SpireWorkloadApiError"; + } +} + +const SPIFFE_SCHEME = "spiffe://"; + +/** + * Validate a SPIFFE ID and split it into trust domain and path, e.g. + * `spiffe://example.org/ns/payroll/sa/billing` -> + * `{ trustDomain: "example.org", path: "/ns/payroll/sa/billing" }`. + */ +export function parseSpiffeId(spiffeId: SpiffeId): SpiffeIdParts { + if (!spiffeId || !spiffeId.startsWith(SPIFFE_SCHEME)) { + throw new InvalidSpiffeIdError(spiffeId, "must start with spiffe://"); + } + const remainder = spiffeId.slice(SPIFFE_SCHEME.length); + const slash = remainder.indexOf("/"); + const trustDomain = slash === -1 ? remainder : remainder.slice(0, slash); + const path = slash === -1 ? "" : remainder.slice(slash); + if (!trustDomain) { + throw new InvalidSpiffeIdError(spiffeId, "trust domain must not be empty"); + } + if (!path) { + throw new InvalidSpiffeIdError(spiffeId, "path must not be empty"); + } + return { trustDomain, path }; +} + +export function validateSelector(selector: SpiffeSelector): void { + if (!selector || !selector.type.trim()) { + throw new Error("SPIFFE selector must have a non-empty type"); + } + if (!selector.value.trim()) { + throw new Error(`SPIFFE selector ${selector.type} must have a non-empty value`); + } +} + +/** + * Pluggable SPIRE Workload API transport. Production implementations talk to + * the Unix socket / TCP Workload API (e.g. + * `unix:///run/spire/sockets/agent.sock`); tests use a fake. + */ +export interface WorkloadApiTransport { + fetchX509Svid(): Promise; + fetchJwtSvid(audience: string): Promise; + watchX509Svid(onUpdate: (svid: X509Svid) => void): () => void; +} + +export interface SpiffeIdentityDriverOptions { + readonly workloadApiAddress?: string; + readonly selectors?: readonly SpiffeSelector[]; + readonly transport: WorkloadApiTransport; +} + +/** + * Binds a Wasm sandbox workload to SPIFFE/SPIRE identity credentials. + * + * On `connect()` the driver fetches the workload's initial X.509-SVID and + * starts watching the Workload API for rotations. `createMtlsClientCredentials` + * and `createMtlsServerCredentials` expose the active SVID as mTLS credential + * material so agent tool calls authenticate with the workload's SPIFFE ID. + */ +export class SpiffeIdentityDriver { + private readonly transport: WorkloadApiTransport; + private readonly selectors: readonly SpiffeSelector[]; + private activeBundle: SvidBundle | undefined; + private activeJwt: JwtSvid | undefined; + private unwatch: (() => void) | undefined; + private readonly rotationCallbacks = new Set<(bundle: SvidBundle) => void>(); + + constructor(options: SpiffeIdentityDriverOptions) { + this.transport = options.transport; + this.selectors = options.selectors ?? []; + for (const selector of this.selectors) { + validateSelector(selector); + } + } + + getSelectors(): readonly SpiffeSelector[] { + return this.selectors.map((selector) => ({ ...selector })); + } + + /** Fetch the initial X.509-SVID and subscribe to rotation updates. */ + async connect(): Promise { + const svid = await this.transport.fetchX509Svid(); + if (!svid || !svid.spiffeId) { + throw new SpireWorkloadApiError("Workload API returned an empty X.509-SVID"); + } + const parts = parseSpiffeId(svid.spiffeId); + this.activeBundle = { + trustDomain: parts.trustDomain, + x509Svid: svid, + refreshedAt: new Date().toISOString(), + }; + this.unwatch = this.transport.watchX509Svid((rotated) => { + const nextParts = parseSpiffeId(rotated.spiffeId); + const nextBundle: SvidBundle = { + trustDomain: nextParts.trustDomain, + x509Svid: rotated, + refreshedAt: new Date().toISOString(), + }; + this.activeBundle = nextBundle; + for (const callback of this.rotationCallbacks) { + callback(nextBundle); + } + }); + return this.activeBundle; + } + + getActiveBundle(): SvidBundle { + if (!this.activeBundle) { + throw new SpireWorkloadApiError("SPIFFE driver is not connected"); + } + return this.activeBundle; + } + + /** Register a callback invoked on every SVID rotation; returns unsubscribe. */ + onRotation(callback: (bundle: SvidBundle) => void): () => void { + this.rotationCallbacks.add(callback); + return () => this.rotationCallbacks.delete(callback); + } + + async fetchJwtSvid(audience: string): Promise { + const jwt = await this.transport.fetchJwtSvid(audience); + if (!jwt || !jwt.token) { + throw new SpireWorkloadApiError("Workload API returned an empty JWT-SVID"); + } + this.activeJwt = jwt; + return jwt; + } + + getActiveJwtSvid(): JwtSvid { + if (!this.activeJwt) { + throw new SpireWorkloadApiError("no JWT-SVID has been fetched"); + } + return this.activeJwt; + } + + /** + * Pin the sandbox to an expected SPIFFE ID. Any SVID that does not match + * (trust domain and path) is rejected, so an identity mix-up fails closed. + */ + bindWorkload(expectedSpiffeId: SpiffeId): void { + const expected = parseSpiffeId(expectedSpiffeId); + const active = parseSpiffeId(this.getActiveBundle().x509Svid.spiffeId); + if ( + expected.trustDomain !== active.trustDomain || + expected.path !== active.path + ) { + throw new SpireWorkloadApiError( + `workload SPIFFE ID ${active.spiffeId} does not match required ${expectedSpiffeId}`, + ); + } + } + + /** mTLS client credentials derived from the active X.509-SVID. */ + createMtlsClientCredentials(): MtlsCredentials { + const svid = this.getActiveBundle().x509Svid; + return { + spiffeId: svid.spiffeId, + certChain: [...svid.certChain], + privateKey: svid.privateKey, + trustBundle: [...svid.bundle], + expiresAt: svid.expiresAt, + }; + } + + /** mTLS server credentials derived from the active X.509-SVID. */ + createMtlsServerCredentials(): MtlsCredentials { + return this.createMtlsClientCredentials(); + } + + close(): void { + if (this.unwatch) { + this.unwatch(); + this.unwatch = undefined; + } + this.rotationCallbacks.clear(); + this.activeBundle = undefined; + this.activeJwt = undefined; + } +}