diff --git a/docs/adr/003-trust-boundaries.md b/docs/adr/003-trust-boundaries.md new file mode 100644 index 0000000..340acee --- /dev/null +++ b/docs/adr/003-trust-boundaries.md @@ -0,0 +1,282 @@ +# ADR-003: Trust Boundaries and Component Trust Levels + +## Status + +Accepted + +## Context + +Glassbox spans multiple execution environments and process boundaries: a Go CLI +host process, a Rust/WASM simulator subprocess, a plugin subprocess tier, a +browser-targeted TypeScript bindings layer, a signing provider tier (software, +PKCS#11 HSM, AWS KMS), an RPC tier (Soroban JSON-RPC), and local persistence +(SQLite + content-addressed snapshot store). Because these components differ in +privilege, language runtime, network access, and operator control, a shared +understanding of which components are trusted and which are not is required +before reasoning about what data may flow across each boundary and what controls +must apply. + +Without an explicit trust model, security controls cannot be evaluated +consistently. A reviewer cannot determine, for example, whether input from the +simulator subprocess is validated by the host before use, or whether a plugin's +claimed identity has been verified before it is granted filesystem access. + +## Decision + +Glassbox components are grouped into four trust tiers. Each tier records whether +the component runs in the same process as the CLI host, how its identity is +established, and what the controls at the inbound and outbound edges of that +tier are. + +### Tier 0 — CLI Host Process (fully trusted) + +**Components:** `internal/cmd/*`, `internal/config`, `internal/audit`, +`internal/session`, `internal/signer` (factory + registry), `internal/snapshot` +(dedup store), crash reporter. + +The Go CLI host is the root of trust for a Glassbox session. It owns the +configuration surface, the signing provider lifecycle, and the final +decision about what output is written to disk or transmitted to RPC. All other +tiers are subordinate to Tier 0. + +Controls applied at this tier: +- Config loaded from operator-controlled TOML / environment; no user-supplied + data is allowed to override signing provider selection at runtime. +- Crash reporter (Sentry) is opt-in; disabled unless `crash_reporting = true` + and a DSN is configured. Sensitive values are not included in crash payloads. +- Build-time version and commit SHA injected via `-ldflags`; no runtime self- + modification. + +### Tier 1 — Simulator Subprocess (untrusted, isolated) + +**Components:** Rust/WASM simulator binary invoked by `internal/simulator/runner.go`. + +The simulator is an **untrusted subprocess**. It executes arbitrary WASM +bytecode supplied by the operator (contract under test) and is treated as +potentially hostile code that may produce malformed output, excessive resource +usage, or malicious byte sequences. + +Controls applied at the boundary: +- The simulator binary is launched as a child process via `os/exec`; it does not + share memory or file descriptors with the host. +- The subprocess environment is constructed by `simulatorEnv()`, which starts + from `os.Environ()` (the full parent environment) and adjusts `RUST_LOG` to + match `GLASSBOX_LOG_LEVEL`. It does **not** strip signing credentials or RPC + tokens from the inherited environment. Operators who require credential + isolation must not export secrets into the CLI process environment. +- All IPC uses length-bounded stdin/stdout JSON (see ADR-004). Stdout and stderr + buffers are capped to prevent memory exhaustion. +- Sandbox mode (`sandbox_mode: true`) requires an explicit `memory_limit` and + `allowed_host_functions` allowlist before the subprocess is started; the host + rejects sandbox requests that omit either field. +- The host validates the structured `SimulationResponseSchema` before consuming + any field from the simulator response. + +### Tier 2 — Plugin Subprocess (conditionally trusted, isolated) + +**Components:** Plugin binaries described by `internal/plugin/manifest.go`, +sandboxed by `internal/plugin/sandbox.go`, policy-controlled by +`internal/plugin/policy.go`. + +Plugins are **conditionally trusted** based on their declared `TrustLevel`: + +| Trust level | Meaning | Default policy | +|---|---|---| +| `verified` | Checksum-verified, maintainer-signed binary | Permitted by default | +| `community` | Known-source but not maintainer-signed | Permitted by default (`AllowUntrusted` defaults to `true`) | +| `untrusted` | Unknown or unverified provenance | Permitted by default; blocked when `AllowUntrusted = false` | + +Controls applied at the boundary: +- Plugin binary checksum is verified against the manifest before execution. +- Each plugin runs in its own child process; IPC is via stdin/stdout JSON only. +- The subprocess environment is stripped; no signing credentials, RPC tokens, or + session secrets are propagated. +- A `DeniedCapabilities` and `DeniedPermissions` list is enforced by the host + before the plugin subprocess is launched; a plugin that declares a denied + capability is refused at load time, not at runtime. +- Each plugin call is wrapped in a `context.WithTimeout` of 10 seconds; the + child process is killed by the OS when the context is cancelled. +- `DeniedPlugins` (by plugin ID) allows operator-level blocklisting. + +### Tier 3 — Signing Provider Tier (trusted-by-configuration) + +**Components:** `internal/signer/inmemory.go` (software Ed25519), +`internal/signer/provider_pkcs11.go` (PKCS#11 HSM), +`internal/signer/kms.go` (AWS KMS). + +Signing providers are **trusted-by-configuration**: the operator selects the +provider via `GLASSBOX_SIGNING_PROVIDER` / `GLASSBOX_SIGNER_TYPE` or config +file. The host does not independently verify the provider's correctness at +runtime beyond the preflight checks described in the signing docs. + +Controls: +- Provider selection is resolved once at startup by the factory + (`internal/signer/factory.go`) via the `GLASSBOX_SIGNER_TYPE` environment + variable (or `audit.signing_provider` in the config TOML when the higher-level + `cmd` layer reads it); there is no runtime provider switching. +- The software provider holds the raw Ed25519 seed in process memory; its trust + derives entirely from OS process isolation. +- PKCS#11 interacts with a hardware token via a vendor `.so`/`.dll`; the module + path is validated for existence and file type before the module is loaded. +- AWS KMS never exposes the private key to the host process; signing happens + inside AWS infrastructure and the host holds only IAM credentials. + +### Tier 4 — External Services (untrusted network tier) + +**Components:** Soroban JSON-RPC (`soroban_rpc_urls`), AWS KMS API, +Sentry crash endpoint, OTLP telemetry collector, IPFS/Arweave +(optional publish targets in `--publish-ipfs` / `--publish-arweave`). + +All external network services are **untrusted** at the application layer. +Data received from them is parsed defensively and never executed. + +Controls: +- RPC responses are parsed as XDR or JSON and validated against expected schema + before use; raw bytes are never executed or passed to the signing path. +- URL credentials (`user:password@`) are stripped before display + (`config show`) and before telemetry emission. +- `source` and `signature` hint fields in deep links are not validated and are + explicitly documented as untrusted free-form strings. +- Crash and telemetry payloads are sanitised (command name truncated, hash + values fingerprinted) before transmission to external endpoints. + +### Tier 5 — Browser Bindings (restricted, no subprocess access) + +**Components:** TypeScript bindings generated with `--runtime browser` +(`docs/bindings-environments.md`). + +Browser-targeted bindings run in a **restricted environment** with no access to +Node.js primitives. + +Controls: +- The generated `package.json` `"browser"` field excludes `child_process`, `fs`, + and `path`; bundlers will not link these modules. +- Simulator interaction uses the HTTP fetch API (pointing at an RPC endpoint) + rather than spawning a local process. +- ABI hash metadata is embedded in generated files for staleness detection; the + hash is SHA-256 of the canonical JSON ABI, not a security signature. + +### Trust diagram + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Tier 0: CLI Host Process (root of trust) │ +│ ┌──────────────┐ ┌────────────────┐ ┌──────────────────┐ │ +│ │ cmd / config │ │ audit/signer │ │ snapshot / session│ │ +│ └──────┬───────┘ └───────┬────────┘ └──────────────────┘ │ +│ │ │ provider interface │ +│ stdin/stdout JSON │ │ +│ ┌──────▼───────┐ ┌───────▼────────────────────────────┐ │ +│ │ Tier 1 │ │ Tier 3: Signing Providers │ │ +│ │ Simulator │ │ software / PKCS#11 HSM / AWS KMS │ │ +│ │ subprocess │ └────────────────────────────────────-┘ │ +│ └──────────────┘ │ +│ │ +│ stdin/stdout JSON (separate invocation per plugin call) │ +│ ┌──────────────┐ │ +│ │ Tier 2 │ │ +│ │ Plugin │ │ +│ │ subprocess │ │ +│ └──────────────┘ │ +└──────────────────────────────────────────────────────────────┘ + ↕ HTTPS / JSON-RPC +┌──────────────────────────────────────────────────────────────┐ +│ Tier 4: External Services │ +│ (Soroban RPC, AWS KMS API, Sentry, OTLP, IPFS/Arweave) │ +└──────────────────────────────────────────────────────────────┘ + + Tier 5: Browser Bindings (separate bundle, fetch API only) +``` + +## Rationale + +### Why not run the simulator in-process? + +Running WASM in-process via a Go WASM runtime would reduce IPC overhead but +would share the host's memory space, file descriptors, and OS signal handlers +with potentially hostile WASM bytecode. The subprocess model provides OS-level +isolation at the cost of serialisation latency, which is acceptable given that +simulation is already the dominant latency. + +### Why is plugin trust level enforced at load time, not runtime? + +A plugin that attempts a denied operation at runtime would have already +executed partially, potentially leaving side effects. Refusing at load time +ensures no code from the plugin runs until the policy check passes. + +### Why is the AWS KMS provider trusted-by-configuration rather than Tier 0? + +AWS KMS performs signing in AWS infrastructure. The host cannot independently +verify that AWS signed with the correct key without also trusting the KMS API +response — a circularity. The trust therefore derives from the operator's IAM +policy and AWS account controls, not from in-process verification. + +### Alternatives considered + +**Single-process sandbox via seccomp/landlock:** Would provide finer-grained +syscall filtering but is Linux-only and complicates cross-platform support. +Rejected for portability reasons; subprocess isolation is cross-platform. + +**Plugin signed manifests with key pinning:** Would raise plugin trust from +checksum-based to cryptographically-signed. Deferred; the current checksum +model is a necessary prerequisite and is sufficient for the current threat model. + +**Browser bindings running the simulator locally via WASM-in-WASM:** Technically +possible but would expose the full simulator attack surface to browser +sandboxing, which is weaker than OS process isolation. Rejected; browser +bindings delegate simulation to an RPC endpoint. + +## Implementation + +| Claim | Verified in | +|---|---| +| Simulator env: inherits parent env, adjusts `RUST_LOG` only | `internal/simulator/runner.go` (`simulatorEnv`) | +| Stdout/stderr buffer caps on simulator | `internal/simulator/runner.go` | +| Plugin checksum verification | `internal/plugin/sandbox.go` (`verifyChecksum`) | +| Plugin 10s per-call timeout via `context.WithTimeout` | `internal/plugin/sandbox.go` (`sandboxTimeout`) | +| Plugin denied caps/perms enforced at load | `internal/plugin/policy.go`, `internal/plugin/manifest.go` | +| Sandbox mode requires `memory_limit` + allowlist | `internal/simulator/sandbox_cap.go`, `docs/sandboxed-replay.md` | +| Provider selected once at startup via `GLASSBOX_SIGNER_TYPE` | `internal/signer/factory.go`, `internal/signer/registry.go` | +| KMS key never leaves AWS | `docs/audit-kms-signing.md`, `internal/signer/kms.go` | +| Browser bindings exclude `child_process`/`fs`/`path` | `docs/bindings-environments.md` | +| URL credential stripping in `config show` | `docs/security-warnings.md` | +| Crash reporting opt-in only | `cmd/glassbox/main.go` | + +## Consequences + +**Positive:** +- A reviewer can identify the trust level of any component from this document + without reading source code. +- The subprocess model makes lateral movement from a compromised plugin or + simulator significantly harder: the adversary is confined to the child process + and the bounded IPC channel. +- Trust-by-configuration for signing providers means operators can enforce + hardware-only signing in CI by setting `GLASSBOX_SIGNING_PROVIDER=pkcs11` + or `aws-kms` and not supplying a software key. + +**Negative / trade-offs:** +- IPC serialisation overhead adds latency per simulation request. +- Plugin trust level `community` (and `untrusted`) is allowed by default because + `DefaultPolicy()` sets `AllowUntrusted = true`. Operators who require + `verified`-only plugins must set `AllowUntrusted = false` in their policy file. +- Browser bindings cannot run the simulator locally; they require an accessible + RPC endpoint, which introduces a network trust dependency not present in CLI + mode. + +**Migration impact:** +- Existing deployments that run with `GLASSBOX_SIGNER_TYPE=mock` (test HSM) + should audit whether that configuration is present in production; mock signers + are Tier 3 trust-by-configuration but do not provide hardware isolation. +- Plugin deployments should review `policy.go` to ensure `DeniedCapabilities` + aligns with their environment before enabling community plugins. + +## Related + +- [ADR-004: Data Classification and Cross-Boundary Data Flows](004-data-classification.md) +- [ADR-005: Canonicalization Ownership](005-canonicalization-ownership.md) +- [ADR-006: Provider Isolation](006-provider-isolation.md) +- [ADR-007: Offline Guarantees](007-offline-guarantees.md) +- [ADR-002: HSM Integration](002-hsm-integration.md) +- [Deep Link Parameter Semantics](deeplink-parameters.md) +- [Audit Signing](../audit-signing.md) +- [Sandboxed Replay](../sandboxed-replay.md) diff --git a/docs/adr/004-data-classification.md b/docs/adr/004-data-classification.md new file mode 100644 index 0000000..2a0573f --- /dev/null +++ b/docs/adr/004-data-classification.md @@ -0,0 +1,251 @@ +# ADR-004: Data Classification and Cross-Boundary Data Flows + +## Status + +Accepted + +## Context + +Glassbox handles several categories of data with materially different +sensitivity: signing credentials, session tokens, transaction payloads, +simulation results, telemetry, and user-supplied metadata. The same session can +involve all of them simultaneously, and they flow across at least five process +or network boundaries (see ADR-003). + +Without an explicit classification of what data is sensitive and a description +of what controls apply at each boundary crossing, it is impossible to audit +whether sensitive data is being inadvertently leaked — for example into telemetry +payloads, simulator subprocess arguments, crash reports, or the signed audit log. + +This ADR records the classification taxonomy, the controls that apply at each +crossing, and the properties that are absent and therefore must not be claimed. + +## Decision + +### 1. Data classification taxonomy + +| Class | Label | Examples | Controls required | +|---|---|---|---| +| **Signing credentials** | `SECRET` | Ed25519 PEM / hex seed, PKCS#11 PIN (`GLASSBOX_PKCS11_PIN`), AWS IAM key/secret | Never logged, never telemetered, never passed to subprocesses, redacted in audit log | +| **RPC / network credentials** | `SECRET` | RPC bearer tokens (`--rpc-token`), URL userinfo (`user:password@host`) | Redacted from audit log, stripped from displayed URLs, not passed to simulator subprocess | +| **Private key material (HSM/KMS)** | `SECRET` | Raw Ed25519 seed (software provider), KMS private key bytes | Software seed lives only in process memory; KMS key never leaves AWS | +| **Transaction payload** | `INTERNAL` | Envelope XDR, ledger entries, linear memory, `ResultMetaXdr` | Stays within CLI host and simulator boundary; not transmitted to telemetry or crash endpoints | +| **Simulation result** | `INTERNAL` | Trace events, budget usage, auth diagnostics, stack traces | Crosses CLI↔simulator boundary via bounded JSON IPC; validated before host consumption | +| **Signed audit log** | `INTERNAL` | Canonical payload, SHA-256 hash, Ed25519 signature, public key | Written to disk by host only; secrets are redacted before signing | +| **Snapshot / ledger state** | `INTERNAL` | `ledgerEntries`, `linearMemory`, fingerprint | Stored content-addressed locally (`~/.glassbox/cache/snapshots/`); not transmitted to external services | +| **User metadata** | `INTERNAL` | `--metadata key=value` entries | Length-capped (key ≤128 chars, value ≤1024 chars), null-byte stripped, embedded in signed audit log | +| **Telemetry / OTLP** | `PUBLIC` | Command name, trace spans, hash fingerprints | Command name sanitised (alphanumeric/dash/colon/underscore, ≤64 chars); hash values replaced with 32-char fingerprint (`sha256:`) | +| **Crash reports** | `PUBLIC` | Panic stack, error message | Opt-in only; no transaction payload, no credentials, version/commit only | +| **ABI / contract spec** | `PUBLIC` | Function signatures, struct layouts | May be written to disk, embedded in TypeScript bindings, or transmitted as part of check-bindings CI workflow | +| **Deep link parameters** | `UNTRUSTED INPUT` | `glassbox://` URI query params | Validated before dispatch; null bytes rejected; `source` and `signature` params are free-form and explicitly untrusted | + +### 2. Boundary crossing catalogue + +Each row describes data that physically crosses a boundary, the mechanism, and +the controls applied. + +#### Boundary A: CLI Host → Simulator Subprocess + +| Data | Direction | Mechanism | Controls | +|---|---|---|---| +| `SimulationRequestSchema` (XDR envelope, ledger overrides, network, sandbox config) | Host → Simulator | Length-bounded stdin JSON | Subprocess environment inherits parent env with `RUST_LOG` adjusted; credentials must not be exported into the CLI process environment | +| `SimulationResponseSchema` (trace, budget, auth, stack, error codes) | Simulator → Host | Length-bounded stdout JSON | Host validates schema before consuming any field; stdout capped at 10 MB, stderr capped at 1 MB | + +**What does NOT cross this boundary in the payload:** +- Any signing credential or RPC token (these are not serialised into the JSON request body) +- The raw Ed25519 seed or PKCS#11 PIN +- The signed audit log or snapshot store paths + +**Important:** `simulatorEnv()` in `internal/simulator/runner.go` starts from `os.Environ()` and only adjusts `RUST_LOG`; it does **not** strip secrets from the inherited environment. Operators must not export signing credentials (e.g. `GLASSBOX_SOFTWARE_PRIVATE_KEY_HEX`, `GLASSBOX_PKCS11_PIN`) into the shell environment in which the CLI runs if the simulator subprocess must not see them. + +#### Boundary B: CLI Host → Plugin Subprocess + +| Data | Direction | Mechanism | Controls | +|---|---|---|---| +| Plugin input payload (command-specific JSON) | Host → Plugin | stdin JSON | Minimal env (`GLASSBOX_PLUGIN_NAME`, `GLASSBOX_PLUGIN_VERSION`, `GLASSBOX_API_VERSION`, `PATH`); plugin binary checksum verified before exec | +| Plugin output payload | Plugin → Host | stdout JSON | Host validates; per-call `context.WithTimeout(10s)` kills the child process if it does not respond in time; stderr is discarded (`io.Discard`) | + +**What does NOT cross this boundary:** +- Signing credentials +- Session secrets or RPC tokens +- Raw ledger state or snapshots (unless the plugin has `read_fs` permission explicitly granted) + +#### Boundary C: CLI Host → Signing Provider + +| Data | Direction | Mechanism | Controls | +|---|---|---|---| +| `data []byte` — the 32-byte SHA-256 digest of the canonical payload, passed as `hash[:]` | Host → Provider | In-process function call (`Signer.Sign(data []byte)`) for software/PKCS#11, or HTTPS to AWS KMS | The `Signer` interface accepts raw bytes; by convention the CLI passes only the 32-byte hash. Raw payload bytes are never passed. | +| Ed25519 signature bytes (64 bytes) | Provider → Host | `Signer.Sign` return value | Returned to host for embedding in audit log | +| Public key bytes (32 bytes) | Provider → Host | `Signer.PublicKey()` return value | Embedded in audit log | + +**Note on the interface:** `internal/signer/signer.go` declares `Sign(data []byte) ([]byte, error)` — the parameter is untyped bytes. The CLI layer (`internal/cmd/audit.go`) is responsible for passing `hash[:]` (the SHA-256 digest) rather than the raw payload. The interface itself does not enforce this. + +**What does NOT cross this boundary for KMS:** +- The canonical JSON payload +- The raw private key bytes (key never leaves AWS) + +#### Boundary D: CLI Host → External RPC / Network Services + +| Data | Direction | Mechanism | Controls | +|---|---|---|---| +| Transaction hash, network identifier | Host → RPC | HTTPS JSON-RPC | Not a secret; used to fetch transaction data | +| Ledger entries, XDR response | RPC → Host | HTTPS JSON-RPC | Treated as untrusted input; parsed and validated before use | +| OTLP trace spans | Host → Telemetry collector | HTTPS | Command name sanitised; hash values fingerprinted; no payload data | +| Crash report (panic + stack) | Host → Sentry endpoint | HTTPS | Opt-in; no credentials, no payload; version/commit only | + +**What does NOT cross this boundary:** +- The signed audit log or snapshot files (these are local only, unless the + operator explicitly uses `--publish-ipfs` or `--publish-arweave`) +- Signing credentials + +#### Boundary E: CLI Host → Local Persistence + +| Data | Direction | Mechanism | Controls | +|---|---|---|---| +| `PersistedSnapshot` (ledger entries, linear memory, metadata) | Host → Filesystem | Atomic write (write-to-tmp then rename) | Fingerprint computed and embedded; verified on load | +| Session state | Host → SQLite (`modernc.org/sqlite`) | In-process SQL | No credentials stored; session IDs only | +| Signed audit log | Host → Filesystem | Write to operator-specified path | Secrets redacted before signing; canonical form is the signed artifact | + +**What does NOT cross this boundary:** +- Raw signing credentials (the key is never written to the snapshot store or session DB) + +#### Boundary F: OS / Browser → CLI (Deep Link) + +| Data | Direction | Mechanism | Controls | +|---|---|---|---| +| `glassbox://` URI | OS → CLI | `protocol:handle` command dispatch | Null bytes rejected; hash must be 64 hex chars; network must be allowlisted enum; `mock-ledger-manifest` path checked for null bytes; `mock-ledger-entry` values must be valid base64 | +| Translated CLI flags | Deep link parser → `debug` command | Internal re-invocation | All validation happens in `ParseDebugURI` before flags are constructed | + +#### Boundary G: TypeScript Bindings (Browser) → RPC + +| Data | Direction | Mechanism | Controls | +|---|---|---|---| +| Simulation request (contract ID, function args) | Browser → RPC | `fetch` API (HTTPS) | No local subprocess; `child_process`/`fs`/`path` excluded from bundle | +| Simulation response | RPC → Browser | `fetch` API (HTTPS) | Consumed by generated client code; no CLI host process involved | + +### 3. Redaction rules for the audit log + +Before a payload is canonicalised and signed, the following redaction pass is +applied by `internal/cmd/audit.go`: + +| Trigger | Action | +|---|---| +| CLI flag name contains `token`, `secret`, `password`, `private`, `key`, `pin`, or `passphrase` | Flag value replaced with `REDACTED` | +| Long (≥16 char) flag value that contains hex characters | Value replaced with `REDACTED` (likely-secret heuristic) | +| Error message contains a file path (`/…` or `C:\…`) | Path segments replaced with `` | +| Error message contains a likely-secret value | Value replaced with `REDACTED` | +| `--metadata` key empty or >128 chars, value >1024 chars, or contains null bytes | Entry skipped silently | + +### 4. Properties explicitly NOT claimed + +The following properties are **not** provided and must not be assumed: + +- **End-to-end encryption of the IPC channel.** Communication between the CLI + host and the simulator subprocess is via local stdio — there is no + cryptographic confidentiality or integrity protection beyond OS process + isolation. A local attacker with sufficient privilege could observe or tamper + with the pipe. +- **Simulator environment credential isolation.** `simulatorEnv()` inherits the + full parent environment and only adjusts `RUST_LOG`. Signing credentials and + RPC tokens present in the shell environment are visible to the simulator + subprocess. Operators are responsible for not exporting secrets before + invoking the CLI. +- **Confidentiality of the snapshot store.** Snapshots are stored in + `~/.glassbox/cache/snapshots/` in plaintext JSON. The fingerprint detects + tampering but does not encrypt contents. An attacker with read access to the + home directory can read ledger state. +- **Integrity of the telemetry pipeline.** Telemetry is emitted over HTTPS but + the OTLP collector is operator-configured and its security is outside + Glassbox's control. +- **Audit log confidentiality.** The audit log is signed but not encrypted; + anyone with the file can read the payload (post-redaction). + +## Rationale + +### Why classify at data level rather than component level? + +Component-level controls (e.g. "the simulator is untrusted") do not answer the +question "can the PKCS#11 PIN reach the simulator?" Data-level classification +paired with an explicit boundary-crossing catalogue answers that question +directly. + +### Why is ledger state `INTERNAL` rather than `SECRET`? + +Ledger state is transaction data that the operator is actively debugging; it +is not a credential. However, it may contain sensitive business logic, so it is +classified `INTERNAL` (not transmitted to external services, not included in +telemetry) rather than `PUBLIC`. + +### Why is the deep link `source` parameter explicitly untrusted? + +The `source` parameter is a free-form analytics label and is not validated +beyond URL encoding. Treating it as untrusted prevents future code from +relying on it for access control or audit provenance. + +### Alternatives considered + +**Encrypt the snapshot store at rest:** Would protect ledger state from local +read attacks. Rejected for the current version because key management for +at-rest encryption introduces its own complexity; the fingerprint provides +tamper detection. At-rest encryption is a candidate for a future ADR. + +**Structured redaction schema (allowlist instead of denylist):** The current +redaction is heuristic (flag-name denylist + length/hex heuristic). An allowlist +of fields permitted in the audit log would be stronger. Deferred; the heuristic +is conservative enough for the current threat model and a schema-validated +approach is planned as the `AuditPayloadSchema` matures. + +## Implementation + +| Claim | Verified in | +|---|---| +| Simulator env: inherits parent env, adjusts `RUST_LOG` only | `internal/simulator/runner.go` (`simulatorEnv`) | +| Stdout cap 10 MB / stderr cap 1 MB on simulator | `internal/simulator/runner.go` (`limitedBuffer`) | +| Plugin minimal env (`GLASSBOX_PLUGIN_*`, `PATH` only) | `internal/plugin/sandbox.go` (`buildSandboxEnv`) | +| Plugin 10s per-call timeout via `context.WithTimeout` | `internal/plugin/sandbox.go` (`sandboxTimeout = 10 * time.Second`) | +| Plugin stderr discarded (`io.Discard`) | `internal/plugin/sandbox.go` (`cmd.Stderr = io.Discard`) | +| Redaction rules for audit log | `internal/cmd/audit.go`, `docs/security-warnings.md` | +| Metadata key/value validation | `docs/security-warnings.md` | +| Command name sanitisation for telemetry | `docs/security-warnings.md` | +| Hash fingerprinting for telemetry | `docs/security-warnings.md` | +| URL credential stripping in `config show` | `docs/security-warnings.md` | +| Snapshot atomic write + fingerprint | `internal/snapshot/`, `docs/snapshot-deduplication.md` | +| Deep link null-byte / base64 / length validation | `internal/protocolreg/uri.go`, `docs/adr/deeplink-parameters.md` | +| Browser bindings exclude `child_process`/`fs`/`path` | `docs/bindings-environments.md` | +| Crash reporting opt-in, no payload | `cmd/glassbox/main.go` | +| KMS: only hash bytes cross to AWS | `internal/signer/kms.go`, `docs/audit-kms-signing.md` | + +## Consequences + +**Positive:** +- A reviewer can trace any named piece of data (e.g. the PKCS#11 PIN) through + the boundary catalogue and confirm it does not reach the simulator, plugins, + telemetry, or crash endpoints. +- The explicit "not claimed" section prevents false assurances in security + reviews. + +**Negative / trade-offs:** +- Snapshot store is plaintext; operators with sensitive ledger data should + apply OS-level filesystem permissions (chmod 700 on `~/.glassbox/`) until + at-rest encryption is implemented. +- The heuristic redaction for audit logs will miss a credential whose flag name + does not match the denylist and whose value is shorter than 16 chars or + contains no hex. Operators should review `--metadata` payloads before + distributing signed audit logs. + +**Migration impact:** +- No changes to data handling are introduced by this ADR; it documents existing + behaviour derived from code inspection. +- Teams using `--audit-log` in regulated environments should review the + "not claimed" section and apply additional controls (filesystem ACLs, log + encryption) appropriate to their compliance requirements. + +## Related + +- [ADR-003: Trust Boundaries and Component Trust Levels](003-trust-boundaries.md) +- [ADR-005: Canonicalization Ownership](005-canonicalization-ownership.md) +- [Audit Signing](../audit-signing.md) +- [Security Warnings and Redaction](../security-warnings.md) +- [Snapshot Deduplication](../snapshot-deduplication.md) +- [Deep Link Parameter Semantics](deeplink-parameters.md) +- [Bindings Environments](../bindings-environments.md) diff --git a/docs/adr/005-canonicalization-ownership.md b/docs/adr/005-canonicalization-ownership.md new file mode 100644 index 0000000..dffc46f --- /dev/null +++ b/docs/adr/005-canonicalization-ownership.md @@ -0,0 +1,281 @@ +# ADR-005: Canonicalization Ownership + +## Status + +Accepted + +## Context + +An Ed25519 signature over a JSON payload is only reproducible if the verifier +can reconstruct the exact same byte sequence that the signer hashed. JSON +serialisation is non-deterministic by default: key ordering varies across +runtimes, compilers, and standard library versions; number formatting may +differ; whitespace is unspecified. + +Glassbox audit logs are produced by the Go CLI (`internal/cmd/audit.go`) and +may be verified by downstream tools including TypeScript (browser, CI), other +Go processes, and out-of-band scripts with only the public key and the signed +file. Cross-runtime reproducibility is therefore not an optional nicety — it is +a hard requirement for the signing guarantee to be meaningful. + +The question this ADR answers is: **which component owns the canonical form +definition, who must implement it, and how is cross-language equivalence +guaranteed?** + +## Decision + +### 1. The CLI host process owns the canonical form + +The Tier-0 CLI host (Go) is the sole authority on what constitutes the canonical +form of an audit payload. The TypeScript SDK implements the same algorithm +independently, but the Go implementation is the reference. Any discrepancy is +treated as a TypeScript bug. + +Rationale for Go as reference: the audit log is created and signed by the Go +CLI. Verification of a log signed by the CLI must work with the same bytes the +CLI produced. Starting from the signer's output and working backwards is the +only definition that guarantees correctness. + +### 2. Canonical form specification + +The canonical form is **RFC 8785 (JCS) inspired** deterministic JSON with the +following invariants, implemented identically in Go and TypeScript: + +| Rule | Detail | Rationale | +|---|---|---| +| Key ordering | Object keys sorted by Unicode code-point order (lexicographic), applied recursively at every nesting level | Deterministic across all JSON producers | +| Whitespace | Zero extra whitespace — no spaces after `:` or `,`, no newlines, no indentation | Minimal and unambiguous byte sequence | +| String encoding | UTF-8; escape sequences follow JSON spec; no locale-specific collation | Cross-platform stability | +| Numbers | IEEE 754 double precision; `NaN` and `Infinity` are rejected before serialisation | JSON does not support non-finite floats; rejection ensures no silent encoding variation | +| Arrays | Insertion order preserved; arrays are not sorted | Arrays carry semantic ordering | +| Null | Serialised as `null` | Standard JSON | +| Booleans | Serialised as `true` / `false` | Standard JSON | + +**The canonical form is NOT full RFC 8785.** It is inspired by JCS but is not +a complete implementation. In particular, Unicode normalisation of string values +is not applied, and the number serialisation diverges from the RFC's ULP-exact +approach. The invariants above are the operative spec; the RFC is a reference +point, not an authority. + +### 3. Hashing + +``` +canonical_bytes = canonical_json(payload) // UTF-8 encoded +hash = SHA-256(canonical_bytes) // 32 bytes +``` + +When hardware attestation is present it is included in the hash input to prevent +strip-and-replace attacks: + +``` +canonical_bytes = canonical_json({ trace, hardware_attestation }) +hash = SHA-256(canonical_bytes) +``` + +The `trace_hash` field embedded in the signed audit log is the hex encoding of +this SHA-256 digest. + +### 4. Go implementation + +Located in `internal/cmd/canonical.go` and used by `internal/cmd/audit.go`. + +Algorithm: +1. Marshal the Go struct to raw JSON (`encoding/json`). +2. Unmarshal into `interface{}` to erase struct field ordering imposed by + Go's reflection-based encoder. +3. Recursively sort all `map[string]interface{}` keys with `sort.Strings`. +4. Re-encode using `encoding/json` without indentation. + +```go +// internal/cmd/canonical.go (representative pseudocode) +func marshalCanonical(v interface{}) ([]byte, error) { + raw, _ := json.Marshal(v) + var generic interface{} + json.Unmarshal(raw, &generic) + sorted := sortMapKeys(generic) + return json.Marshal(sorted) +} + +// internal/cmd/audit.go +// Note: Signer.Sign(data []byte) accepts raw bytes; the CLI always passes +// the 32-byte hash slice — the interface does not enforce this constraint. +payloadBytes, _ := marshalCanonical(payload) +hash := sha256.Sum256(payloadBytes) +signature, _ := signer.Sign(hash[:]) +``` + +### 5. TypeScript implementation + +Located in `src/audit/AuditLogger.ts`. Uses `fast-json-stable-stringify` for +key-sorted serialisation, which does not rely on `JSON.stringify` key ordering +(insertion-order in V8, unspecified by spec) and is therefore stable across +Node.js LTS versions. + +```typescript +// src/audit/AuditLogger.ts (representative pseudocode) +import stringify from 'fast-json-stable-stringify'; +const canonicalString = stringify({ trace, hardware_attestation }); +const hash = createHash('sha256').update(canonicalString).digest('hex'); +const signature = await signer.sign(Buffer.from(hash)); +``` + +### 6. Schema validation precedes canonicalisation + +Before either implementation computes the canonical form, it validates the +payload against the `AuditPayload` schema: + +| Field | Type | Required | Constraint | +|---|---|---|---| +| `timestamp` | string | yes | Non-empty, valid ISO 8601 | +| `input` | object | yes | Plain object (not array, not null) | +| `state` | object | yes | Plain object (not array, not null) | +| `events` | array | yes | Any array | +| `metadata` | object | no | Plain object when present | + +Additional constraints at all nesting levels: no `NaN`, no `Infinity`, no +circular references. Validation precedes canonicalisation so that +malformed payloads cannot reach the signing path. + +### 7. Stability guarantees + +The canonical form is stable — meaning the same logical payload always produces +the same bytes — across: +- Go versions (uses `sort.Strings`, not reflection key ordering) +- Node.js LTS versions (`fast-json-stable-stringify` does not use + `JSON.stringify` key order) +- Operating systems (no locale-sensitive operations) +- Time (timestamp is a field value, not used in sorting) + +**Not stable when:** +- Optional fields are added to the schema. Adding a new optional field changes + the canonical bytes and invalidates previously signed logs when the new field + is present. Old verifiers that do not know about the new field will correctly + reject logs that include it (by design — the hash covers all fields). + +### 8. Verification procedure + +A verifier that holds only the signed audit log file and the signer's public +key performs four steps, all of which must pass: + +1. **Reconstruct canonical bytes:** apply the algorithm above to the stored + `trace` field (and `hardware_attestation` if present). +2. **Compute hash:** `SHA-256(canonical_bytes)`. +3. **Compare to stored hash:** the computed digest must equal `trace_hash`. +4. **Verify signature:** Ed25519 verify(`signature`, `hash_bytes`, `public_key`) + must pass. (`Signer.Sign(data []byte)` accepts the hash bytes as `data`; + the interface does not name the argument `digest`, but by convention the + CLI always passes the 32-byte SHA-256 hash.) + +This is the complete verification; no network access or external state is +required. + +### 9. Cross-language equivalence test + +Both implementations must produce byte-identical SHA-256 hashes for the same +logical payload. This is verified by: + +| Test | Location | +|---|---| +| Go canonical encoder: key ordering, arrays, types, struct marshaling, determinism across 10 invocations | `internal/cmd/canonical_test.go` | +| Go end-to-end: same payload → same `TraceHash` across 20 `Generate` calls | `internal/cmd/canonical_test.go::TestGenerate_DeterministicHash` | +| TypeScript: key ordering, byte-identical output across 100 invocations, hash stability, schema validation (15 invalid cases) | `tests/audit-canonical.test.ts` | +| TypeScript: tamper detection for payload, attestation removal, attestation modification | `internal/cmd/audit_test.go` | + +Cross-language byte-identity is validated in the test suite via a shared +fixture file; any divergence is a test failure. + +## Rationale + +### Why Go as the reference rather than a shared library? + +A shared Rust library via CGO would introduce a cgo build dependency that breaks +cross-compilation and complicates Windows support. A shared WASM module would +require a WASM runtime in the verifier. Independent identical implementations +with cross-language fixture tests provide the same correctness guarantee without +build complexity. + +### Why not use a standard JCS library? + +At the time of implementation, production-ready JCS libraries for Go were either +unmaintained or had known deviations from the RFC in number serialisation. The +custom recursive encoder is 30 lines, has full test coverage, and is +unambiguously specified by the invariant table above. If a well-maintained Go JCS +library matures, migration would be a drop-in replacement with the same tests. + +### Why `fast-json-stable-stringify` rather than `JSON.stringify` with key sort? + +`JSON.stringify` key ordering in V8 follows insertion order for string keys that +are not array indices; this is not guaranteed by the ECMAScript spec and has +differed between V8 versions for certain edge cases. `fast-json-stable-stringify` +sorts keys unconditionally and does not delegate to the engine's object property +enumeration order. + +### Why reject `NaN` / `Infinity` rather than encoding them as `null`? + +Silently replacing a non-finite value with `null` would change the meaning of +the payload. Rejection surfaces encoding bugs at the source and prevents a +signed log from containing a field whose value has been silently altered. + +### Alternatives considered + +**Full RFC 8785 implementation:** Would require ULP-exact number serialisation +(complex, rarely matters in practice for audit payloads) and Unicode +normalisation (introduces a dependency, rarely matters for ASCII-heavy payloads). +Rejected in favour of the simpler invariant set that covers all known Glassbox +payload types. + +**CBOR canonical encoding (RFC 7049 section 3.9):** Binary; not human-readable; +would complicate manual verification. Rejected. + +**Protocol Buffers with deterministic serialisation:** Requires a .proto schema +for every payload version; adds a code-generation step. Rejected; the JSON-based +approach allows ad-hoc payload fields without schema changes. + +## Implementation + +| Claim | Verified in | +|---|---| +| Go canonical encoder exists and uses recursive key sort | `internal/cmd/canonical.go` | +| Go implementation determinism across 20 invocations | `internal/cmd/canonical_test.go::TestGenerate_DeterministicHash` | +| TypeScript implementation uses `fast-json-stable-stringify` | `src/audit/AuditLogger.ts` | +| TypeScript determinism across 100 invocations | `tests/audit-canonical.test.ts` | +| Schema validation precedes canonicalisation | `src/audit/AuditPayloadSchema.ts`, `internal/cmd/audit.go` | +| `NaN`/`Infinity` rejected | `src/audit/AuditPayloadSchema.ts` | +| Hardware attestation included in hash when present | `docs/audit-canonicalization.md` | +| Four-step verification procedure | `docs/audit-verify-command.md` | + +## Consequences + +**Positive:** +- Any holder of the Ed25519 public key can independently verify a signed audit + log without network access and without Glassbox installed, using only a + standard SHA-256 and Ed25519 implementation. +- The canonical form is language-agnostic; a future Rust verifier, Python CI + script, or Java audit tool can implement the same algorithm from the invariant + table. + +**Negative / trade-offs:** +- Adding optional fields to `AuditPayload` is a breaking change for logs that + include those fields: existing verifiers will fail to verify them. Operators + must update verifiers before deploying a version that adds optional fields. +- The non-standard NaN/Infinity rejection means payloads from runtimes that + represent missing values as `NaN` (some Rust XDR decoders) must be + post-processed before audit logging. + +**Migration impact:** +- The canonical form has been stable since the initial implementation. Logs + signed by earlier versions remain verifiable as long as the payload schema has + not added new fields. +- If a future version adopts a different canonical form (e.g. full RFC 8785), + a new `canonical_version` field should be added to the signed log envelope, + and old logs should retain the old verifier path. This ADR should be + superseded by the new one. + +## Related + +- [ADR-003: Trust Boundaries and Component Trust Levels](003-trust-boundaries.md) +- [ADR-004: Data Classification and Cross-Boundary Data Flows](004-data-classification.md) +- [ADR-006: Provider Isolation](006-provider-isolation.md) +- [Audit Canonicalization](../audit-canonicalization.md) +- [Audit Signing](../audit-signing.md) +- [Audit Verify Command](../audit-verify-command.md) diff --git a/docs/adr/006-provider-isolation.md b/docs/adr/006-provider-isolation.md new file mode 100644 index 0000000..62adeda --- /dev/null +++ b/docs/adr/006-provider-isolation.md @@ -0,0 +1,310 @@ +# ADR-006: Provider Isolation + +## Status + +Accepted + +## Context + +Glassbox supports three signing provider backends — software Ed25519, PKCS#11 +(HSM), and AWS KMS — through a common `Signer` interface. Each backend has a +fundamentally different trust posture: the software provider holds raw key +material in process memory; the PKCS#11 provider loads a vendor-supplied shared +library into the host process; the KMS provider transmits signing requests over +the network to AWS infrastructure. + +Without explicit isolation boundaries between the provider and the rest of the +CLI host, a misconfigured or malicious PKCS#11 module could, in principle, read +process memory (including the session state, snapshot cache, or even another +provider's key material if both are loaded). Conversely, without clearly defined +interfaces and lifecycle controls, an operator cannot reason about what key +material is present in memory at any given point or whether it has been +correctly zeroed on teardown. + +This ADR records what isolation each provider tier provides, what it does not +provide, how providers are selected and composed, and what the consequences are +for multi-provider configurations. + +## Decision + +### 1. The `Signer` interface is the only crossing point + +All provider backends implement `internal/signer/signer.go`: + +```go +// Signer is the only interface the CLI host uses to perform a signing operation. +type Signer interface { + // Sign accepts raw bytes and returns the digital signature. + // By convention the CLI layer passes the 32-byte SHA-256 digest of + // the canonical payload (hash[:]) — the interface itself does not + // enforce this; the constraint lives in internal/cmd/audit.go. + Sign(data []byte) ([]byte, error) + + // PublicKey returns the raw public key bytes associated with the + // signing key held by this provider. + PublicKey() ([]byte, error) + + // Algorithm returns the signing algorithm name (e.g. "ed25519"). + Algorithm() string +} +``` + +The host never casts a `Signer` to a concrete type after construction. The +signing path passes only: +- the 32-byte SHA-256 digest (as `data []byte`) — never the raw canonical payload +- and receives a 64-byte signature and 32-byte public key in return + +This is the minimal interface needed to sign and verify; no key material, PIN, +or credential is reachable through it. + +### 2. Provider selection and lifecycle + +Providers are selected **once at startup** by `internal/signer/factory.go`, +registered in `internal/signer/registry.go`, and not changed for the duration +of the process. + +Selection precedence as implemented in `factory.go` (first match wins): + +| Priority | Config path | +|---|---| +| 1 | `GLASSBOX_SIGNER_TYPE` environment variable — values: `software`, `pkcs11` | +| 2 | Default: `software` (when env var is absent or empty) | + +The higher-level `cmd` layer reads `audit.signing_provider` from the TOML config +and translates it to the correct `GLASSBOX_SIGNER_TYPE` / `ProviderConfig` before +calling the factory. AWS KMS is wired through the registry (`internal/signer/registry.go`) +and the `cmd` layer directly; it is not handled by `NewFromEnv()` in `factory.go`. + +Once the factory constructs a provider, the raw configuration values (PIN, +key bytes, AWS credentials) are consumed and not stored as accessible fields on +the returned `Signer`. The lifecycle is: + +``` +startup → factory / registry → Signer interface → use → process exit +``` + +There is no runtime provider switching. A session that starts with the PKCS#11 +provider uses the PKCS#11 provider for every signing operation in that session. + +### 3. Software provider isolation + +**Component:** `internal/signer/inmemory.go`, `internal/signer/provider_software.go` + +The software provider holds the raw 64-byte Ed25519 key (seed + public key) in +Go process heap memory. + +Isolation properties: +- Key material is visible to any goroutine in the host process via the Go + garbage collector scan; there is no memory pinning or explicit zeroing on + teardown in the current implementation. +- Key material does NOT cross the simulator or plugin subprocess boundary + (env is stripped; the key is not passed as an IPC payload field). +- Key material is NOT written to the snapshot store, session DB, or telemetry. +- Input validation is performed at construction time: PEM format, key type + (must be Ed25519 PKCS#8), and key length are all checked before the key is + accepted. + +**Not provided:** +- Memory-locked (mlock) pages to prevent key material from being swapped to disk. +- Explicit zeroing of key bytes on `Signer` teardown. +- Protection against a local attacker with read access to the process's + `/proc//mem`. + +These properties are noted as limitations, not bugs; they are consistent with +the current threat model (single-user workstation, not a multi-tenant server). + +### 4. PKCS#11 provider isolation + +**Component:** `internal/signer/provider_pkcs11.go` + +The PKCS#11 provider loads a vendor-supplied shared library (`.so` / `.dylib` / +`.dll`) into the host process via cgo/dlopen. The private key **never leaves the +HSM token** — the host sends only the digest to the module for signing via +`C_SignInit` / `C_Sign`, and receives only the signature bytes back. + +Isolation properties: +- The PKCS#11 module executes in the same process address space as the CLI host. + A compromised or malicious PKCS#11 module has unrestricted access to process + memory. +- Module file existence and type (`.so`/`.dylib`/`.dll`, not a directory) are + validated before `dlopen`. +- A 10-second timeout is enforced on module initialisation; a hung module is + killed and the CLI exits with an error. +- PIN authentication is performed once per session; the raw PIN string is held + in memory only during the `C_Login` call. +- The `--validate-only` preflight (`glassbox audit:sign --validate-only + --signing-provider pkcs11`) runs the full module/slot/token/session/PIN/key/ + test-sign sequence without committing to a real signing operation. + +**Not provided:** +- Address Space Layout Randomisation (ASLR) specific to the loaded module; this + is an OS responsibility. +- Verification that the PKCS#11 module is the expected binary (hash pinning of + the `.so` itself). Operators are responsible for verifying module provenance. +- Protection against a malicious PKCS#11 module reading other in-process + secrets (RPC tokens, software key material if both providers are configured). + +**Consequence of the same-process model:** operators who require that the +PKCS#11 module cannot read in-process memory should run Glassbox in an +environment where only the PKCS#11 provider is configured (no software key, no +RPC tokens in environment variables). + +### 5. AWS KMS provider isolation + +**Component:** `internal/signer/kms.go` + +The KMS provider performs signing in AWS infrastructure. The private key never +leaves AWS KMS; the host only transmits the 32-byte SHA-256 digest to the KMS +`Sign` API and receives the 64-byte signature. + +Isolation properties: +- No key material is present in the host process; the host holds only IAM + credentials (environment variables or instance profile). +- IAM credentials are consumed by the AWS SDK v2 credential chain (explicit → + profile → environment → EC2 IMDS → ECS task); they are not stored on the + `Signer` struct after construction. +- The `kms:GetPublicKey` API is called once at construction to obtain the + verifiable public key; subsequent signing operations require only `kms:Sign`. +- All KMS traffic is HTTPS; the AWS SDK validates the TLS certificate against + the AWS trust store. + +**Minimum required IAM permissions:** +```json +{ + "Action": ["kms:Sign", "kms:GetPublicKey", "kms:DescribeKey"], + "Resource": "arn:aws:kms:REGION:ACCOUNT_ID:key/KEY_ID" +} +``` + +**Not provided:** +- Mutual TLS between the CLI host and the KMS API endpoint; standard AWS SDK + TLS is used. +- Auditability of what the KMS service signed on the AWS side; AWS CloudTrail + provides this at the AWS account level, not within Glassbox. +- Offline operation; the KMS provider requires network access for every signing + call (see ADR-007). + +### 6. Mock / test provider + +A `mock` signer type (`GLASSBOX_SIGNER_TYPE=mock` or `glassbox.example.toml`) +is available for CI and local testing. It generates a fresh ephemeral Ed25519 +key on each invocation and performs all signing in memory. + +**The mock provider must not be used in production.** It provides no persistence +of the signing key; signatures produced by one invocation cannot be verified by +a subsequent invocation using the same configuration. The config documentation +clearly labels it as a test-only option. + +### 7. Multiple providers are not simultaneously loaded + +The factory constructs exactly one `Signer` per process. There is no +configuration path that loads both a PKCS#11 module and holds a software key +simultaneously in the same process (because selection is first-match and the +factory does not merge providers). This constraint limits the blast radius if +one provider backend is compromised: it cannot observe the key material of +another provider because that material is never in the same process. + +### 8. Provider-specific input validation + +Each provider validates its configuration at construction time and fails fast +with an actionable error before any signing work begins. The validation +sequence for PKCS#11 is: module path → file type → module load → slot +enumeration → token info → session open → PIN auth → key lookup → test sign. +A failure at any step produces a `[FAIL]` diagnostic and the process exits +non-zero. + +## Rationale + +### Why is the `Signer` interface restricted to `Sign(data []byte)` rather than `Sign(payload)`? + +Passing the full payload to the provider would require every provider to +implement canonicalisation — creating the risk of divergence (see ADR-005). +Passing only the SHA-256 digest (as the `data` argument, by convention in the +CLI layer) also limits what data a potentially compromised PKCS#11 module can +observe: it sees 32 bytes of a SHA-256 hash, not the plaintext payload. The +interface itself accepts `[]byte` without enforcement; the restriction is upheld +by `internal/cmd/audit.go` always passing `hash[:]`. + +### Why is the PKCS#11 module loaded in-process rather than a subprocess? + +An out-of-process PKCS#11 bridge (similar to the simulator subprocess model) +would prevent the module from reading host memory, but would require a stable +IPC protocol between the bridge and the CLI, and would add latency per signing +operation. For a CLI tool where signing happens once per audit session, the +latency trade-off is acceptable but the implementation complexity is not. +Operators who require stronger isolation should use the AWS KMS provider, which +performs signing entirely outside the host process. + +### Why is there no provider fallback chain? + +A fallback chain (try PKCS#11, fall back to software) would silently degrade +security if the HSM becomes unavailable. Operators who require hardware-only +signing would have no reliable way to detect when the fallback was triggered. +Strict first-match single-provider semantics make this detectable (the process +exits with an error if the configured provider is unavailable). + +### Alternatives considered + +**Out-of-process PKCS#11 bridge:** Stronger isolation; rejected for complexity +and latency reasons (see above). A future ADR may revisit this. + +**Key material in a Go `runtime.Pinner`-pinned allocation with explicit zero on +teardown:** Would prevent GC scan exposure and swapping. Deferred; requires +careful lifecycle management and is a low-priority hardening for single-user +workstations. + +**Provider hot-swap without restart:** Would allow rotating keys without a +process restart. Rejected; the single-provider model is simpler to audit. + +## Implementation + +| Claim | Verified in | +|---|---| +| `Signer` interface definition (3 methods: `Sign`, `PublicKey`, `Algorithm`) | `internal/signer/signer.go` | +| Provider selected via `GLASSBOX_SIGNER_TYPE`; AWS KMS wired through registry/cmd | `internal/signer/factory.go`, `internal/signer/registry.go` | +| Provider registered in registry | `internal/signer/registry.go` | +| Software provider: key validated at construction | `internal/signer/provider_software.go` | +| PKCS#11: module path/type validated before load | `internal/signer/provider_pkcs11.go`, `docs/audit-signing.md` | +| PKCS#11: 10s module initialisation timeout | `internal/signer/provider_pkcs11.go` | +| PKCS#11: `--validate-only` preflight | `docs/audit-signing.md` | +| KMS: private key never leaves AWS | `internal/signer/kms.go`, `docs/audit-kms-signing.md` | +| KMS: `kms:GetPublicKey` called once at construction | `internal/signer/kms.go` | +| Mock provider documented as test-only | `glassbox.example.toml` | +| Only SHA-256 digest (not payload) passed as `data` to `Signer.Sign` | `internal/cmd/audit.go` | + +## Consequences + +**Positive:** +- The `Signer` interface allows operators to select a provider that matches + their compliance requirements (software for development, PKCS#11 for + on-premises HSM, KMS for cloud-native key management) without any change + to the CLI signing path. +- Single-provider-per-process semantics mean a PKCS#11 module cannot observe + software key material (and vice versa) because they are never co-loaded. + +**Negative / trade-offs:** +- PKCS#11 module runs in-process; a malicious or vulnerable `.so` can read host + memory. Operators must verify module provenance independently. +- The software provider does not mlock or zero key bytes; a swap file or core + dump on a workstation could expose the key. Operators with strict key + protection requirements should use PKCS#11 or KMS. +- The KMS provider requires network access; air-gapped signing is only available + via the software or PKCS#11 providers. + +**Migration impact:** +- Operators currently using `GLASSBOX_SIGNER_TYPE=mock` in production must + migrate to a persistent signing provider before key continuity matters (i.e., + before distributing signed audit logs that must be verifiable in future + sessions). +- Operators migrating from PKCS#11 to KMS (or vice versa) must re-sign any + audit logs that were signed with the previous key, or distribute both public + keys to verifiers. + +## Related + +- [ADR-003: Trust Boundaries and Component Trust Levels](003-trust-boundaries.md) +- [ADR-005: Canonicalization Ownership](005-canonicalization-ownership.md) +- [ADR-007: Offline Guarantees](007-offline-guarantees.md) +- [ADR-002: HSM Integration](002-hsm-integration.md) +- [Audit Signing](../audit-signing.md) +- [AWS KMS Signing](../audit-kms-signing.md) diff --git a/docs/adr/007-offline-guarantees.md b/docs/adr/007-offline-guarantees.md new file mode 100644 index 0000000..c43f08d --- /dev/null +++ b/docs/adr/007-offline-guarantees.md @@ -0,0 +1,267 @@ +# ADR-007: Offline Guarantees + +## Status + +Accepted + +## Context + +Glassbox is primarily designed as a connected tool: it fetches transaction data +from a Soroban JSON-RPC node, submits signed audit logs via RPC, and optionally +sends telemetry to an OTLP collector. However, several important workflows must +remain functional without network access: + +1. **Air-gapped audit signing** — a security-sensitive environment may require + that signing happen on a machine with no network connection. +2. **Snapshot replay** — re-running a simulation from a previously captured + local snapshot (e.g. for reproducible debugging, regression tests, or CI + without RPC access). +3. **Offline verification** — verifying a signed audit log using only the public + key and the log file, without contacting any Glassbox service. +4. **Deferred submission** — signing locally and submitting the signed envelope + later when network connectivity is available. + +Without an explicit record of what is and is not guaranteed to work offline, an +operator cannot know whether an air-gapped signing pipeline is safe, or whether +a CI job that loses RPC access mid-run will silently degrade. + +## Decision + +### 1. Offline-capable operations + +The following operations are guaranteed to work with no network access, +provided the required local inputs are available: + +| Operation | Required local inputs | Network used? | +|---|---|---| +| `audit:sign` (software or PKCS#11 provider) | Canonical payload or payload file, Ed25519 PEM key or PKCS#11 token | No | +| `audit:sign` (envelope file flow, `internal/offline/envelope.go`) | `EnvelopeFile` with embedded payload, signing key | No | +| `audit:verify` | Signed audit log file, public key (embedded or `--public-key`) | No | +| Snapshot replay (`--offline` / local snapshot) | `PersistedSnapshot` file in `~/.glassbox/cache/snapshots/` | No | +| Binding staleness check (`check-bindings`) against a local WASM or ABI file | WASM binary or JSON ABI file on disk | No | +| `glassbox config show` | Local config TOML | No | + +### 2. Operations that require network access + +| Operation | Why network is required | +|---|---| +| `audit:sign` (AWS KMS provider) | Every `kms:Sign` call requires HTTPS to the KMS API | +| `debug` command (live transaction) | Fetches envelope XDR and ledger state from Soroban RPC | +| RPC submission of signed audit envelope | `internal/offline/submitter.go` pushes the signed envelope to RPC | +| Telemetry emission (OTLP) | Sends trace spans to configured collector | +| Crash reporting (Sentry) | Sends panic reports to configured endpoint | +| IPFS / Arweave publish (`--publish-ipfs`, `--publish-arweave`) | Uploads signed data to decentralised storage | + +### 3. The air-gapped signing pipeline + +`internal/offline/envelope.go` implements a three-stage pipeline for +environments where the signing machine has no network access: + +``` +Stage 1 (online machine): Fetch transaction → produce EnvelopeFile +Stage 2 (air-gapped signer): Load EnvelopeFile → sign → produce SignedEnvelopeFile +Stage 3 (online machine): Load SignedEnvelopeFile → submit to RPC +``` + +**EnvelopeFile structure:** +- Payload (canonical JSON blob) +- SHA-256 checksum of the payload (computed before transfer to air-gapped machine) +- Metadata (timestamp, network, transaction hash) + +**Signing stage controls:** +- The signer computes its own SHA-256 of the received payload and compares it + to the embedded checksum before signing. A mismatch is a hard error; the + operation is aborted. +- The signer uses the local software or PKCS#11 provider; no network call is + made. +- The output `SignedEnvelopeFile` carries the signature, the public key, and + the original payload checksum. + +**Submission stage:** +- `internal/offline/submitter.go` reads the `SignedEnvelopeFile`, re-verifies + the signature against the embedded public key, and submits via RPC. +- Re-verification before submission ensures the file was not tampered with + during the transfer from the air-gapped machine back to the online machine. + +### 4. Snapshot replay guarantees + +The snapshot store (`~/.glassbox/cache/snapshots/`) is a content-addressed +filesystem store (SHA-256 keyed, atomic writes). Replay from a stored snapshot +does not contact the network: + +- The CLI host loads the `PersistedSnapshot` from disk. +- The embedded fingerprint (SHA-256 of sorted ledger entry key-value pairs) is + verified on load. A mismatch is logged as a `DRIFT WARNING`; the snapshot is + still returned to the caller so the operator can inspect it, but the warning + signals that the stored content may have been tampered with or corrupted. +- The snapshot is passed to the simulator subprocess as the ledger state for + the replay run. +- No RPC calls are made during a pure replay; the simulator operates entirely + on the in-memory ledger state provided by the host. + +**What the snapshot does NOT guarantee:** +- The snapshot reflects the on-chain state at the time of capture, not the + current on-chain state. A replay is therefore a historical re-execution, not + a proof of current correctness. +- A fingerprint mismatch is a warning, not a hard rejection. A corrupt or + tampered snapshot will still be replayed with the corrupted state; the + `DRIFT WARNING` is the only signal to the operator. +- The `network`, `tx_hash`, and `saved_at` metadata fields are stored but are + not included in the content hash (see ADR-004 and `docs/snapshot-deduplication.md`). + A snapshot cannot prove which transaction it came from; provenance depends on + the operator's capture workflow. + +### 5. Offline verification guarantee + +`glassbox audit:verify` re-derives the payload hash from the stored `trace` +field using the same canonicalisation algorithm used at signing time (see +ADR-005) and verifies the Ed25519 signature using the embedded or out-of-band +public key. No network access, no Glassbox service, and no external state are +required. The four-step verification procedure is: + +1. Reconstruct canonical bytes from stored `trace` (and `hardware_attestation` + if present). +2. `hash = SHA-256(canonical_bytes)`. +3. Assert `hash == stored trace_hash`. +4. Assert `Ed25519.Verify(public_key, hash_bytes, signature)`. + +This guarantee is unconditional: a verifier that has only the log file and the +public key can always verify, regardless of network availability. + +### 6. RPC failover and degradation behaviour + +When the primary RPC endpoint is unavailable, the CLI applies the configured +`failover_strategy`: + +| Strategy | Behaviour | +|---|---| +| `round-robin` | Cycle through `soroban_rpc_urls` list in order | +| `first-available` | Try each URL in order, use the first that responds | +| (none configured) | Single URL; error immediately on failure | + +Failover applies to live transaction fetching and RPC submission only. It does +not affect offline-capable operations. + +**Degradation is never silent.** If all RPC endpoints are unreachable, the CLI +exits with an explicit error. The operator is not left with a partial result +that silently omits network-fetched data. + +### 7. KMS provider and offline signing + +The AWS KMS provider is **not compatible with air-gapped signing**. Every +signing call requires `kms:Sign` over HTTPS to the AWS KMS API. Operators who +need air-gapped signing must use the software or PKCS#11 provider. Attempting +to use the KMS provider without network access will produce a clear error from +the AWS SDK. + +### 8. Telemetry and crash reporting offline behaviour + +If the OTLP collector or Sentry endpoint is unreachable: +- Telemetry spans are dropped silently (OTLP export failures do not abort the + CLI command). +- Crash reports are attempted once; a failed send is silently swallowed (the + panic is still re-raised and the CLI exits non-zero regardless). + +These are best-effort transmissions; offline operation does not degrade the +core CLI functionality. + +## Rationale + +### Why is the air-gapped pipeline a three-stage design rather than a single offline binary? + +A single binary that signs and submits would need to be deployed to the +air-gapped machine, which may itself be a compliance violation. The three-stage +design allows the signing binary to be audited and deployed to the air-gapped +machine independently; the submission stage runs on a network-connected machine +that never holds the signing key. + +### Why is the payload checksum verified again on the air-gapped machine? + +A transfer medium (USB drive, QR code, encrypted email) between the online fetch +stage and the air-gapped signing stage is not trusted. The checksum verification +ensures the air-gapped signer is signing exactly the payload that was prepared +by the online stage, not a modified version. + +### Why is the snapshot content hash omitted from provenance metadata? + +Transaction hash, network, and timestamp are excluded from the content hash +(used for deduplication) because the same logical snapshot (identical ledger +state) from two different transactions or networks should deduplicate to a single +file. Provenance is carried in the `metadata` fields, which are not covered by +the content hash. Operators who need cryptographic provenance of a snapshot's +origin should use the signed audit log, not the snapshot metadata. + +### Why does snapshot replay not contact the network even when a network is available? + +Network access during replay would introduce non-determinism: re-running the +same simulation at a later time could produce a different result if on-chain +state has changed. Strict offline replay ensures that two runs with the same +snapshot always produce the same simulation output. + +### Alternatives considered + +**Encrypt the EnvelopeFile for the air-gapped transfer:** Would protect the +payload in transit. The current design relies on the transfer medium's security +(e.g. an encrypted USB drive) rather than adding application-layer encryption. +Application-layer encryption of the envelope is a candidate future enhancement. + +**Offline KMS via AWS CloudHSM custom key store:** Would allow KMS-style API +with a local HSM, eliminating the network requirement. This is a valid +deployment pattern and is outside Glassbox's control; the PKCS#11 provider +already supports any PKCS#11-compliant HSM including CloudHSM. + +**Cache RPC responses for offline replay automatically:** Would capture ledger +state transparently during live debug sessions. The current design requires +explicit snapshot capture. Automatic caching is a candidate for a future `--auto-snapshot` flag. + +## Implementation + +| Claim | Verified in | +|---|---| +| Air-gapped envelope: checksum verified before signing | `internal/offline/envelope.go` | +| Air-gapped envelope: re-verified before RPC submission | `internal/offline/submitter.go` | +| Software + PKCS#11 providers require no network | `internal/signer/inmemory.go`, `internal/signer/provider_pkcs11.go` | +| KMS provider requires network for every sign call | `internal/signer/kms.go`, `docs/audit-kms-signing.md` | +| Snapshot replay uses local store, no RPC | `internal/snapshot/`, `docs/snapshot-deduplication.md` | +| Snapshot fingerprint: mismatch logged as `DRIFT WARNING` (load proceeds) | `docs/snapshot-deduplication.md` | +| `audit:verify` requires no network | `docs/audit-verify-command.md` | +| Telemetry / crash report failures are silent | `cmd/glassbox/main.go` | +| RPC failover strategy (round-robin / first-available) | `glassbox.example.toml` | + +## Consequences + +**Positive:** +- The air-gapped signing pipeline allows security-sensitive organisations to + keep signing keys on hardware with no network exposure while still producing + signed audit logs that are submittable and verifiable. +- Offline verification means a signed log remains auditable indefinitely, + independent of Glassbox service availability or version. + +**Negative / trade-offs:** +- Air-gapped signing requires manual coordination of three stages; operators + must establish and document the transfer workflow themselves. +- Snapshot replay captures point-in-time ledger state; a simulation that passes + on a stale snapshot may fail against current on-chain state. Operators must + track when snapshots were taken relative to the contract upgrade lifecycle. +- The KMS provider cannot participate in air-gapped workflows; organisations + that standardise on KMS must maintain a fallback PKCS#11 token for air-gapped + environments. + +**Migration impact:** +- Operators who currently run `glassbox audit:sign` on connected machines and + want to migrate to air-gapped signing must adopt the envelope file workflow + (`internal/offline/envelope.go`) and adjust their CI/CD pipeline accordingly. +- Existing snapshot files in the flat storage format (pre-deduplication) are + compatible with replay; the dedup index is rebuilt automatically on first load. + +## Related + +- [ADR-003: Trust Boundaries and Component Trust Levels](003-trust-boundaries.md) +- [ADR-004: Data Classification and Cross-Boundary Data Flows](004-data-classification.md) +- [ADR-005: Canonicalization Ownership](005-canonicalization-ownership.md) +- [ADR-006: Provider Isolation](006-provider-isolation.md) +- [Audit Signing](../audit-signing.md) +- [AWS KMS Signing](../audit-kms-signing.md) +- [Audit Verify Command](../audit-verify-command.md) +- [Snapshot Deduplication](../snapshot-deduplication.md) +- [Sandboxed Replay](../sandboxed-replay.md) diff --git a/docs/adr/README.md b/docs/adr/README.md index 9f601b3..78e4b7a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -18,6 +18,11 @@ Each ADR follows the standard format: |-----|-------|--------|------| | [001](001-snapshot-caching-strategy.md) | Snapshot Caching Strategy using Bincode and SHA256 | Accepted | 2026-02-26 | | [002](002-hsm-integration.md) | HSM Integration for Cryptographic Operations | Accepted | 2026-02-26 | +| [003](003-trust-boundaries.md) | Trust Boundaries and Component Trust Levels | Accepted | 2026-08-31 | +| [004](004-data-classification.md) | Data Classification and Cross-Boundary Data Flows | Accepted | 2026-08-31 | +| [005](005-canonicalization-ownership.md) | Canonicalization Ownership | Accepted | 2026-08-31 | +| [006](006-provider-isolation.md) | Provider Isolation | Accepted | 2026-08-31 | +| [007](007-offline-guarantees.md) | Offline Guarantees | Accepted | 2026-08-31 | ## ADR Process diff --git a/docs/audit-canonicalization.md b/docs/audit-canonicalization.md index faccfff..ce6172c 100644 --- a/docs/audit-canonicalization.md +++ b/docs/audit-canonicalization.md @@ -140,3 +140,13 @@ All four steps must pass for the log to be considered valid. - Adding new **optional** fields to the schema is backward compatible: old verifiers that do not know about the new field will fail to verify logs that include it (by design — the hash covers all fields). + +--- + +## Architecture Decision Records + +The following ADR governs the design decisions documented on this page: + +- [ADR-005: Canonicalization Ownership](adr/005-canonicalization-ownership.md) — records why the Go CLI is the canonical form authority, the full invariant specification, the rationale for not adopting full RFC 8785, and the cross-language equivalence testing strategy. +- [ADR-004: Data Classification and Cross-Boundary Data Flows](adr/004-data-classification.md) — documents that only the 32-byte hash (not the canonical payload bytes) is passed to the signing provider. +- [ADR-006: Provider Isolation](adr/006-provider-isolation.md) — explains why canonicalization happens in the host process before the digest is handed to any provider. diff --git a/docs/audit-signing.md b/docs/audit-signing.md index c77b536..ad285d0 100644 --- a/docs/audit-signing.md +++ b/docs/audit-signing.md @@ -505,3 +505,14 @@ Common signing errors and their meanings: | `CKR_KEY_FUNCTION_NOT_PERMITTED` | Key lacks sign permission | Recreate key with `CKA_SIGN=true` | | `CKR_SESSION_CLOSED` | Session expired | Reinitialize session | | `CKR_DEVICE_REMOVED` | Token unplugged | Reinsert token and retry | + +--- + +## Architecture Decision Records + +The following ADRs govern the design decisions behind audit signing: + +- [ADR-003: Trust Boundaries and Component Trust Levels](adr/003-trust-boundaries.md) — classifies the signing provider tier (Tier 3) and the controls that govern provider selection. +- [ADR-006: Provider Isolation](adr/006-provider-isolation.md) — documents the isolation properties of software, PKCS#11, and KMS providers; explains why only the hash digest crosses the provider boundary. +- [ADR-005: Canonicalization Ownership](adr/005-canonicalization-ownership.md) — specifies the canonical form and hashing algorithm whose output is passed to the signing provider. +- [ADR-007: Offline Guarantees](adr/007-offline-guarantees.md) — documents which providers work without network access and describes the air-gapped signing pipeline. diff --git a/docs/bindings-environments.md b/docs/bindings-environments.md index 88c1b24..2cba2bb 100644 --- a/docs/bindings-environments.md +++ b/docs/bindings-environments.md @@ -263,3 +263,12 @@ Flags: --debug-metadata Emit ABI metadata objects and withDebugMetadata() wrappers --wasm-source Source path hint embedded in debug metadata ``` + +--- + +## Architecture Decision Records + +The following ADRs govern the design decisions behind multi-environment bindings: + +- [ADR-003: Trust Boundaries and Component Trust Levels](adr/003-trust-boundaries.md) — classifies browser-targeted bindings as Tier 5 (restricted, no subprocess access) and documents the controls — `child_process`/`fs`/`path` exclusion, fetch-API-only simulation — that apply in the browser runtime. +- [ADR-004: Data Classification and Cross-Boundary Data Flows](adr/004-data-classification.md) — documents Boundary G (TypeScript Bindings → RPC) and confirms that no CLI host process is involved in browser-based simulation requests. diff --git a/docs/sandboxed-replay.md b/docs/sandboxed-replay.md index cac36cd..07159bb 100644 --- a/docs/sandboxed-replay.md +++ b/docs/sandboxed-replay.md @@ -23,3 +23,13 @@ Glassbox rejects sandbox requests that omit the memory limit or allowlist before starting the simulator process. The allowlist and memory limit are also passed to the simulator custom configuration for runtime enforcement by integrations that support restricted host function exposure. + +--- + +## Architecture Decision Records + +The following ADRs govern the design decisions behind sandboxed replay: + +- [ADR-003: Trust Boundaries and Component Trust Levels](adr/003-trust-boundaries.md) — classifies the simulator subprocess as Tier 1 (untrusted, isolated) and documents the controls applied at the CLI↔simulator boundary, including the requirement that `memory_limit` and `allowed_host_functions` are set before the subprocess starts. +- [ADR-004: Data Classification and Cross-Boundary Data Flows](adr/004-data-classification.md) — enumerates exactly what data crosses Boundary A (CLI Host → Simulator Subprocess) and confirms that no signing credentials or session secrets are propagated to the subprocess environment. +- [ADR-007: Offline Guarantees](adr/007-offline-guarantees.md) — documents that snapshot replay makes no network calls and that the replay result is deterministic for a given snapshot. diff --git a/docs/security-warnings.md b/docs/security-warnings.md index c309455..359e6fa 100644 --- a/docs/security-warnings.md +++ b/docs/security-warnings.md @@ -94,3 +94,13 @@ findings := detector.AnalyzeContractSource(security.SourceContext{ Metadata: metadata, }) ``` + +--- + +## Architecture Decision Records + +The following ADRs govern the design decisions behind the security controls described on this page: + +- [ADR-004: Data Classification and Cross-Boundary Data Flows](adr/004-data-classification.md) — provides the full data classification taxonomy that underlies the redaction rules, telemetry sanitisation, and URL credential stripping documented here. The audit log redaction rules are derived from the `SECRET` data class. +- [ADR-003: Trust Boundaries and Component Trust Levels](adr/003-trust-boundaries.md) — documents the telemetry and crash report boundaries (Tier 4, external services) and the controls — command name sanitisation, hash fingerprinting — applied before data crosses those boundaries. +- [ADR-005: Canonicalization Ownership](adr/005-canonicalization-ownership.md) — documents the schema validation and `NaN`/`Infinity` rejection that prevents malformed values from reaching the signing path. diff --git a/docs/snapshot-deduplication.md b/docs/snapshot-deduplication.md index f2b3451..70f12ef 100644 --- a/docs/snapshot-deduplication.md +++ b/docs/snapshot-deduplication.md @@ -336,3 +336,13 @@ Potential improvements to the deduplication system: - [ ] Diff storage: Store only differences between similar snapshots - [ ] Distributed index: Share dedup index across multiple Glassbox instances - [ ] Verification: Periodic integrity checks on stored snapshots + +--- + +## Architecture Decision Records + +The following ADRs govern the design decisions behind snapshot storage: + +- [ADR-004: Data Classification and Cross-Boundary Data Flows](adr/004-data-classification.md) — classifies snapshot / ledger state as `INTERNAL` data, documents that it is stored locally only and not transmitted to external services, and records the atomic write and fingerprint controls at the local persistence boundary (Boundary E). +- [ADR-007: Offline Guarantees](adr/007-offline-guarantees.md) — documents the offline replay guarantee, the snapshot fingerprint verification on load, and why provenance metadata is excluded from the content hash. +- [ADR-003: Trust Boundaries and Component Trust Levels](adr/003-trust-boundaries.md) — places the snapshot store in the Tier-0 CLI host process and confirms that no signing credentials are stored in snapshot files.