diff --git a/Cargo.lock b/Cargo.lock index 9992d87f..b6c6a334 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -785,6 +785,7 @@ dependencies = [ "cpex-plugin-delegator-oauth", "cpex-plugin-elicitation-ciba", "cpex-plugin-identity-jwt", + "cpex-plugin-ocsf-audit", "cpex-plugin-pii-scanner", "cpex-session-valkey", "tokio", @@ -974,6 +975,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "cpex-plugin-ocsf-audit" +version = "0.2.2" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "cpex-core", + "p256", + "serde", + "serde_json", + "sha2 0.10.9", + "tokio", + "tracing", +] + [[package]] name = "cpex-plugin-pii-scanner" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 5031b8a3..56068c16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "crates/apl-cpex", "builtins/plugins/pii-scanner", "builtins/plugins/audit-logger", + "builtins/plugins/ocsf-audit", "builtins/plugins/identity-jwt", "builtins/plugins/delegator-oauth", "builtins/plugins/delegator-biscuit", @@ -56,6 +57,7 @@ default-members = [ "crates/apl-cpex", "builtins/plugins/pii-scanner", "builtins/plugins/audit-logger", + "builtins/plugins/ocsf-audit", "builtins/plugins/identity-jwt", "builtins/plugins/delegator-oauth", "builtins/plugins/delegator-biscuit", @@ -125,6 +127,7 @@ apl-cmf = { path = "crates/apl-cmf", apl-cpex = { path = "crates/apl-cpex", version = "0.2.2" } cpex-plugin-pii-scanner = { path = "builtins/plugins/pii-scanner", version = "0.2.2" } cpex-plugin-audit-logger = { path = "builtins/plugins/audit-logger", version = "0.2.2" } +cpex-plugin-ocsf-audit = { path = "builtins/plugins/ocsf-audit", version = "0.2.2" } cpex-plugin-identity-jwt = { path = "builtins/plugins/identity-jwt", version = "0.2.2" } cpex-plugin-delegator-oauth = { path = "builtins/plugins/delegator-oauth", version = "0.2.2" } cpex-plugin-delegator-biscuit = { path = "builtins/plugins/delegator-biscuit", version = "0.2.2" } diff --git a/builtins/plugins/ocsf-audit/Cargo.toml b/builtins/plugins/ocsf-audit/Cargo.toml new file mode 100644 index 00000000..60ac099d --- /dev/null +++ b/builtins/plugins/ocsf-audit/Cargo.toml @@ -0,0 +1,47 @@ +# Location: ./builtins/plugins/ocsf-audit/Cargo.toml +# Copyright 2026 AI Identity +# SPDX-License-Identifier: Apache-2.0 +# Authors: Jeff Leva +# +# cpex-plugin-ocsf-audit — CMF plugin that serializes each dispatched +# request into an OCSF API Activity event (class 6003, ai_operation + +# security_control profiles), optionally hash-chained into a +# tamper-evident attestation (record_integrity) and DSSE-signable. +# Near-twin of audit-logger (same observation-only, always-allow +# contract); the difference is the record shape — OCSF instead of a +# free-form JSON line, so the enforcement record is interoperable and +# independently verifiable offline. + +[package] +name = "cpex-plugin-ocsf-audit" +version.workspace = true +edition.workspace = true +license.workspace = true +authors = ["Jeff Leva"] +description = "CPEX CMF plugin — emit OCSF API Activity events with a tamper-evident attestation chain." +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +rust-version.workspace = true + +[dependencies] +cpex-core = { workspace = true } + +async-trait = { workspace = true } +base64 = "0.22" +chrono = { workspace = true } +# ECDSA P-256 for the DSSE signer. RustCrypto's ecdsa signs per RFC 6979 +# (deterministic), which preserves the byte-identical-output-across-runs +# invariant that keeps SAMPLE-OUTPUT.md reproducible. +p256 = { version = "0.13", features = ["ecdsa", "pem", "pkcs8"] } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = "0.10" +tracing = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } + +[lints] +workspace = true diff --git a/builtins/plugins/ocsf-audit/README.md b/builtins/plugins/ocsf-audit/README.md new file mode 100644 index 00000000..ecb50eb9 --- /dev/null +++ b/builtins/plugins/ocsf-audit/README.md @@ -0,0 +1,116 @@ +# cpex-plugin-ocsf-audit + +A CMF plugin that emits each dispatched request as an **OCSF API Activity event** +(class 6003, `ai_operation` + `security_control` profiles) — optionally wrapped in a +tamper-evident **attestation chain** (`record_integrity` profile) that an +independent party can verify offline. + +It is a near-twin of the [`audit-logger`](../audit-logger) builtin — same +observation-only, always-allow contract, same factory + hook wiring. The difference is +the record shape: + +| | `audit-logger` | `ocsf-audit` (this crate) | +|---|---|---| +| Output | free-form JSON line | OCSF API Activity event | +| Verifiability | none | hash chain (`fingerprint` → `prev_event`), DSSE-signed (ECDSA P-256) | +| Schema | ad hoc | OCSF — interoperable across tools | + +CPEX produces the enforcement record; this plugin makes it portable (OCSF) and +independently verifiable (attestation chain) without CPEX having to own a schema. + +## Wiring (APL) + +```yaml +plugins: + - name: ocsf-audit + kind: audit/ocsf + hooks: # POST hooks: result/taint/delegation resolved + - cmf.tool_post_invoke + - cmf.llm_output + - cmf.resource_post_fetch + - cmf.prompt_post_invoke # NOT cmf.prompt_post_fetch — see note below + config: + destination: stderr # or: tracing + chain: true # tamper-evident fingerprint chain + signing: dsse # or: none (chained-but-unsigned) + signing_key_pem_path: /etc/cpex/keys/ocsf-signing.pem # PKCS#8 P-256 + signing_key_id: "prod-2026-07" # JWKS kid -> unmapped.signature_key_id + authority_uid: "org-example" # the party the signing key belongs to + chain_uid: "org-example" # stable chain id across the deployment +``` + +> **Prompt hook name.** `cpex-core` ships two prompt-hook vocabularies: +> `hooks/types.rs` has `cmf.prompt_pre/post_fetch`, but the Rust CMF/APL runtime +> dispatches the `cmf/constants.rs` names `cmf.prompt_pre/post_invoke`. A Rust CMF +> plugin must register on the `_invoke` names or prompt events silently never fire. +> (The resource hook names agree across both files — only prompt diverges.) + +## Record shape + +- **Host class:** API Activity (`class_uid: 6003`, `category_uid: 6`), carrying the + `ai_operation` profile objects (`ai_agent`, `ai_model`, `message_context`) plus + `delegation`, actor/user, and tool/resource coordinates. +- **Activity ids** follow API Activity's real enum. Resources, prompts, and tools + annotated `readOnlyHint: true` map to `2 (Read)`; other tool invocations are the + honest `99 (Other)` + `activity_name: "Invoke Tool"` (no Create/Update/Delete claim + without knowing the operation); completions are `99` + `"Completion"`. +- **security_control:** this passive post-hook stream is `action_id: 3 (Observed)` / + `disposition_id: 17 (Logged)`. Deny/modify records (`action_id` 2/4) require the + framework to surface its decision to a plugin — the AuditHook design + (2026-07-31) is that seam; not in this crate yet. +- **Gap fields** with no OCSF home yet (`completion.stop_reason`, `mcp.*`, + `framework.*`, monotonic security labels, workload identity) are emitted under + OCSF `unmapped` (config `include_gap_fields`, default on), which preserves the + evidence and makes the open schema gaps self-documenting. Upstream OCSF issues for + these gaps are being filed. + +## The attestation chain + +With `chain: true`, every event carries an `attestation_list[]` entry in the merged +OCSF 1.9 `record_integrity` shape (`fingerprint` / `prev_event` / `signatures` +objects), with **predecessor binding**: the fingerprint commits to the record's +position in its chain, not just its content. + +``` +fingerprint.value = sha256( canonical_bytes( event ) ) + where `event` includes the attestation's own chain_uid and prev_event, + and excludes only the fingerprint and signatures members +``` + +`canonical_bytes` is a JCS-style (RFC 8785) canonical serialization (sorted keys, +compact output; set-derived arrays are sorted at build time), so a verifier following +the OCSF schema can recompute the chain from the emitted JSON alone — no access to +this process, no shared secret, no knowledge of this crate's conventions. Tampering +with any record, reordering records, or splicing a record into a different chain +breaks recomputation at that entry. + +**Signing** (`signing: dsse`) produces ECDSA-P256-SHA256 over the DSSE PAE of the +same canonical bytes the fingerprint covers, so a signature commits to the record's +chain position. Signing is deterministic (RFC 6979), which keeps `SAMPLE-OUTPUT.md` +byte-identical across runs. The key is an operator-provided PKCS#8 PEM +(`signing_key_pem` / `signing_key_pem_path`) — a key handle, not a key service: +custody (HSM/KMS residency, rotation, key publication) belongs to the authority named +by `attestation.authority_uid`, which sits inside the hashed bytes so the claimed +authority cannot be swapped post-hoc. Signature bytes + key id ride `unmapped` +(`signature_b64`, `signature_key_id`) pending a schema home via +[ocsf-schema#1709](https://github.com/ocsf/ocsf-schema/pull/1709). A configured +signer that fails to construct is a loud startup error — never silently-unsigned +records. The verifier rule ships as running code: `sign::signing_input` + +`sign::dsse_pae`, exercised end-to-end by the `signed_event_verifies_offline` test +and printed as the `// verify` lines of the example. + +Known limitation (tracked for productionization): the chain head lives in process +memory — one chain per plugin instance, reset on restart. A durable-append sink with +WAL replay (per the 2026-07-31 audit design, §6) retires this: recover the last +fingerprint on restart and continue the chain. + +## Building and testing + +```bash +cargo build -p cpex-plugin-ocsf-audit +cargo test -p cpex-plugin-ocsf-audit +cargo run -p cpex-plugin-ocsf-audit --example emit_sample +``` + +`SAMPLE-OUTPUT.md` holds the deterministic output of the example — two chained, +signed events with reproducible hashes and signatures. diff --git a/builtins/plugins/ocsf-audit/SAMPLE-OUTPUT.md b/builtins/plugins/ocsf-audit/SAMPLE-OUTPUT.md new file mode 100644 index 00000000..443b070a --- /dev/null +++ b/builtins/plugins/ocsf-audit/SAMPLE-OUTPUT.md @@ -0,0 +1,270 @@ +# Sample output — `cargo run --example emit_sample` + +Real OCSF events produced by the mapping in [`src/ocsf.rs`](src/ocsf.rs) from the two +demo turns in [`examples/emit_sample.rs`](examples/emit_sample.rs). Regenerated 2026-07-31 +for the **merged #1661 shape** (PR merged upstream 2026-07-17, `2a244bc9`) — the +attestation carrier changed, so the hashes changed with it. Notes on fidelity: + +- **Host class is API Activity (6003)** with its real activity enum: a tool call without + `readOnlyHint` is the honest `activity_id: 99` + source-defined `activity_name` + ("Invoke Tool" / "Completion"); reads (resources, prompts, read-only-hinted tools) are + `2 (Read)` with the normalized caption. `metadata.profiles` declares `ai_operation` + + `security_control` (+ `record_integrity` when chained), and the passive stream carries + `action_id: 3 (Observed)` / `disposition_id: 17 (Logged)` — deny/modify records arrive + with the cpex-core decision event (WS-A / P1). +- **Merged attestation shape.** Records carry `attestation_list[]`, each entry holding a + `fingerprint` object (`algorithm_id` 3 = SHA-256, `encoding_id` 1 = Hex, + `serialization_id` 2 = JCS, bare-hex `value`), a `chain_uid`, an attestation `uid`, and — + from the second record on — a `prev_event` naming its predecessor by `uid` + `type_uid` + and binding it by `fingerprint`. The pre-merge draft form (string `entry_hash` / + `prev_entry_hash`, singular `signature`) no longer exists in the schema. +- **The fingerprint commits to the record's chain position** (review §4-B, carried into the + merged shape): it is computed over the JCS-style canonical bytes of the **whole event** + with `attestation_list[0]` present and carrying `uid` / `chain_uid` / `authority_uid` / + `prev_event`, and only `fingerprint` / `signatures` excluded (plus the two post-hash + signature extras under `unmapped` — the rule is running code, `sign::signing_input`). + So a verifier does not need to know anything about this crate: strip those members, + canonicalize per RFC 8785, recompute. That is a change from the pre-merge construction, + which hashed a private wrapper object. +- **Signed, and independently verifiable.** `signing: dsse` produces ECDSA-P256-SHA256 + over the DSSE PAE of the same canonical bytes the fingerprint covers (deterministic per + RFC 6979, which is why this output is byte-identical across runs). `signatures[]` + carries the `digital_signature` descriptor — `algorithm_id` 3 = ECDSA, + `serialization_id` 5 = DSSE, enum ids verified against ocsf-schema main 2026-07-31 — + and the raw bytes + JWKS `kid` ride in `unmapped.signature_b64` / + `unmapped.signature_key_id` pending + [ocsf-schema#1709](https://github.com/ocsf/ocsf-schema/pull/1709). The demo key is + generated at runtime from a fixed scalar (no key material in the repo); the `// verify` + lines at the bottom are the example itself re-deriving everything from the emitted JSON + and the public key alone. +- **`attestation.authority_uid` names the party the signing credential belongs to** + (`org-f3576cf6`, matching the production reference bundle's demo org). It sits inside + the hashed bytes, so the claimed authority cannot be swapped after the fact without + breaking the fingerprint — and keys rotate, so this is the stable identifier a verifier + checks the resolved JWKS key against. +- **`metadata.correlation_uid` is the run id** (`AgentExtension.conversation_id`; review + C1): both events of this run carry `"conv-9"`, so a SIEM can join them. It sits on + `metadata`, which is where OCSF defines it — it was previously emitted at the event root. + The per-call `tool_call_id` rides at `api.request.uid`. `metadata.uid` identifies the + record itself, and is what the next record's `prev_event.uid` points at. +- **Key ordering is alphabetical** because `serde_json::Map` is backed by a `BTreeMap` by + default in Rust (the canonical hash bytes sort keys explicitly and do not rely on this). + +```jsonc +// ===== OCSF event 1 — Invoke Tool (get_compensation) ===== +{ + "action": "Observed", + "action_id": 3, + "activity_id": 99, + "activity_name": "Invoke Tool", + "actor": { + "roles": [ + "hr" + ], + "user": { + "groups": [ + "people-ops" + ], + "uid": "alice@corp.com" + } + }, + "ai_agent": { + "conversation_uid": "conv-9", + "instance_uid": "sess-42", + "parent_uid": "orchestrator-1", + "turn": 3, + "uid": "agent-7" + }, + "api": { + "request": { + "uid": "call-001" + } + }, + "attestation_list": [ + { + "authority_uid": "org-f3576cf6", + "chain_uid": "demo-chain-org-f3576cf6", + "fingerprint": { + "algorithm": "SHA-256", + "algorithm_id": 3, + "encoding": "Hex", + "encoding_id": 1, + "serialization": "JCS", + "serialization_id": 2, + "value": "25ed739d1fea4a60e64b2b61aacdc59b14a24b89e46ecedab2314917dce74d12" + }, + "signatures": [ + { + "algorithm": "ECDSA", + "algorithm_id": 3, + "serialization": "DSSE", + "serialization_id": 5 + } + ], + "uid": "demo-chain-org-f3576cf6-att-000000" + } + ], + "category_uid": 6, + "class_uid": 6003, + "delegation": { + "actor_subject_uid": "agent-7", + "chain": [ + { + "audience": "workday-api", + "scopes_granted": [ + "read_compensation" + ], + "subject_uid": "agent-7", + "timestamp": "1970-01-01T00:00:00+00:00", + "ttl_seconds": 300 + } + ], + "depth": 1, + "origin_subject_uid": "alice@corp.com" + }, + "disposition": "Logged", + "disposition_id": 17, + "metadata": { + "correlation_uid": "conv-9", + "product": { + "name": "AI Identity OCSF Audit", + "vendor_name": "AI Identity" + }, + "profiles": [ + "ai_operation", + "security_control", + "record_integrity" + ], + "uid": "demo-chain-org-f3576cf6-000000", + "version": "1.9.0-dev" + }, + "severity_id": 1, + "time": "2026-06-30T12:00:00.000Z", + "tool": { + "name": "get_compensation", + "namespace": "hr", + "uid": "call-001" + }, + "type_uid": 600399, + "unmapped": { + "cmf.framework": { + "framework": "langgraph", + "framework_version": null, + "graph_id": "graph-hr", + "node_id": "node-compensation" + }, + "cmf.mcp": { + "tool": { + "annotations": {}, + "name": "get_compensation", + "namespace": "hr", + "server_id": "hr-mcp" + } + }, + "cmf.security.labels": [ + "PII", + "secret" + ], + "cmf.workload_identity": { + "attested_at": null, + "attestor": "gke-workload-identity", + "spiffe_id": "spiffe://corp/agent/hr-bot", + "trust_domain": "corp" + }, + "signature_b64": "MEUCIQD89xutRcH/wFhwiRIIEV8mpyy3qzhPSwc4atxzSfJPYgIgD3JDploPrXZPxGGSeIF5LS5C860rccDCIMgXWtS1Vrs=", + "signature_key_id": "demo-key-2026-07" + } +} + +// ===== OCSF event 2 — Completion (chained to event 1) ===== +{ + "action": "Observed", + "action_id": 3, + "activity_id": 99, + "activity_name": "Completion", + "ai_agent": { + "conversation_uid": "conv-9", + "instance_uid": "sess-42", + "parent_uid": null, + "turn": 4, + "uid": "agent-7" + }, + "ai_model": { + "name": "claude-opus-4-8" + }, + "attestation_list": [ + { + "authority_uid": "org-f3576cf6", + "chain_uid": "demo-chain-org-f3576cf6", + "fingerprint": { + "algorithm": "SHA-256", + "algorithm_id": 3, + "encoding": "Hex", + "encoding_id": 1, + "serialization": "JCS", + "serialization_id": 2, + "value": "88376850d7518d2b8a2536e5e135b5e4f0235c15d1c70cb3ab8af9ba426260ca" + }, + "prev_event": { + "fingerprint": { + "algorithm": "SHA-256", + "algorithm_id": 3, + "encoding": "Hex", + "encoding_id": 1, + "serialization": "JCS", + "serialization_id": 2, + "value": "25ed739d1fea4a60e64b2b61aacdc59b14a24b89e46ecedab2314917dce74d12" + }, + "type_uid": 600399, + "uid": "demo-chain-org-f3576cf6-000000" + }, + "signatures": [ + { + "algorithm": "ECDSA", + "algorithm_id": 3, + "serialization": "DSSE", + "serialization_id": 5 + } + ], + "uid": "demo-chain-org-f3576cf6-att-000001" + } + ], + "category_uid": 6, + "class_uid": 6003, + "disposition": "Logged", + "disposition_id": 17, + "duration": 842, + "message_context": { + "completion_tokens": 28, + "prompt_tokens": 120, + "total_tokens": 148 + }, + "metadata": { + "correlation_uid": "conv-9", + "product": { + "name": "AI Identity OCSF Audit", + "vendor_name": "AI Identity" + }, + "profiles": [ + "ai_operation", + "security_control", + "record_integrity" + ], + "uid": "demo-chain-org-f3576cf6-000001", + "version": "1.9.0-dev" + }, + "severity_id": 1, + "time": "2026-06-30T12:00:01.000Z", + "type_uid": 600399, + "unmapped": { + "cmf.completion.stop_reason": "End", + "signature_b64": "MEUCIQCsNtZsEFzBcAEmp+Vkg+y4bVLTaUqCyQiR27qPHrk1GwIgcGuddgH+Z34kMcqnQMHsqS5xTi/gqx0Eyx+Yrb349wY=", + "signature_key_id": "demo-key-2026-07" + } +} + +// chain check: event2.prev_event.fingerprint == event1.fingerprint -> true +// chain check: event2.prev_event.uid == event1.metadata.uid -> true +// verify event1: fingerprint recomputed -> true · DSSE signature -> true +// verify event2: fingerprint recomputed -> true · DSSE signature -> true +``` diff --git a/builtins/plugins/ocsf-audit/examples/emit_sample.rs b/builtins/plugins/ocsf-audit/examples/emit_sample.rs new file mode 100644 index 00000000..9f84af5a --- /dev/null +++ b/builtins/plugins/ocsf-audit/examples/emit_sample.rs @@ -0,0 +1,261 @@ +// Location: ./builtins/plugins/ocsf-audit/examples/emit_sample.rs +// Copyright 2026 AI Identity +// SPDX-License-Identifier: Apache-2.0 +// Authors: Jeff Leva +// +// Demo: build two realistic CMF turns (a tool invocation, then an LLM +// completion), run them through the OCSF audit emitter with attestation +// chaining on, and pretty-print the resulting OCSF events. +// +// Purpose: show what the plugin emits — including every gap field +// (stop_reason, mcp, framework, monotonic labels, workload identity) +// and the tamper-evident hash chain linking the two events — WITHOUT +// standing up a full CPEX gateway. +// +// cargo run --example emit_sample +// +// The timestamps are fixed so the output is deterministic (and so the +// fingerprint chain is reproducible across runs). + +use std::collections::HashMap; +use std::sync::Arc; + +use serde_json::json; + +use cpex_plugin_ocsf_audit::OcsfAuditEmitter; + +use cpex_core::cmf::{ContentPart, Message, MessagePayload, Role, ToolCall}; +use cpex_core::extensions::{ + AgentExtension, CompletionExtension, DelegationExtension, DelegationHop, Extensions, + FrameworkExtension, MCPExtension, SecurityExtension, StopReason, SubjectExtension, TokenUsage, + ToolMetadata, WorkloadIdentity, +}; +use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; + +/// Demo signing key, generated at runtime from a fixed scalar so the +/// sample output is byte-identical across runs (RFC 6979 deterministic +/// ECDSA) WITHOUT any key material living in the repo. Demo only — a +/// real deployment points signing_key_pem_path at a provisioned key and +/// publishes the public half (JWKS) under the authority named by +/// authority_uid. +fn demo_key_pem() -> String { + use p256::pkcs8::EncodePrivateKey; + p256::ecdsa::SigningKey::from_slice(&[0x42u8; 32]) + .expect("valid P-256 scalar") + .to_pkcs8_pem(p256::pkcs8::LineEnding::LF) + .expect("pem") + .to_string() +} + +fn emitter() -> OcsfAuditEmitter { + let config = PluginConfig { + name: "ocsf-audit-demo".into(), + kind: "audit/ocsf".into(), + hooks: vec!["cmf.tool_post_invoke".into(), "cmf.llm_output".into()], + mode: PluginMode::Sequential, + priority: 50, + on_error: OnError::Fail, + config: Some(json!({ + "chain": true, + "signing": "dsse", + "signing_key_pem": demo_key_pem(), + "signing_key_id": "demo-key-2026-07", + "authority_uid": "org-f3576cf6", + "chain_uid": "demo-chain-org-f3576cf6", + "product_name": "AI Identity OCSF Audit", + "vendor_name": "AI Identity", + })), + ..Default::default() + }; + OcsfAuditEmitter::new(config).expect("valid demo config") +} + +/// Turn 1 — an agent invokes the `get_compensation` HR tool. Carries +/// identity, delegation, MCP tool metadata, framework context, taint +/// labels, and an attested workload identity. +fn tool_turn() -> (MessagePayload, Extensions) { + let payload = MessagePayload { + message: Message::with_content( + Role::Tool, + vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "call-001".into(), + name: "get_compensation".into(), + arguments: HashMap::from([("employee_id".to_string(), json!("EMP-001234"))]), + namespace: Some("hr".into()), + }, + }], + ), + }; + + let mut sec = SecurityExtension::default(); + let mut subj = SubjectExtension::default(); + subj.id = Some("alice@corp.com".into()); + subj.roles.insert("hr".into()); + subj.teams.insert("people-ops".into()); + sec.subject = Some(subj); + sec.labels.insert("PII".into()); + sec.labels.insert("secret".into()); + sec.caller_workload = Some(WorkloadIdentity { + spiffe_id: Some("spiffe://corp/agent/hr-bot".into()), + trust_domain: Some("corp".into()), + attestor: Some("gke-workload-identity".into()), + ..Default::default() + }); + + let agent = AgentExtension { + agent_id: Some("agent-7".into()), + parent_agent_id: Some("orchestrator-1".into()), + session_id: Some("sess-42".into()), + conversation_id: Some("conv-9".into()), + turn: Some(3), + ..Default::default() + }; + + let delegation = DelegationExtension { + delegated: true, + depth: 1, + origin_subject_id: Some("alice@corp.com".into()), + actor_subject_id: Some("agent-7".into()), + chain: vec![DelegationHop { + subject_id: "agent-7".into(), + audience: Some("workday-api".into()), + scopes_granted: vec!["read_compensation".into()], + ttl_seconds: Some(300), + ..Default::default() + }], + ..Default::default() + }; + + let mcp = MCPExtension { + tool: Some(ToolMetadata { + name: "get_compensation".into(), + server_id: Some("hr-mcp".into()), + namespace: Some("hr".into()), + ..Default::default() + }), + ..Default::default() + }; + + let framework = FrameworkExtension { + framework: Some("langgraph".into()), + node_id: Some("node-compensation".into()), + graph_id: Some("graph-hr".into()), + ..Default::default() + }; + + let ext = Extensions { + security: Some(Arc::new(sec)), + agent: Some(Arc::new(agent)), + delegation: Some(Arc::new(delegation)), + mcp: Some(Arc::new(mcp)), + framework: Some(Arc::new(framework)), + ..Default::default() + }; + + (payload, ext) +} + +/// Turn 2 — the model produces output. Carries completion metadata: +/// stop reason (a gap), token usage, model, latency. +fn completion_turn() -> (MessagePayload, Extensions) { + let payload = MessagePayload { + message: Message::with_content( + Role::Assistant, + vec![ContentPart::Text { + text: "Alice's current base compensation is redacted per policy.".into(), + }], + ), + }; + + let completion = CompletionExtension { + stop_reason: Some(StopReason::End), + tokens: Some(TokenUsage { + input_tokens: 120, + output_tokens: 28, + total_tokens: 148, + }), + model: Some("claude-opus-4-8".into()), + latency_ms: Some(842), + ..Default::default() + }; + + let agent = AgentExtension { + agent_id: Some("agent-7".into()), + session_id: Some("sess-42".into()), + // Same run as turn 1 — so both events carry + // correlation_uid = "conv-9" and are joinable (review C1). + conversation_id: Some("conv-9".into()), + turn: Some(4), + ..Default::default() + }; + + let ext = Extensions { + completion: Some(Arc::new(completion)), + agent: Some(Arc::new(agent)), + ..Default::default() + }; + + (payload, ext) +} + +fn main() { + let e = emitter(); + + let (p1, x1) = tool_turn(); + let ev1 = e.build(&p1, &x1, "2026-06-30T12:00:00.000Z"); + + let (p2, x2) = completion_turn(); + let ev2 = e.build(&p2, &x2, "2026-06-30T12:00:01.000Z"); + + println!("// ===== OCSF event 1 — Invoke Tool (get_compensation) ====="); + println!("{}", serde_json::to_string_pretty(&ev1).unwrap()); + println!(); + println!("// ===== OCSF event 2 — Completion (chained to event 1) ====="); + println!("{}", serde_json::to_string_pretty(&ev2).unwrap()); + println!(); + + // Demonstrate the tamper-evident chain: event 2's + // prev_event.fingerprint equals event 1's fingerprint. + let fp1 = &ev1["attestation_list"][0]["fingerprint"]; + let prev2 = &ev2["attestation_list"][0]["prev_event"]["fingerprint"]; + println!( + "// chain check: event2.prev_event.fingerprint == event1.fingerprint -> {}", + fp1 == prev2 + ); + // And the retrieval coordinates the merged shape adds: prev_event + // names the record it points at, so a consumer can go fetch it. + println!( + "// chain check: event2.prev_event.uid == event1.metadata.uid -> {}", + ev2["attestation_list"][0]["prev_event"]["uid"] == ev1["metadata"]["uid"] + ); + + // The independent-verifier loop, from nothing but the emitted JSON + // and the public key: reconstruct the signed bytes, recompute the + // fingerprint, verify the DSSE signature over the PAE. + { + use base64::Engine; + use cpex_plugin_ocsf_audit::sign::{dsse_pae, fingerprint_value, signing_input}; + use p256::ecdsa::signature::Verifier; + + let vk = *p256::ecdsa::SigningKey::from_slice(&[0x42u8; 32]) + .unwrap() + .verifying_key(); + for (label, ev) in [("event1", &ev1), ("event2", &ev2)] { + let bytes = signing_input(ev); + let fp_ok = fingerprint_value(&bytes) + == ev["attestation_list"][0]["fingerprint"]["value"] + .as_str() + .unwrap(); + let der = base64::engine::general_purpose::STANDARD + .decode(ev["unmapped"]["signature_b64"].as_str().unwrap()) + .unwrap(); + let sig_ok = p256::ecdsa::Signature::from_der(&der) + .map(|sig| vk.verify(&dsse_pae(&bytes), &sig).is_ok()) + .unwrap_or(false); + println!( + "// verify {label}: fingerprint recomputed -> {fp_ok} · DSSE signature -> {sig_ok}" + ); + } + } +} diff --git a/builtins/plugins/ocsf-audit/src/config.rs b/builtins/plugins/ocsf-audit/src/config.rs new file mode 100644 index 00000000..7262fc6e --- /dev/null +++ b/builtins/plugins/ocsf-audit/src/config.rs @@ -0,0 +1,126 @@ +// Location: ./builtins/plugins/ocsf-audit/src/config.rs +// Copyright 2026 AI Identity +// SPDX-License-Identifier: Apache-2.0 +// Authors: Jeff Leva +// +// Operator-facing config for the OCSF audit plugin. Mirrors the +// upstream audit-logger's config style (serde, snake_case enums, +// stderr default) and adds OCSF/attestation knobs. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct OcsfAuditConfig { + /// Where OCSF events go. Stderr default keeps the demo flow + /// (`docker compose logs -f | jq`) identical to audit-logger. + #[serde(default)] + pub destination: OcsfDestination, + + /// Populates OCSF `metadata.product` so a single collector can + /// attribute events to a deployment. + #[serde(default = "default_product_name")] + pub product_name: String, + + /// Populates OCSF `metadata.product.vendor_name`. + #[serde(default = "default_vendor_name")] + pub vendor_name: String, + + /// When true, attach an attestation to every event: compute a + /// `fingerprint` over the canonical event and reference the previous + /// event through `prev_event` (its uid, type_uid and fingerprint), + /// forming a tamper-evident chain. This is the integrity seam from + /// the field map, and it declares the `record_integrity` profile. + #[serde(default = "default_true")] + pub chain: bool, + + /// Stable identifier for this attestation chain (OCSF + /// `attestation.chain_uid`). If absent, a process-lifetime random + /// uid is generated at startup. + #[serde(default)] + pub chain_uid: Option, + + /// Signing mode for the attestation. `none` produces an unsigned + /// (but still hash-chained) record — valid under the merged shape, + /// whose `at_least_one(fingerprint, signatures)` constraint the + /// fingerprint alone satisfies. `dsse` is the production mode and + /// declares `signatures[0].serialization_id = DSSE`; it REQUIRES a + /// key via exactly one of `signing_key_pem` / + /// `signing_key_pem_path` — a missing key fails construction loudly + /// rather than silently emitting unsigned records. + #[serde(default)] + pub signing: SigningMode, + + /// Inline PKCS#8 P-256 private key PEM for `signing: dsse`. + /// Mutually exclusive with `signing_key_pem_path`. Inline is for + /// tests/demos and secret-manager injection; operators with a key + /// file should prefer the path form. + #[serde(default)] + pub signing_key_pem: Option, + + /// Path to a PKCS#8 P-256 private key PEM for `signing: dsse`. + /// Mutually exclusive with `signing_key_pem`. + #[serde(default)] + pub signing_key_pem_path: Option, + + /// Key identifier (JWKS `kid`) stamped at + /// `unmapped.signature_key_id`, so a verifier can resolve the + /// public key from the authority's published key set. Rides in + /// `unmapped` (outside the hashed bytes, like the signature itself) + /// until ocsf-schema#1709 gives signature material a schema home. + #[serde(default)] + pub signing_key_id: Option, + + /// OCSF `attestation.authority_uid` — identifies the authority the + /// signing credential belongs to. Signing keys rotate and expire; + /// this is the stable party identifier a verifier checks the + /// resolved key AGAINST, which is what defeats an + /// otherwise-valid-credential substitution. Part of the hashed + /// canonical serialization (merged #1661 semantics), so it cannot + /// be swapped after the fact without breaking the fingerprint. + /// `recommended` in the schema; set it whenever signing is on. + #[serde(default)] + pub authority_uid: Option, + + /// When true (default), gap fields that have no native OCSF home + /// yet — `completion.stop_reason`, `mcp.*`, `framework.*`, + /// monotonic security labels — are emitted under OCSF `unmapped` + /// rather than dropped. This is deliberate: it preserves evidence + /// AND surfaces exactly which WS4/OCSF gaps the plugin had to work + /// around. See CMF-OCSF-FIELD-MAP.md §5. + #[serde(default = "default_true")] + pub include_gap_fields: bool, +} + +fn default_product_name() -> String { + "AI Identity OCSF Audit".to_string() +} +fn default_vendor_name() -> String { + "AI Identity".to_string() +} +fn default_true() -> bool { + true +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OcsfDestination { + /// One OCSF JSON object per line to stderr. + #[default] + Stderr, + /// Emit via `tracing::info!` at target `ocsf.audit`. + Tracing, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SigningMode { + /// Hash-chained but unsigned. Useful for the demo and for + /// environments where the signing key isn't provisioned yet. + #[default] + None, + /// DSSE-signed (merged in OCSF #1662 via + /// `digital_signature.serialization_id`; DSSE = 5, verified against + /// ocsf-schema main 2026-07-31). ECDSA-P256-SHA256 over the PAE of + /// the event's canonical bytes — see sign.rs. Requires a key. + Dsse, +} diff --git a/builtins/plugins/ocsf-audit/src/emitter.rs b/builtins/plugins/ocsf-audit/src/emitter.rs new file mode 100644 index 00000000..f8575ffc --- /dev/null +++ b/builtins/plugins/ocsf-audit/src/emitter.rs @@ -0,0 +1,839 @@ +// Location: ./builtins/plugins/ocsf-audit/src/emitter.rs +// Copyright 2026 AI Identity +// SPDX-License-Identifier: Apache-2.0 +// Authors: Jeff Leva +// +// The plugin proper. Mirrors audit-logger::AuditLogger: holds config, +// implements Plugin + HookHandler, builds a record, emits, +// and returns allow() (observation-only, never blocks). +// +// Added over audit-logger: +// * OCSF mapping (ocsf::build_ai_operation) +// * optional attestation with a tamper-evident hash chain +// (fingerprint -> prev_event.fingerprint) threaded across calls +// * a pluggable signer (sign::OcsfSigner) + +use std::sync::Mutex; + +use async_trait::async_trait; +use serde_json::{json, Value}; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::config::{OcsfAuditConfig, OcsfDestination, SigningMode}; +use crate::ocsf; +use crate::sign::{canonical_bytes, fingerprint_value, DsseSigner, NoopSigner, OcsfSigner}; + +/// Back-reference to the preceding record in the chain, i.e. everything +/// the merged `prev_event` object needs: the predecessor's +/// `metadata.uid` (schema-required), its `type_uid` (which tells a +/// consumer the class, and therefore the store, to retrieve it from), +/// and its fingerprint value (what actually binds the link to content). +#[derive(Clone)] +struct PrevRef { + uid: String, + type_uid: i64, + fingerprint: String, +} + +#[derive(Default)] +struct ChainState { + /// Monotonic per-emitter counter. Drives deterministic record and + /// attestation uids so example/demo output stays reproducible. + seq: u64, + prev: Option, +} + +pub struct OcsfAuditEmitter { + cfg: PluginConfig, + typed: OcsfAuditConfig, + chain_uid: String, + signer: Box, + chain: Mutex, +} + +/// Build a merged-shape `fingerprint` object around a hex digest. +/// +/// `algorithm_id` 3 = SHA-256, `encoding_id` 1 = Hex, `serialization_id` +/// 2 = JCS — the last being how a verifier knows which bytes were +/// hashed (our JCS-style canonicalizer, see sign.rs). +fn fingerprint_obj(value: &str) -> Value { + json!({ + "algorithm_id": 3, + "algorithm": "SHA-256", + "encoding_id": 1, + "encoding": "Hex", + "serialization_id": 2, + "serialization": "JCS", + "value": value, + }) +} + +impl std::fmt::Debug for OcsfAuditEmitter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OcsfAuditEmitter") + .field("name", &self.cfg.name) + .field("chain_uid", &self.chain_uid) + .finish() + } +} + +impl OcsfAuditEmitter { + pub fn new(cfg: PluginConfig) -> Result> { + let typed: OcsfAuditConfig = match cfg.config.as_ref() { + Some(raw) => serde_json::from_value(raw.clone()).map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (cpex-plugin-ocsf-audit) config parse failed: {e}", + cfg.name + ), + }) + })?, + None => OcsfAuditConfig::default(), + }; + + let chain_uid = typed + .chain_uid + .clone() + // Process-lifetime fallback uid. Not random across restarts — + // operators who need a stable chain set chain_uid explicitly. + .unwrap_or_else(|| format!("ocsf-chain-{}", cfg.name)); + + let signer: Box = match typed.signing { + SigningMode::None => Box::new(NoopSigner), + SigningMode::Dsse => { + // A missing/unreadable/invalid key fails construction + // loudly. The alternative — falling back to unsigned — + // would emit records that LOOK like the operator's + // signing policy while silently lacking the signatures + // it promised. + let config_err = |message: String| Box::new(PluginError::Config { message }); + let pem = match (&typed.signing_key_pem, &typed.signing_key_pem_path) { + (Some(inline), None) => inline.clone(), + (None, Some(path)) => std::fs::read_to_string(path).map_err(|e| { + config_err(format!( + "plugin '{}' (cpex-plugin-ocsf-audit): signing=dsse could not \ + read signing_key_pem_path '{path}': {e}", + cfg.name + )) + })?, + (Some(_), Some(_)) => { + return Err(config_err(format!( + "plugin '{}' (cpex-plugin-ocsf-audit): set exactly one of \ + signing_key_pem / signing_key_pem_path, not both", + cfg.name + ))) + }, + (None, None) => { + return Err(config_err(format!( + "plugin '{}' (cpex-plugin-ocsf-audit): signing=dsse requires a \ + key — set signing_key_pem (inline PKCS#8 PEM) or \ + signing_key_pem_path", + cfg.name + ))) + }, + }; + Box::new( + DsseSigner::from_pem(&pem, typed.signing_key_id.clone()).map_err(|e| { + config_err(format!( + "plugin '{}' (cpex-plugin-ocsf-audit): {e}", + cfg.name + )) + })?, + ) + }, + }; + + Ok(Self { + cfg, + typed, + chain_uid, + signer, + chain: Mutex::new(ChainState::default()), + }) + } + + /// Build the OCSF event and, if chaining is on, wrap it in an + /// attestation. `now_rfc3339` injected for testability and for + /// deterministic example/demo output. Public so `examples/` and + /// downstream tooling can obtain the event without going through + /// the stderr/tracing emit path. + pub fn build(&self, payload: &MessagePayload, ext: &Extensions, now_rfc3339: &str) -> Value { + let event = ocsf::build_ai_operation(payload, ext, &self.typed, now_rfc3339); + + if !self.typed.chain { + return event; + } + + // Predecessor binding, merged-#1661 semantics: the fingerprint + // is computed over the canonical serialization of the WHOLE + // EVENT, including this attestation's own `uid`, `chain_uid`, + // `authority_uid` and `prev_event`, and excluding only + // `fingerprint` and `signatures`. So the record's chain position + // is inside the hashed input — deleting, reordering or splicing + // a record changes its own fingerprint, and every later link + // with it. + // + // This replaces the pre-merge construction, which hashed a + // synthetic wrapper `{chain_uid, event, prev_entry_hash}`. That + // form preserved the same property but was only reproducible by + // a verifier who knew our wrapper convention; the merged form is + // reproducible by anyone following the schema. + // + // Canonical bytes are JCS-style (review C2): key-sorted, + // compact, set-derived arrays already sorted at build time + // (ocsf.rs). + let mut out = event; + + // Held across the whole build: seq allocation, predecessor read + // and chain advance must be one atomic step, or two concurrent + // invocations can mint the same uid and fork the chain off the + // same predecessor. `build` is sync, so there is no await under + // the guard. + let mut guard = self.chain.lock().unwrap(); + let record_uid = format!("{}-{:06}", self.chain_uid, guard.seq); + let att_uid = format!("{}-att-{:06}", self.chain_uid, guard.seq); + let prev = guard.prev.clone(); + + // `metadata.uid` identifies this record; the NEXT record's + // `prev_event.uid` points at it, so it must exist before we hash. + let type_uid = out.get("type_uid").and_then(Value::as_i64).unwrap_or(0); + if let Some(m) = out.get_mut("metadata").and_then(Value::as_object_mut) { + m.insert("uid".into(), json!(record_uid)); + } + + // Attestation minus fingerprint/signatures — the hashed form. + let mut attestation = json!({ + "uid": att_uid, + "chain_uid": self.chain_uid, + }); + // authority_uid is part of the hashed serialization (merged + // #1661 semantics list it alongside chain_uid / prev_event), so + // the claimed authority cannot be swapped post-hoc without + // breaking the fingerprint — and every signature over it. + if let Some(authority) = &self.typed.authority_uid { + attestation["authority_uid"] = json!(authority); + } + if let Some(p) = &prev { + attestation["prev_event"] = json!({ + "uid": p.uid, + "type_uid": p.type_uid, + "fingerprint": fingerprint_obj(&p.fingerprint), + }); + } + if let Value::Object(m) = &mut out { + m.insert("attestation_list".into(), json!([attestation])); + } + + let bytes = canonical_bytes(&out); + let this_fp = fingerprint_value(&bytes); + + // Now fill in the two excluded members. + out["attestation_list"][0]["fingerprint"] = fingerprint_obj(&this_fp); + if let Some(signed) = self.signer.sign(&bytes) { + out["attestation_list"][0]["signatures"] = json!([signed.digital_signature]); + // The signature bytes (and the JWKS kid that resolves the + // public key) have no home on `digital_signature` yet — that + // gap is filed as ocsf-schema#1709. Until it lands they ride + // in `unmapped`, matching the production reference bundle. + // MERGE into any existing `unmapped` — the gap fields from + // ocsf.rs already live there, and those are inside the + // hashed bytes; only these two post-hash keys are excluded + // by a verifier (see sign::signing_input). + if let Value::Object(m) = &mut out { + let un = m + .entry("unmapped") + .or_insert_with(|| Value::Object(Default::default())); + if let Some(un) = un.as_object_mut() { + un.insert("signature_b64".into(), json!(signed.signature)); + if let Some(kid) = &signed.key_id { + un.insert("signature_key_id".into(), json!(kid)); + } + } + } + } + + guard.prev = Some(PrevRef { + uid: record_uid, + type_uid, + fingerprint: this_fp, + }); + guard.seq += 1; + drop(guard); + + out + } + + fn emit(&self, event: &Value) { + match self.typed.destination { + OcsfDestination::Stderr => eprintln!("{event}"), + OcsfDestination::Tracing => { + tracing::info!(target: "ocsf.audit", event = %event, "ocsf"); + }, + } + } +} + +#[async_trait] +impl Plugin for OcsfAuditEmitter { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for OcsfAuditEmitter { + async fn handle( + &self, + payload: &MessagePayload, + ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + let event = self.build(payload, ext, &now); + self.emit(&event); + // Observation-only: never block the request. + PluginResult::allow() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::cmf::{ContentPart, Message, Role, ToolCall}; + use cpex_core::extensions::{SecurityExtension, SubjectExtension}; + use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; + use std::collections::HashMap; + use std::sync::Arc; + + fn cfg(extra: serde_json::Value) -> PluginConfig { + PluginConfig { + name: "ocsf-audit".into(), + kind: super::super::factory::KIND.into(), + hooks: vec!["cmf.tool_post_invoke".into()], + mode: PluginMode::Sequential, + priority: 50, + on_error: OnError::Fail, + config: Some(extra), + ..Default::default() + } + } + + fn tool_payload() -> MessagePayload { + MessagePayload { + message: Message::with_content( + Role::Tool, + vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "call-1".into(), + name: "get_compensation".into(), + arguments: HashMap::new(), + namespace: Some("hr".into()), + }, + }], + ), + } + } + + fn subject_ext() -> Extensions { + let mut sec = SecurityExtension::default(); + sec.subject = Some(SubjectExtension { + id: Some("alice@corp.com".into()), + ..Default::default() + }); + Extensions { + security: Some(Arc::new(sec)), + ..Default::default() + } + } + + #[test] + fn maps_tool_call_to_ocsf_ai_operation() { + let e = OcsfAuditEmitter::new(cfg(json!({ "chain": false }))).unwrap(); + let ev = e.build(&tool_payload(), &subject_ext(), "2026-06-30T12:00:00.000Z"); + + // Host class: API Activity (P0, 2026-07-18 thread). + assert_eq!(ev["class_uid"], 6003); + // No readOnlyHint on this tool -> honest 99 (Other) with a + // source-defined name, per the OCSF enum contract. + assert_eq!(ev["activity_id"], 99); + assert_eq!(ev["activity_name"], "Invoke Tool"); + assert_eq!(ev["type_uid"], 600399); + // Passive post-hook stream = security_control Observed/Logged. + assert_eq!(ev["action_id"], 3); + assert_eq!(ev["disposition_id"], 17); + // Review C1: the per-call id lands at api.request.uid, NOT + // correlation_uid (which mirrors the run id and is absent here + // because this payload carries no AgentExtension). + assert_eq!(ev["api"]["request"]["uid"], "call-1"); + assert!(ev["metadata"]["correlation_uid"].is_null()); + assert_eq!(ev["tool"]["name"], "get_compensation"); + assert_eq!(ev["tool"]["namespace"], "hr"); + assert_eq!(ev["actor"]["user"]["uid"], "alice@corp.com"); + assert_eq!(ev["metadata"]["product"]["vendor_name"], "AI Identity"); + } + + #[test] + fn chains_fingerprints_across_calls() { + let e = OcsfAuditEmitter::new(cfg(json!({ "chain": true }))).unwrap(); + + let ev1 = e.build(&tool_payload(), &subject_ext(), "2026-06-30T12:00:00.000Z"); + let ev2 = e.build(&tool_payload(), &subject_ext(), "2026-06-30T12:00:01.000Z"); + + let (a1, a2) = (&ev1["attestation_list"][0], &ev2["attestation_list"][0]); + + // Genesis record carries no prev_event at all (the merged shape + // omits it rather than emitting an explicit null). + assert!(a1.get("prev_event").is_none()); + + // Second record's prev_event binds the first: fingerprint by + // content, uid + type_uid for retrieval. + assert_eq!(a2["prev_event"]["fingerprint"], a1["fingerprint"]); + assert_eq!(a2["prev_event"]["uid"], ev1["metadata"]["uid"]); + assert_eq!(a2["prev_event"]["type_uid"], ev1["type_uid"]); + + // Fingerprint is a merged-shape object, bare-hex valued. + assert_eq!(a1["fingerprint"]["algorithm_id"], 3); + assert_eq!(a1["fingerprint"]["encoding_id"], 1); + assert_eq!(a1["fingerprint"]["serialization_id"], 2); + assert_eq!(a1["fingerprint"]["value"].as_str().unwrap().len(), 64); + + // Unsigned-but-chained in the default (None) signing mode: the + // at_least_one(fingerprint, signatures) constraint is satisfied + // by the fingerprint, and no empty signatures array is emitted. + assert!(a1.get("signatures").is_none()); + assert!(ev1.get("unmapped").is_none()); + } + + #[test] + fn read_only_hint_maps_tool_call_to_read() { + use cpex_core::extensions::{MCPExtension, ToolMetadata}; + + let mut ext = subject_ext(); + ext.mcp = Some(Arc::new(MCPExtension { + tool: Some(ToolMetadata { + name: "get_compensation".into(), + annotations: HashMap::from([("readOnlyHint".to_string(), json!(true))]), + ..Default::default() + }), + ..Default::default() + })); + + let e = OcsfAuditEmitter::new(cfg(json!({ "chain": false }))).unwrap(); + let ev = e.build(&tool_payload(), &ext, "2026-07-20T12:00:00.000Z"); + + // readOnlyHint: true -> known id 2 with the normalized caption. + assert_eq!(ev["activity_id"], 2); + assert_eq!(ev["activity_name"], "Read"); + assert_eq!(ev["type_uid"], 600302); + } + + #[test] + fn read_only_hint_for_different_tool_is_ignored() { + use cpex_core::extensions::{MCPExtension, ToolMetadata}; + + let mut ext = subject_ext(); + ext.mcp = Some(Arc::new(MCPExtension { + tool: Some(ToolMetadata { + name: "some_other_tool".into(), + annotations: HashMap::from([("readOnlyHint".to_string(), json!(true))]), + ..Default::default() + }), + ..Default::default() + })); + + let e = OcsfAuditEmitter::new(cfg(json!({ "chain": false }))).unwrap(); + let ev = e.build(&tool_payload(), &ext, "2026-07-20T12:00:00.000Z"); + + // The hint describes a different tool than the one invoked. + assert_eq!(ev["activity_id"], 99); + assert_eq!(ev["activity_name"], "Invoke Tool"); + } + + #[test] + fn profiles_reflect_chain_config() { + let chained = OcsfAuditEmitter::new(cfg(json!({ "chain": true }))) + .unwrap() + .build(&tool_payload(), &subject_ext(), "2026-07-20T12:00:00.000Z"); + assert_eq!( + chained["metadata"]["profiles"], + json!(["ai_operation", "security_control", "record_integrity"]) + ); + + let unchained = OcsfAuditEmitter::new(cfg(json!({ "chain": false }))) + .unwrap() + .build(&tool_payload(), &subject_ext(), "2026-07-20T12:00:00.000Z"); + assert_eq!( + unchained["metadata"]["profiles"], + json!(["ai_operation", "security_control"]) + ); + } + + /// Review §4-B (fixed 2026-07-20; carried into the merged shape + /// 2026-07-31): the predecessor is folded into the hashed input, + /// now by `prev_event` living inside the serialized event rather + /// than via a synthetic wrapper. Two byte-identical events at + /// different chain positions must produce different fingerprints — + /// under a plain back-pointer design they collide, so reordering or + /// splicing records between positions (or chains) is undetectable + /// from the hashes alone. + #[test] + fn fingerprint_binds_predecessor_into_hashed_input() { + let e = OcsfAuditEmitter::new(cfg(json!({ "chain": true }))).unwrap(); + let t = "2026-07-20T12:00:00.000Z"; + let ev1 = e.build(&tool_payload(), &subject_ext(), t); + let ev2 = e.build(&tool_payload(), &subject_ext(), t); + + // Identical event content (attestation and record uid aside)... + let strip = |v: &Value| { + let mut v = v.clone(); + let m = v.as_object_mut().unwrap(); + m.remove("attestation_list"); + m.get_mut("metadata") + .and_then(Value::as_object_mut) + .map(|md| md.remove("uid")); + v + }; + assert_eq!(strip(&ev1), strip(&ev2)); + + // ...but a different chain position -> a different fingerprint, + // while linkage still holds. + let (a1, a2) = (&ev1["attestation_list"][0], &ev2["attestation_list"][0]); + assert_ne!(a1["fingerprint"]["value"], a2["fingerprint"]["value"]); + assert_eq!(a2["prev_event"]["fingerprint"], a1["fingerprint"]); + } + + // --- signing (DSSE) -------------------------------------------------- + + /// Deterministic test key (RFC 6979 makes ECDSA deterministic per + /// key+message, so signed sample output stays reproducible). PEM is + /// generated at runtime — no key material lives in the repo. + fn test_key_pem() -> String { + use p256::pkcs8::EncodePrivateKey; + p256::ecdsa::SigningKey::from_slice(&[0x11u8; 32]) + .unwrap() + .to_pkcs8_pem(p256::pkcs8::LineEnding::LF) + .unwrap() + .to_string() + } + + fn signed_cfg() -> PluginConfig { + cfg(json!({ + "chain": true, + "signing": "dsse", + "signing_key_pem": test_key_pem(), + "signing_key_id": "test-key-1", + "authority_uid": "org-test-authority", + })) + } + + /// The full independent-verifier loop, from nothing but the emitted + /// JSON and the public key: reconstruct the signed bytes + /// (sign::signing_input), recompute the fingerprint, verify the + /// DSSE signature over the PAE. + #[test] + fn signed_event_verifies_offline() { + use crate::sign::{dsse_pae, signing_input}; + use base64::Engine; + use p256::ecdsa::signature::Verifier; + + let e = OcsfAuditEmitter::new(signed_cfg()).unwrap(); + let ev = e.build(&tool_payload(), &full_ext(), "2026-07-31T12:00:00.000Z"); + + let att = &ev["attestation_list"][0]; + // #2: authority_uid emitted, and it names the configured party. + assert_eq!(att["authority_uid"], "org-test-authority"); + // Descriptor carries the verified enum ids: ECDSA (3) / DSSE (5), + // with normalized captions. + assert_eq!(att["signatures"][0]["algorithm_id"], 3); + assert_eq!(att["signatures"][0]["algorithm"], "ECDSA"); + assert_eq!(att["signatures"][0]["serialization_id"], 5); + assert_eq!(att["signatures"][0]["serialization"], "DSSE"); + // kid rides beside the bytes so a verifier can resolve the key. + assert_eq!(ev["unmapped"]["signature_key_id"], "test-key-1"); + + // Independent reconstruction: fingerprint matches... + let bytes = signing_input(&ev); + assert_eq!( + crate::sign::fingerprint_value(&bytes), + att["fingerprint"]["value"].as_str().unwrap() + ); + // ...and the signature verifies over the PAE of the same bytes. + let der = base64::engine::general_purpose::STANDARD + .decode(ev["unmapped"]["signature_b64"].as_str().unwrap()) + .unwrap(); + let sig = p256::ecdsa::Signature::from_der(&der).unwrap(); + let vk = *p256::ecdsa::SigningKey::from_slice(&[0x11u8; 32]) + .unwrap() + .verifying_key(); + vk.verify(&dsse_pae(&bytes), &sig) + .expect("emitted signature must verify offline"); + } + + /// Regression: signing must MERGE into `unmapped`, not replace it — + /// the gap fields (stop_reason, mcp, framework, labels, workload) + /// live there and are part of the hashed evidence. + #[test] + fn signing_preserves_gap_fields_in_unmapped() { + let e = OcsfAuditEmitter::new(signed_cfg()).unwrap(); + let ev = e.build(&tool_payload(), &full_ext(), "2026-07-31T12:00:00.000Z"); + + let un = ev["unmapped"].as_object().unwrap(); + assert!(un.contains_key("signature_b64")); + assert!(un.contains_key("cmf.completion.stop_reason")); + assert!(un.contains_key("cmf.mcp")); + assert!(un.contains_key("cmf.security.labels")); + } + + /// authority_uid sits INSIDE the hashed serialization: two otherwise + /// identical records claiming different authorities must fingerprint + /// differently — the claimed authority can't be swapped post-hoc. + #[test] + fn authority_uid_is_bound_into_the_fingerprint() { + let build = |authority: &str| { + OcsfAuditEmitter::new(cfg(json!({ + "chain": true, + "authority_uid": authority, + }))) + .unwrap() + .build(&tool_payload(), &subject_ext(), "2026-07-31T12:00:00.000Z") + }; + let a = build("org-alpha"); + let b = build("org-beta"); + assert_ne!( + a["attestation_list"][0]["fingerprint"]["value"], + b["attestation_list"][0]["fingerprint"]["value"] + ); + } + + /// signing=dsse with no key must fail construction loudly — never + /// fall back to silently-unsigned records. + #[test] + fn dsse_without_key_fails_construction() { + let err = OcsfAuditEmitter::new(cfg(json!({ "signing": "dsse" }))).unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("requires a key"), "unexpected error: {msg}"); + } + + #[tokio::test] + async fn handler_is_observation_only() { + let e = OcsfAuditEmitter::new(cfg(json!({}))).unwrap(); + let mut ctx = PluginContext::default(); + let r = e.handle(&tool_payload(), &subject_ext(), &mut ctx).await; + assert!(r.continue_processing); + assert!(r.violation.is_none()); + } + + // --- gap-branch coverage -------------------------------------------- + // The happy-path test above only exercises a tool call + subject. These + // build a fully-populated Extensions set and assert every gap field + // lands where CMF-OCSF-FIELD-MAP.md says it should. + + use cpex_core::extensions::{ + AgentExtension, CompletionExtension, DelegationExtension, DelegationHop, + FrameworkExtension, MCPExtension, StopReason, TokenUsage, ToolMetadata, WorkloadIdentity, + }; + + /// Extensions with every audit-relevant branch populated. + fn full_ext() -> Extensions { + let mut sec = SecurityExtension::default(); + let mut subj = SubjectExtension::default(); + subj.id = Some("alice@corp.com".into()); + subj.roles.insert("hr".into()); + subj.teams.insert("people-ops".into()); + sec.subject = Some(subj); + // monotonic taint labels (gap 4) + sec.labels.insert("PII".into()); + sec.labels.insert("secret".into()); + // workload attestation (gap 5) + sec.caller_workload = Some(WorkloadIdentity { + spiffe_id: Some("spiffe://corp/agent/hr-bot".into()), + trust_domain: Some("corp".into()), + attestor: Some("gke-workload-identity".into()), + ..Default::default() + }); + + let agent = AgentExtension { + agent_id: Some("agent-7".into()), + parent_agent_id: Some("orchestrator-1".into()), + session_id: Some("sess-42".into()), + conversation_id: Some("conv-9".into()), + turn: Some(3), + ..Default::default() + }; + + let completion = CompletionExtension { + stop_reason: Some(StopReason::MaxTokens), // gap 3 + tokens: Some(TokenUsage { + input_tokens: 120, + output_tokens: 30, + total_tokens: 150, + }), + model: Some("claude-opus-4-8".into()), + latency_ms: Some(842), + ..Default::default() + }; + + let delegation = DelegationExtension { + delegated: true, + depth: 1, + origin_subject_id: Some("alice@corp.com".into()), + actor_subject_id: Some("agent-7".into()), + chain: vec![DelegationHop { + subject_id: "agent-7".into(), + audience: Some("workday-api".into()), + scopes_granted: vec!["read_compensation".into()], + ttl_seconds: Some(300), + ..Default::default() + }], + ..Default::default() + }; + + let mcp = MCPExtension { + tool: Some(ToolMetadata { + name: "get_compensation".into(), + server_id: Some("hr-mcp".into()), + namespace: Some("hr".into()), + ..Default::default() + }), + ..Default::default() + }; + + let framework = FrameworkExtension { + framework: Some("langgraph".into()), + node_id: Some("node-compensation".into()), + graph_id: Some("graph-hr".into()), + ..Default::default() + }; + + Extensions { + security: Some(Arc::new(sec)), + agent: Some(Arc::new(agent)), + completion: Some(Arc::new(completion)), + delegation: Some(Arc::new(delegation)), + mcp: Some(Arc::new(mcp)), + framework: Some(Arc::new(framework)), + ..Default::default() + } + } + + #[test] + fn gap_fields_land_in_unmapped() { + let e = OcsfAuditEmitter::new(cfg(json!({ "chain": false }))).unwrap(); + let ev = e.build(&tool_payload(), &full_ext(), "2026-06-30T12:00:00.000Z"); + + let un = &ev["unmapped"]; + assert_eq!(un["cmf.completion.stop_reason"], "MaxTokens"); + assert_eq!(un["cmf.framework"]["framework"], "langgraph"); + assert_eq!(un["cmf.framework"]["graph_id"], "graph-hr"); + assert_eq!(un["cmf.mcp"]["tool"]["server_id"], "hr-mcp"); + assert_eq!( + un["cmf.workload_identity"]["spiffe_id"], + "spiffe://corp/agent/hr-bot" + ); + assert_eq!( + un["cmf.workload_identity"]["attestor"], + "gke-workload-identity" + ); + // monotonic labels — order-independent membership check + let labels = un["cmf.security.labels"].as_array().expect("labels array"); + assert!(labels.iter().any(|v| v == "PII")); + assert!(labels.iter().any(|v| v == "secret")); + } + + #[test] + fn mapped_objects_populate_from_extensions() { + let e = OcsfAuditEmitter::new(cfg(json!({ "chain": false }))).unwrap(); + let ev = e.build(&tool_payload(), &full_ext(), "2026-06-30T12:00:00.000Z"); + + // ai_agent + lineage (PR #1641) + assert_eq!(ev["ai_agent"]["uid"], "agent-7"); + assert_eq!(ev["ai_agent"]["parent_uid"], "orchestrator-1"); + // Review C1: correlation_uid mirrors the run id + // (AgentExtension.conversation_id) so every event of one run + // carries the same value — a per-event id correlates nothing. + // It lives on `metadata`, which is where OCSF defines it. + assert_eq!(ev["metadata"]["correlation_uid"], "conv-9"); + assert!(ev.get("correlation_uid").is_none()); + assert_eq!(ev["api"]["request"]["uid"], "call-1"); + // message_context tokens (merged) + assert_eq!(ev["message_context"]["total_tokens"], 150); + assert_eq!(ev["ai_model"]["name"], "claude-opus-4-8"); + assert_eq!(ev["duration"], 842); + // delegation object (upcoming/Ania) + assert_eq!(ev["delegation"]["depth"], 1); + assert_eq!(ev["delegation"]["chain"][0]["audience"], "workday-api"); + assert_eq!( + ev["delegation"]["chain"][0]["scopes_granted"][0], + "read_compensation" + ); + } + + /// Review C2: HashSet/MonotonicSet iteration order is randomized per + /// instance, so the builder must sort set-derived arrays — otherwise + /// the same logical event canonicalizes to different bytes across + /// process runs and an independent verifier can't recompute + /// the fingerprint. + #[test] + fn set_derived_arrays_are_sorted_for_canonical_hashing() { + let mut sec = SecurityExtension::default(); + let mut subj = SubjectExtension::default(); + subj.id = Some("alice@corp.com".into()); + for r in ["zeta", "alpha", "mid"] { + subj.roles.insert(r.into()); + } + for t in ["t2", "t1", "t3"] { + subj.teams.insert(t.into()); + } + sec.subject = Some(subj); + for l in ["secret", "PII", "internal", "export-controlled"] { + sec.labels.insert(l.into()); + } + let ext = Extensions { + security: Some(Arc::new(sec)), + ..Default::default() + }; + + let e = OcsfAuditEmitter::new(cfg(json!({ "chain": true }))).unwrap(); + let ev = e.build(&tool_payload(), &ext, "2026-07-06T12:00:00.000Z"); + + assert_eq!( + ev["unmapped"]["cmf.security.labels"], + json!(["PII", "export-controlled", "internal", "secret"]) + ); + assert_eq!(ev["actor"]["roles"], json!(["alpha", "mid", "zeta"])); + assert_eq!(ev["actor"]["user"]["groups"], json!(["t1", "t2", "t3"])); + } + + /// Structural OCSF conformance — NOT full schema validation (that needs + /// the published schema + a validator; see README). Asserts the base + /// event has the required, correctly-typed fields every OCSF consumer + /// relies on to route a record. + #[test] + fn emits_required_ocsf_base_fields() { + let e = OcsfAuditEmitter::new(cfg(json!({ "chain": false }))).unwrap(); + let ev = e.build(&tool_payload(), &full_ext(), "2026-06-30T12:00:00.000Z"); + + for key in [ + "activity_id", + "category_uid", + "class_uid", + "type_uid", + "severity_id", + ] { + assert!(ev[key].is_u64(), "{key} must be an integer"); + } + assert!(ev["time"].is_string(), "time must be present"); + assert!(ev["metadata"]["version"].is_string()); + assert!(ev["metadata"]["product"]["name"].is_string()); + // type_uid convention: class_uid * 100 + activity_id + assert_eq!( + ev["type_uid"].as_u64().unwrap(), + ev["class_uid"].as_u64().unwrap() * 100 + ev["activity_id"].as_u64().unwrap() + ); + } +} diff --git a/builtins/plugins/ocsf-audit/src/factory.rs b/builtins/plugins/ocsf-audit/src/factory.rs new file mode 100644 index 00000000..0b4fa6b2 --- /dev/null +++ b/builtins/plugins/ocsf-audit/src/factory.rs @@ -0,0 +1,63 @@ +// Location: ./builtins/plugins/ocsf-audit/src/factory.rs +// Copyright 2026 AI Identity +// SPDX-License-Identifier: Apache-2.0 +// Authors: Jeff Leva +// +// Factory — registers the emitter under every CMF hook the operator +// lists in `hooks:`. Structurally identical to the upstream +// audit-logger factory (TypedHandlerAdapter per hook name). + +use std::sync::Arc; + +use cpex_core::{ + cmf::CmfHook, + error::PluginError, + factory::{PluginFactory, PluginInstance}, + hooks::TypedHandlerAdapter, + plugin::PluginConfig, +}; + +use crate::emitter::OcsfAuditEmitter; + +/// `kind:` string operators write in CPEX YAML to declare an OCSF +/// audit emitter instance. +pub const KIND: &str = "audit/ocsf"; + +pub struct OcsfAuditFactory; + +impl PluginFactory for OcsfAuditFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let emitter = Arc::new(OcsfAuditEmitter::new(config.clone())?); + + if config.hooks.is_empty() { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}' (cpex-plugin-ocsf-audit): `hooks:` must list at least \ + one CMF hook to emit on. For audit, prefer the POST hooks: \ + cmf.tool_post_invoke, cmf.llm_output, cmf.resource_post_fetch, \ + cmf.prompt_post_invoke. (NOT cmf.prompt_post_fetch — that name \ + exists in hooks/types.rs but the Rust CMF/APL runtime dispatches \ + the cmf/constants.rs name, cmf.prompt_post_invoke; a handler on \ + the _fetch name silently never fires.)", + config.name + ), + })); + } + + let handlers: Vec<_> = config + .hooks + .iter() + .map(|h| -> (&'static str, _) { + let leaked: &'static str = Box::leak(h.clone().into_boxed_str()); + let adapter: Arc = + Arc::new(TypedHandlerAdapter::::new(Arc::clone(&emitter))); + (leaked, adapter) + }) + .collect(); + + Ok(PluginInstance { + plugin: emitter, + handlers, + }) + } +} diff --git a/builtins/plugins/ocsf-audit/src/lib.rs b/builtins/plugins/ocsf-audit/src/lib.rs new file mode 100644 index 00000000..4efd3487 --- /dev/null +++ b/builtins/plugins/ocsf-audit/src/lib.rs @@ -0,0 +1,77 @@ +// Location: ./builtins/plugins/ocsf-audit/src/lib.rs +// Copyright 2026 AI Identity +// SPDX-License-Identifier: Apache-2.0 +// Authors: Jeff Leva +// +// cpex-plugin-ocsf-audit — CMF plugin that emits one OCSF AI Operation +// event per dispatched request, off the CPEX `run(audit-log)` seam. +// +// It is a near-twin of the upstream `audit-logger` builtin (same +// observation-only, always-allow contract, same factory + hook wiring). +// The difference is the record shape: instead of a free-form JSON line, +// it serializes the CMF `Message` + `Extensions` into an OCSF event, +// following docs/cosai-ws4-ocsf-mapping/CMF-OCSF-FIELD-MAP.md, then +// (optionally) attaches a tamper-evident attestation chain +// (fingerprint → prev_event.fingerprint) and signs it. +// +// Why this exists: it makes CPEX's enforcement record interoperable +// (OCSF) and independently verifiable (signed attestation chain), +// without CPEX having to own a schema. CPEX produces the event; this +// plugin makes it portable and verifiable offline. +// +// CMF = ContextForge Message Format (per cpex-core/src/cmf/mod.rs). +// +// Status: builds green against cpex@feat/hil_apl `ad666ba` (cargo build +// + cargo test; Teryl's review baseline, 2026-07-06). The Extension +// field reads and ContentPart variant shapes are confirmed against that +// commit. Review corrections applied 2026-07-06 (see +// docs/cosai-ws4-ocsf-mapping/cmf-ocsf-mapping-review.md): prompt hooks +// register on cmf.prompt_*_invoke (C6 — the _fetch names silently never +// fire), correlation_uid mirrors the run id (C1), and events are +// JCS-style canonically serialized so the fingerprint chain verifies +// independently (C2 caveat). +// +// Revision 2026-07-20 (P0 + review §4-B, per the production-readiness +// plan agreed 2026-07-17/18): host class is now API Activity (6003) with +// its real activity enum (CRUD via readOnlyHint, else 99 + source name); +// metadata.profiles declares ai_operation + security_control (+ +// record_integrity when chained) and the passive stream carries +// action_id 3 (Observed) / disposition_id 17 (Logged); and the hash +// commits to the record's chain position — predecessor binding, not a +// back-pointer. Remaining by design: deny/modify records (action_id +// 2/4) wait on the cpex-core decision event (WS-A / P1). +// +// Revision 2026-07-31 — MERGED #1661 SHAPE. PR #1661 merged upstream +// 2026-07-17 (`2a244bc9`), and the emitted attestation now matches it: +// `attestation_list[]` carrying `fingerprint` / `prev_event` / +// `signatures` objects, replacing the draft `attestation` member with +// string `entry_hash` / `prev_entry_hash` / singular `signature`. The +// fingerprint is computed per the merged semantics — over the whole +// event including the attestation's own uid/chain_uid/prev_event and +// excluding only fingerprint/signatures — so a verifier following the +// schema can reproduce it without knowing anything about this crate. +// `metadata.uid` is now emitted (prev_event references point at it) and +// `correlation_uid` moved to `metadata`, which is where OCSF defines +// it. Signature bytes ride in `unmapped.signature_b64` pending +// ocsf-schema#1709. +// +// Revision 2026-07-31 (same day, later) — SIGNER WIRED + authority_uid. +// `sign::DsseSigner` is real: ECDSA-P256-SHA256 over the DSSE PAE of +// the fingerprint's canonical bytes (RFC 6979 deterministic), key +// operator-provided as PKCS#8 PEM, loud config failure when missing. +// `attestation.authority_uid` (recommended in the merged schema) names +// the party the signing credential belongs to and sits INSIDE the +// hashed bytes. Verifier rule is running code: `sign::signing_input` + +// `sign::dsse_pae`. Key custody (HSM/KMS, rotation epochs, JWKS +// publication) is deliberately out of plugin scope — it belongs to the +// operating authority. + +pub mod config; +pub mod emitter; +pub mod factory; +pub mod ocsf; +pub mod sign; + +pub use config::{OcsfAuditConfig, OcsfDestination, SigningMode}; +pub use emitter::OcsfAuditEmitter; +pub use factory::{OcsfAuditFactory, KIND}; diff --git a/builtins/plugins/ocsf-audit/src/ocsf.rs b/builtins/plugins/ocsf-audit/src/ocsf.rs new file mode 100644 index 00000000..0018307b --- /dev/null +++ b/builtins/plugins/ocsf-audit/src/ocsf.rs @@ -0,0 +1,454 @@ +// Location: ./builtins/plugins/ocsf-audit/src/ocsf.rs +// Copyright 2026 AI Identity +// SPDX-License-Identifier: Apache-2.0 +// Authors: Jeff Leva +// +// CMF -> OCSF mapping. This is the running-code form of +// docs/cosai-ws4-ocsf-mapping/CMF-OCSF-FIELD-MAP.md. Each block below +// cites the field-map row it implements. +// +// Design choices: +// * We build a serde_json::Value rather than hand-rolling fully +// typed OCSF structs — OCSF object shapes still move release to +// release, and a Value keeps the scaffold honest about what's +// proposed vs merged. +// * Fields with no native OCSF home yet (the five gaps) go under +// `unmapped` when cfg.include_gap_fields is set. That is correct +// OCSF practice AND it makes the gaps self-documenting in the +// emitted evidence. +// +// OCSF MODELING NOTE: `ai_operation` is a PROFILE, not a class. PR #1641 +// (merged 2026-06-29, "Add ai_agent object and extend ai_operation profile +// coverage") makes it contribute `ai_agent` / `ai_model` / `message_context` +// to existing base classes — all in the Application category (6). The host +// class is **API Activity (6003)** — agreed with the CPEX team 2026-07-17/18 +// (matches AOS's host-class choice and AI Identity's production gateway). +// Activity ids follow API Activity's real enum (CRUD + 99 Other), NOT a +// bespoke enum: per the OCSF enum contract, a known id carries the +// normalized caption as activity_name; source-defined names ride with 99. + +use serde_json::{json, Map, Value}; + +use cpex_core::cmf::{ContentPart, MessagePayload}; +use cpex_core::hooks::payload::Extensions; + +use crate::config::OcsfAuditConfig; + +// --- OCSF identifiers --- +const SCHEMA_VERSION: &str = "1.9.0-dev"; +const CATEGORY_UID_APPLICATION: u32 = 6; +/// API Activity — the concrete Application-category class hosting the +/// ai_operation profile (P0 decision, 2026-07-18 thread). +const CLASS_UID_API_ACTIVITY: u32 = 6003; +const SEVERITY_INFORMATIONAL: u32 = 1; + +/// OCSF activity on API Activity (6003): 0 Unknown · 1 Create · 2 Read · +/// 3 Update · 4 Delete · 99 Other. +/// +/// Mapping convention (2026-07-18 thread): +/// * Read Resource / Invoke Prompt -> 2 (Read) +/// * Invoke Tool with readOnlyHint: true -> 2 (Read) +/// * Invoke Tool otherwise -> 99 + activity_name "Invoke Tool" +/// (we can't honestly claim Create/Update/Delete without knowing the +/// operation; destructiveHint stays context, not a Delete mapping) +/// * Completion -> 99 + activity_name "Completion" +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Activity { + Unknown, + Read, + /// 99 (Other) with a source-defined activity_name, per the OCSF + /// enum contract. + Other(&'static str), +} + +impl Activity { + fn id(self) -> u32 { + match self { + Activity::Unknown => 0, + Activity::Read => 2, + Activity::Other(_) => 99, + } + } + fn name(self) -> &'static str { + match self { + Activity::Unknown => "Unknown", + // Known id -> normalized enum caption, never a source-defined + // string (the AOS pin-1-vary-name practice violates this). + Activity::Read => "Read", + Activity::Other(n) => n, + } + } +} + +/// True when the MCP tool metadata for this invocation carries +/// `readOnlyHint: true`. When the content part names the tool, the hint +/// only applies if the MCP slot describes that same tool. +fn tool_read_only_hint(ext: &Extensions, call_name: Option<&str>) -> bool { + let Some(mcp) = ext.mcp.as_ref() else { + return false; + }; + let Some(tool) = mcp.tool.as_ref() else { + return false; + }; + if let Some(name) = call_name { + if tool.name != name { + return false; + } + } + matches!( + tool.annotations.get("readOnlyHint"), + Some(Value::Bool(true)) + ) +} + +/// Infer the OCSF activity from the message content parts (the typed +/// handler does not receive the hook name, so we classify from content — +/// which is more robust anyway) plus the MCP tool annotations. +pub fn activity_of(payload: &MessagePayload, ext: &Extensions) -> Activity { + for part in &payload.message.content { + match part { + // ContentPart variant shapes confirmed against cpex@feat/hil_apl ad666ba (2026-07-06). + ContentPart::ToolCall { content } => { + return if tool_read_only_hint(ext, Some(&content.name)) { + Activity::Read + } else { + Activity::Other("Invoke Tool") + } + }, + ContentPart::ToolResult { .. } => { + // Result side of the same invocation; the result part + // carries no tool name, so the MCP slot speaks for it. + return if tool_read_only_hint(ext, None) { + Activity::Read + } else { + Activity::Other("Invoke Tool") + }; + }, + ContentPart::PromptRequest { .. } + | ContentPart::PromptResult { .. } + | ContentPart::Resource { .. } + | ContentPart::ResourceRef { .. } => return Activity::Read, + _ => {}, + } + } + // Plain assistant text / thinking with completion metadata = LLM output. + if payload + .message + .content + .iter() + .any(|p| matches!(p, ContentPart::Text { .. } | ContentPart::Thinking { .. })) + { + return Activity::Other("Completion"); + } + Activity::Unknown +} + +/// Build the OCSF AI Operation event (the inner event, pre-attestation). +/// `now_rfc3339` is injected so the caller controls the clock (testable). +pub fn build_ai_operation( + payload: &MessagePayload, + ext: &Extensions, + cfg: &OcsfAuditConfig, + now_rfc3339: &str, +) -> Value { + let activity = activity_of(payload, ext); + + let mut ev = Map::new(); + + // --- base event --------------------------------------------------- + ev.insert("activity_id".into(), json!(activity.id())); + ev.insert("activity_name".into(), json!(activity.name())); + ev.insert("category_uid".into(), json!(CATEGORY_UID_APPLICATION)); + ev.insert("class_uid".into(), json!(CLASS_UID_API_ACTIVITY)); + ev.insert( + "type_uid".into(), + json!(CLASS_UID_API_ACTIVITY * 100 + activity.id()), + ); + ev.insert("severity_id".into(), json!(SEVERITY_INFORMATIONAL)); + ev.insert("time".into(), json!(now_rfc3339)); + + // security_control profile: this passive post-hook stream is + // action_id 3 (Observed) / disposition_id 17 (Logged). The deny and + // modify mappings (action_id 2 / 4) arrive with the cpex-core + // decision event (WS-A / P1) — the plugin structurally cannot see a + // denial from a post hook. + ev.insert("action_id".into(), json!(3)); + ev.insert("action".into(), json!("Observed")); + ev.insert("disposition_id".into(), json!(17)); + ev.insert("disposition".into(), json!("Logged")); + + // metadata + product (field map: `meta`/`request` -> base metadata) + let mut profiles = vec!["ai_operation", "security_control"]; + if cfg.chain { + // `attestation_list` is the record_integrity profile from + // PR #1661, merged 2026-07-17 (`2a244bc9`), shipping in 1.9. + profiles.push("record_integrity"); + } + let mut metadata = json!({ + "version": SCHEMA_VERSION, + "profiles": profiles, + "product": { "name": cfg.product_name, "vendor_name": cfg.vendor_name }, + }); + + // correlation (field map: AgentExtension.conversation_id -> + // metadata.correlation_uid). Review C1: the correlation key must be + // stable across every event of one run — conversation_id IS the run. + // Per-event ids (request_id, tool_call_id) correlate nothing; + // tool_call_id rides at api.request.uid instead (see + // attach_capability_coords). + // + // Placement (2026-07-31): `correlation_uid` is an attribute of + // `metadata`, not of base_event — it was previously emitted at the + // event root, where no OCSF consumer would look for it. + if let Some(cid) = correlation_uid(ext) { + metadata["correlation_uid"] = json!(cid); + } + ev.insert("metadata".into(), metadata); + + // status (field map: ToolResult.is_error -> status) + if let Some(is_err) = first_tool_error(payload) { + ev.insert("status_id".into(), json!(if is_err { 2 } else { 1 })); // 1=Success 2=Failure + } + + // --- actor / user (field map: SecurityExtension.SubjectExtension) - + if let Some(sec) = ext.security.as_ref() { + if let Some(s) = &sec.subject { + // roles/teams are HashSets — sort so the emitted event is + // canonical and the fingerprint is reproducible (review C2). + let mut groups: Vec<&String> = s.teams.iter().collect(); + groups.sort_unstable(); + let mut roles: Vec<&String> = s.roles.iter().collect(); + roles.sort_unstable(); + ev.insert( + "actor".into(), + json!({ + "user": { + "uid": s.id, + "groups": groups, + }, + // roles/permissions ride along as enrichment. + "roles": roles, + }), + ); + } + } + + // --- ai_agent (field map: AgentExtension; PR #1641) --------------- + if let Some(ag) = ext.agent.as_ref() { + ev.insert( + "ai_agent".into(), + json!({ + "uid": ag.agent_id, + "instance_uid": ag.session_id, + // multi-agent lineage + "parent_uid": ag.parent_agent_id, + "conversation_uid": ag.conversation_id, + "turn": ag.turn, + }), + ); + } + + // --- ai_model + message_context (field map: LLMExtension / + // CompletionExtension; mostly merged) ------------------------- + if let Some(comp) = ext.completion.as_ref() { + let mut mctx = Map::new(); + if let Some(tok) = &comp.tokens { + mctx.insert("prompt_tokens".into(), json!(tok.input_tokens)); + mctx.insert("completion_tokens".into(), json!(tok.output_tokens)); + mctx.insert("total_tokens".into(), json!(tok.total_tokens)); + } + if !mctx.is_empty() { + ev.insert("message_context".into(), Value::Object(mctx)); + } + if let Some(model) = &comp.model { + ev.insert("ai_model".into(), json!({ "name": model })); + } + if let Some(ms) = comp.latency_ms { + ev.insert("duration".into(), json!(ms)); // base `duration` (ms) + } + } + + // --- delegation (field map: DelegationExtension; upcoming/Ania) --- + if let Some(del) = ext.delegation.as_ref() { + if del.delegated || !del.chain.is_empty() { + let chain: Vec = del + .chain + .iter() + .map(|hop| { + json!({ + "subject_uid": hop.subject_id, + "audience": hop.audience, + "scopes_granted": hop.scopes_granted, + "ttl_seconds": hop.ttl_seconds, + "timestamp": hop.timestamp.to_rfc3339(), + }) + }) + .collect(); + ev.insert( + "delegation".into(), + json!({ + "depth": del.depth, + "origin_subject_uid": del.origin_subject_id, + "actor_subject_uid": del.actor_subject_id, + "chain": chain, + }), + ); + } + } + + // --- tool/prompt/resource coordinates from content ---------------- + attach_capability_coords(&mut ev, payload); + + // --- the five gaps -> unmapped (field map §5) --------------------- + if cfg.include_gap_fields { + let unmapped = build_unmapped_gaps(payload, ext); + if let Value::Object(m) = &unmapped { + if !m.is_empty() { + ev.insert("unmapped".into(), unmapped); + } + } + } + + Value::Object(ev) +} + +/// Gap fields with no native OCSF home yet. Emitting them under +/// `unmapped` keeps the evidence complete and documents the gaps. +fn build_unmapped_gaps(payload: &MessagePayload, ext: &Extensions) -> Value { + let mut g = Map::new(); + + // gap 3: completion.stop_reason + if let Some(comp) = ext.completion.as_ref() { + if let Some(sr) = &comp.stop_reason { + g.insert( + "cmf.completion.stop_reason".into(), + json!(format!("{sr:?}")), + ); + } + } + + // gap 1: mcp tool/resource/prompt metadata + if let Some(mcp) = ext.mcp.as_ref() { + // MCPExtension = { tool, resource, prompt } (confirmed cpex@feat/hil_apl ad666ba (2026-07-06)). + // Serialized whole; each sub-object carries server_id/namespace/schemas. + g.insert("cmf.mcp".into(), json!(mcp)); + } + + // gap 2: framework context + if let Some(fw) = ext.framework.as_ref() { + g.insert( + "cmf.framework".into(), + json!({ + "framework": fw.framework, + "framework_version": fw.framework_version, + "node_id": fw.node_id, + "graph_id": fw.graph_id, + }), + ); + } + + // gap 4: monotonic security labels (taint set) + if let Some(sec) = ext.security.as_ref() { + // SecurityExtension.labels: MonotonicSet (add-only taint), + // iterated via .iter() (confirmed cpex@feat/hil_apl ad666ba (2026-07-06)). + let labels = security_labels(sec); + if !labels.is_empty() { + g.insert("cmf.security.labels".into(), json!(labels)); + } + } + + // gap 5: workload attestation (SPIFFE) — partial OCSF home + if let Some(wl) = caller_workload(ext) { + g.insert("cmf.workload_identity".into(), wl); + } + + // multimodal content kinds present (lightweight provenance of shape) + let _ = payload; + + Value::Object(g) +} + +// --------------------------------------------------------------------- +// Helpers — small, content-shape-dependent extractors. CMF accessor and +// variant shapes confirmed against cpex@feat/hil_apl ad666ba (2026-07-06). +// --------------------------------------------------------------------- + +fn correlation_uid(ext: &Extensions) -> Option { + // Review C1: correlation_uid must be multi-event-stable, so it + // mirrors the run id (AgentExtension.conversation_id) — NOT + // request_id or tool_call_id, which are per-event unique and + // correlate nothing. Session-grain grouping stays a join on + // ai_agent.instance_uid (session_id); the run is the primary + // forensic grain a SIEM keys on. + ext.agent.as_ref()?.conversation_id.clone() +} + +fn first_tool_error(payload: &MessagePayload) -> Option { + for part in &payload.message.content { + if let ContentPart::ToolResult { content } = part { + return Some(content.is_error); + } + } + None +} + +fn attach_capability_coords(ev: &mut Map, payload: &MessagePayload) { + for part in &payload.message.content { + match part { + ContentPart::ToolCall { content } => { + ev.insert( + "tool".into(), + json!({ + "name": content.name, + "uid": content.tool_call_id, + "namespace": content.namespace, + }), + ); + // Review C1: the per-call id's home is api.request.uid + // (one request = one tool call), not correlation_uid. + ev.insert( + "api".into(), + json!({ "request": { "uid": content.tool_call_id } }), + ); + return; + }, + ContentPart::Resource { content } => { + ev.insert( + "resource".into(), + json!({ "uri": content.uri, "type": format!("{:?}", content.resource_type) }), + ); + return; + }, + _ => {}, + } + } +} + +// The following two isolate the less-obvious accessor paths to one place +// each (both confirmed against cpex@feat/hil_apl ad666ba (2026-07-06)). + +fn security_labels(sec: &cpex_core::extensions::SecurityExtension) -> Vec { + // MonotonicSet::iter() -> impl Iterator. + // The backing HashSet iterates in randomized, seed-dependent order; + // sort so the emitted array is canonical and the fingerprint an + // independent verifier recomputes matches ours (review C2). + let mut labels: Vec = sec.labels.iter().cloned().collect(); + labels.sort_unstable(); + labels +} + +fn caller_workload(ext: &Extensions) -> Option { + // Confirmed cpex@feat/hil_apl ad666ba (2026-07-06): the resolved inbound workload identity + // is reachable at Extensions.security.caller_workload (the executor + // applies IdentityPayload.caller_workload onto the security ext). + // `this_workload` (the gateway's OWN attested id) is the signer + // identity and is handled in sign.rs, not here. + let sec = ext.security.as_ref()?; + let wl = sec.caller_workload.as_ref()?; + Some(json!({ + "spiffe_id": wl.spiffe_id, + "trust_domain": wl.trust_domain, + "attestor": wl.attestor, // e.g. gke-workload-identity, spire-agent, mtls + "attested_at": wl.attested_at, // for stale-evidence rejection + })) +} diff --git a/builtins/plugins/ocsf-audit/src/sign.rs b/builtins/plugins/ocsf-audit/src/sign.rs new file mode 100644 index 00000000..c77615b0 --- /dev/null +++ b/builtins/plugins/ocsf-audit/src/sign.rs @@ -0,0 +1,412 @@ +// Location: ./builtins/plugins/ocsf-audit/src/sign.rs +// Copyright 2026 AI Identity +// SPDX-License-Identifier: Apache-2.0 +// Authors: Jeff Leva +// +// Signing seam for the attestation. The hash chain (fingerprint / +// prev_event) works with no signer at all; a signer adds the +// `signatures` entry that makes the record verifiable against an +// identity, offline. +// +// OCSF #1662 merged `digital_signature.serialization_id`, which is how +// a record declares its signing envelope. Enum ids verified against +// ocsf-schema main 2026-07-31: 1 Flat · 2 JCS · 3 JWS · 4 COSE · +// 5 DSSE; and `digital_signature.algorithm_id` 3 = ECDSA (a different +// enum than `fingerprint.algorithm_id`, where 3 = SHA-256 — same +// number, different meaning; don't conflate them). +// +// We emit DSSE (serialization_id 5): the signature is ECDSA-P256-SHA256 +// over the DSSE PAE of the event's canonical bytes — the SAME bytes the +// fingerprint covers (event with the attestation's uid / chain_uid / +// authority_uid / prev_event present, fingerprint / signatures absent). +// So the signature commits to the record's chain position, and a +// verifier needs exactly three things: the emitted JSON, the public +// key, and this file's documented PAE rule. +// +// Shape note (2026-07-31): this targets the MERGED #1661 shape — +// `attestation_list[]` carrying `fingerprint` / `prev_event` / +// `signatures` objects. The pre-merge draft shape (string `entry_hash`, +// `prev_entry_hash`, singular `signature`) is gone from the schema. + +use base64::Engine; +use p256::ecdsa::signature::Signer; +use p256::ecdsa::SigningKey; +use p256::pkcs8::DecodePrivateKey; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +/// JCS-style (RFC 8785) canonical serialization of an event. +/// +/// This is what the fingerprint and the signer consume, so an independent +/// verifier can recompute the hash from the emitted JSON without +/// depending on our serializer's internals (review C2). Guarantees: +/// * object keys sorted, compact output (no insignificant whitespace) +/// — explicitly, not via serde_json's default BTreeMap-backed Map +/// (a downstream workspace enabling serde_json's `preserve_order` +/// feature would silently switch that to insertion order); +/// * arrays serialized in the order given. Array order is semantic +/// in JSON (delegation chain, profiles), so the canonicalizer must +/// NOT sort them — instead, set-derived arrays (security labels, +/// roles, teams — HashSet/MonotonicSet, randomized iteration) are +/// sorted at build time in ocsf.rs, making the emitted event itself +/// canonical. +/// +/// Caveats vs full RFC 8785: keys are sorted by Rust byte order, which +/// equals the mandated UTF-16 code-unit order for the ASCII key names +/// we emit; all numbers we emit are integers, where serde_json's +/// formatting matches the mandated ES6 form. Revisit both if non-ASCII +/// keys or floats ever enter the event shape. +pub fn canonical_bytes(v: &Value) -> Vec { + let mut out = Vec::new(); + write_canonical(v, &mut out); + out +} + +fn write_canonical(v: &Value, out: &mut Vec) { + match v { + Value::Object(m) => { + out.push(b'{'); + let mut keys: Vec<&String> = m.keys().collect(); + keys.sort_unstable(); + for (i, k) in keys.iter().enumerate() { + if i > 0 { + out.push(b','); + } + // We emit `fingerprint.serialization_id = 2` (JCS), which + // tells a verifier to reproduce these bytes with a real + // RFC 8785 library. That claim holds only for the value + // space documented on `canonical_bytes` — ASCII keys + // (byte order == UTF-16 code-unit order) and integer + // numbers. Assert it rather than leave it a convention, + // so the claim cannot go quietly false if the event + // shape grows a non-ASCII key. + debug_assert!( + k.is_ascii(), + "non-ASCII key {k:?} breaks the JCS (serialization_id 2) claim — \ + sort by UTF-16 code units or drop to 99/Other" + ); + // serde_json string form = RFC 8785 escaping (two-char + // escapes for control chars where defined, \u00XX else). + out.extend_from_slice( + serde_json::to_string(k.as_str()) + .expect("string") + .as_bytes(), + ); + out.push(b':'); + write_canonical(&m[k.as_str()], out); + } + out.push(b'}'); + }, + Value::Array(a) => { + out.push(b'['); + for (i, el) in a.iter().enumerate() { + if i > 0 { + out.push(b','); + } + write_canonical(el, out); + } + out.push(b']'); + }, + // Same reasoning as the key assertion above: serde_json's integer + // formatting matches RFC 8785, its float formatting does not. + Value::Number(n) => { + debug_assert!( + n.is_i64() || n.is_u64(), + "non-integer number {n} breaks the JCS (serialization_id 2) claim — \ + RFC 8785 mandates the ES6 float form" + ); + out.extend_from_slice(serde_json::to_string(n).expect("number").as_bytes()); + }, + leaf => out.extend_from_slice(serde_json::to_string(leaf).expect("leaf").as_bytes()), + } +} + +/// Compute the fingerprint value over canonical bytes (callers obtain +/// them via `canonical_bytes`). +/// +/// Returns bare lowercase hex — no `sha256:` prefix. The algorithm is +/// declared by the sibling `fingerprint.algorithm_id` (3 = SHA-256) and +/// the representation by `fingerprint.encoding_id` (1 = Hex), so a +/// prefix inside `value` would both duplicate that and break a verifier +/// decoding `value` per `encoding_id`. +/// +/// Per the merged `attestation` semantics, the emitter passes the +/// canonical bytes of the WHOLE EVENT with its `attestation_list[0]` +/// present and carrying `uid` / `chain_uid` / `authority_uid` / +/// `prev_event`, but with `fingerprint` and `signatures` absent. So the +/// record's position in its chain is inside the hashed input (a spliced +/// or reordered record changes its own fingerprint), and the signer +/// consumes the same bytes. +pub fn fingerprint_value(canonical_bytes: &[u8]) -> String { + let mut h = Sha256::new(); + h.update(canonical_bytes); + format!("{:x}", h.finalize()) +} + +/// Reconstruct, from an EMITTED event, the exact bytes its fingerprint +/// and signature were computed over. This is the verifier rule as +/// running code: +/// +/// 1. remove `attestation_list[0].fingerprint` and `.signatures` +/// (the schema's own exclusions); +/// 2. remove `unmapped.signature_b64` / `unmapped.signature_key_id` — +/// the signature bytes have no `digital_signature` home until +/// ocsf-schema#1709 lands, so they ride in `unmapped`, and being +/// derived FROM the hash they cannot be inside it (dropping +/// `unmapped` entirely if that leaves it empty, since an empty +/// object was never emitted pre-signing); +/// 3. canonicalize (JCS). +/// +/// Then `fingerprint_value(bytes)` must equal the attestation's +/// `fingerprint.value`, and the DSSE signature verifies over +/// `dsse_pae(bytes)`. +pub fn signing_input(event: &Value) -> Vec { + let mut ev = event.clone(); + if let Some(att) = ev + .get_mut("attestation_list") + .and_then(|l| l.get_mut(0)) + .and_then(Value::as_object_mut) + { + att.remove("fingerprint"); + att.remove("signatures"); + } + let mut drop_unmapped = false; + if let Some(un) = ev.get_mut("unmapped").and_then(Value::as_object_mut) { + un.remove("signature_b64"); + un.remove("signature_key_id"); + drop_unmapped = un.is_empty(); + } + if drop_unmapped { + if let Some(m) = ev.as_object_mut() { + m.remove("unmapped"); + } + } + canonical_bytes(&ev) +} + +/// DSSE payload type for an OCSF event's canonical bytes. +pub const DSSE_PAYLOAD_TYPE: &str = "application/vnd.ocsf.event+json"; + +/// DSSE Pre-Authentication Encoding over `DSSE_PAYLOAD_TYPE`: +/// `"DSSEv1" SP LEN(type) SP type SP LEN(payload) SP payload`. +/// The signature is computed over these bytes, never the raw payload — +/// that's what makes the envelope resistant to cross-protocol reuse of +/// the same key. +pub fn dsse_pae(payload: &[u8]) -> Vec { + let t = DSSE_PAYLOAD_TYPE.as_bytes(); + let mut out = Vec::with_capacity(payload.len() + t.len() + 32); + out.extend_from_slice(b"DSSEv1 "); + out.extend_from_slice(t.len().to_string().as_bytes()); + out.push(b' '); + out.extend_from_slice(t); + out.push(b' '); + out.extend_from_slice(payload.len().to_string().as_bytes()); + out.push(b' '); + out.extend_from_slice(payload); + out +} + +/// Result of signing: the detached signature bytes (base64 DER) and the +/// OCSF `digital_signature` descriptor to embed in +/// `attestation.signatures`. `key_id` (the JWKS `kid`) rides beside the +/// bytes at `unmapped.signature_key_id`, matching the production +/// reference bundle, until #1709 gives both a schema home. +pub struct Signed { + pub signature: String, + pub key_id: Option, + pub digital_signature: serde_json::Value, +} + +pub trait OcsfSigner: Send + Sync { + fn sign(&self, canonical_bytes: &[u8]) -> Option; +} + +/// No-op: hash-chained but unsigned. Default for demo / unprovisioned. +pub struct NoopSigner; + +impl OcsfSigner for NoopSigner { + fn sign(&self, _canonical_bytes: &[u8]) -> Option { + None + } +} + +/// DSSE signer — ECDSA-P256-SHA256 over the PAE of the event's +/// canonical bytes, deterministic per RFC 6979. +/// +/// The key is operator-provided (config: `signing_key_pem` inline or +/// `signing_key_pem_path`) — this crate holds a key handle, not a key +/// service. Custody — HSM/KMS residency, rotation epochs, JWKS +/// publication and its never-unpublish guarantee — is deliberately NOT +/// plugin scope; it belongs to whoever operates the signing authority +/// named by `attestation.authority_uid`. +pub struct DsseSigner { + key: SigningKey, + key_id: Option, +} + +impl DsseSigner { + /// Build from a PKCS#8 PEM (`-----BEGIN PRIVATE KEY-----`). SEC1 + /// (`BEGIN EC PRIVATE KEY`) is deliberately not parsed — convert + /// once with `openssl pkcs8 -topk8 -nocrypt` rather than making the + /// plugin guess at formats. + pub fn from_pem(pem: &str, key_id: Option) -> Result { + let key = SigningKey::from_pkcs8_pem(pem).map_err(|e| { + format!( + "signing=dsse requires a PKCS#8 P-256 private key PEM \ + (-----BEGIN PRIVATE KEY-----); parse failed: {e}. \ + A SEC1 'BEGIN EC PRIVATE KEY' file converts with: \ + openssl pkcs8 -topk8 -nocrypt -in key.pem" + ) + })?; + Ok(Self { key, key_id }) + } + + /// The corresponding public key — what an operator publishes (JWKS) + /// and a verifier fetches. Exposed for tests, the example, and + /// downstream verification tooling. + pub fn verifying_key(&self) -> p256::ecdsa::VerifyingKey { + *self.key.verifying_key() + } +} + +impl OcsfSigner for DsseSigner { + fn sign(&self, canonical_bytes: &[u8]) -> Option { + let sig: p256::ecdsa::Signature = self.key.sign(&dsse_pae(canonical_bytes)); + Some(Signed { + signature: base64::engine::general_purpose::STANDARD.encode(sig.to_der().as_bytes()), + key_id: self.key_id.clone(), + // Descriptor only — enum ids verified against ocsf-schema + // main 2026-07-31. `algorithm` / `serialization` carry the + // normalized captions (the production bundle's + // "ECDSA-P256-SHA256" string predates this and is not the + // schema caption; curve + hash resolve via the JWKS kid). + digital_signature: json!({ + "algorithm_id": 3, + "algorithm": "ECDSA", + "serialization_id": 5, + "serialization": "DSSE", + }), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use p256::ecdsa::signature::Verifier; + use serde_json::json; + + #[test] + fn canonical_form_is_sorted_and_compact() { + // Build with unsorted key insertion; canonical output must be + // key-sorted, compact, and nest-stable. + let v = json!({ + "z": [3, 1, 2], // array order preserved + "a": { "y": "b", "x": true }, // nested keys sorted + "m": null, + }); + assert_eq!( + String::from_utf8(canonical_bytes(&v)).unwrap(), + r#"{"a":{"x":true,"y":"b"},"m":null,"z":[3,1,2]}"# + ); + } + + /// The emitted `fingerprint.serialization_id = 2` (JCS) is a promise + /// that a verifier can reproduce our bytes with an off-the-shelf + /// RFC 8785 implementation. That promise is true for the value space + /// we actually emit — ASCII keys, integer numbers — and this pins + /// the two properties a real 8785 library would exercise: keys in + /// code-unit order, integers in ES6 form. Floats or non-ASCII keys + /// trip the debug assertions in `write_canonical`; if the event + /// shape ever needs them, the honest move is `serialization_id` 99 + /// with `serialization` naming the scheme. + #[test] + fn canonical_form_matches_jcs_for_the_value_space_we_emit() { + let v = json!({ + "b": 10, + "a": -1, + "A": 0, // uppercase sorts before lowercase in both orders + "nested": { "z": [1, 2], "y": "s" }, + }); + assert_eq!( + String::from_utf8(canonical_bytes(&v)).unwrap(), + r#"{"A":0,"a":-1,"b":10,"nested":{"y":"s","z":[1,2]}}"# + ); + } + + #[test] + fn fingerprint_is_reproducible_from_canonical_bytes() { + let v = json!({ "b": 1, "a": ["PII", "secret"] }); + let h1 = fingerprint_value(&canonical_bytes(&v)); + let h2 = fingerprint_value(&canonical_bytes(&v.clone())); + assert_eq!(h1, h2); + // Bare lowercase hex — algorithm/encoding are declared by the + // sibling fingerprint fields, not smuggled into `value`. + assert_eq!(h1.len(), 64); + assert!(h1 + .chars() + .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))); + } + + #[test] + fn dsse_pae_matches_the_spec_form() { + // PAE("application/vnd.ocsf.event+json", "hello"): + // the payload type is 31 bytes, the payload 5. + assert_eq!( + dsse_pae(b"hello"), + b"DSSEv1 31 application/vnd.ocsf.event+json 5 hello".to_vec() + ); + } + + fn test_key() -> SigningKey { + // Fixed scalar (well below the P-256 group order) so signature + // output is deterministic across runs — RFC 6979 makes ECDSA + // deterministic per (key, message). Test/demo key only; never a + // production pattern. + SigningKey::from_slice(&[0x11u8; 32]).expect("valid P-256 scalar") + } + + #[test] + fn dsse_signature_is_deterministic_and_verifies() { + let pem = { + use p256::pkcs8::EncodePrivateKey; + test_key() + .to_pkcs8_pem(p256::pkcs8::LineEnding::LF) + .unwrap() + .to_string() + }; + let signer = DsseSigner::from_pem(&pem, Some("test-key-1".into())).unwrap(); + + let payload = canonical_bytes(&json!({ "class_uid": 6003, "activity_id": 99 })); + let s1 = signer.sign(&payload).unwrap(); + let s2 = signer.sign(&payload).unwrap(); + // RFC 6979: same key + same message -> same signature. + assert_eq!(s1.signature, s2.signature); + assert_eq!(s1.key_id.as_deref(), Some("test-key-1")); + assert_eq!(s1.digital_signature["algorithm_id"], 3); + assert_eq!(s1.digital_signature["serialization_id"], 5); + + // Round-trip verify with nothing but the public key and the + // documented PAE rule. + let der = base64::engine::general_purpose::STANDARD + .decode(&s1.signature) + .unwrap(); + let sig = p256::ecdsa::Signature::from_der(&der).unwrap(); + signer + .verifying_key() + .verify(&dsse_pae(&payload), &sig) + .expect("signature must verify over the PAE bytes"); + } + + #[test] + fn from_pem_rejects_garbage_loudly() { + let err = match DsseSigner::from_pem("not a pem", None) { + Ok(_) => panic!("garbage PEM must not parse"), + Err(e) => e, + }; + assert!( + err.contains("PKCS#8"), + "error must name the expected format: {err}" + ); + } +} diff --git a/crates/cpex-builtins/Cargo.toml b/crates/cpex-builtins/Cargo.toml index 13383363..213b4738 100644 --- a/crates/cpex-builtins/Cargo.toml +++ b/crates/cpex-builtins/Cargo.toml @@ -34,6 +34,7 @@ default = ["pii-scanner", "audit-logger", "identity-jwt", "delegator-oauth", "el # Each feature pulls exactly one builtin crate. pii-scanner = ["dep:cpex-plugin-pii-scanner"] audit-logger = ["dep:cpex-plugin-audit-logger"] +ocsf-audit = ["dep:cpex-plugin-ocsf-audit"] identity-jwt = ["dep:cpex-plugin-identity-jwt"] delegator-oauth = ["dep:cpex-plugin-delegator-oauth"] elicitation-ciba = ["dep:cpex-plugin-elicitation-ciba"] @@ -55,6 +56,7 @@ apl-cpex = { workspace = true } # Builtin extension crates — each behind its feature. cpex-plugin-pii-scanner = { workspace = true, optional = true } cpex-plugin-audit-logger = { workspace = true, optional = true } +cpex-plugin-ocsf-audit = { workspace = true, optional = true } cpex-plugin-identity-jwt = { workspace = true, optional = true } cpex-plugin-delegator-oauth = { workspace = true, optional = true } cpex-plugin-elicitation-ciba = { workspace = true, optional = true } diff --git a/crates/cpex-builtins/src/lib.rs b/crates/cpex-builtins/src/lib.rs index edf999f6..b6755993 100644 --- a/crates/cpex-builtins/src/lib.rs +++ b/crates/cpex-builtins/src/lib.rs @@ -48,6 +48,8 @@ pub use cpex_plugin_delegator_oauth::{OAuthDelegatorFactory, KIND as OAUTH_KIND} pub use cpex_plugin_elicitation_ciba::{CibaApproverFactory, KIND as CIBA_KIND}; #[cfg(feature = "identity-jwt")] pub use cpex_plugin_identity_jwt::{JwtIdentityFactory, KIND as JWT_KIND}; +#[cfg(feature = "ocsf-audit")] +pub use cpex_plugin_ocsf_audit::{OcsfAuditFactory, KIND as OCSF_AUDIT_KIND}; #[cfg(feature = "pii-scanner")] pub use cpex_plugin_pii_scanner::{PiiScannerFactory, KIND as PII_KIND}; #[cfg(feature = "valkey")] @@ -89,6 +91,7 @@ register_builtins! { feature "elicitation-ciba" => cpex_plugin_elicitation_ciba::CibaApproverFactory, feature "pii-scanner" => cpex_plugin_pii_scanner::PiiScannerFactory, feature "audit-logger" => cpex_plugin_audit_logger::AuditLoggerFactory, + feature "ocsf-audit" => cpex_plugin_ocsf_audit::OcsfAuditFactory, } /// The enabled PDP factories, ready to drop into