From 3dc3e9ee37ddceb088d06ebc41333eef401412046d5e887221b2bd5e9182f7dd Mon Sep 17 00:00:00 2001 From: Tyler King Date: Wed, 18 Feb 2026 10:47:09 -0500 Subject: [PATCH] Initial scaffolding: specs, plugins, pkg/shellstream --- .gitignore | 31 + LICENSE | 190 +++++ Makefile | 30 + README.md | 59 ++ buf.gen.yaml | 8 + buf.yaml | 11 + cmd/governance-notifier/main.go | 24 + cmd/governance-notifier/plugin.go | 21 + cmd/oidc-attestor/main.go | 21 + cmd/oidc-attestor/plugin.go | 18 + cmd/ssh-credential-composer/main.go | 23 + cmd/ssh-credential-composer/plugin.go | 23 + cmd/substrate-keymanager/main.go | 22 + cmd/substrate-keymanager/plugin.go | 21 + deploy/kustomization.yaml | 68 ++ deploy/spire-agent-config.yaml | 36 + deploy/spire-server-config.yaml | 60 ++ docs/architecture.md | 114 +++ docs/deployment.md | 144 ++++ docs/governance-integration.md | 139 ++++ docs/oidc-attestation.md | 110 +++ docs/plugin-types.md | 115 +++ docs/ssh-certificate-flow.md | 122 +++ docs/testing.md | 112 +++ go.mod | 3 + pkg/config/config.go | 39 + pkg/config/config_test.go | 21 + pkg/governance/governance.go | 74 ++ pkg/governance/governance_test.go | 22 + pkg/oidc/oidc.go | 43 + pkg/oidc/oidc_test.go | 19 + pkg/shellstream/doc.go | 9 + pkg/shellstream/shellstream.go | 318 ++++++++ pkg/shellstream/shellstream_test.go | 550 +++++++++++++ pkg/sshcert/sshcert.go | 56 ++ pkg/sshcert/sshcert_test.go | 30 + proto/bascule/v1/ceremony.proto | 162 ++++ proto/quartermaster/v1/credentials.proto | 77 ++ proto/quartermaster/v1/governance.proto | 125 +++ proto/quartermaster/v1/notary.proto | 54 ++ specs/credential-governance.md | 735 ++++++++++++++++++ specs/shellstream-extensions.md | 491 ++++++++++++ specs/spiffe-ssh-svid.md | 534 +++++++++++++ test/fixtures/sample-oidc-token.json | 22 + test/fixtures/sample-sat-scope.json | 5 + test/fixtures/sample-ssh-cert-extensions.json | 13 + test/fixtures/spire-test-config.hcl | 12 + 47 files changed, 4936 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 buf.gen.yaml create mode 100644 buf.yaml create mode 100644 cmd/governance-notifier/main.go create mode 100644 cmd/governance-notifier/plugin.go create mode 100644 cmd/oidc-attestor/main.go create mode 100644 cmd/oidc-attestor/plugin.go create mode 100644 cmd/ssh-credential-composer/main.go create mode 100644 cmd/ssh-credential-composer/plugin.go create mode 100644 cmd/substrate-keymanager/main.go create mode 100644 cmd/substrate-keymanager/plugin.go create mode 100644 deploy/kustomization.yaml create mode 100644 deploy/spire-agent-config.yaml create mode 100644 deploy/spire-server-config.yaml create mode 100644 docs/architecture.md create mode 100644 docs/deployment.md create mode 100644 docs/governance-integration.md create mode 100644 docs/oidc-attestation.md create mode 100644 docs/plugin-types.md create mode 100644 docs/ssh-certificate-flow.md create mode 100644 docs/testing.md create mode 100644 go.mod create mode 100644 pkg/config/config.go create mode 100644 pkg/config/config_test.go create mode 100644 pkg/governance/governance.go create mode 100644 pkg/governance/governance_test.go create mode 100644 pkg/oidc/oidc.go create mode 100644 pkg/oidc/oidc_test.go create mode 100644 pkg/shellstream/doc.go create mode 100644 pkg/shellstream/shellstream.go create mode 100644 pkg/shellstream/shellstream_test.go create mode 100644 pkg/sshcert/sshcert.go create mode 100644 pkg/sshcert/sshcert_test.go create mode 100644 proto/bascule/v1/ceremony.proto create mode 100644 proto/quartermaster/v1/credentials.proto create mode 100644 proto/quartermaster/v1/governance.proto create mode 100644 proto/quartermaster/v1/notary.proto create mode 100644 specs/credential-governance.md create mode 100644 specs/shellstream-extensions.md create mode 100644 specs/spiffe-ssh-svid.md create mode 100644 test/fixtures/sample-oidc-token.json create mode 100644 test/fixtures/sample-sat-scope.json create mode 100644 test/fixtures/sample-ssh-cert-extensions.json create mode 100644 test/fixtures/spire-test-config.hcl diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..309bd7a --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Binaries +/bin/ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary +*.test + +# Output of go coverage +*.out + +# Go workspace +go.work +go.work.sum + +# Generated proto code +/gen/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e7e0acd --- /dev/null +++ b/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2024-2026 Guildhouse Cooperative + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8433cd5 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +MODULE := github.com/guildhouse-cooperative/guildhouse-spire-plugins +BINDIR := bin + +PLUGINS := \ + oidc-attestor \ + ssh-credential-composer \ + governance-notifier \ + substrate-keymanager + +.PHONY: all build test lint clean proto-gen + +all: build + +build: $(addprefix $(BINDIR)/,$(PLUGINS)) + +$(BINDIR)/%: cmd/%/*.go + @mkdir -p $(BINDIR) + go build -o $@ ./cmd/$* + +test: + go test ./... + +lint: + go vet ./... + +clean: + rm -rf $(BINDIR) gen/ + +proto-gen: + buf generate diff --git a/README.md b/README.md new file mode 100644 index 0000000..06900dc --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Guildhouse SPIRE Plugins + +SPIRE plugins and specifications for governed SSH access via SPIFFE identity. + +This repository extends the [SPIFFE](https://spiffe.io/) ecosystem with SSH certificate +issuance, governance-aware credential lifecycle management, and Guildhouse platform integration. + +## Specifications + +The primary deliverables are three formal specifications in [`specs/`](specs/): + +- **[SPIFFE SSH SVID](specs/spiffe-ssh-svid.md)** — Defines SSH certificates whose identity derives from SPIFFE IDs +- **[Shellstream Extensions](specs/shellstream-extensions.md)** — Vendor-suffixed SSH certificate extensions for governance metadata +- **[Credential Governance](specs/credential-governance.md)** — Credential lifecycle events as governed mutations with merkle anchoring + +## Plugins + +Four SPIRE plugins in [`cmd/`](cmd/): + +| Plugin | SPIRE Type | Runs In | Purpose | +|--------|-----------|---------|---------| +| `oidc-attestor` | WorkloadAttestor | Agent | OIDC token verification, claim-to-selector mapping | +| `ssh-credential-composer` | CredentialComposer | Server | SSH certificate generation with Shellstream extensions | +| `governance-notifier` | Notifier | Server | Credential event notification, merkle anchoring | +| `substrate-keymanager` | KeyManager | Server | Governance-aware signing key management | + +## Packages + +Shared Go libraries in [`pkg/`](pkg/): + +- **`shellstream`** — Encode/decode Shellstream SSH certificate extensions (fully implemented) +- **`oidc`** — OIDC token verification (scaffolded) +- **`governance`** — GovernanceService/CeremonyService gRPC client (scaffolded) +- **`sshcert`** — SSH certificate builder (scaffolded) +- **`config`** — Plugin configuration loading (scaffolded) + +## Building + +```bash +make build # Build all plugin binaries +make test # Run tests +make lint # Run go vet +make clean # Remove build artifacts +``` + +## Proto Code Generation + +Proto files in `proto/` are copies from the [Guildhouse](https://github.com/guildhouse-cooperative/guildhouse) +monorepo. To regenerate Go bindings: + +```bash +make proto-gen +``` + +Requires [buf](https://buf.build/docs/installation) to be installed. + +## License + +Apache License 2.0 — see [LICENSE](LICENSE). diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 0000000..ab1a086 --- /dev/null +++ b/buf.gen.yaml @@ -0,0 +1,8 @@ +version: v2 +plugins: + - remote: buf.build/protocolbuffers/go + out: gen + opt: paths=source_relative + - remote: buf.build/grpc/go + out: gen + opt: paths=source_relative diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 0000000..82e2d4e --- /dev/null +++ b/buf.yaml @@ -0,0 +1,11 @@ +version: v2 +modules: + - path: proto +deps: + - buf.build/protocolbuffers/wellknowntypes +lint: + use: + - STANDARD +breaking: + use: + - FILE diff --git a/cmd/governance-notifier/main.go b/cmd/governance-notifier/main.go new file mode 100644 index 0000000..6a8f191 --- /dev/null +++ b/cmd/governance-notifier/main.go @@ -0,0 +1,24 @@ +// Governance Notifier — SPIRE Notifier plugin. +// +// Runs in SPIRE Server. Notifies the Guildhouse GovernanceService of credential +// lifecycle events (issue, rotate, revoke) and submits MutationEnvelopes to the +// NotaryService for merkle anchoring. +package main + +import ( + "fmt" + "os" +) + +func main() { + // TODO: wire up go-plugin serve with SPIRE Notifier interface + // The plugin will: + // 1. Receive credential lifecycle notifications from SPIRE Server + // 2. Construct a CreateIntentRequest for the credential event + // 3. Call GovernanceService.CreateIntent + // 4. If ceremony required, monitor CeremonyService for resolution + // 5. Construct MutationEnvelope (RFC 8785 JCS → domain-separated SHA-256) + // 6. Submit merkle leaf to NotaryService.CreateAnchor + fmt.Fprintln(os.Stderr, "governance-notifier: SPIRE Notifier plugin (not yet implemented)") + os.Exit(1) +} diff --git a/cmd/governance-notifier/plugin.go b/cmd/governance-notifier/plugin.go new file mode 100644 index 0000000..ed38a5f --- /dev/null +++ b/cmd/governance-notifier/plugin.go @@ -0,0 +1,21 @@ +package main + +// GovernanceNotifier implements the SPIRE Notifier plugin interface. +// +// SPIRE Server calls Notify() on credential lifecycle events. This plugin +// bridges those events into the Guildhouse governance framework: +// +// 1. Credential issued → CreateIntent(registry_type="credential", verb="issue") +// 2. Credential rotated → CreateIntent(registry_type="credential", verb="rotate") +// 3. Credential revoked → CreateIntent(registry_type="credential", verb="revoke") +// +// For each event, the plugin also constructs a MutationEnvelope containing +// the event payload (JCS-canonicalized) and submits the SHA-256 hash as a +// merkle leaf to the NotaryService for audit anchoring. +// +// See specs/credential-governance.md for the full specification. +type GovernanceNotifier struct { + // TODO: add fields + // - governance.Client for GovernanceService/CeremonyService/NotaryService + // - config for cluster ID, trust domain +} diff --git a/cmd/oidc-attestor/main.go b/cmd/oidc-attestor/main.go new file mode 100644 index 0000000..f2ac307 --- /dev/null +++ b/cmd/oidc-attestor/main.go @@ -0,0 +1,21 @@ +// OIDC Attestor — SPIRE WorkloadAttestor plugin. +// +// Runs in SPIRE Agent. Verifies OIDC tokens presented by workloads +// and maps their claims to SPIRE selectors for registration matching. +package main + +import ( + "fmt" + "os" +) + +func main() { + // TODO: wire up go-plugin serve with SPIRE WorkloadAttestor interface + // The plugin will: + // 1. Receive a workload PID from SPIRE Agent + // 2. Read the workload's OIDC token (from filesystem or environment) + // 3. Verify the token using pkg/oidc + // 4. Return selectors: oidc:sub:, oidc:iss:, oidc:email: + fmt.Fprintln(os.Stderr, "oidc-attestor: SPIRE WorkloadAttestor plugin (not yet implemented)") + os.Exit(1) +} diff --git a/cmd/oidc-attestor/plugin.go b/cmd/oidc-attestor/plugin.go new file mode 100644 index 0000000..b0ede6a --- /dev/null +++ b/cmd/oidc-attestor/plugin.go @@ -0,0 +1,18 @@ +package main + +// OIDCAttestor implements the SPIRE WorkloadAttestor plugin interface. +// +// When SPIRE Agent needs to attest a workload, it calls Attest() with the +// workload's process ID. This plugin reads the workload's OIDC token and +// returns selectors based on the verified claims. +// +// Selectors produced: +// - oidc:sub: — OIDC subject claim +// - oidc:iss: — OIDC issuer +// - oidc:email: — OIDC email claim (if present) +// - oidc:group: — One per OIDC group claim (if present) +type OIDCAttestor struct { + // TODO: add fields + // - oidc.Verifier for token validation + // - config for token discovery path +} diff --git a/cmd/ssh-credential-composer/main.go b/cmd/ssh-credential-composer/main.go new file mode 100644 index 0000000..c1349ed --- /dev/null +++ b/cmd/ssh-credential-composer/main.go @@ -0,0 +1,23 @@ +// SSH Credential Composer — SPIRE CredentialComposer plugin. +// +// Runs in SPIRE Server. Intercepts SVID minting to generate SSH certificates +// with Shellstream governance extensions. Handles both SSH certificate creation +// and governance metadata injection in a single plugin. +package main + +import ( + "fmt" + "os" +) + +func main() { + // TODO: wire up go-plugin serve with SPIRE CredentialComposer interface + // The plugin will: + // 1. Receive SVID minting request from SPIRE Server + // 2. Generate an SSH certificate with the SPIFFE ID as principal + // 3. Encode Shellstream extensions (sat-scope, tenant-id, roles, etc.) + // 4. Sign the certificate with the SSH CA key + // 5. Return the composed credential + fmt.Fprintln(os.Stderr, "ssh-credential-composer: SPIRE CredentialComposer plugin (not yet implemented)") + os.Exit(1) +} diff --git a/cmd/ssh-credential-composer/plugin.go b/cmd/ssh-credential-composer/plugin.go new file mode 100644 index 0000000..d8e2125 --- /dev/null +++ b/cmd/ssh-credential-composer/plugin.go @@ -0,0 +1,23 @@ +package main + +// SSHCredentialComposer implements the SPIRE CredentialComposer plugin interface. +// +// This is a merged plugin that handles both SSH certificate generation and +// Shellstream extension injection. In SPIRE's model, CredentialComposer plugins +// can modify credentials during the minting pipeline. +// +// The plugin: +// - Creates an SSH user certificate with the SPIFFE ID as the primary principal +// - Embeds Shellstream @guildhouse.io extensions carrying governance metadata +// - Signs the certificate using the SSH CA key (from KeyManager) +// - Returns the certificate as part of the composed credential bundle +// +// This was originally designed as two separate plugins (ssh-svid-handler and +// shellstream-composer) but merged because both are CredentialComposer plugins +// performing conceptually one operation. +type SSHCredentialComposer struct { + // TODO: add fields + // - sshcert.Builder for certificate construction + // - governance.Client for fetching current governance state + // - config for trust domain, default TTL, etc. +} diff --git a/cmd/substrate-keymanager/main.go b/cmd/substrate-keymanager/main.go new file mode 100644 index 0000000..6478970 --- /dev/null +++ b/cmd/substrate-keymanager/main.go @@ -0,0 +1,22 @@ +// Substrate KeyManager — SPIRE KeyManager plugin. +// +// Runs in SPIRE Server. Manages signing keys with governance-aware rotation. +// Key rotation events require ceremony approval when the Accord policy demands it, +// ensuring that CA key changes are governed mutations. +package main + +import ( + "fmt" + "os" +) + +func main() { + // TODO: wire up go-plugin serve with SPIRE KeyManager interface + // The plugin will: + // 1. Generate and store signing keys (Ed25519 for SSH, ECDSA for X.509) + // 2. Provide signing operations to SPIRE Server + // 3. On key rotation: create a governance intent and await ceremony approval + // 4. Submit key rotation events to NotaryService for merkle anchoring + fmt.Fprintln(os.Stderr, "substrate-keymanager: SPIRE KeyManager plugin (not yet implemented)") + os.Exit(1) +} diff --git a/cmd/substrate-keymanager/plugin.go b/cmd/substrate-keymanager/plugin.go new file mode 100644 index 0000000..52d5919 --- /dev/null +++ b/cmd/substrate-keymanager/plugin.go @@ -0,0 +1,21 @@ +package main + +// SubstrateKeyManager implements the SPIRE KeyManager plugin interface. +// +// SPIRE Server uses KeyManager plugins to generate, store, and use signing +// keys for SVID issuance. This plugin adds governance awareness: +// +// - Key generation: Standard Ed25519/ECDSA key generation +// - Key storage: Keys stored in memory (ephemeral) or filesystem (persistent) +// - Key rotation: Triggers a governance ceremony when Accord policy requires it +// - Audit: Key lifecycle events (generate, rotate, destroy) are merkle-anchored +// +// The governance integration ensures that CA key changes (which affect all +// issued SVIDs) are treated as high-impact governed mutations, typically +// requiring quorum approval. +type SubstrateKeyManager struct { + // TODO: add fields + // - key store (in-memory or filesystem) + // - governance.Client for ceremony-gated rotation + // - config for key algorithm, rotation policy +} diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml new file mode 100644 index 0000000..53b39e8 --- /dev/null +++ b/deploy/kustomization.yaml @@ -0,0 +1,68 @@ +# Kustomize overlay for deploying Guildhouse SPIRE plugins. +# +# This overlay patches the base SPIRE deployment to include plugin binaries +# and configuration. Apply on top of the standard SPIRE Helm chart or +# kustomize base. +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: [] + +# Plugin binaries are distributed as a container image. +# Mount them into the SPIRE server/agent pods via an init container. +patches: + - target: + kind: Deployment + name: spire-server + patch: | + apiVersion: apps/v1 + kind: Deployment + metadata: + name: spire-server + spec: + template: + spec: + initContainers: + - name: guildhouse-plugins + image: ghcr.io/guildhouse-cooperative/spire-plugins:latest + command: ["cp", "-r", "/plugins/", "/opt/spire/plugins/"] + volumeMounts: + - name: plugins + mountPath: /opt/spire/plugins + containers: + - name: spire-server + volumeMounts: + - name: plugins + mountPath: /opt/spire/plugins + readOnly: true + volumes: + - name: plugins + emptyDir: {} + + - target: + kind: Deployment + name: spire-agent + patch: | + apiVersion: apps/v1 + kind: Deployment + metadata: + name: spire-agent + spec: + template: + spec: + initContainers: + - name: guildhouse-plugins + image: ghcr.io/guildhouse-cooperative/spire-plugins:latest + command: ["cp", "/plugins/oidc-attestor", "/opt/spire/plugins/"] + volumeMounts: + - name: plugins + mountPath: /opt/spire/plugins + containers: + - name: spire-agent + volumeMounts: + - name: plugins + mountPath: /opt/spire/plugins + readOnly: true + volumes: + - name: plugins + emptyDir: {} diff --git a/deploy/spire-agent-config.yaml b/deploy/spire-agent-config.yaml new file mode 100644 index 0000000..27769fe --- /dev/null +++ b/deploy/spire-agent-config.yaml @@ -0,0 +1,36 @@ +# SPIRE Agent configuration with Guildhouse OIDC Attestor plugin. +# +# This is a reference configuration — adapt paths and addresses for your cluster. +# See docs/deployment.md for full deployment instructions. + +agent: + data_dir: /var/lib/spire/agent + log_level: INFO + server_address: spire-server.spire.svc.cluster.local + server_port: 8081 + socket_path: /run/spire/sockets/agent.sock + trust_domain: guildhouse.example.org + +plugins: + NodeAttestor: + k8s_psat: + plugin_data: + cluster: guildhouse + + KeyManager: + memory: + plugin_data: {} + + WorkloadAttestor: + # Standard Kubernetes workload attestation. + k8s: + plugin_data: + skip_kubelet_verification: false + + # Guildhouse OIDC attestation — verifies workload OIDC tokens. + guildhouse_oidc: + plugin_cmd: /opt/spire/plugins/oidc-attestor + plugin_data: + issuer: https://keycloak.guildhouse.example.org/realms/platform + audience: spire + token_path: /var/run/secrets/oidc/token diff --git a/deploy/spire-server-config.yaml b/deploy/spire-server-config.yaml new file mode 100644 index 0000000..9d5884e --- /dev/null +++ b/deploy/spire-server-config.yaml @@ -0,0 +1,60 @@ +# SPIRE Server configuration with Guildhouse plugins. +# +# This is a reference configuration — adapt paths and addresses for your cluster. +# See docs/deployment.md for full deployment instructions. + +server: + bind_address: 0.0.0.0 + bind_port: 8081 + data_dir: /var/lib/spire/server + log_level: INFO + trust_domain: guildhouse.example.org + ca_ttl: 24h + default_x509_svid_ttl: 1h + default_jwt_svid_ttl: 5m + +plugins: + DataStore: + sql: + plugin_data: + database_type: sqlite3 + connection_string: /var/lib/spire/server/datastore.sqlite3 + + NodeAttestor: + k8s_psat: + plugin_data: + clusters: + guildhouse: + service_account_allow_list: + - spire:spire-agent + + KeyManager: + # Guildhouse Substrate KeyManager — governance-aware key management. + guildhouse_substrate: + plugin_cmd: /opt/spire/plugins/substrate-keymanager + plugin_data: + trust_domain: guildhouse.example.org + governance_addr: governance.quartermaster.svc.cluster.local:50051 + notary_addr: notary.quartermaster.svc.cluster.local:50051 + cluster_id: guildhouse-prod + + CredentialComposer: + # Guildhouse SSH Credential Composer — SSH certificate + Shellstream extensions. + guildhouse_ssh: + plugin_cmd: /opt/spire/plugins/ssh-credential-composer + plugin_data: + trust_domain: guildhouse.example.org + governance_addr: governance.quartermaster.svc.cluster.local:50051 + default_cert_ttl: 5m + max_cert_ttl: 1h + + Notifier: + # Guildhouse Governance Notifier — credential lifecycle → governance events. + guildhouse_governance: + plugin_cmd: /opt/spire/plugins/governance-notifier + plugin_data: + governance_addr: governance.quartermaster.svc.cluster.local:50051 + ceremony_addr: ceremony.bascule.svc.cluster.local:50052 + notary_addr: notary.quartermaster.svc.cluster.local:50051 + cluster_id: guildhouse-prod + trust_domain: guildhouse.example.org diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..7f28f49 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,114 @@ +# Architecture Overview + +This document describes how the Guildhouse SPIRE plugins integrate SPIFFE workload identity with Guildhouse's governance platform. + +## System Diagram + +``` ++----------------------------------------------------------+ +| Kubernetes Cluster | +| | +| +--------------------+ +-------------------------+ | +| | SPIRE Agent | | SPIRE Server | | +| | | | | | +| | +----------------+ | | +---------------------+ | | +| | | oidc-attestor | | | | ssh-credential- | | | +| | | (WorkloadAttr) | | | | composer (CredComp) | | | +| | +----------------+ | | +---------------------+ | | +| | | | | | +| +--------|-----------+ | +---------------------+ | | +| | | | governance-notifier | | | +| | | | (Notifier) | | | +| | | +---------------------+ | | +| | | | | +| +--------v-----------+ | +---------------------+ | | +| | Workloads | | | substrate-keymanager| | | +| | (present OIDC | | | (KeyManager) | | | +| | tokens for | | +---------------------+ | | +| | attestation) | +------|--------|----------+ | +| +--------------------+ | | | +| gRPC | gRPC | | +| (mTLS) | | (mTLS) | +| +-----------------------------+ | +------------------+ +| | Quartermaster |<--+ | Bascule | +| | | | | +| | - GovernanceService | | - CeremonyServ | +| | (MutationIntents) | | (multi-party | +| | - NotaryService | | approval) | +| | (Merkle anchoring) | | | +| +-----------------------------+ +------------------+ ++----------------------------------------------------------+ +``` + +## Components + +### SPIRE Agent Plugins + +**oidc-attestor** (WorkloadAttestor) runs inside the SPIRE Agent. When a workload calls the Workload API to request an SVID, the agent invokes oidc-attestor to identify the workload. The plugin discovers an OIDC token from the workload's environment (file path or environment variable), verifies it against a JWKS endpoint, and returns selectors derived from token claims. + +### SPIRE Server Plugins + +Three plugins run inside the SPIRE Server: + +- **ssh-credential-composer** (CredentialComposer) intercepts credential minting. When the server issues an SSH SVID, this plugin encodes Shellstream extensions into the SSH certificate's critical options, embedding governance metadata (intent ID, ceremony outcome, SAT hash) into the cert itself. + +- **governance-notifier** (Notifier) receives SPIRE lifecycle events (bundle updates, registration entry changes, SVID rotations). On relevant events, it calls GovernanceService to create or update MutationIntents, and may trigger CeremonyService flows for operations requiring multi-stakeholder approval. + +- **substrate-keymanager** (KeyManager) manages the signing keys used by the SPIRE Server. It stores key material with governance-aware lifecycle management, ensuring key rotations are captured as auditable intents. + +## Guildhouse gRPC Services + +All plugin-to-service communication uses mTLS via SPIFFE SVIDs. The plugins themselves hold SPIFFE identities and authenticate to Guildhouse services through mutual TLS. + +| Service | Package | Plugin Consumers | +|---------|---------|-----------------| +| GovernanceService | `quartermaster.v1` | governance-notifier, ssh-credential-composer | +| NotaryService | `quartermaster.v1` | governance-notifier, ssh-credential-composer | +| CeremonyService | `bascule.v1` | governance-notifier | + +## Data Flow: SSH Certificate Issuance + +1. A workload calls the SPIRE Workload API requesting an SSH SVID. +2. The SPIRE Agent invokes **oidc-attestor**, which discovers and verifies the workload's OIDC token and returns selectors. +3. The agent matches selectors against registration entries and forwards the request to the SPIRE Server. +4. The server invokes **ssh-credential-composer** during credential minting. +5. The composer calls **GovernanceService.CreateIntent** to record the SSH cert issuance as a MutationIntent. +6. If the intent requires approval, **CeremonyService.CreateCeremony** is called and the flow blocks until approval. +7. The composer constructs a MutationEnvelope (RFC 8785 JCS canonicalization, domain-separated SHA-256 hash), which becomes a merkle leaf. +8. **NotaryService.CreateAnchor** anchors the envelope hash into the merkle tree. +9. The composer encodes Shellstream extensions into the SSH certificate's critical options (intent ID, SAT hash, ceremony result). +10. The signed SSH certificate is returned to the workload through the agent. + +## Package Map + +``` +guildhouse-spire-plugins/ + cmd/ + oidc-attestor/ # WorkloadAttestor plugin binary + ssh-credential-composer/ # CredentialComposer plugin binary + governance-notifier/ # Notifier plugin binary + substrate-keymanager/ # KeyManager plugin binary + pkg/ + shellstream/ # Encode/decode SSH cert Shellstream extensions + oidc/ # OIDC token discovery and verification + governance/ # GovernanceService + CeremonyService client + sshcert/ # SSH certificate construction helpers + config/ # Shared plugin configuration loading + proto/ + quartermaster/v1/ # governance.proto, notary.proto, credentials.proto + bascule/v1/ # ceremony.proto + specs/ # Formal specifications (read-only reference) + deploy/ # Kubernetes manifests and Helm values + test/ # Integration tests and fixtures +``` + +## Proto Dependencies + +| Proto File | Service | Package | +|-----------|---------|---------| +| `governance.proto` | GovernanceService | `quartermaster.v1` | +| `notary.proto` | NotaryService | `quartermaster.v1` | +| `credentials.proto` | Credential types | `quartermaster.v1` | +| `ceremony.proto` | CeremonyService | `bascule.v1` | + +Generated Go code from these protos lives alongside the `.proto` files or in a generated output directory. The `pkg/governance` package wraps the raw gRPC stubs with higher-level client logic (retries, circuit breaking, intent lifecycle management). diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..2362df4 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,144 @@ +# Deployment Guide + +Kubernetes deployment of Guildhouse SPIRE plugins. + +## Prerequisites + +- SPIRE Server and Agent deployed (v1.9+) +- Guildhouse Quartermaster services running (GovernanceService, NotaryService) +- Guildhouse Bascule services running (CeremonyService) +- mTLS connectivity between SPIRE and Guildhouse services via SPIFFE + +## Plugin Distribution + +Plugin binaries are packaged as a container image: + +``` +ghcr.io/guildhouse-cooperative/spire-plugins:latest +``` + +The image contains: +``` +/plugins/ + oidc-attestor + ssh-credential-composer + governance-notifier + substrate-keymanager +``` + +## Installation via Kustomize + +Apply the overlay in `deploy/kustomization.yaml` on top of your SPIRE deployment: + +```bash +kubectl apply -k deploy/ +``` + +This patches the SPIRE Server and Agent Deployments to: +1. Add an init container that copies plugin binaries to a shared volume +2. Mount the plugin directory into the SPIRE containers + +## SPIRE Server Configuration + +Add plugin blocks to your SPIRE Server configuration (`server.conf`): + +```hcl +plugins { + KeyManager "guildhouse_substrate" { + plugin_cmd = "/opt/spire/plugins/substrate-keymanager" + plugin_data { + trust_domain = "guildhouse.example.org" + governance_addr = "governance.quartermaster.svc.cluster.local:50051" + notary_addr = "notary.quartermaster.svc.cluster.local:50051" + cluster_id = "guildhouse-prod" + } + } + + CredentialComposer "guildhouse_ssh" { + plugin_cmd = "/opt/spire/plugins/ssh-credential-composer" + plugin_data { + trust_domain = "guildhouse.example.org" + governance_addr = "governance.quartermaster.svc.cluster.local:50051" + default_cert_ttl = "5m" + max_cert_ttl = "1h" + } + } + + Notifier "guildhouse_governance" { + plugin_cmd = "/opt/spire/plugins/governance-notifier" + plugin_data { + governance_addr = "governance.quartermaster.svc.cluster.local:50051" + ceremony_addr = "ceremony.bascule.svc.cluster.local:50052" + notary_addr = "notary.quartermaster.svc.cluster.local:50051" + cluster_id = "guildhouse-prod" + trust_domain = "guildhouse.example.org" + } + } +} +``` + +## SPIRE Agent Configuration + +Add the OIDC attestor to your SPIRE Agent configuration (`agent.conf`): + +```hcl +plugins { + WorkloadAttestor "guildhouse_oidc" { + plugin_cmd = "/opt/spire/plugins/oidc-attestor" + plugin_data { + issuer = "https://keycloak.guildhouse.example.org/realms/platform" + audience = "spire" + token_path = "/var/run/secrets/oidc/token" + } + } +} +``` + +## RBAC + +The SPIRE Server ServiceAccount needs access to read Kubernetes Secrets (for webhook secrets) and ConfigMaps (for Accord policy): + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: spire-server-guildhouse +rules: + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get"] + resourceNames: ["guildhouse-governance-certs"] + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["get", "list", "watch"] +``` + +## mTLS Configuration + +Plugins authenticate to Guildhouse services using the SPIRE Server's own SVID. The plugin inherits the server's SPIFFE identity and uses it for mTLS. + +No additional certificate configuration is needed — the plugin obtains its mTLS credentials from the SPIRE Server's Workload API socket. + +## Health Checks + +Each plugin logs its health status at startup. Monitor SPIRE Server logs for: + +``` +level=info msg="guildhouse_ssh: connected to GovernanceService" +level=info msg="guildhouse_governance: connected to CeremonyService" +level=info msg="guildhouse_substrate: key manager initialized" +``` + +Plugin failures surface as SPIRE Server errors during credential minting or event notification. + +## Environment Variables + +Plugins read configuration from HCL `plugin_data` blocks. No environment variables are required. The SPIRE plugin framework passes configuration directly. + +## Upgrading + +To upgrade plugins: +1. Build new plugin container image +2. Update image tag in `deploy/kustomization.yaml` +3. Restart SPIRE Server and Agent pods +4. SPIRE reloads plugins on startup diff --git a/docs/governance-integration.md b/docs/governance-integration.md new file mode 100644 index 0000000..407038a --- /dev/null +++ b/docs/governance-integration.md @@ -0,0 +1,139 @@ +# Governance Integration + +How plugins interact with GovernanceService, CeremonyService, and NotaryService. + +## Services Overview + +| Service | Proto Package | Purpose | +|---------|--------------|---------| +| GovernanceService | `quartermaster.v1` | MutationIntent lifecycle (create, redeem, revoke) | +| CeremonyService | `bascule.v1` | Multi-stakeholder approval workflows | +| NotaryService | `quartermaster.v1` | Merkle tree anchoring for audit | + +All communication uses gRPC with mTLS via SPIFFE SVIDs. + +## Intent Lifecycle + +Every credential operation follows the intent lifecycle: + +``` +CreateIntent ──> [authorized] ──> RedeemIntent ──> SAT ──> Operation ──> Anchor + | + v + [ceremony_pending] ──> CeremonyService ──> [approved] ──> RedeemIntent + | + v + [denied] ──> Abort +``` + +### CreateIntent + +```protobuf +rpc CreateIntent(CreateIntentRequest) returns (CreateIntentResponse); +``` + +Fields: +- `registry_type`: `"credential"` for all credential operations +- `verb`: `"issue"`, `"rotate"`, or `"revoke"` +- `artifact_scope`: JSON description of the credential parameters +- `tenant_id`: Owning tenant UUID +- `identity_claim`: OIDC token or external event claim +- `ttl_seconds`: Intent lifetime (default 300) +- `max_redemptions`: Always `1` for credential operations +- `idempotency_key`: `"{registry_type}:{verb}:{credential_id}"` + +Response includes: +- `intent_id`: Unique identifier for the intent +- `ceremony_id`: Non-empty if a governance ceremony is required +- `denied`: True if the Accord policy immediately rejects the operation + +### RedeemIntent + +Called after the intent is authorized (immediately or after ceremony approval): + +```protobuf +rpc RedeemIntent(RedeemIntentRequest) returns (RedeemIntentResponse); +``` + +Returns a `SatToken` containing: +- `sat_hash`: Cryptographic binding of the SAT contents +- `bearer_svid`: SPIFFE ID authorized to perform the operation +- `scopes`: `SatScopeMsg` entries defining permitted operations +- `issued_at` / `expires_at`: SAT validity window + +## Ceremony Flow + +When `CreateIntentResponse.ceremony_id` is non-empty: + +1. Plugin monitors ceremony status via `CeremonyService.GetCeremony` +2. Approvers use `ApproveCeremony` or `DenyCeremony` (via Bascule shell or API) +3. On approval, GovernanceService transitions the intent to `authorized` +4. Plugin proceeds with `RedeemIntent` + +Ceremony types (from Accord policy): +- **Autonomous**: No approval needed, intent authorized immediately +- **SelfGrant**: Requestor self-approves +- **SingleApproval**: One external approver required +- **QuorumApproval**: Multiple approvers required (configurable quorum) +- **EmergencyBreakGlass**: Proceed immediately, require post-hoc approval + +## MutationEnvelope Construction + +After the credential operation succeeds, the plugin constructs a MutationEnvelope for audit anchoring: + +### Step 1: Canonicalize Payload + +Serialize the credential event payload using RFC 8785 JSON Canonicalization Scheme (JCS): +- Sorted keys +- No whitespace +- Deterministic number formatting + +### Step 2: Domain-Separated Hash + +``` +payload_hash = SHA-256("guildhouse.credential.v1:" + jcs_bytes) +``` + +The domain prefix prevents cross-protocol hash collisions. + +### Step 3: Build Envelope + +```json +{ + "domain": "guildhouse.credential.v1", + "payload_hash": "", + "timestamp": "", + "actor_svid": "", + "tenant_id": "", + "event_type": "issue", + "intent_id": "", + "sat_hash": "" +} +``` + +### Step 4: Anchor + +JCS-canonicalize the envelope, SHA-256 hash it, submit as a leaf to `NotaryService.CreateAnchor`. + +## Error Handling + +| Failure | Behavior | +|---------|----------| +| GovernanceService unreachable | **Fail closed** — credential operation fails | +| Ceremony timeout | Treat as denial, abort operation | +| NotaryService unreachable | Credential operation proceeds, queue leaf for retry | +| No Accord policy match | Default to SingleApproval (fail safe) | + +## Plugin Configuration + +All plugins share governance connection configuration: + +```hcl +plugin_data { + governance_addr = "governance.quartermaster.svc.cluster.local:50051" + ceremony_addr = "ceremony.bascule.svc.cluster.local:50052" + notary_addr = "notary.quartermaster.svc.cluster.local:50051" + trust_domain = "guildhouse.example.org" + cluster_id = "guildhouse-prod" +} +``` diff --git a/docs/oidc-attestation.md b/docs/oidc-attestation.md new file mode 100644 index 0000000..e77e46e --- /dev/null +++ b/docs/oidc-attestation.md @@ -0,0 +1,110 @@ +# OIDC Workload Attestation + +How the `oidc-attestor` plugin identifies workloads via OIDC tokens. + +## Flow + +``` +Workload SPIRE Agent oidc-attestor OIDC Provider + | | | | + | FetchX509SVID | | | + |----------------->| | | + | | Attest(pid=1234) | | + | |------------------->| | + | | | GET /.well-known/ | + | | | openid-configuration| + | | |-------------------->| + | | | JWKS endpoint | + | | |<--------------------| + | | | | + | | | Read token from | + | | | /var/run/secrets/ | + | | | oidc/token | + | | | | + | | | Verify signature | + | | | via JWKS | + | | | | + | | Selectors | | + | |<-------------------| | + | X509-SVID | | | + |<-----------------| | | +``` + +## Token Discovery + +The plugin discovers the workload's OIDC token via a configurable path. In Kubernetes, the token is typically projected into the pod filesystem: + +```yaml +# Pod spec +volumes: + - name: oidc-token + projected: + sources: + - serviceAccountToken: + audience: spire + expirationSeconds: 3600 + path: token +containers: + - name: app + volumeMounts: + - name: oidc-token + mountPath: /var/run/secrets/oidc + readOnly: true +``` + +Plugin configuration: + +```hcl +WorkloadAttestor "guildhouse_oidc" { + plugin_cmd = "/opt/spire/plugins/oidc-attestor" + plugin_data { + issuer = "https://keycloak.guildhouse.example.org/realms/platform" + audience = "spire" + token_path = "/var/run/secrets/oidc/token" + } +} +``` + +## Token Verification + +1. Plugin reads the JWT from the configured path. +2. Fetches the OIDC discovery document from `{issuer}/.well-known/openid-configuration`. +3. Retrieves the JWKS from the `jwks_uri` in the discovery document. +4. Verifies the JWT signature using the matching key from the JWKS. +5. Validates standard claims: `iss` matches configured issuer, `aud` includes configured audience, `exp` is in the future. + +## Selector Output + +The plugin returns selectors that SPIRE uses to match workloads against registration entries: + +| Selector | Source Claim | Example | +|----------|-------------|---------| +| `oidc_attestor:iss:` | `iss` | `oidc_attestor:iss:https://keycloak.example.org/realms/platform` | +| `oidc_attestor:sub:` | `sub` | `oidc_attestor:sub:f47ac10b-58cc-4372-a567-0e02b2c3d479` | +| `oidc_attestor:email:` | `email` | `oidc_attestor:email:operator@guildhouse.coop` | +| `oidc_attestor:group:` | `groups[]` | `oidc_attestor:group:platform-engineers` | + +## Registration Entry Matching + +To grant an SVID to workloads authenticated via OIDC: + +```bash +spire-server entry create \ + -spiffeID spiffe://guildhouse.io/ns/prod/sa/web-server \ + -parentID spiffe://guildhouse.io/spire/agent/k8s_psat/guildhouse/... \ + -selector oidc_attestor:sub:f47ac10b-58cc-4372-a567-0e02b2c3d479 +``` + +Multiple selectors can be combined (AND logic): + +```bash +spire-server entry create \ + -spiffeID spiffe://guildhouse.io/ns/prod/sa/admin-tool \ + -parentID spiffe://guildhouse.io/spire/agent/k8s_psat/guildhouse/... \ + -selector oidc_attestor:iss:https://keycloak.example.org/realms/platform \ + -selector oidc_attestor:group:platform-engineers +``` + +## JWKS Caching + +The plugin caches JWKS responses for the duration specified by the `Cache-Control` header (or 5 minutes if not present). This avoids hitting the OIDC provider on every attestation. diff --git a/docs/plugin-types.md b/docs/plugin-types.md new file mode 100644 index 0000000..8f42187 --- /dev/null +++ b/docs/plugin-types.md @@ -0,0 +1,115 @@ +# SPIRE Plugin Types + +This document describes the four SPIRE plugin interfaces implemented by this repository, their callback methods, invocation timing, and the Guildhouse plugin that implements each. + +## Overview + +SPIRE's plugin architecture uses the go-plugin (hashicorp/go-plugin) framework. Plugins are compiled as separate binaries and loaded by the SPIRE Agent or Server at startup via the plugin configuration in `server.conf` or `agent.conf`. + +| Interface | Side | Guildhouse Plugin | Purpose | +|-----------|------|-------------------|---------| +| WorkloadAttestor | Agent | oidc-attestor | Identify workloads by OIDC token | +| CredentialComposer | Server | ssh-credential-composer | Modify credentials during minting | +| Notifier | Server | governance-notifier | React to SPIRE lifecycle events | +| KeyManager | Server | substrate-keymanager | Manage server signing keys | + +## WorkloadAttestor + +**Side:** SPIRE Agent + +**Plugin:** `cmd/oidc-attestor` + +**Interface methods:** + +```go +Attest(ctx context.Context, pid int32) ([]*common.Selector, error) +``` + +**When called:** Every time a workload calls the Workload API (typically `FetchX509SVID` or `FetchJWTSVID`). The agent identifies the calling process by PID, then passes that PID to all registered WorkloadAttestor plugins. Each plugin returns zero or more selectors. + +**What oidc-attestor does:** Given the workload PID, it discovers an OIDC token associated with that process (via a projected volume path or environment variable), validates the token signature against a JWKS endpoint, and returns selectors derived from token claims (issuer, subject, audience, custom claims). + +**Selector format:** +``` +oidc_attestor:iss: +oidc_attestor:sub: +oidc_attestor:aud: +oidc_attestor:claim:: +``` + +## CredentialComposer + +**Side:** SPIRE Server + +**Plugin:** `cmd/ssh-credential-composer` + +**Interface methods:** + +```go +ComposeServerX509CA(ctx context.Context, attributes X509CAAttributes) (X509CAAttributes, error) +ComposeServerX509SVID(ctx context.Context, attributes X509SVIDAttributes) (X509SVIDAttributes, error) +ComposeAgentX509SVID(ctx context.Context, attributes X509SVIDAttributes) (X509SVIDAttributes, error) +ComposeWorkloadX509SVID(ctx context.Context, attributes X509SVIDAttributes) (X509SVIDAttributes, error) +ComposeWorkloadJWTSVID(ctx context.Context, attributes JWTSVIDAttributes) (JWTSVIDAttributes, error) +``` + +**When called:** During credential minting on the server. After the server decides to issue a credential (X.509 SVID, JWT SVID, or CA certificate), it passes the proposed attributes through all registered CredentialComposer plugins. Each plugin may modify the attributes before the credential is signed. + +**What ssh-credential-composer does:** Intercepts `ComposeWorkloadX509SVID` (and potentially future SSH-specific hooks). It reads the SPIFFE ID and registration entry metadata, calls GovernanceService to create a MutationIntent for the issuance, constructs a MutationEnvelope, anchors it via NotaryService, then encodes the governance metadata as Shellstream extensions in the SSH certificate's critical options using the `pkg/shellstream` encoder. + +**Key detail:** The composer does not modify X.509 SVIDs for non-SSH use cases. It checks registration entry selectors or hints to determine if the credential is destined for SSH usage before injecting extensions. + +## Notifier + +**Side:** SPIRE Server + +**Plugin:** `cmd/governance-notifier` + +**Interface methods:** + +```go +Notify(ctx context.Context, event *NotifyRequest) (*NotifyResponse, error) +NotifyAndAdvise(ctx context.Context, event *NotifyAndAdviseRequest) (*NotifyAndAdviseResponse, error) +``` + +**When called:** +- `Notify`: After a bundle update or registration entry change has been committed. This is fire-and-forget; the server does not block on the response. +- `NotifyAndAdvise`: Before certain operations where the plugin can influence the outcome. The server waits for the response. + +**Event types the plugin handles:** +- `BundleUpdated` — A trust bundle was rotated. +- `EntryUpdated` — A registration entry was created, updated, or deleted. + +**What governance-notifier does:** On bundle rotation events, it creates a MutationIntent via GovernanceService to record the rotation as an auditable governance event. For sensitive entry changes (e.g., high-privilege SPIFFE IDs), it triggers a CeremonyService flow requiring multi-stakeholder approval. All events are anchored in the merkle tree via NotaryService. + +## KeyManager + +**Side:** SPIRE Server + +**Plugin:** `cmd/substrate-keymanager` + +**Interface methods:** + +```go +GenerateKey(ctx context.Context, req *GenerateKeyRequest) (*GenerateKeyResponse, error) +GetPublicKey(ctx context.Context, req *GetPublicKeyRequest) (*GetPublicKeyResponse, error) +GetPublicKeys(ctx context.Context, req *GetPublicKeysRequest) (*GetPublicKeysResponse, error) +SignData(ctx context.Context, req *SignDataRequest) (*SignDataResponse, error) +``` + +**When called:** +- `GenerateKey`: When the server needs a new signing key (initial startup, key rotation). +- `GetPublicKey`/`GetPublicKeys`: When the server needs to retrieve existing key material. +- `SignData`: Every time the server signs a credential (SVID, CA cert). + +**What substrate-keymanager does:** Manages the SPIRE Server's signing keys with governance-aware lifecycle. Key generation and rotation events are recorded as MutationIntents. The plugin stores keys in a configured backend (filesystem, Kubernetes Secret, or remote KMS) and ensures every key operation is traceable through the governance audit trail. + +## Plugin Loading Order + +SPIRE loads plugins in the order they appear in configuration. For correctness: + +1. **substrate-keymanager** must load first (server needs signing keys before issuing anything). +2. **ssh-credential-composer** loads next (must be present before any credential minting). +3. **governance-notifier** loads last (reacts to events that other plugins may trigger). + +On the agent side, **oidc-attestor** is loaded alongside any other workload attestors (e.g., the built-in `k8s` attestor). Multiple attestors can coexist; their selectors are unioned. diff --git a/docs/ssh-certificate-flow.md b/docs/ssh-certificate-flow.md new file mode 100644 index 0000000..ef439b8 --- /dev/null +++ b/docs/ssh-certificate-flow.md @@ -0,0 +1,122 @@ +# SSH Certificate Issuance Flow + +End-to-end flow from workload request to governed SSH session. + +## Sequence + +``` +Workload SPIRE Agent SPIRE Server ssh-credential- Governance Notary + composer Service Service + | | | | | | + | FetchSSHSVID | | | | | + |-------------->| | | | | + | | Attest(pid) | | | | + | |--+ | | | | + | | | oidc-attestor | | | + | |<-+ | | | | + | | | | | | + | | MintSSHSVID | | | | + | |-------------->| | | | + | | | ComposeWorkloadCred | | + | | |-------------->| | | + | | | | | | + | | | | CreateIntent | | + | | | |-------------->| | + | | | | intent_id | | + | | | |<--------------| | + | | | | | | + | | | | RedeemIntent | | + | | | |-------------->| | + | | | | SAT | | + | | | |<--------------| | + | | | | | | + | | | | Build SSH cert with | + | | | | Shellstream extensions | + | | | | | | + | | | | CreateAnchor | | + | | | |------------------------------>| + | | | | merkle_root | | + | | | |<------------------------------| + | | | | | | + | | | SSH cert | | | + | | |<--------------| | | + | | SSH cert | | | | + | |<--------------| | | | + | SSH cert | | | | | + |<--------------| | | | | +``` + +## Step-by-Step + +### 1. Workload Requests SSH SVID + +The workload calls the SPIRE Workload API via Unix domain socket, requesting an SSH-SVID. It provides its SSH public key (Ed25519). + +### 2. Agent Attests Workload + +The SPIRE Agent identifies the calling process via kernel-level attestation. The `oidc-attestor` plugin verifies the workload's OIDC token and returns selectors. The agent matches selectors against registration entries. + +### 3. Agent Forwards to Server + +The agent sends a `MintSSHSVID` request to the SPIRE Server over the Agent-Server mTLS channel, including the workload's SPIFFE ID, public key, and requested principals. + +### 4. CredentialComposer Pipeline + +The SPIRE Server invokes the `ssh-credential-composer` plugin during credential minting. + +### 5. Governance Intent + +The composer calls `GovernanceService.CreateIntent` with: +- `registry_type`: `"credential"` +- `verb`: `"issue"` +- `artifact_scope`: JSON describing the SSH certificate parameters +- Identity from SPIRE attestation + +If the Accord policy requires a ceremony, the flow blocks until approval. + +### 6. SAT Issuance + +The composer calls `GovernanceService.RedeemIntent` to obtain a SAT (Substrate Attestation Token) authorizing the credential issuance. + +### 7. SSH Certificate Construction + +The composer builds the SSH certificate: +- **Key ID**: SPIFFE ID +- **Principals**: SPIFFE ID + additional principals from registration +- **TTL**: Per-workload configuration (default 5 minutes) +- **Extensions**: Standard SSH extensions + Shellstream governance extensions + +Shellstream extensions embedded: +- `sat-scope@guildhouse.io` — SAT authorization scope +- `sat-hash@guildhouse.io` — SHA-256 of the SAT +- `tenant-id@guildhouse.io` — Tenant UUID +- `roles@guildhouse.io` — Assigned roles +- `merkle-root@guildhouse.io` — Governance tree root at issuance +- `governance-epoch@guildhouse.io` — Current epoch counter + +### 8. Merkle Anchoring + +The composer constructs a MutationEnvelope (RFC 8785 JCS + domain-separated SHA-256) and submits the leaf hash to `NotaryService.CreateAnchor`. + +### 9. Certificate Delivery + +The signed SSH certificate flows back through the server → agent → workload chain. The workload receives the certificate, private key (if agent-generated), and SSH CA trust bundle. + +## Using the Certificate + +The workload uses the SSH-SVID to connect to governed hosts: + +```bash +ssh -o CertificateFile=/tmp/ssh-svid-cert.pub \ + -i /tmp/ssh-svid-key \ + target-host.example.org +``` + +## Server-Side Validation + +The SSH server: +1. Validates the certificate signature against `TrustedUserCAKeys` (from SPIRE trust bundle) +2. Checks the certificate validity period +3. Matches principals against `AuthorizedPrincipalsFile` +4. Reads Shellstream extensions for authorization decisions (tenant, roles, SAT scope) +5. Optionally verifies the merkle proof against the NotaryService for audit diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..1ca9d36 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,112 @@ +# Testing Strategy + +How to test the Guildhouse SPIRE plugins. + +## Running Tests + +```bash +make test # Run all tests +make lint # Run go vet + +# Verbose output +go test -v ./... + +# Single package +go test -v ./pkg/shellstream/... + +# With coverage +go test -coverprofile=coverage.out ./... +go tool cover -html=coverage.out +``` + +## Test Structure + +### Unit Tests (`pkg/`) + +Each package has `*_test.go` files with unit tests: + +| Package | Tests | Coverage Focus | +|---------|-------|---------------| +| `pkg/shellstream` | ~28 tests | Encode/decode round-trips, validation rules, format constraints, co-occurrence rules | +| `pkg/oidc` | 2 tests | Config validation (scaffolded) | +| `pkg/governance` | 2 tests | Client construction (scaffolded) | +| `pkg/sshcert` | 3 tests | Builder config validation (scaffolded) | +| `pkg/config` | 2 tests | Plugin config validation (scaffolded) | + +### `pkg/shellstream` Tests (Fully Implemented) + +The shellstream package has comprehensive tests covering: + +- **Round-trip**: Encode → Decode produces identical values (full and minimal) +- **Required fields**: Missing tenant-id, missing roles produce validation errors +- **Format validation**: Invalid UUID, wrong hex length, uppercase hex, invalid base64 +- **Co-occurrence**: sat-scope without sat-hash, ceremony-id without ceremony-type, merkle-proof without merkle-root +- **Parsing**: JSON sat-scope, comma-separated roles, base64 merkle proof, uint64 governance epoch +- **Edge cases**: Epoch value 0, nil extensions, unknown extensions ignored +- **Enum validation**: Unknown ceremony-type rejected + +### Integration Test Approach (Future) + +When plugin implementations are complete, integration tests will use mock gRPC servers: + +```go +// Example mock GovernanceService +type mockGovernanceServer struct { + quartermasterv1.UnimplementedGovernanceServiceServer + intents map[string]*Intent +} + +func (s *mockGovernanceServer) CreateIntent(ctx context.Context, req *quartermasterv1.CreateIntentRequest) (*quartermasterv1.CreateIntentResponse, error) { + // Return predictable responses for testing +} +``` + +Integration tests will: +1. Start mock gRPC servers for GovernanceService, CeremonyService, NotaryService +2. Create plugin instances pointed at the mock servers +3. Invoke plugin methods (Attest, Compose, Notify) +4. Verify the plugin made expected gRPC calls with correct parameters +5. Verify the plugin handled error conditions (unreachable service, denied intent, ceremony timeout) + +## Test Fixtures + +Test fixture files in `test/fixtures/`: + +| File | Purpose | +|------|---------| +| `sample-oidc-token.json` | Example OIDC token payload for oidc-attestor testing | +| `sample-sat-scope.json` | Example SatScope for governance client testing | +| `sample-ssh-cert-extensions.json` | Example Shellstream extensions map for shellstream package testing | +| `spire-test-config.hcl` | Example SPIRE plugin configuration for config package testing | + +Load fixtures in tests: + +```go +func loadFixture(t *testing.T, name string) []byte { + t.Helper() + data, err := os.ReadFile(filepath.Join("../../test/fixtures", name)) + if err != nil { + t.Fatalf("load fixture %s: %v", name, err) + } + return data +} +``` + +## CI Pipeline + +```yaml +# .github/workflows/test.yaml +name: Test +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.23' + - run: make test + - run: make lint + - run: make build +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5ea6f67 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/guildhouse-cooperative/guildhouse-spire-plugins + +go 1.23.6 diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..fc840d3 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,39 @@ +// Package config provides configuration loading for SPIRE plugins. +// SPIRE plugins receive configuration via HCL in the SPIRE server/agent config file. +package config + +import ( + "fmt" +) + +// PluginConfig holds common configuration fields shared by all Guildhouse SPIRE plugins. +type PluginConfig struct { + // GovernanceAddr is the gRPC address of the GovernanceService. + GovernanceAddr string `hcl:"governance_addr"` + + // CeremonyAddr is the gRPC address of the CeremonyService. + CeremonyAddr string `hcl:"ceremony_addr"` + + // NotaryAddr is the gRPC address of the NotaryService. + NotaryAddr string `hcl:"notary_addr"` + + // TrustDomain is the SPIFFE trust domain. + TrustDomain string `hcl:"trust_domain"` + + // ClusterID identifies this cluster for notary anchoring. + ClusterID string `hcl:"cluster_id"` +} + +// Validate checks that required fields are present. +func (c *PluginConfig) Validate() error { + if c.TrustDomain == "" { + return fmt.Errorf("config: trust_domain is required") + } + return nil +} + +// LoadFromHCL parses plugin configuration from HCL bytes. +// TODO: implement — use hashicorp/hcl to parse configuration. +func LoadFromHCL(data []byte) (*PluginConfig, error) { + return nil, fmt.Errorf("config: LoadFromHCL not yet implemented") +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..f0dac14 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,21 @@ +package config + +import ( + "testing" +) + +func TestValidateRequiresTrustDomain(t *testing.T) { + cfg := &PluginConfig{} + err := cfg.Validate() + if err == nil { + t.Fatal("expected error for empty trust domain") + } +} + +func TestValidateAcceptsMinimalConfig(t *testing.T) { + cfg := &PluginConfig{TrustDomain: "example.org"} + err := cfg.Validate() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/pkg/governance/governance.go b/pkg/governance/governance.go new file mode 100644 index 0000000..3cf3d0d --- /dev/null +++ b/pkg/governance/governance.go @@ -0,0 +1,74 @@ +// Package governance provides a gRPC client for the Guildhouse GovernanceService +// and CeremonyService, used by SPIRE plugins to participate in governed mutations. +package governance + +import ( + "context" + "fmt" +) + +// Config holds governance client configuration. +type Config struct { + // GovernanceAddr is the gRPC address of the GovernanceService. + GovernanceAddr string + + // CeremonyAddr is the gRPC address of the CeremonyService. + CeremonyAddr string + + // NotaryAddr is the gRPC address of the NotaryService. + NotaryAddr string +} + +// IntentResult holds the result of a CreateIntent call. +type IntentResult struct { + IntentID string + CeremonyID string // non-empty if ceremony required + Denied bool + Error string +} + +// RedeemResult holds the result of a RedeemIntent call. +type RedeemResult struct { + Success bool + SatHash []byte + Status string + Error string +} + +// Client wraps gRPC clients for GovernanceService, CeremonyService, and NotaryService. +type Client struct { + config Config +} + +// NewClient creates a governance client. +func NewClient(cfg Config) (*Client, error) { + if cfg.GovernanceAddr == "" { + return nil, fmt.Errorf("governance: governance address is required") + } + // TODO: implement — establish gRPC connections with mTLS + return &Client{config: cfg}, nil +} + +// CreateIntent creates a MutationIntent for a credential operation. +func (c *Client) CreateIntent(ctx context.Context, registryType, verb, artifactScope, tenantID string) (*IntentResult, error) { + // TODO: implement — call GovernanceService.CreateIntent + return nil, fmt.Errorf("governance: CreateIntent not yet implemented") +} + +// RedeemIntent redeems a MutationIntent to obtain a SAT. +func (c *Client) RedeemIntent(ctx context.Context, intentID string) (*RedeemResult, error) { + // TODO: implement — call GovernanceService.RedeemIntent + return nil, fmt.Errorf("governance: RedeemIntent not yet implemented") +} + +// CreateCeremony creates a governance ceremony. +func (c *Client) CreateCeremony(ctx context.Context, ceremonyType, intentID string, requiredApprovals uint32) (string, error) { + // TODO: implement — call CeremonyService.CreateCeremony + return "", fmt.Errorf("governance: CreateCeremony not yet implemented") +} + +// SubmitMerkleLeaf submits a credential event as a merkle leaf to the NotaryService. +func (c *Client) SubmitMerkleLeaf(ctx context.Context, clusterID string, leaf []byte) (string, error) { + // TODO: implement — call NotaryService.CreateAnchor + return "", fmt.Errorf("governance: SubmitMerkleLeaf not yet implemented") +} diff --git a/pkg/governance/governance_test.go b/pkg/governance/governance_test.go new file mode 100644 index 0000000..c9bae96 --- /dev/null +++ b/pkg/governance/governance_test.go @@ -0,0 +1,22 @@ +package governance + +import ( + "testing" +) + +func TestNewClientRequiresAddress(t *testing.T) { + _, err := NewClient(Config{}) + if err == nil { + t.Fatal("expected error for empty governance address") + } +} + +func TestNewClientAcceptsValidConfig(t *testing.T) { + c, err := NewClient(Config{GovernanceAddr: "localhost:50051"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c == nil { + t.Fatal("client should not be nil") + } +} diff --git a/pkg/oidc/oidc.go b/pkg/oidc/oidc.go new file mode 100644 index 0000000..27ce11d --- /dev/null +++ b/pkg/oidc/oidc.go @@ -0,0 +1,43 @@ +// Package oidc provides OIDC token verification for SPIRE workload attestation. +package oidc + +import ( + "context" + "fmt" +) + +// Config holds OIDC verifier configuration. +type Config struct { + // Issuer is the expected OIDC issuer URL. + Issuer string + + // Audience is the expected token audience. + Audience string + + // JWKSURL overrides automatic OIDC discovery for the JWKS endpoint. + JWKSURL string +} + +// Claims represents the verified claims from an OIDC token. +type Claims struct { + Subject string + Issuer string + Audience []string + Email string + Groups []string +} + +// Verifier validates OIDC tokens and extracts claims. +type Verifier interface { + // Verify validates the token and returns the claims. + Verify(ctx context.Context, rawToken string) (*Claims, error) +} + +// NewVerifier creates an OIDC token verifier from the given configuration. +func NewVerifier(cfg Config) (Verifier, error) { + if cfg.Issuer == "" { + return nil, fmt.Errorf("oidc: issuer is required") + } + // TODO: implement — fetch OIDC discovery document, configure JWKS validation + return nil, fmt.Errorf("oidc: not yet implemented") +} diff --git a/pkg/oidc/oidc_test.go b/pkg/oidc/oidc_test.go new file mode 100644 index 0000000..def07c6 --- /dev/null +++ b/pkg/oidc/oidc_test.go @@ -0,0 +1,19 @@ +package oidc + +import ( + "testing" +) + +func TestNewVerifierRequiresIssuer(t *testing.T) { + _, err := NewVerifier(Config{}) + if err == nil { + t.Fatal("expected error for empty issuer") + } +} + +func TestNewVerifierNotYetImplemented(t *testing.T) { + _, err := NewVerifier(Config{Issuer: "https://accounts.example.com"}) + if err == nil { + t.Fatal("expected not-yet-implemented error") + } +} diff --git a/pkg/shellstream/doc.go b/pkg/shellstream/doc.go new file mode 100644 index 0000000..2078e04 --- /dev/null +++ b/pkg/shellstream/doc.go @@ -0,0 +1,9 @@ +// Package shellstream encodes and decodes Shellstream SSH certificate extensions. +// +// Shellstream extensions use the @guildhouse.io vendor suffix to carry +// governance metadata in SSH certificates. This package provides Encode, +// Decode, and Validate functions that operate on the extension map within +// an OpenSSH certificate. +// +// See specs/shellstream-extensions.md for the full specification. +package shellstream diff --git a/pkg/shellstream/shellstream.go b/pkg/shellstream/shellstream.go new file mode 100644 index 0000000..363e755 --- /dev/null +++ b/pkg/shellstream/shellstream.go @@ -0,0 +1,318 @@ +package shellstream + +import ( + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// Extension key constants — all use the @guildhouse.io vendor suffix. +const ( + ExtSatScope = "sat-scope@guildhouse.io" + ExtSatHash = "sat-hash@guildhouse.io" + ExtTenantID = "tenant-id@guildhouse.io" + ExtRoles = "roles@guildhouse.io" + ExtCeremonyID = "ceremony-id@guildhouse.io" + ExtCeremonyType = "ceremony-type@guildhouse.io" + ExtMerkleRoot = "merkle-root@guildhouse.io" + ExtMerkleProof = "merkle-proof@guildhouse.io" + ExtGovernanceEpoch = "governance-epoch@guildhouse.io" + + // Vendor suffix for identifying Shellstream extensions. + VendorSuffix = "@guildhouse.io" +) + +// Valid ceremony types. +var validCeremonyTypes = map[string]bool{ + "self_grant": true, + "single_approval": true, + "quorum_approval": true, + "emergency_break_glass": true, +} + +// SatScope represents a Substrate Attestation Token scope. +type SatScope struct { + RegistryType string `json:"registry_type"` + Verbs []string `json:"verbs"` + ResourcePattern string `json:"resource_pattern"` +} + +// ShellstreamExtensions holds all Shellstream extension values. +type ShellstreamExtensions struct { + // SatScope is the authorization scope. Required when SAT is present. + SatScope *SatScope + + // SatHash is the hex-encoded SHA-256 of the SAT bytes. Required when SatScope is set. + SatHash string + + // TenantID is the tenant UUID. Required. + TenantID string + + // Roles is the list of role names. Required (at least one). + Roles []string + + // CeremonyID is the governance ceremony UUID. Optional (elevated sessions only). + CeremonyID string + + // CeremonyType is the ceremony type. Required when CeremonyID is set. + CeremonyType string + + // MerkleRoot is the hex-encoded governance merkle root at issuance. Optional. + MerkleRoot string + + // MerkleProof is the binary inclusion proof. Optional, requires MerkleRoot. + MerkleProof []byte + + // GovernanceEpoch is the monotonic governance state counter. Optional. + GovernanceEpoch uint64 + + // Internal tracking for whether epoch was explicitly set (0 is valid). + hasGovernanceEpoch bool +} + +// WithGovernanceEpoch sets the governance epoch and marks it as explicitly set. +func (e *ShellstreamExtensions) WithGovernanceEpoch(epoch uint64) { + e.GovernanceEpoch = epoch + e.hasGovernanceEpoch = true +} + +// HasGovernanceEpoch returns true if the governance epoch was explicitly set. +func (e *ShellstreamExtensions) HasGovernanceEpoch() bool { + return e.hasGovernanceEpoch +} + +// Encode serializes ShellstreamExtensions into an SSH certificate extensions map. +func Encode(ext *ShellstreamExtensions) (map[string]string, error) { + if ext == nil { + return nil, fmt.Errorf("shellstream: nil extensions") + } + + m := make(map[string]string) + + // Required fields. + m[ExtTenantID] = ext.TenantID + m[ExtRoles] = strings.Join(ext.Roles, ",") + + // SAT fields (both or neither). + if ext.SatScope != nil { + scopeJSON, err := json.Marshal(ext.SatScope) + if err != nil { + return nil, fmt.Errorf("shellstream: marshal sat-scope: %w", err) + } + m[ExtSatScope] = string(scopeJSON) + m[ExtSatHash] = ext.SatHash + } + + // Ceremony fields. + if ext.CeremonyID != "" { + m[ExtCeremonyID] = ext.CeremonyID + m[ExtCeremonyType] = ext.CeremonyType + } + + // Merkle fields. + if ext.MerkleRoot != "" { + m[ExtMerkleRoot] = ext.MerkleRoot + } + if len(ext.MerkleProof) > 0 { + m[ExtMerkleProof] = base64.StdEncoding.EncodeToString(ext.MerkleProof) + } + + // Governance epoch. + if ext.hasGovernanceEpoch { + m[ExtGovernanceEpoch] = strconv.FormatUint(ext.GovernanceEpoch, 10) + } + + return m, nil +} + +// Decode parses an SSH certificate extensions map into ShellstreamExtensions. +// Unknown extensions (including non-Shellstream keys) are silently ignored. +func Decode(extensions map[string]string) (*ShellstreamExtensions, error) { + ext := &ShellstreamExtensions{} + + // Required: tenant-id. + if v, ok := extensions[ExtTenantID]; ok { + ext.TenantID = v + } + + // Required: roles. + if v, ok := extensions[ExtRoles]; ok && v != "" { + ext.Roles = strings.Split(v, ",") + } + + // Optional: sat-scope. + if v, ok := extensions[ExtSatScope]; ok { + scope := &SatScope{} + if err := json.Unmarshal([]byte(v), scope); err != nil { + return nil, fmt.Errorf("shellstream: unmarshal sat-scope: %w", err) + } + ext.SatScope = scope + } + + // Optional: sat-hash. + if v, ok := extensions[ExtSatHash]; ok { + ext.SatHash = v + } + + // Optional: ceremony-id. + if v, ok := extensions[ExtCeremonyID]; ok { + ext.CeremonyID = v + } + + // Optional: ceremony-type. + if v, ok := extensions[ExtCeremonyType]; ok { + ext.CeremonyType = v + } + + // Optional: merkle-root. + if v, ok := extensions[ExtMerkleRoot]; ok { + ext.MerkleRoot = v + } + + // Optional: merkle-proof. + if v, ok := extensions[ExtMerkleProof]; ok { + proof, err := base64.StdEncoding.DecodeString(v) + if err != nil { + return nil, fmt.Errorf("shellstream: decode merkle-proof: %w", err) + } + ext.MerkleProof = proof + } + + // Optional: governance-epoch. + if v, ok := extensions[ExtGovernanceEpoch]; ok { + epoch, err := strconv.ParseUint(v, 10, 64) + if err != nil { + return nil, fmt.Errorf("shellstream: parse governance-epoch: %w", err) + } + ext.GovernanceEpoch = epoch + ext.hasGovernanceEpoch = true + } + + return ext, nil +} + +// Validate checks that a ShellstreamExtensions value satisfies all format +// constraints and co-occurrence rules from the specification. +func Validate(ext *ShellstreamExtensions) error { + if ext == nil { + return fmt.Errorf("shellstream: nil extensions") + } + + // Required: tenant-id. + if ext.TenantID == "" { + return fmt.Errorf("shellstream: tenant-id is required") + } + if !isValidUUID(ext.TenantID) { + return fmt.Errorf("shellstream: tenant-id is not a valid UUID: %q", ext.TenantID) + } + + // Required: roles (at least one). + if len(ext.Roles) == 0 { + return fmt.Errorf("shellstream: roles is required (at least one role)") + } + for _, r := range ext.Roles { + if r == "" { + return fmt.Errorf("shellstream: empty role name") + } + if strings.ContainsAny(r, ", ") { + return fmt.Errorf("shellstream: role name must not contain commas or spaces: %q", r) + } + } + + // Co-occurrence: sat-scope requires sat-hash and vice versa. + if ext.SatScope != nil && ext.SatHash == "" { + return fmt.Errorf("shellstream: sat-scope requires sat-hash") + } + if ext.SatHash != "" && ext.SatScope == nil { + return fmt.Errorf("shellstream: sat-hash requires sat-scope") + } + + // sat-hash format: 64 lowercase hex characters. + if ext.SatHash != "" { + if len(ext.SatHash) != 64 { + return fmt.Errorf("shellstream: sat-hash must be 64 hex characters, got %d", len(ext.SatHash)) + } + if _, err := hex.DecodeString(ext.SatHash); err != nil { + return fmt.Errorf("shellstream: sat-hash is not valid hex: %w", err) + } + if ext.SatHash != strings.ToLower(ext.SatHash) { + return fmt.Errorf("shellstream: sat-hash must be lowercase") + } + } + + // sat-scope fields. + if ext.SatScope != nil { + if ext.SatScope.RegistryType == "" { + return fmt.Errorf("shellstream: sat-scope.registry_type is required") + } + if len(ext.SatScope.Verbs) == 0 { + return fmt.Errorf("shellstream: sat-scope.verbs is required (at least one verb)") + } + } + + // Co-occurrence: ceremony-id requires ceremony-type. + if ext.CeremonyID != "" && ext.CeremonyType == "" { + return fmt.Errorf("shellstream: ceremony-id requires ceremony-type") + } + if ext.CeremonyType != "" && ext.CeremonyID == "" { + return fmt.Errorf("shellstream: ceremony-type requires ceremony-id") + } + + // ceremony-id format: UUID. + if ext.CeremonyID != "" { + if !isValidUUID(ext.CeremonyID) { + return fmt.Errorf("shellstream: ceremony-id is not a valid UUID: %q", ext.CeremonyID) + } + } + + // ceremony-type: must be a known value. + if ext.CeremonyType != "" { + if !validCeremonyTypes[ext.CeremonyType] { + return fmt.Errorf("shellstream: unknown ceremony-type: %q", ext.CeremonyType) + } + } + + // merkle-root format: 64 lowercase hex characters. + if ext.MerkleRoot != "" { + if len(ext.MerkleRoot) != 64 { + return fmt.Errorf("shellstream: merkle-root must be 64 hex characters, got %d", len(ext.MerkleRoot)) + } + if _, err := hex.DecodeString(ext.MerkleRoot); err != nil { + return fmt.Errorf("shellstream: merkle-root is not valid hex: %w", err) + } + if ext.MerkleRoot != strings.ToLower(ext.MerkleRoot) { + return fmt.Errorf("shellstream: merkle-root must be lowercase") + } + } + + // Co-occurrence: merkle-proof requires merkle-root. + if len(ext.MerkleProof) > 0 && ext.MerkleRoot == "" { + return fmt.Errorf("shellstream: merkle-proof requires merkle-root") + } + + return nil +} + +// isValidUUID checks if a string is a valid RFC 4122 UUID (lowercase, hyphenated). +func isValidUUID(s string) bool { + // Format: 8-4-4-4-12 = 36 characters. + if len(s) != 36 { + return false + } + for i, c := range s { + switch i { + case 8, 13, 18, 23: + if c != '-' { + return false + } + default: + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + return false + } + } + } + return true +} diff --git a/pkg/shellstream/shellstream_test.go b/pkg/shellstream/shellstream_test.go new file mode 100644 index 0000000..0f126f7 --- /dev/null +++ b/pkg/shellstream/shellstream_test.go @@ -0,0 +1,550 @@ +package shellstream + +import ( + "encoding/base64" + "strings" + "testing" +) + +// Helper to create a minimal valid extensions set. +func minimalExtensions() *ShellstreamExtensions { + return &ShellstreamExtensions{ + TenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + Roles: []string{"analyst"}, + } +} + +// Helper to create a fully-populated extensions set. +func fullExtensions() *ShellstreamExtensions { + ext := &ShellstreamExtensions{ + SatScope: &SatScope{ + RegistryType: "oci", + Verbs: []string{"push", "pull"}, + ResourcePattern: "tenant-a/*", + }, + SatHash: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + TenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + Roles: []string{"administrator", "engineer"}, + CeremonyID: "11223344-5566-7788-99aa-bbccddeeff00", + CeremonyType: "single_approval", + MerkleRoot: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", + MerkleProof: []byte{0x01, 0x02, 0x03, 0x04, 0x05}, + } + ext.WithGovernanceEpoch(42) + return ext +} + +func TestEncodeDecodeRoundTrip(t *testing.T) { + ext := fullExtensions() + + encoded, err := Encode(ext) + if err != nil { + t.Fatalf("Encode: %v", err) + } + + decoded, err := Decode(encoded) + if err != nil { + t.Fatalf("Decode: %v", err) + } + + // Verify all fields round-trip. + if decoded.TenantID != ext.TenantID { + t.Errorf("TenantID: got %q, want %q", decoded.TenantID, ext.TenantID) + } + if len(decoded.Roles) != len(ext.Roles) { + t.Fatalf("Roles length: got %d, want %d", len(decoded.Roles), len(ext.Roles)) + } + for i, r := range decoded.Roles { + if r != ext.Roles[i] { + t.Errorf("Roles[%d]: got %q, want %q", i, r, ext.Roles[i]) + } + } + if decoded.SatHash != ext.SatHash { + t.Errorf("SatHash: got %q, want %q", decoded.SatHash, ext.SatHash) + } + if decoded.SatScope == nil { + t.Fatal("SatScope: got nil") + } + if decoded.SatScope.RegistryType != ext.SatScope.RegistryType { + t.Errorf("SatScope.RegistryType: got %q, want %q", decoded.SatScope.RegistryType, ext.SatScope.RegistryType) + } + if len(decoded.SatScope.Verbs) != len(ext.SatScope.Verbs) { + t.Fatalf("SatScope.Verbs length: got %d, want %d", len(decoded.SatScope.Verbs), len(ext.SatScope.Verbs)) + } + if decoded.SatScope.ResourcePattern != ext.SatScope.ResourcePattern { + t.Errorf("SatScope.ResourcePattern: got %q, want %q", decoded.SatScope.ResourcePattern, ext.SatScope.ResourcePattern) + } + if decoded.CeremonyID != ext.CeremonyID { + t.Errorf("CeremonyID: got %q, want %q", decoded.CeremonyID, ext.CeremonyID) + } + if decoded.CeremonyType != ext.CeremonyType { + t.Errorf("CeremonyType: got %q, want %q", decoded.CeremonyType, ext.CeremonyType) + } + if decoded.MerkleRoot != ext.MerkleRoot { + t.Errorf("MerkleRoot: got %q, want %q", decoded.MerkleRoot, ext.MerkleRoot) + } + if len(decoded.MerkleProof) != len(ext.MerkleProof) { + t.Fatalf("MerkleProof length: got %d, want %d", len(decoded.MerkleProof), len(ext.MerkleProof)) + } + for i, b := range decoded.MerkleProof { + if b != ext.MerkleProof[i] { + t.Errorf("MerkleProof[%d]: got %x, want %x", i, b, ext.MerkleProof[i]) + } + } + if decoded.GovernanceEpoch != ext.GovernanceEpoch { + t.Errorf("GovernanceEpoch: got %d, want %d", decoded.GovernanceEpoch, ext.GovernanceEpoch) + } + if !decoded.HasGovernanceEpoch() { + t.Error("HasGovernanceEpoch: got false, want true") + } +} + +func TestEncodeDecodeMinimal(t *testing.T) { + ext := minimalExtensions() + + encoded, err := Encode(ext) + if err != nil { + t.Fatalf("Encode: %v", err) + } + + // Only tenant-id and roles should be present. + if len(encoded) != 2 { + t.Errorf("encoded map length: got %d, want 2 (keys: %v)", len(encoded), mapKeys(encoded)) + } + if _, ok := encoded[ExtTenantID]; !ok { + t.Error("missing tenant-id") + } + if _, ok := encoded[ExtRoles]; !ok { + t.Error("missing roles") + } + + decoded, err := Decode(encoded) + if err != nil { + t.Fatalf("Decode: %v", err) + } + if decoded.TenantID != ext.TenantID { + t.Errorf("TenantID: got %q, want %q", decoded.TenantID, ext.TenantID) + } + if decoded.SatScope != nil { + t.Error("SatScope should be nil for minimal extensions") + } + if decoded.CeremonyID != "" { + t.Error("CeremonyID should be empty for minimal extensions") + } + if decoded.HasGovernanceEpoch() { + t.Error("HasGovernanceEpoch should be false for minimal extensions") + } +} + +func TestDecodeUnknownExtensionsIgnored(t *testing.T) { + m := map[string]string{ + ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + ExtRoles: "analyst", + "unknown-ext@guildhouse.io": "some-value", + "permit-pty": "", + "completely-unrelated": "ignored", + } + + decoded, err := Decode(m) + if err != nil { + t.Fatalf("Decode: %v", err) + } + if decoded.TenantID != "a1b2c3d4-e5f6-7890-abcd-ef1234567890" { + t.Errorf("TenantID: got %q", decoded.TenantID) + } +} + +func TestValidateRequiredTenantID(t *testing.T) { + ext := &ShellstreamExtensions{ + Roles: []string{"analyst"}, + } + err := Validate(ext) + if err == nil { + t.Fatal("expected error for missing tenant-id") + } + if !strings.Contains(err.Error(), "tenant-id is required") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestValidateRequiredRoles(t *testing.T) { + ext := &ShellstreamExtensions{ + TenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + } + err := Validate(ext) + if err == nil { + t.Fatal("expected error for missing roles") + } + if !strings.Contains(err.Error(), "roles is required") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestValidateInvalidTenantIDFormat(t *testing.T) { + ext := &ShellstreamExtensions{ + TenantID: "not-a-uuid", + Roles: []string{"analyst"}, + } + err := Validate(ext) + if err == nil { + t.Fatal("expected error for invalid UUID") + } + if !strings.Contains(err.Error(), "not a valid UUID") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestValidateSatScopeRequiresSatHash(t *testing.T) { + ext := minimalExtensions() + ext.SatScope = &SatScope{ + RegistryType: "oci", + Verbs: []string{"pull"}, + ResourcePattern: "*", + } + // Missing SatHash. + err := Validate(ext) + if err == nil { + t.Fatal("expected error for sat-scope without sat-hash") + } + if !strings.Contains(err.Error(), "sat-scope requires sat-hash") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestValidateSatHashRequiresSatScope(t *testing.T) { + ext := minimalExtensions() + ext.SatHash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + // Missing SatScope. + err := Validate(ext) + if err == nil { + t.Fatal("expected error for sat-hash without sat-scope") + } + if !strings.Contains(err.Error(), "sat-hash requires sat-scope") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestValidateSatHashFormat(t *testing.T) { + ext := minimalExtensions() + ext.SatScope = &SatScope{ + RegistryType: "oci", + Verbs: []string{"pull"}, + ResourcePattern: "*", + } + + // Too short. + ext.SatHash = "abcdef" + err := Validate(ext) + if err == nil || !strings.Contains(err.Error(), "64 hex characters") { + t.Errorf("expected 64-char error, got: %v", err) + } + + // Uppercase. + ext.SatHash = "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789" + err = Validate(ext) + if err == nil || !strings.Contains(err.Error(), "lowercase") { + t.Errorf("expected lowercase error, got: %v", err) + } + + // Valid. + ext.SatHash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + err = Validate(ext) + if err != nil { + t.Errorf("unexpected error for valid sat-hash: %v", err) + } +} + +func TestValidateCeremonyCooccurrence(t *testing.T) { + ext := minimalExtensions() + + // ceremony-id without ceremony-type. + ext.CeremonyID = "11223344-5566-7788-99aa-bbccddeeff00" + err := Validate(ext) + if err == nil || !strings.Contains(err.Error(), "ceremony-id requires ceremony-type") { + t.Errorf("expected co-occurrence error, got: %v", err) + } + + // ceremony-type without ceremony-id. + ext.CeremonyID = "" + ext.CeremonyType = "single_approval" + err = Validate(ext) + if err == nil || !strings.Contains(err.Error(), "ceremony-type requires ceremony-id") { + t.Errorf("expected co-occurrence error, got: %v", err) + } +} + +func TestValidateUnknownCeremonyType(t *testing.T) { + ext := minimalExtensions() + ext.CeremonyID = "11223344-5566-7788-99aa-bbccddeeff00" + ext.CeremonyType = "unknown_type" + err := Validate(ext) + if err == nil || !strings.Contains(err.Error(), "unknown ceremony-type") { + t.Errorf("expected unknown ceremony-type error, got: %v", err) + } +} + +func TestValidateMerkleProofRequiresRoot(t *testing.T) { + ext := minimalExtensions() + ext.MerkleProof = []byte{0x01, 0x02} + // Missing MerkleRoot. + err := Validate(ext) + if err == nil || !strings.Contains(err.Error(), "merkle-proof requires merkle-root") { + t.Errorf("expected co-occurrence error, got: %v", err) + } +} + +func TestValidateMerkleRootFormat(t *testing.T) { + ext := minimalExtensions() + + // Too short. + ext.MerkleRoot = "abcdef" + err := Validate(ext) + if err == nil || !strings.Contains(err.Error(), "64 hex characters") { + t.Errorf("expected 64-char error, got: %v", err) + } + + // Uppercase. + ext.MerkleRoot = "FEDCBA9876543210FEDCBA9876543210FEDCBA9876543210FEDCBA9876543210" + err = Validate(ext) + if err == nil || !strings.Contains(err.Error(), "lowercase") { + t.Errorf("expected lowercase error, got: %v", err) + } +} + +func TestValidateEmptyRoleName(t *testing.T) { + ext := &ShellstreamExtensions{ + TenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + Roles: []string{"analyst", ""}, + } + err := Validate(ext) + if err == nil || !strings.Contains(err.Error(), "empty role name") { + t.Errorf("expected empty role error, got: %v", err) + } +} + +func TestValidateRoleWithComma(t *testing.T) { + ext := &ShellstreamExtensions{ + TenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + Roles: []string{"analyst,viewer"}, + } + err := Validate(ext) + if err == nil || !strings.Contains(err.Error(), "commas or spaces") { + t.Errorf("expected comma error, got: %v", err) + } +} + +func TestDecodeSatScopeJSON(t *testing.T) { + m := map[string]string{ + ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + ExtRoles: "analyst", + ExtSatScope: `{"registry_type":"helm","verbs":["install","upgrade"],"resource_pattern":"ns/*"}`, + ExtSatHash: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + } + + decoded, err := Decode(m) + if err != nil { + t.Fatalf("Decode: %v", err) + } + if decoded.SatScope == nil { + t.Fatal("SatScope is nil") + } + if decoded.SatScope.RegistryType != "helm" { + t.Errorf("RegistryType: got %q, want %q", decoded.SatScope.RegistryType, "helm") + } + if len(decoded.SatScope.Verbs) != 2 || decoded.SatScope.Verbs[0] != "install" { + t.Errorf("Verbs: got %v", decoded.SatScope.Verbs) + } + if decoded.SatScope.ResourcePattern != "ns/*" { + t.Errorf("ResourcePattern: got %q", decoded.SatScope.ResourcePattern) + } +} + +func TestDecodeInvalidSatScopeJSON(t *testing.T) { + m := map[string]string{ + ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + ExtRoles: "analyst", + ExtSatScope: "not-valid-json", + } + + _, err := Decode(m) + if err == nil { + t.Fatal("expected error for invalid JSON") + } + if !strings.Contains(err.Error(), "unmarshal sat-scope") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestDecodeRolesCommaParsing(t *testing.T) { + m := map[string]string{ + ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + ExtRoles: "administrator,engineer,analyst", + } + + decoded, err := Decode(m) + if err != nil { + t.Fatalf("Decode: %v", err) + } + if len(decoded.Roles) != 3 { + t.Fatalf("Roles length: got %d, want 3", len(decoded.Roles)) + } + expected := []string{"administrator", "engineer", "analyst"} + for i, r := range decoded.Roles { + if r != expected[i] { + t.Errorf("Roles[%d]: got %q, want %q", i, r, expected[i]) + } + } +} + +func TestDecodeMerkleProofBase64(t *testing.T) { + proof := []byte{0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04} + m := map[string]string{ + ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + ExtRoles: "analyst", + ExtMerkleRoot: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", + ExtMerkleProof: base64.StdEncoding.EncodeToString(proof), + } + + decoded, err := Decode(m) + if err != nil { + t.Fatalf("Decode: %v", err) + } + if len(decoded.MerkleProof) != len(proof) { + t.Fatalf("MerkleProof length: got %d, want %d", len(decoded.MerkleProof), len(proof)) + } + for i, b := range decoded.MerkleProof { + if b != proof[i] { + t.Errorf("MerkleProof[%d]: got %x, want %x", i, b, proof[i]) + } + } +} + +func TestDecodeInvalidMerkleProofBase64(t *testing.T) { + m := map[string]string{ + ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + ExtRoles: "analyst", + ExtMerkleProof: "not-valid-base64!!!", + } + + _, err := Decode(m) + if err == nil { + t.Fatal("expected error for invalid base64") + } + if !strings.Contains(err.Error(), "decode merkle-proof") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestDecodeGovernanceEpoch(t *testing.T) { + m := map[string]string{ + ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + ExtRoles: "analyst", + ExtGovernanceEpoch: "12345", + } + + decoded, err := Decode(m) + if err != nil { + t.Fatalf("Decode: %v", err) + } + if decoded.GovernanceEpoch != 12345 { + t.Errorf("GovernanceEpoch: got %d, want 12345", decoded.GovernanceEpoch) + } + if !decoded.HasGovernanceEpoch() { + t.Error("HasGovernanceEpoch: got false, want true") + } +} + +func TestDecodeGovernanceEpochZero(t *testing.T) { + m := map[string]string{ + ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + ExtRoles: "analyst", + ExtGovernanceEpoch: "0", + } + + decoded, err := Decode(m) + if err != nil { + t.Fatalf("Decode: %v", err) + } + if decoded.GovernanceEpoch != 0 { + t.Errorf("GovernanceEpoch: got %d, want 0", decoded.GovernanceEpoch) + } + if !decoded.HasGovernanceEpoch() { + t.Error("HasGovernanceEpoch: got false, want true (epoch 0 is valid)") + } +} + +func TestDecodeInvalidGovernanceEpoch(t *testing.T) { + m := map[string]string{ + ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + ExtRoles: "analyst", + ExtGovernanceEpoch: "not-a-number", + } + + _, err := Decode(m) + if err == nil { + t.Fatal("expected error for invalid epoch") + } + if !strings.Contains(err.Error(), "parse governance-epoch") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestEncodeNilReturnsError(t *testing.T) { + _, err := Encode(nil) + if err == nil { + t.Fatal("expected error for nil extensions") + } +} + +func TestValidateNilReturnsError(t *testing.T) { + err := Validate(nil) + if err == nil { + t.Fatal("expected error for nil extensions") + } +} + +func TestValidateFullExtensions(t *testing.T) { + ext := fullExtensions() + err := Validate(ext) + if err != nil { + t.Fatalf("unexpected validation error: %v", err) + } +} + +func TestValidateMinimalExtensions(t *testing.T) { + ext := minimalExtensions() + err := Validate(ext) + if err != nil { + t.Fatalf("unexpected validation error: %v", err) + } +} + +func TestIsValidUUID(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"a1b2c3d4-e5f6-7890-abcd-ef1234567890", true}, + {"00000000-0000-0000-0000-000000000000", true}, + {"A1B2C3D4-E5F6-7890-ABCD-EF1234567890", false}, // uppercase + {"a1b2c3d4e5f6-7890-abcd-ef1234567890", false}, // missing hyphen + {"too-short", false}, + {"", false}, + {"a1b2c3d4-e5f6-7890-abcd-ef12345678901", false}, // too long + } + for _, tt := range tests { + got := isValidUUID(tt.input) + if got != tt.want { + t.Errorf("isValidUUID(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +// mapKeys returns the keys of a map for debug output. +func mapKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} diff --git a/pkg/sshcert/sshcert.go b/pkg/sshcert/sshcert.go new file mode 100644 index 0000000..b768f2e --- /dev/null +++ b/pkg/sshcert/sshcert.go @@ -0,0 +1,56 @@ +// Package sshcert builds SSH certificates with Shellstream extensions, +// bridging SPIFFE identity and Guildhouse governance metadata. +package sshcert + +import ( + "fmt" + + "github.com/guildhouse-cooperative/guildhouse-spire-plugins/pkg/shellstream" +) + +// Config holds SSH certificate builder configuration. +type Config struct { + // TrustDomain is the SPIFFE trust domain. + TrustDomain string +} + +// CertRequest describes an SSH certificate to build. +type CertRequest struct { + // SpiffeID is the workload's SPIFFE ID (used as principal). + SpiffeID string + + // Extensions are the Shellstream governance extensions to embed. + Extensions *shellstream.ShellstreamExtensions + + // ValidSeconds is the certificate lifetime in seconds. + ValidSeconds uint64 + + // Principals are additional SSH principals beyond the SPIFFE ID. + Principals []string +} + +// Builder creates SSH certificates with Shellstream extensions. +type Builder struct { + config Config +} + +// NewBuilder creates an SSH certificate builder. +func NewBuilder(cfg Config) (*Builder, error) { + if cfg.TrustDomain == "" { + return nil, fmt.Errorf("sshcert: trust domain is required") + } + return &Builder{config: cfg}, nil +} + +// Build creates an SSH certificate from the request. +// TODO: implement — create golang.org/x/crypto/ssh.Certificate with Shellstream extensions. +func (b *Builder) Build(req *CertRequest) ([]byte, error) { + if req == nil { + return nil, fmt.Errorf("sshcert: nil request") + } + if req.SpiffeID == "" { + return nil, fmt.Errorf("sshcert: spiffe ID is required") + } + // TODO: implement — generate key pair, build certificate, sign with CA key + return nil, fmt.Errorf("sshcert: Build not yet implemented") +} diff --git a/pkg/sshcert/sshcert_test.go b/pkg/sshcert/sshcert_test.go new file mode 100644 index 0000000..d67b584 --- /dev/null +++ b/pkg/sshcert/sshcert_test.go @@ -0,0 +1,30 @@ +package sshcert + +import ( + "testing" +) + +func TestNewBuilderRequiresTrustDomain(t *testing.T) { + _, err := NewBuilder(Config{}) + if err == nil { + t.Fatal("expected error for empty trust domain") + } +} + +func TestNewBuilderAcceptsValidConfig(t *testing.T) { + b, err := NewBuilder(Config{TrustDomain: "example.org"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if b == nil { + t.Fatal("builder should not be nil") + } +} + +func TestBuildRequiresSpiffeID(t *testing.T) { + b, _ := NewBuilder(Config{TrustDomain: "example.org"}) + _, err := b.Build(&CertRequest{}) + if err == nil { + t.Fatal("expected error for empty spiffe ID") + } +} diff --git a/proto/bascule/v1/ceremony.proto b/proto/bascule/v1/ceremony.proto new file mode 100644 index 0000000..4a9960e --- /dev/null +++ b/proto/bascule/v1/ceremony.proto @@ -0,0 +1,162 @@ +// Source of truth: guildhouse monorepo +// services/bascule-proto/proto/bascule/v1/ceremony.proto +// This file is a copy for Go code generation. Do not edit here. + +syntax = "proto3"; +package bascule.v1; + +option go_package = "github.com/guildhouse-cooperative/guildhouse-spire-plugins/gen/bascule/v1;basculev1"; + +import "google/protobuf/timestamp.proto"; + +// Governance Ceremony Service — multi-stakeholder approval flows +// triggered by Accord policy when a mutation requires human sign-off. +service CeremonyService { + // Create a new governance ceremony. + rpc CreateCeremony (CreateCeremonyRequest) returns (CreateCeremonyResponse); + + // Record an approval or denial on a pending ceremony. + rpc ApproveCeremony (ApproveCeremonyRequest) returns (ApproveCeremonyResponse); + + // Deny a pending ceremony. + rpc DenyCeremony (DenyCeremonyRequest) returns (DenyCeremonyResponse); + + // Cancel a pending ceremony (requestor or admin). + rpc CancelCeremony (CancelCeremonyRequest) returns (CancelCeremonyResponse); + + // Get the current status of a ceremony. + rpc GetCeremony (GetCeremonyRequest) returns (GetCeremonyResponse); + + // List pending ceremonies, optionally filtered. + rpc ListPendingCeremonies (ListPendingCeremoniesRequest) returns (ListPendingCeremoniesResponse); + + // Get the resolution proof for a completed ceremony. + rpc GetCeremonyProof (GetCeremonyProofRequest) returns (GetCeremonyProofResponse); +} + +// --- Create --- + +message CreateCeremonyRequest { + string ceremony_type = 1; // "single_approval", "quorum_approval", etc. + CeremonySubjectMsg subject = 2; + uint32 required_approvals = 3; + repeated string approver_roles = 4; + uint32 ttl_hours = 5; // 0 = default (24h) + string intent_id = 6; // optional linked MutationIntent + string run_id = 7; // optional linked pipeline run + uint64 pr_number = 8; // optional linked PR + string remote_name = 9; // optional remote name +} + +message CreateCeremonyResponse { + string ceremony_id = 1; + string status = 2; // "pending" or "approved" (for self-grant) + google.protobuf.Timestamp expires_at = 3; + string error = 4; +} + +// --- Approve --- + +message ApproveCeremonyRequest { + string ceremony_id = 1; + string approver_identity = 2; + string approver_role = 3; + string comment = 4; +} + +message ApproveCeremonyResponse { + bool success = 1; + string status = 2; // updated status after approval + string error = 3; +} + +// --- Deny --- + +message DenyCeremonyRequest { + string ceremony_id = 1; + string approver_identity = 2; + string approver_role = 3; + string comment = 4; +} + +message DenyCeremonyResponse { + bool success = 1; + string status = 2; + string error = 3; +} + +// --- Cancel --- + +message CancelCeremonyRequest { + string ceremony_id = 1; +} + +message CancelCeremonyResponse { + bool success = 1; + string error = 2; +} + +// --- Get --- + +message GetCeremonyRequest { + string ceremony_id = 1; +} + +message GetCeremonyResponse { + string ceremony_id = 1; + string ceremony_type = 2; + CeremonySubjectMsg subject = 3; + string status = 4; + uint32 required_approvals = 5; + uint32 current_approvals = 6; + repeated CeremonyApprovalMsg approvals = 7; + google.protobuf.Timestamp created_at = 8; + google.protobuf.Timestamp expires_at = 9; + string intent_id = 10; + string run_id = 11; + uint64 pr_number = 12; + string remote_name = 13; + string error = 14; +} + +// --- List Pending --- + +message ListPendingCeremoniesRequest { + string intent_id = 1; // optional filter +} + +message ListPendingCeremoniesResponse { + repeated GetCeremonyResponse ceremonies = 1; +} + +// --- Proof --- + +message GetCeremonyProofRequest { + string ceremony_id = 1; +} + +message GetCeremonyProofResponse { + string ceremony_id = 1; + string status = 2; + string proof_hash = 3; + repeated CeremonyApprovalMsg approvals = 4; + google.protobuf.Timestamp resolved_at = 5; + string error = 6; +} + +// --- Shared messages --- + +message CeremonySubjectMsg { + string subject_type = 1; // "mutation_intent", "pipeline_merge", "schematic_publish", "custom" + string reference_id = 2; // intent_id, run_id, "name:version", or custom ref + string description = 3; // human-readable label + map metadata = 4; // extra fields +} + +message CeremonyApprovalMsg { + string approver_identity = 1; + string approver_role = 2; + string decision = 3; // "approve" or "deny" + string comment = 4; + google.protobuf.Timestamp decided_at = 5; +} diff --git a/proto/quartermaster/v1/credentials.proto b/proto/quartermaster/v1/credentials.proto new file mode 100644 index 0000000..b9ed271 --- /dev/null +++ b/proto/quartermaster/v1/credentials.proto @@ -0,0 +1,77 @@ +// Source of truth: guildhouse monorepo +// services/qm-proto/proto/quartermaster/v1/credentials.proto +// This file is a copy for Go code generation. Do not edit here. + +syntax = "proto3"; +package quartermaster.v1; + +option go_package = "github.com/guildhouse-cooperative/guildhouse-spire-plugins/gen/quartermaster/v1;quartermasterv1"; + +import "google/protobuf/timestamp.proto"; + +service QuartermasterCredentials { + rpc ProvisionDatabase (ProvisionDatabaseRequest) returns (ProvisionDatabaseResponse); + rpc RotateCredential (RotateCredentialRequest) returns (RotateCredentialResponse); + rpc RevokeCredential (RevokeCredentialRequest) returns (RevokeCredentialResponse); + rpc GetCredentialRef (GetCredentialRefRequest) returns (GetCredentialRefResponse); + rpc ListCredentials (ListCredentialsRequest) returns (ListCredentialsResponse); +} + +message ProvisionDatabaseRequest { + string cluster_id = 1; + string service_name = 2; + string database_name = 3; +} + +message ProvisionDatabaseResponse { + string credential_id = 1; + string secret_ref = 2; + string secret_namespace = 3; + google.protobuf.Timestamp issued_at = 4; + bytes merkle_leaf = 5; +} + +message RotateCredentialRequest { + string credential_id = 1; +} + +message RotateCredentialResponse { + string new_credential_id = 1; + string secret_ref = 2; + google.protobuf.Timestamp issued_at = 3; + bytes merkle_leaf = 4; +} + +message RevokeCredentialRequest { + string credential_id = 1; +} + +message RevokeCredentialResponse { + google.protobuf.Timestamp revoked_at = 1; +} + +message GetCredentialRefRequest { + string credential_id = 1; +} + +message GetCredentialRefResponse { + string credential_id = 1; + string cluster_id = 2; + string service_name = 3; + string credential_type = 4; + string username = 5; + string database_name = 6; + string secret_ref = 7; + string secret_namespace = 8; + google.protobuf.Timestamp issued_at = 9; + google.protobuf.Timestamp expires_at = 10; + bool revoked = 11; +} + +message ListCredentialsRequest { + string cluster_id = 1; +} + +message ListCredentialsResponse { + repeated GetCredentialRefResponse credentials = 1; +} diff --git a/proto/quartermaster/v1/governance.proto b/proto/quartermaster/v1/governance.proto new file mode 100644 index 0000000..262bb65 --- /dev/null +++ b/proto/quartermaster/v1/governance.proto @@ -0,0 +1,125 @@ +// Source of truth: guildhouse monorepo +// services/qm-proto/proto/quartermaster/v1/governance.proto +// This file is a copy for Go code generation. Do not edit here. + +syntax = "proto3"; + +package quartermaster.v1; + +option go_package = "github.com/guildhouse-cooperative/guildhouse-spire-plugins/gen/quartermaster/v1;quartermasterv1"; + +import "google/protobuf/timestamp.proto"; + +// Governance service for intent lifecycle and SAT issuance. +service GovernanceService { + // Create a MutationIntent — called by application at user-request time. + rpc CreateIntent(CreateIntentRequest) returns (CreateIntentResponse); + + // Redeem a MutationIntent — called by worker at execution time. + rpc RedeemIntent(RedeemIntentRequest) returns (RedeemIntentResponse); + + // Revoke a MutationIntent — called to cancel pending authorization. + rpc RevokeIntent(RevokeIntentRequest) returns (RevokeIntentResponse); + + // Query intents for a tenant (admin/audit use). + rpc ListIntents(ListIntentsRequest) returns (ListIntentsResponse); +} + +message CreateIntentRequest { + string registry_type = 1; + string verb = 2; + string artifact_scope = 3; + string tenant_id = 4; + + // Identity claim — one of these should be set. + oneof identity_claim { + string oidc_token = 5; + ExternalEventClaim external_event = 6; + } + + uint32 ttl_seconds = 7; + uint32 max_redemptions = 8; + string idempotency_key = 9; +} + +message ExternalEventClaim { + string source = 1; + string event_id = 2; + string event_type = 3; + string verification = 4; +} + +message CreateIntentResponse { + string intent_id = 1; + google.protobuf.Timestamp expires_at = 2; + bytes intent_hash = 3; + string error = 4; + bool denied = 5; + string denial_reason = 6; + // If a governance ceremony is required, this field contains the + // ceremony ID. The intent status is "ceremony_pending" and cannot + // be redeemed until the ceremony resolves. + string ceremony_id = 7; +} + +message RedeemIntentRequest { + string intent_id = 1; +} + +message RedeemIntentResponse { + bool success = 1; + SatToken sat = 2; + int32 remaining_redemptions = 3; + string status = 4; + string error = 5; +} + +message SatToken { + bytes sat_hash = 1; + string bearer_svid = 2; + repeated SatScopeMsg scopes = 3; + google.protobuf.Timestamp issued_at = 4; + google.protobuf.Timestamp expires_at = 5; + bytes signature = 6; + bytes sat_bytes = 7; +} + +message SatScopeMsg { + string registry_type = 1; + repeated string verbs = 2; + string resource_pattern = 3; +} + +message RevokeIntentRequest { + string intent_id = 1; +} + +message RevokeIntentResponse { + bool success = 1; + string error = 2; +} + +message ListIntentsRequest { + string tenant_id = 1; + string status_filter = 2; + int32 limit = 3; +} + +message ListIntentsResponse { + repeated IntentSummary intents = 1; +} + +message IntentSummary { + string intent_id = 1; + string registry_type = 2; + string verb = 3; + string artifact_scope = 4; + string tenant_id = 5; + string claim_type = 6; + string claim_subject = 7; + string status = 8; + int32 max_redemptions = 9; + int32 redeemed_count = 10; + google.protobuf.Timestamp authorized_at = 11; + google.protobuf.Timestamp expires_at = 12; +} diff --git a/proto/quartermaster/v1/notary.proto b/proto/quartermaster/v1/notary.proto new file mode 100644 index 0000000..a3a8f86 --- /dev/null +++ b/proto/quartermaster/v1/notary.proto @@ -0,0 +1,54 @@ +// Source of truth: guildhouse monorepo +// services/qm-proto/proto/quartermaster/v1/notary.proto +// This file is a copy for Go code generation. Do not edit here. + +syntax = "proto3"; +package quartermaster.v1; + +option go_package = "github.com/guildhouse-cooperative/guildhouse-spire-plugins/gen/quartermaster/v1;quartermasterv1"; + +import "google/protobuf/timestamp.proto"; + +service QuartermasterNotary { + rpc CreateAnchor (CreateAnchorRequest) returns (CreateAnchorResponse); + rpc GetLatestAnchor (GetLatestAnchorRequest) returns (GetLatestAnchorResponse); + rpc VerifyInclusion (VerifyInclusionRequest) returns (VerifyInclusionResponse); +} + +message CreateAnchorRequest { + string cluster_id = 1; + repeated bytes leaves = 2; + int64 etcd_revision = 3; // 0 means not set +} + +message CreateAnchorResponse { + string anchor_id = 1; + bytes merkle_root = 2; + bytes previous_root = 3; + int32 leaf_count = 4; + google.protobuf.Timestamp time = 5; +} + +message GetLatestAnchorRequest { + string cluster_id = 1; +} + +message GetLatestAnchorResponse { + string anchor_id = 1; + string cluster_id = 2; + bytes merkle_root = 3; + bytes previous_root = 4; + int64 etcd_revision = 5; + int32 leaf_count = 6; + google.protobuf.Timestamp time = 7; +} + +message VerifyInclusionRequest { + string anchor_id = 1; + bytes leaf = 2; + repeated bytes proof = 3; +} + +message VerifyInclusionResponse { + bool valid = 1; +} diff --git a/specs/credential-governance.md b/specs/credential-governance.md new file mode 100644 index 0000000..69bbbdb --- /dev/null +++ b/specs/credential-governance.md @@ -0,0 +1,735 @@ +# Credential Governance Specification + +**Version:** 0.1.0-draft +**Date:** 2026-02-18 +**Authors:** Guildhouse Cooperative + +--- + +## 1. Abstract + +This specification defines the integration between SPIRE credential lifecycle events and the Guildhouse governance framework. Credential operations (issuance, rotation, revocation) are modeled as governed mutations, subject to Accord policy classification, optional ceremony approval, and merkle-anchored audit recording. The goal is to bring every credential operation under a unified governance model that provides authorization control, multi-stakeholder approval where required, and an immutable, verifiable audit trail. + +## 2. Status + +**Draft specification.** This document is a working draft and is subject to change. It has not yet been ratified by the Guildhouse governance body. Normative requirements use the key words defined in RFC 2119. + +### 2.1 Key Words + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). + +## 3. Terminology + +**Credential Event** +A discrete lifecycle operation on a credential resource: issuance, rotation, or revocation. Each credential event is the atomic unit of governance for this specification. + +**Governed Mutation** +Any state change within the Guildhouse platform that is subject to intent authorization, policy evaluation, and audit recording. A credential event becomes a governed mutation when it enters the governance flow. + +**MutationIntent** +An authorization request registered with the GovernanceService (`quartermaster.v1.GovernanceService.CreateIntent`). Represents a declared intention to perform a mutation. An intent transitions through states: `pending` -> `ceremony_pending` | `authorized` -> `redeemed` | `expired` | `denied`. + +**MutationEnvelope** +The universal wrapper for governed mutations. Contains the domain-separated hash of the canonicalized event payload, actor identity, timestamp, and tenant context. Serves as the merkle leaf input for audit anchoring. + +**SAT (Substrate Attestation Token)** +The authorization token issued upon successful intent redemption (`quartermaster.v1.SatToken`). Carries `SatScopeMsg` entries specifying `registry_type`, `verbs`, and `resource_pattern`. The SAT is the bearer credential that authorizes the actual credential operation to proceed. + +**Accord Policy** +A declarative policy evaluated by the Accord policy engine that classifies mutations and determines their ceremony requirements. Accord policies map `(registry_type, verb, artifact_scope)` tuples to ceremony classification levels. + +**Ceremony** +A multi-stakeholder approval flow managed by the CeremonyService (`bascule.v1.CeremonyService`). Ceremonies require one or more identified approvers to authorize a mutation before the corresponding intent becomes redeemable. + +**Merkle Anchor** +An immutable record created by the NotaryService (`quartermaster.v1.NotaryService.CreateAnchor`) that commits a batch of mutation envelope hashes into a merkle tree. Each anchor contains a `merkle_root`, `previous_root`, and the tree's leaf data. + +**Governance Epoch** +A time-bounded interval during which merkle leaves accumulate before being committed in an anchor. The epoch boundary triggers anchor creation. Epoch duration is deployment-configurable. + +**Trust Domain** +A SPIFFE trust domain (e.g., `spiffe://guildhouse.io`) that defines the boundary of identity authority. Cross-trust-domain credential operations carry elevated governance requirements. + +**Credential Lifecycle** +The complete sequence of states a credential passes through: creation (issuance), active use, rotation (replacement), and termination (revocation or expiry). Each state transition that involves an active operation maps to a credential event. + +## 4. Introduction + +Traditional credential management -- certificates, SSH keys, database passwords, API tokens -- treats lifecycle operations as administrative actions executed by privileged operators with no formal governance beyond access control. In a multi-tenant, multi-stakeholder environment, credential operations carry security implications that extend beyond the immediate operator: + +- An SSH certificate minted for a workload in Tenant A may grant access to shared infrastructure. +- A database credential rotation affects downstream services that depend on the credential. +- An emergency revocation of a CA signing key impacts every workload in the trust domain. + +These operations require varying levels of scrutiny. Routine certificate issuance for short-lived workload identities needs no human approval, while CA key rotation demands multi-stakeholder consensus. + +This specification bridges SPIRE's credential lifecycle operations with the Guildhouse governance model by: + +1. Defining a canonical schema for each credential event type. +2. Mapping credential events to governed mutations via the GovernanceService intent lifecycle. +3. Leveraging Accord policy to classify events into ceremony tiers. +4. Anchoring every credential event in the NotaryService merkle tree for immutable audit. + +The result is that every credential operation -- from the most routine certificate mint to an emergency CA revocation -- passes through a uniform governance pipeline with policy-appropriate controls and a verifiable audit trail. + +## 5. Credential Events + +Each credential lifecycle operation maps to a typed credential event. Events are the input to the governance pipeline and the payload of the resulting MutationEnvelope. + +### 5.1 Issue + +An issue event occurs when a new credential is created. Examples include: an SSH certificate minted by the ssh-credential-composer, a database credential provisioned by the substrate-keymanager, or an OIDC token issued by the oidc-attestor. + +**Canonical Fields:** + +| Field | Type | Required | Description | +|----------------------|----------|----------|---------------------------------------------------------------| +| `event_type` | string | REQUIRED | Fixed value: `"issue"` | +| `credential_type` | string | REQUIRED | Type identifier (e.g., `"ssh_user_cert"`, `"db_password"`, `"x509_svid"`) | +| `subject_spiffe_id` | string | REQUIRED | SPIFFE ID of the workload receiving the credential | +| `tenant_id` | string | REQUIRED | UUID of the owning tenant | +| `scope` | string | REQUIRED | Scope descriptor (e.g., host pattern, database name) | +| `requestor_identity` | string | REQUIRED | SPIFFE ID or OIDC subject of the entity requesting issuance | +| `credential_id` | string | REQUIRED | Unique identifier assigned to the new credential | +| `ttl_seconds` | uint32 | REQUIRED | Requested time-to-live for the credential | +| `metadata` | object | OPTIONAL | Additional type-specific metadata (e.g., key algorithm, extensions) | + +**Example (JCS-canonicalized):** + +```json +{"credential_id":"cred-a1b2c3","credential_type":"ssh_user_cert","event_type":"issue","metadata":{"extensions":["permit-pty"],"key_algorithm":"ed25519"},"requestor_identity":"spiffe://guildhouse.io/ns/platform/sa/operator","scope":"*.staging.internal","subject_spiffe_id":"spiffe://guildhouse.io/ns/tenant-acme/sa/web-server","tenant_id":"f47ac10b-58cc-4372-a567-0e02b2c3d479","ttl_seconds":3600} +``` + +### 5.2 Rotate + +A rotate event occurs when an existing credential is replaced with a new one. Rotation may be scheduled (automated lifecycle), manual (operator-initiated), or emergency (compromise response). + +**Canonical Fields:** + +| Field | Type | Required | Description | +|------------------------|----------|----------|-----------------------------------------------------------------| +| `event_type` | string | REQUIRED | Fixed value: `"rotate"` | +| `old_credential_id` | string | REQUIRED | Identifier of the credential being replaced | +| `new_credential_type` | string | REQUIRED | Type identifier for the replacement credential | +| `subject_spiffe_id` | string | REQUIRED | SPIFFE ID of the workload whose credential is rotating | +| `tenant_id` | string | REQUIRED | UUID of the owning tenant | +| `rotation_reason` | string | REQUIRED | One of: `"scheduled"`, `"manual"`, `"compromised"` | +| `requestor_identity` | string | REQUIRED | SPIFFE ID or OIDC subject of the entity requesting rotation | +| `new_credential_id` | string | REQUIRED | Unique identifier assigned to the replacement credential | +| `metadata` | object | OPTIONAL | Additional type-specific metadata | + +**Example (JCS-canonicalized):** + +```json +{"event_type":"rotate","metadata":{"key_algorithm":"ed25519"},"new_credential_id":"cred-d4e5f6","new_credential_type":"ssh_user_cert","old_credential_id":"cred-a1b2c3","requestor_identity":"spiffe://guildhouse.io/ns/platform/sa/rotation-controller","rotation_reason":"scheduled","subject_spiffe_id":"spiffe://guildhouse.io/ns/tenant-acme/sa/web-server","tenant_id":"f47ac10b-58cc-4372-a567-0e02b2c3d479"} +``` + +### 5.3 Revoke + +A revoke event occurs when a credential is invalidated before its natural expiry. Revocation is an irreversible operation within a governance epoch. + +**Canonical Fields:** + +| Field | Type | Required | Description | +|------------------------|----------|----------|-----------------------------------------------------------------| +| `event_type` | string | REQUIRED | Fixed value: `"revoke"` | +| `credential_id` | string | REQUIRED | Identifier of the credential being revoked | +| `credential_type` | string | REQUIRED | Type identifier of the credential being revoked | +| `subject_spiffe_id` | string | REQUIRED | SPIFFE ID of the workload whose credential is being revoked | +| `tenant_id` | string | REQUIRED | UUID of the owning tenant | +| `revocation_reason` | string | REQUIRED | Human-readable reason for revocation | +| `requestor_identity` | string | REQUIRED | SPIFFE ID or OIDC subject of the entity requesting revocation | +| `metadata` | object | OPTIONAL | Additional context (e.g., incident ID, CVE reference) | + +**Example (JCS-canonicalized):** + +```json +{"credential_id":"cred-a1b2c3","credential_type":"ssh_user_cert","event_type":"revoke","metadata":{"incident_id":"INC-2026-0042"},"requestor_identity":"spiffe://guildhouse.io/ns/platform/sa/security-responder","revocation_reason":"Private key compromised per INC-2026-0042","subject_spiffe_id":"spiffe://guildhouse.io/ns/tenant-acme/sa/web-server","tenant_id":"f47ac10b-58cc-4372-a567-0e02b2c3d479"} +``` + +### 5.4 Schema Validation + +All credential event payloads MUST conform to the canonical field definitions above. Implementations MUST reject events with missing REQUIRED fields. Implementations MUST ignore unknown fields during canonicalization but SHOULD preserve them in non-canonical storage for forward compatibility. + +## 6. Governance Integration Flow + +This section defines the end-to-end flow by which a credential lifecycle event becomes a governed mutation. + +### 6.1 Flow Overview + +``` + Credential Lifecycle Event + | + v + +----------------------------+ + | governance-notifier plugin | + | (intercepts event) | + +----------------------------+ + | + Step 2: CreateIntentRequest + | + v + +----------------------------+ + | GovernanceService | + | (evaluates Accord policy)| + +----------------------------+ + / \ + no ceremony ceremony required + required (ceremony_id returned) + | | + | Step 5: Ceremony + | | + | v + | +----------------------------+ + | | CeremonyService | + | | (approval flow) | + | +----------------------------+ + | / \ + | approved denied + | | | + | | v + | | Operation blocked. + | | Intent denied. + v v + Step 6: RedeemIntent + | + v + +----------------------------+ + | SAT issued | + | Credential op proceeds | + +----------------------------+ + | + Step 7: MutationEnvelope + | + v + +----------------------------+ + | NotaryService | + | (merkle anchoring) | + +----------------------------+ + | + v + Audit record committed. +``` + +### 6.2 Step 1: Event Interception + +The `governance-notifier` plugin (running as a SPIRE Server plugin or sidecar) intercepts credential lifecycle events at the point of origin. The plugin MUST intercept the event before the credential operation is executed. The event is the trigger for the governance flow; the credential operation is deferred until governance authorization completes. + +For SSH certificate issuance, the interception point is the `ssh-credential-composer` plugin's `MintCredential` path. For credential rotation, it is the rotation controller's scheduling loop. For revocation, it is the revocation API endpoint. + +### 6.3 Step 2: Intent Creation + +The governance-notifier plugin constructs a `CreateIntentRequest` with the following field mappings: + +| `CreateIntentRequest` field | Value | +|-----------------------------|-----------------------------------------------------------------------| +| `registry_type` | `"credential"` | +| `verb` | One of: `"issue"`, `"rotate"`, `"revoke"` | +| `artifact_scope` | JCS-canonicalized JSON of the credential event payload (Section 5) | +| `tenant_id` | The `tenant_id` from the credential event | +| `identity_claim` | `oidc_token` from OIDC attestation, or `external_event` for automated operations | +| `ttl_seconds` | Plugin-configurable; SHOULD default to 300 seconds | +| `max_redemptions` | `1` (credential events are single-use) | +| `idempotency_key` | `SHA-256(registry_type + ":" + verb + ":" + credential_id)` | + +The plugin MUST set `max_redemptions` to `1` for all credential events. Credential operations are not idempotent at the infrastructure level (a second SSH certificate mint produces a distinct certificate), and the intent MUST NOT be reusable. + +The `idempotency_key` ensures that duplicate event deliveries (e.g., from retry logic) map to the same intent rather than creating parallel authorization flows. + +### 6.4 Step 3: Accord Policy Evaluation + +The GovernanceService evaluates the intent against the Accord policy engine. The policy receives the `(registry_type, verb, artifact_scope)` tuple and returns a classification that determines the ceremony requirement. Policy evaluation is synchronous with the `CreateIntent` call. + +If the Accord policy denies the operation outright (e.g., a forbidden credential type for the tenant), the `CreateIntentResponse` MUST have `denied = true` and `denial_reason` populated. The plugin MUST abort the credential operation. + +### 6.5 Step 4a: No Ceremony Required (Autonomous / SelfGrant) + +If the Accord policy classifies the event as `Autonomous` or `SelfGrant`, the `CreateIntentResponse` returns with `ceremony_id` empty and the intent in `authorized` status. The plugin proceeds directly to Step 6 (RedeemIntent). + +For `SelfGrant` classification, the GovernanceService records the requestor's identity as the implicit approver. No external approval action is required, but the self-grant is recorded in the audit trail. + +### 6.6 Step 4b: Ceremony Required + +If the Accord policy classifies the event as `SingleApproval`, `QuorumApproval`, or `EmergencyBreakGlass`, the `CreateIntentResponse` returns with a non-empty `ceremony_id`. The intent status is `ceremony_pending` and cannot be redeemed until the ceremony resolves. + +### 6.7 Step 5: Ceremony Approval Flow + +When a ceremony is required, the following sub-flow executes: + +1. The GovernanceService has already called `CeremonyService.CreateCeremony` internally during intent creation. +2. The governance-notifier plugin polls or streams the ceremony status via `CeremonyService.GetCeremony` using the `ceremony_id`. +3. Eligible approvers are notified through the platform's notification system (out of scope for this specification). +4. Approvers call `CeremonyService.ApproveCeremony` (or `DenyCeremony`) with their authenticated identity. +5. When the ceremony reaches its approval threshold: + - **Approved:** The GovernanceService transitions the intent from `ceremony_pending` to `authorized`. The plugin proceeds to Step 6. + - **Denied:** The GovernanceService transitions the intent to `denied`. The plugin MUST abort the credential operation and return an error to the caller. + +The plugin MUST implement a configurable timeout for ceremony polling. If the ceremony does not resolve within the timeout, the plugin MUST treat this as a denial and abort the credential operation. The RECOMMENDED default timeout is 600 seconds (10 minutes) for interactive operations and 3600 seconds (1 hour) for operations that can tolerate asynchronous approval. + +### 6.8 Step 6: Intent Redemption + +The plugin calls `GovernanceService.RedeemIntent` with the `intent_id` received in Step 2. + +On success, the `RedeemIntentResponse` contains a `SatToken` with: + +- `bearer_svid`: The SPIFFE ID authorized to perform the credential operation. +- `scopes`: A `SatScopeMsg` entry with `registry_type = "credential"`, `verbs = ["{issue|rotate|revoke}"]`, and `resource_pattern` matching the credential event scope. +- `issued_at` / `expires_at`: The SAT validity window. +- `sat_hash`: The cryptographic binding of the SAT contents. + +The plugin MUST verify that the SAT's `bearer_svid` matches the plugin's own SVID. The plugin MUST NOT proceed with the credential operation if the SAT has expired (`expires_at < now()`). + +### 6.9 Step 7: MutationEnvelope Construction and Anchoring + +After the credential operation completes successfully, the plugin constructs a MutationEnvelope (Section 7) and submits it to the NotaryService for merkle anchoring. + +The plugin MUST construct the envelope after the credential operation succeeds, not before. The envelope records the fact that the operation occurred, not merely that it was authorized. + +The plugin calls `NotaryService.CreateAnchor` with the envelope's merkle leaf hash. If the NotaryService batches leaves within governance epochs, the leaf is queued for the next anchor. The plugin SHOULD NOT block the credential operation on anchor confirmation (Section 10 defines the retry semantics for unreachable NotaryService). + +## 7. MutationEnvelope Construction + +This section defines the deterministic process for constructing a MutationEnvelope from a credential event. + +### 7.1 Payload Canonicalization + +The credential event payload (as defined in Section 5) MUST be serialized using [RFC 8785 JSON Canonicalization Scheme (JCS)](https://www.rfc-editor.org/rfc/rfc8785). JCS produces deterministic JSON output with the following properties: + +- Object keys are sorted lexicographically by Unicode code point. +- No insignificant whitespace. +- Numbers are serialized in their shortest form without trailing zeros. +- Strings use minimal escape sequences. + +Implementations MUST use a JCS-compliant serializer. Hand-rolled JSON serialization is not acceptable, as subtle differences (e.g., Unicode normalization, number formatting) break hash determinism. + +The JCS output is the `jcs_bytes` value used in subsequent steps. + +### 7.2 Domain Separation + +The payload hash uses domain separation to prevent cross-protocol hash collisions. The hash input is constructed as: + +``` +payload_hash = SHA-256("guildhouse.credential.v1:" || jcs_bytes) +``` + +Where: + +- `"guildhouse.credential.v1:"` is the domain separation prefix, encoded as UTF-8 bytes. The trailing colon is part of the prefix. +- `||` denotes byte concatenation. +- `jcs_bytes` is the JCS-canonicalized event payload from Section 7.1. + +The domain prefix `guildhouse.credential.v1` is specific to this specification. Other governed mutation types (e.g., registry mutations, configuration changes) use their own domain prefixes. This ensures that a credential event payload cannot collide with a registry mutation payload even if they happen to contain identical JSON. + +The `payload_hash` is encoded as lowercase hexadecimal for inclusion in the envelope. + +### 7.3 Envelope Fields + +The MutationEnvelope is a JSON object with the following fields: + +| Field | Type | Description | +|----------------|--------|--------------------------------------------------------------| +| `domain` | string | Fixed value: `"guildhouse.credential.v1"` | +| `payload_hash` | string | Lowercase hex-encoded SHA-256 from Section 7.2 | +| `timestamp` | string | RFC 3339 timestamp with UTC offset (`Z` suffix) | +| `actor_svid` | string | SPIFFE ID of the entity that performed the credential operation | +| `tenant_id` | string | UUID of the owning tenant | +| `event_type` | string | One of: `"issue"`, `"rotate"`, `"revoke"` | +| `intent_id` | string | The intent ID from the governance flow | +| `sat_hash` | string | Lowercase hex-encoded hash of the SAT that authorized the operation | + +**Example:** + +```json +{ + "domain": "guildhouse.credential.v1", + "payload_hash": "a3f2b8c1d4e5f67890abcdef1234567890abcdef1234567890abcdef12345678", + "timestamp": "2026-02-18T14:30:00Z", + "actor_svid": "spiffe://guildhouse.io/ns/platform/sa/ssh-credential-composer", + "tenant_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "event_type": "issue", + "intent_id": "intent-x7y8z9", + "sat_hash": "b4c3d2e1f0a9876543210fedcba9876543210fedcba9876543210fedcba98765" +} +``` + +### 7.4 Merkle Leaf Construction + +The merkle leaf is derived from the envelope itself: + +1. The envelope JSON object (Section 7.3) is serialized using JCS (RFC 8785). +2. The JCS output is hashed: `leaf_hash = SHA-256(jcs_envelope_bytes)`. +3. The `leaf_hash` is submitted to `NotaryService.CreateAnchor`. + +Note: The merkle leaf hash does NOT use domain separation. Domain separation is applied at the payload level (Section 7.2). The envelope is already scoped by its `domain` field, and the leaf is always interpreted in the context of the governance merkle tree. + +### 7.5 Determinism Guarantee + +Given identical inputs (same credential event payload, same timestamp, same actor), the MutationEnvelope construction MUST produce identical output bytes and identical leaf hashes. Implementations MUST NOT include non-deterministic data (e.g., random nonces, system-local timestamps with varying precision) in the envelope. + +The `timestamp` field MUST be truncated to whole seconds (no fractional seconds) to ensure cross-implementation consistency. + +## 8. Ceremony Classification + +The Accord policy engine classifies credential events into ceremony tiers based on the event type, credential type, scope, and tenant context. This section defines the standard classification levels and their intended use. + +### 8.1 Classification Levels + +#### 8.1.1 Autonomous + +No human approval is required. The GovernanceService authorizes the intent immediately upon policy evaluation. + +**Intended use:** +- Routine short-lived SSH certificate issuance (TTL <= 8 hours) +- Scheduled credential rotation with `rotation_reason = "scheduled"` +- X.509 SVID minting for registered workloads + +**Rationale:** These operations are high-frequency, low-risk, and automated. Requiring human approval would create an operational bottleneck without meaningful security benefit. + +#### 8.1.2 SelfGrant + +The requestor implicitly approves their own operation. No external approval is required, but the self-grant is recorded distinctly in the audit trail. + +**Intended use:** +- Manual credential rotation (`rotation_reason = "manual"`) for services the requestor owns +- Database credential provisioning for the requestor's own tenant +- Long-lived SSH certificate issuance (TTL > 8 hours, <= 30 days) + +**Rationale:** The requestor has legitimate authority over these operations, but the explicit self-grant classification enables audit differentiation from fully automated operations. + +#### 8.1.3 SingleApproval + +One approver (distinct from the requestor) MUST authorize the operation via the CeremonyService. + +**Intended use:** +- Cross-tenant credential access (credential scope spans multiple tenants) +- Non-standard certificate parameters (unusual extensions, elevated privileges) +- SSH certificate issuance with TTL > 30 days + +**Rationale:** These operations have elevated risk or cross-boundary implications that warrant a second pair of eyes. + +#### 8.1.4 QuorumApproval + +Multiple approvers MUST authorize the operation. The quorum size is policy-configurable (default: 2 of 3). + +**Intended use:** +- CA signing key rotation +- Emergency credential revocation of high-value credentials +- Cross-trust-domain credential operations +- Bulk credential revocation (> 10 credentials in a single operation) + +**Rationale:** These operations have platform-wide or cross-domain impact. Multi-stakeholder consensus prevents unilateral action. + +#### 8.1.5 EmergencyBreakGlass + +The operation proceeds immediately without prior approval. A ceremony is created post-hoc and MUST be approved within a configured window (default: 24 hours). If post-hoc approval is not obtained, an escalation alert is generated. + +**Intended use:** +- Active compromise response requiring immediate credential revocation +- Emergency CA key rotation during a security incident +- Time-critical credential operations where approval delay would cause greater harm than the operation itself + +**Rationale:** Security incidents cannot wait for approval flows. The break-glass mechanism balances immediate response with accountability by requiring after-the-fact justification. + +### 8.2 Policy Example + +The following is an example Accord policy file for credential governance: + +```yaml +# accord-policy/credential-governance.yaml +apiVersion: accord.guildhouse.io/v1 +kind: CredentialGovernancePolicy +metadata: + name: default-credential-policy + tenant: "*" # applies to all tenants unless overridden + +rules: + # Routine SSH certificate issuance — no approval needed + - match: + registry_type: credential + verb: issue + credential_type: ssh_user_cert + conditions: + ttl_seconds_lte: 28800 # <= 8 hours + classification: Autonomous + + # Long-lived SSH certificates — self-grant + - match: + registry_type: credential + verb: issue + credential_type: ssh_user_cert + conditions: + ttl_seconds_gt: 28800 + ttl_seconds_lte: 2592000 # <= 30 days + classification: SelfGrant + + # Very long-lived SSH certificates — requires approval + - match: + registry_type: credential + verb: issue + credential_type: ssh_user_cert + conditions: + ttl_seconds_gt: 2592000 + classification: SingleApproval + + # Scheduled rotation — no approval + - match: + registry_type: credential + verb: rotate + rotation_reason: scheduled + classification: Autonomous + + # Manual rotation — self-grant + - match: + registry_type: credential + verb: rotate + rotation_reason: manual + classification: SelfGrant + + # Compromise-driven rotation — quorum + - match: + registry_type: credential + verb: rotate + rotation_reason: compromised + classification: QuorumApproval + quorum: + required: 2 + pool_size: 3 + + # Standard revocation — single approval + - match: + registry_type: credential + verb: revoke + classification: SingleApproval + + # Cross-trust-domain operations — quorum + - match: + registry_type: credential + conditions: + cross_trust_domain: true + classification: QuorumApproval + quorum: + required: 2 + pool_size: 3 + + # X.509 SVID minting — autonomous + - match: + registry_type: credential + verb: issue + credential_type: x509_svid + classification: Autonomous + + # Database credential provisioning — self-grant + - match: + registry_type: credential + verb: issue + credential_type: db_password + classification: SelfGrant + +defaults: + # If no rule matches, require single approval (fail-safe) + classification: SingleApproval + ceremony_timeout_seconds: 600 + +emergency: + # Break-glass configuration + classification: EmergencyBreakGlass + post_hoc_approval_window_hours: 24 + escalation_channel: platform-security + trigger_conditions: + - revocation_reason_contains: "compromise" + - revocation_reason_contains: "incident" + - metadata_contains_key: "incident_id" +``` + +### 8.3 Policy Precedence + +When multiple rules match a credential event, the following precedence applies: + +1. Tenant-specific policies override the wildcard (`"*"`) tenant policy. +2. More specific `match` criteria (more fields specified) take precedence over less specific rules. +3. If two rules have equal specificity, the rule appearing later in the file takes precedence. +4. If no rule matches, the `defaults.classification` applies. Implementations MUST default to `SingleApproval` if no default is configured (Section 10). + +### 8.4 Emergency Override + +A credential event MAY be elevated to `EmergencyBreakGlass` classification if it matches the `emergency.trigger_conditions`. Emergency classification overrides the normal rule match. The emergency trigger evaluation runs before normal rule matching. + +Implementations MUST log emergency break-glass activations at a severity level of `WARN` or higher. + +## 9. Audit Trail + +### 9.1 Merkle Anchoring + +Every credential event that completes the governance flow (Steps 1-7) produces a merkle leaf in the governance tree. The leaf hash is derived from the MutationEnvelope as defined in Section 7.4. + +Leaves accumulate within a governance epoch. At epoch boundary, the NotaryService creates an anchor containing: + +- `merkle_root`: The root hash of the merkle tree for this epoch. +- `previous_root`: The merkle root of the preceding anchor, forming an append-only chain. +- `leaf_count`: The number of leaves in this epoch. +- `epoch_start` / `epoch_end`: The time boundaries of the epoch. + +### 9.2 Certificate-Embedded Audit References + +For SSH certificates issued through the governance flow, the following certificate extensions MUST be embedded: + +| Extension | Value | +|---------------------------------|--------------------------------------------| +| `merkle-root@guildhouse.io` | Hex-encoded merkle root at issuance time | +| `merkle-proof@guildhouse.io` | Base64-encoded merkle inclusion proof | +| `governance-intent@guildhouse.io` | The `intent_id` from the governance flow | + +These extensions enable offline verification: given a certificate, a verifier can extract the merkle root and proof, then call `NotaryService.VerifyInclusion` to confirm that the credential issuance event was recorded in the governance audit trail. + +For X.509 SVIDs, the equivalent data SHOULD be embedded in a custom X.509 extension with OID `1.3.6.1.4.1.XXXXX.1.1` (OID to be registered). + +### 9.3 Audit Queries + +The following audit queries MUST be supported: + +**Verify a credential's governance record:** +1. Extract `merkle-root@guildhouse.io` and `merkle-proof@guildhouse.io` from the certificate. +2. Call `NotaryService.VerifyInclusion(merkle_root, leaf_hash, proof)`. +3. If inclusion is verified, the credential issuance event is confirmed to exist in the governance audit trail. + +**Retrieve a credential's governance history:** +1. Query `GovernanceService.ListIntents` with `tenant_id` and `status_filter`. +2. Cross-reference intent records with NotaryService anchors by `intent_id`. +3. Reconstruct the complete governance chain for the credential lifecycle. + +**Verify audit chain integrity:** +1. Call `NotaryService.GetLatestAnchor` to obtain the current anchor. +2. Walk the `previous_root` chain backward to verify continuity. +3. For each anchor, verify that the `merkle_root` is consistent with the known leaves. + +### 9.4 Audit Chain Continuity + +Each anchor's `previous_root` field MUST reference the `merkle_root` of the immediately preceding anchor. This forms an append-only linked list of audit states. + +The first anchor in the chain (genesis anchor) MUST have `previous_root` set to the zero hash (`0x0000...0000`, 32 zero bytes). + +Implementations MUST reject any anchor where `previous_root` does not match the `merkle_root` of the stored preceding anchor. This prevents history rewriting. + +### 9.5 Retention + +Anchors are immutable once created. Implementations MUST NOT delete or modify existing anchors. + +Leaf data (the MutationEnvelope content, as opposed to the leaf hash) retention is policy-dependent. Implementations SHOULD retain leaf data for at least the lifetime of the longest-lived credential type in the deployment. Implementations MAY archive leaf data to cold storage after a configurable retention period, provided the leaf hashes remain available for inclusion verification. + +## 10. Error Handling + +This section defines the behavior when components in the governance flow are unavailable or return errors. + +### 10.1 GovernanceService Unreachable + +If the governance-notifier plugin cannot reach the GovernanceService (network error, timeout, or non-retryable gRPC status), the credential operation MUST fail. This is a **fail-closed** policy. + +**Rationale:** Credential operations without governance authorization bypass the entire security model. Allowing ungoverned credential operations, even temporarily, creates an audit gap and potential for abuse. + +The plugin SHOULD retry with exponential backoff (initial delay 100ms, max delay 10s, max retries 5) before declaring the GovernanceService unreachable. The plugin MUST surface the failure to the caller with a clear error message indicating governance unavailability. + +### 10.2 Ceremony Timeout + +If a ceremony does not reach approval or denial within the configured timeout: + +1. The intent remains in `ceremony_pending` status. +2. The plugin MUST treat the timeout as a denial and abort the credential operation. +3. The plugin SHOULD call `GovernanceService.RevokeIntent` to clean up the pending intent. +4. The plugin MUST log the timeout event at `WARN` severity. + +The intent's own `ttl_seconds` provides an additional backstop: even if the plugin fails to revoke the intent, the GovernanceService MUST expire intents that exceed their TTL. + +### 10.3 NotaryService Unreachable + +If the NotaryService is unreachable after the credential operation has completed: + +1. The credential operation MUST proceed. The credential has already been issued/rotated/revoked; failing at this point would leave the system in an inconsistent state. +2. The MutationEnvelope MUST be persisted to local durable storage (e.g., an on-disk queue). +3. The plugin MUST retry anchoring with exponential backoff until the NotaryService becomes available. +4. The plugin MUST flag the credential as "anchoring-pending" in any status reporting. +5. The plugin SHOULD emit a metric (`governance_anchoring_pending_total`) for monitoring. + +**Rationale:** Unlike the GovernanceService (which provides authorization), the NotaryService provides audit recording. A temporary audit gap is preferable to failing a legitimately authorized credential operation. The retry mechanism ensures eventual consistency of the audit trail. + +### 10.4 Accord Policy Missing + +If the Accord policy engine has no matching rule for a credential event's `(registry_type, verb, credential_type)` tuple, the GovernanceService MUST default to `SingleApproval` classification. This is a **fail-safe** policy. + +**Rationale:** An unconfigured credential type is more likely an oversight than an intentionally ungoverned operation. Requiring at least single approval ensures that novel credential types receive human review. + +### 10.5 Duplicate Intent (Idempotency Key Collision) + +If a `CreateIntentRequest` is received with an `idempotency_key` that matches an existing, non-expired intent: + +1. If the existing intent is in `authorized` or `ceremony_pending` status, the GovernanceService MUST return the existing intent's `intent_id` and `ceremony_id` (if applicable). A new intent MUST NOT be created. +2. If the existing intent is in `redeemed`, `expired`, or `denied` status, the GovernanceService MUST create a new intent (the idempotency window has passed). + +This behavior ensures that retry logic in the governance-notifier plugin does not create parallel authorization flows for the same credential event. + +## 11. Security Considerations + +### 11.1 Plugin Trust Boundary + +The governance-notifier plugin runs inside SPIRE Server's trust boundary (as a server plugin) or as a colocated sidecar with access to the SPIRE Server's Unix domain socket. Its gRPC calls to the GovernanceService MUST use mTLS with the SPIRE Server's own SVID as the client certificate. + +The plugin MUST NOT accept or relay credentials from external sources. The identity claim in the `CreateIntentRequest` MUST be derived from SPIRE's own attestation data, not from caller-supplied headers or tokens. + +### 11.2 Hash Determinism and Idempotency + +MutationEnvelope hashes are deterministic: given the same credential event payload, timestamp, and actor, the same hash is always produced. This property enables: + +- **Deduplication:** Duplicate events produce duplicate hashes, which can be detected. +- **Verification:** A verifier can independently reconstruct the expected hash from the event data and compare it to the anchored leaf. + +Implementations MUST use a JCS library that passes the [RFC 8785 test vectors](https://www.rfc-editor.org/rfc/rfc8785#appendix-B). Non-conformant JCS implementations will produce different hashes, breaking verification. + +### 11.3 Append-Only Audit Trail + +The merkle tree anchored by the NotaryService is append-only. Once a leaf hash is included in an anchor, it cannot be removed or modified without invalidating the merkle root (and all subsequent roots in the chain). + +Implementations MUST NOT provide an API to delete anchors or modify leaf data. The only write operations are `CreateAnchor` (append) and the internal leaf accumulation within an epoch. + +### 11.4 Ceremony Identity Verification + +Approvers participating in a ceremony MUST be authenticated via OIDC or SPIFFE identity. Self-asserted identity (e.g., a username/password without federated verification) MUST NOT be accepted for ceremony approval. + +The CeremonyService MUST verify that the approver's identity is distinct from the requestor's identity for `SingleApproval` and `QuorumApproval` classifications. Self-approval MUST be rejected for these tiers. (For `SelfGrant`, the requestor is the implicit approver by definition.) + +### 11.5 Time-of-Check / Time-of-Use (TOCTOU) + +The SAT issued upon intent redemption has a bounded lifetime (`issued_at` to `expires_at`). The credential operation MUST complete within the SAT lifetime. If the SAT expires before the credential operation completes, the operation MUST be aborted. + +The RECOMMENDED SAT TTL for credential operations is 60 seconds. This window is sufficient for the credential operation itself (certificate signing, database credential provisioning) but short enough to limit the window of vulnerability if the SAT is intercepted. + +Implementations MUST check `expires_at` immediately before executing the credential operation, not only at the time the SAT is received. + +### 11.6 Replay Protection + +Each MutationIntent has the following replay protection mechanisms: + +- **`max_redemptions`:** Set to `1` for all credential events (Section 6.3). The intent can only be redeemed once. +- **`idempotency_key`:** Derived from the credential event's identity (`registry_type + verb + credential_id`). Duplicate submissions within the intent TTL window return the existing intent rather than creating a new one (Section 10.5). +- **`ttl_seconds`:** The intent expires after its TTL, preventing stale intents from being redeemed after the operational context has changed. + +The combination of single-use redemption, idempotency keying, and TTL expiration provides defense-in-depth against replay attacks. + +### 11.7 Confidentiality of Credential Event Data + +The `artifact_scope` field in `CreateIntentRequest` contains the JCS-canonicalized credential event payload, which may include sensitive information (e.g., SPIFFE IDs, tenant identifiers, credential scopes). The gRPC channel between the governance-notifier plugin and the GovernanceService MUST be encrypted (mTLS, per Section 11.1). + +The MutationEnvelope stored in the merkle tree contains only the `payload_hash`, not the raw payload. The raw credential event data SHOULD be stored separately with appropriate access controls, linked to the envelope by `intent_id`. + +## 12. References + +- **SPIFFE Specification:** [https://spiffe.io/docs/latest/spiffe-about/overview/](https://spiffe.io/docs/latest/spiffe-about/overview/) -- The Secure Production Identity Framework for Everyone. +- **RFC 8785:** [https://www.rfc-editor.org/rfc/rfc8785](https://www.rfc-editor.org/rfc/rfc8785) -- JSON Canonicalization Scheme (JCS). +- **RFC 3339:** [https://www.rfc-editor.org/rfc/rfc3339](https://www.rfc-editor.org/rfc/rfc3339) -- Date and Time on the Internet: Timestamps. +- **RFC 2119:** [https://www.rfc-editor.org/rfc/rfc2119](https://www.rfc-editor.org/rfc/rfc2119) -- Key words for use in RFCs to Indicate Requirement Levels. +- **GovernanceService proto:** `quartermaster/v1/governance.proto` -- `CreateIntent`, `RedeemIntent`, `RevokeIntent`, `ListIntents`. +- **CeremonyService proto:** `bascule/v1/ceremony.proto` -- `CreateCeremony`, `ApproveCeremony`, `DenyCeremony`, `GetCeremony`. +- **NotaryService proto:** `quartermaster/v1/notary.proto` -- `CreateAnchor`, `GetLatestAnchor`, `VerifyInclusion`. +- **Accord Policy Specification:** `specs/accord-policy.md` (forthcoming) -- Declarative policy classification for governed mutations. + +--- + +*End of specification.* diff --git a/specs/shellstream-extensions.md b/specs/shellstream-extensions.md new file mode 100644 index 0000000..632232a --- /dev/null +++ b/specs/shellstream-extensions.md @@ -0,0 +1,491 @@ +# Shellstream SSH Certificate Extensions + +## 1. Abstract + +Shellstream extensions are a set of SSH certificate extensions using the +`@guildhouse.io` vendor suffix that carry structured governance metadata +within SSH certificates issued by SPIRE. These extensions encode +authorization scope, tenant context, governance ceremony references, and +merkle audit proofs, enabling SSH servers to make fine-grained +authorization decisions based on Guildhouse platform state without +requiring separate API calls at connection time. + +## 2. Status + +**Draft Specification** -- This document is a working draft and subject to +change. It defines the normative behavior that conforming implementations +MUST follow once finalized. + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", +"SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this +document are to be interpreted as described in RFC 2119. + +## 3. Terminology + +**Shellstream Extension** +: An SSH certificate extension whose name uses the `@guildhouse.io` + vendor suffix and whose value carries Guildhouse governance metadata as + defined in this specification. + +**SSH Certificate Extension** +: A key-value string pair embedded in an OpenSSH certificate as defined + in the OpenSSH PROTOCOL.certkeys document. Extensions are advisory and + do not restrict the certificate holder; they convey additional + information to the server. + +**Vendor Suffix** +: The `@domain` portion of an extension name that designates the + namespace owner, following the OpenSSH convention for avoiding + collisions between independently defined extensions. + +**SAT (Substrate Attestation Token)** +: A signed token issued by the Quartermaster credential service that + binds a SPIFFE identity to a set of permitted operations on a specific + registry type. The SAT is the primary authorization artifact in the + Guildhouse platform. + +**SatScope** +: A structured object within a SAT that defines the registry type, + permitted verbs, and resource pattern for which the token grants + access. + +**Governance Ceremony** +: A structured approval workflow managed by the Bascule CeremonyService + that authorizes privilege elevation. Ceremony types range from + self-granted to quorum-based approval. + +**Merkle Anchor** +: A cryptographic commitment (merkle tree root hash) representing the + complete governance state at a point in time. Each governance mutation + is recorded as a leaf in the tree. + +**MutationEnvelope** +: A signed wrapper around a governance state change that is recorded as a + leaf in the governance merkle tree. + +**Accord Policy** +: A tenant-scoped authorization policy evaluated by the Accord policy + engine (backed by OPA) that defines roles, permissions, and ceremony + requirements for a tenant. + +**Trust Domain** +: A SPIFFE trust domain (e.g., `guildhouse.io`) that defines the + boundary within which SPIFFE identities and their associated + attestations are valid. + +## 4. Introduction + +SSH certificates, as defined by OpenSSH's PROTOCOL.certkeys, support +arbitrary extensions as key-value string pairs. By convention, vendor- +specific extensions use the `name@domain` format to avoid collisions with +extensions defined by other parties or by future OpenSSH releases. + +Shellstream extensions embed Guildhouse governance metadata into SSH +certificates so that SSH servers can make authorization decisions based on +platform state without issuing separate API calls. When SPIRE issues an +SSH certificate for a Guildhouse workload or user, the certificate may +include Shellstream extensions that describe: + +- **What** the holder is authorized to do (SAT scope). +- **Who** the holder is acting as (tenant identity, roles). +- **Why** elevated access was granted (ceremony reference). +- **When** the governance state was captured (merkle root, epoch). + +This decoupling of authorization metadata from runtime API lookups enables +offline authorization decisions, improves latency, and creates a +cryptographically verifiable audit trail linking SSH sessions to +governance state. + +## 5. Extension Format + +### 5.1 Naming Convention + +All Shellstream extensions use the `@guildhouse.io` vendor suffix. +Extension names MUST be lowercase, hyphen-separated identifiers followed +by the suffix: + +``` +@guildhouse.io +``` + +where `` matches the regular expression `[a-z][a-z0-9]*(-[a-z0-9]+)*`. + +### 5.2 Value Encoding + +Extension values are UTF-8 strings, as constrained by the SSH certificate +extension format (RFC 4251 string type). All values MUST be valid UTF-8. + +Binary data MUST be encoded as follows: +- Cryptographic hashes: lowercase hexadecimal encoding. +- Proofs and opaque binary payloads: base64 encoding using the standard + alphabet with padding (RFC 4648 Section 4). + +JSON-structured values MUST use compact encoding with no unnecessary +whitespace (no spaces after colons or commas, no newlines or +indentation). + +## 6. Extension Registry + +This section defines each Shellstream extension. Extensions marked +REQUIRED MUST be present in every Shellstream-bearing SSH certificate. +Extensions marked OPTIONAL MAY be omitted. + +### 6.1 `sat-scope@guildhouse.io` + +**Presence:** REQUIRED when a SAT is associated with the certificate. + +**Value:** A JSON object (or JSON array of objects when multiple scopes +exist) with the following structure: + +```json +{"registry_type":"","verbs":["",...],"resource_pattern":""} +``` + +**Fields:** + +| Field | Type | Description | +|--------------------|-----------------|-----------------------------------------------------------------------------| +| `registry_type` | string | Identifies the registry kind. Values include `"oci"`, `"helm"`, `"git"`, `"database"`. | +| `verbs` | array of string | Permitted operations. Examples: `["read","write"]`, `["push","pull"]`. | +| `resource_pattern` | string | Glob pattern for resource matching. Examples: `"tenant-a/*"`, `"ns/repo:*"`. | + +When a single SAT scope exists, the value MUST be a single JSON object. +When multiple SAT scopes exist, the value MUST be a JSON array of scope +objects. Implementations MUST accept both forms. + +**Example (single scope):** +``` +sat-scope@guildhouse.io = {"registry_type":"oci","verbs":["push","pull"],"resource_pattern":"acme-corp/*"} +``` + +**Example (multiple scopes):** +``` +sat-scope@guildhouse.io = [{"registry_type":"oci","verbs":["pull"],"resource_pattern":"acme-corp/*"},{"registry_type":"helm","verbs":["read"],"resource_pattern":"charts/*"}] +``` + +### 6.2 `sat-hash@guildhouse.io` + +**Presence:** REQUIRED when a SAT is associated with the certificate. + +**Value:** Lowercase hex-encoded SHA-256 hash of the raw SAT bytes. + +The value MUST be exactly 64 hexadecimal characters (`[0-9a-f]{64}`). +This hash is used for audit correlation -- it links the SSH session to the +SAT without embedding the SAT itself in the certificate. + +**Example:** +``` +sat-hash@guildhouse.io = a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 +``` + +### 6.3 `tenant-id@guildhouse.io` + +**Presence:** REQUIRED. + +**Value:** UUID string formatted per RFC 4122, using lowercase hexadecimal +digits with hyphens in the 8-4-4-4-12 grouping. + +The value MUST match the regular expression: +``` +[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12} +``` + +This extension identifies the tenant context for the SSH session. All +authorization decisions MUST be scoped to this tenant. + +**Example:** +``` +tenant-id@guildhouse.io = 7b2a91c4-3f8e-4d12-b5a6-9c0e1d2f3a4b +``` + +### 6.4 `roles@guildhouse.io` + +**Presence:** REQUIRED. + +**Value:** Comma-separated list of role names with no whitespace. Each +role name MUST match `[a-z][a-z0-9_]*`. + +Roles are defined by the tenant's Accord policy and represent the set of +permissions the certificate holder has been granted for this session. + +**Example:** +``` +roles@guildhouse.io = analyst,viewer +``` + +``` +roles@guildhouse.io = administrator +``` + +### 6.5 `ceremony-id@guildhouse.io` + +**Presence:** OPTIONAL -- present only for elevated sessions that were +authorized through a governance ceremony. + +**Value:** UUID string formatted per RFC 4122, using the same format +specified in Section 6.3. + +This extension references the governance ceremony that authorized the +privilege elevation for this session. + +**Example:** +``` +ceremony-id@guildhouse.io = e4f5a6b7-8c9d-0e1f-2a3b-4c5d6e7f8a9b +``` + +### 6.6 `ceremony-type@guildhouse.io` + +**Presence:** OPTIONAL -- MUST be present when `ceremony-id@guildhouse.io` +is present. MUST NOT be present when `ceremony-id@guildhouse.io` is +absent. + +**Value:** One of the following string literals, corresponding to the +`CeremonyType` enum from the Bascule CeremonyService: + +| Value | Description | +|-------------------------|---------------------------------------------------------------------| +| `self_grant` | The holder approved their own elevation (permitted by policy). | +| `single_approval` | A single designated approver authorized the elevation. | +| `quorum_approval` | A quorum of approvers collectively authorized the elevation. | +| `emergency_break_glass` | Emergency access granted outside normal approval flow, with enhanced audit. | + +**Example:** +``` +ceremony-type@guildhouse.io = quorum_approval +``` + +### 6.7 `merkle-root@guildhouse.io` + +**Presence:** OPTIONAL. + +**Value:** Lowercase hex-encoded SHA-256 merkle root hash. The value MUST +be exactly 64 hexadecimal characters (`[0-9a-f]{64}`). + +This is the root of the governance merkle tree at the time the certificate +was issued. It allows offline verification that the certificate was issued +during a known governance state. + +**Example:** +``` +merkle-root@guildhouse.io = 4d7a9c2e1f3b5a8d0e6c4b2a9f7e5d3c1b0a8f6e4d2c0b9a7f5e3d1c0b8a7f +``` + +### 6.8 `merkle-proof@guildhouse.io` + +**Presence:** OPTIONAL -- MUST be present only when +`merkle-root@guildhouse.io` is present. MAY be omitted even when +`merkle-root` is present (the root alone is useful for epoch pinning). + +**Value:** Base64-encoded (standard alphabet with padding, per RFC 4648 +Section 4) serialized inclusion proof. + +**Proof wire format:** The proof is a byte sequence consisting of +concatenated 32-byte SHA-256 sibling hashes followed by a single final +byte encoding the direction bits. Each bit in the direction byte indicates +whether the corresponding sibling is on the left (0) or right (1) of the +path, with the least-significant bit corresponding to the first sibling. +This limits proofs to trees of depth 8 (256 leaves). For deeper trees, a +multi-byte direction encoding will be specified in a future revision. + +**Example:** +``` +merkle-proof@guildhouse.io = QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ehQ= +``` + +### 6.9 `governance-epoch@guildhouse.io` + +**Presence:** OPTIONAL. + +**Value:** Decimal string representation of an unsigned 64-bit integer. +The value MUST match `[1-9][0-9]*|0` (no leading zeros except for the +value zero itself). + +The governance epoch is a monotonically increasing counter that increments +with each governance state change. It enables stale-state detection +without requiring full merkle verification: if a server's last-known epoch +is greater than the certificate's epoch, the certificate was issued +against stale governance state. + +**Example:** +``` +governance-epoch@guildhouse.io = 42 +``` + +## 7. Encoding Rules + +The following encoding rules apply to all Shellstream extension values: + +1. All values MUST be valid UTF-8 (RFC 3629). +2. Hash values (in `sat-hash`, `merkle-root`) MUST be lowercase + hexadecimal (`[0-9a-f]+`). Uppercase hex digits MUST be rejected. +3. Base64 values (in `merkle-proof`) MUST use the standard alphabet + (`A-Z`, `a-z`, `0-9`, `+`, `/`) with `=` padding as specified in + RFC 4648 Section 4. URL-safe base64 MUST NOT be used. +4. JSON values (in `sat-scope`) MUST use compact encoding: no spaces + after `:` or `,`, no newlines, no indentation. Parsers SHOULD accept + non-compact JSON for robustness but generators MUST produce compact + JSON. +5. UUID values (in `tenant-id`, `ceremony-id`) MUST be lowercase with + hyphens in 8-4-4-4-12 format. Uppercase UUID strings MUST be + rejected. +6. Numeric values (in `governance-epoch`) MUST be decimal strings + without leading zeros (except for the literal `"0"`). +7. Comma-separated values (in `roles`) MUST NOT contain whitespace + between items. + +## 8. Validation Requirements + +Implementations receiving SSH certificates with Shellstream extensions +MUST validate the following constraints before using extension values for +authorization or audit purposes: + +### 8.1 Structural Validation + +- Each extension value MUST conform to the format specified in its + registry entry (Section 6). +- Values that fail format validation MUST be treated as absent. The + server MAY log a warning but MUST NOT terminate the SSH connection + solely due to a malformed extension value. + +### 8.2 Co-occurrence Constraints + +The following co-occurrence rules MUST be enforced: + +| If present | Then MUST also be present | +|-------------------------------|--------------------------------| +| `sat-scope@guildhouse.io` | `sat-hash@guildhouse.io` | +| `sat-hash@guildhouse.io` | `sat-scope@guildhouse.io` | +| `ceremony-id@guildhouse.io` | `ceremony-type@guildhouse.io` | +| `ceremony-type@guildhouse.io` | `ceremony-id@guildhouse.io` | +| `merkle-proof@guildhouse.io` | `merkle-root@guildhouse.io` | + +Note: `merkle-root@guildhouse.io` MAY be present without +`merkle-proof@guildhouse.io` (root-only pinning). + +### 8.3 Required Extensions + +The following extensions MUST always be present in a Shellstream-bearing +SSH certificate: + +- `tenant-id@guildhouse.io` +- `roles@guildhouse.io` + +A certificate that contains any `@guildhouse.io` extension but is missing +either `tenant-id` or `roles` MUST be treated as invalid. The server +SHOULD reject the session. + +### 8.4 Forward Compatibility + +Unknown extensions bearing the `@guildhouse.io` suffix MUST be ignored. +Implementations MUST NOT reject a certificate solely because it contains +unrecognized `@guildhouse.io` extensions. This ensures that new +extensions can be introduced without breaking existing deployments. + +## 9. Security Considerations + +### 9.1 Extension Visibility + +SSH certificate extensions are not encrypted. Any entity that can read the +certificate (including intermediate proxies, logging infrastructure, and +the SSH server) can read extension values. Accordingly: + +- Implementations MUST NOT embed secrets, bearer tokens, private keys, or + other sensitive credentials in extension values. +- The `sat-hash` extension contains a SHA-256 hash of the SAT, not the + SAT itself. The SAT MUST be validated through a separate, secure + channel. + +### 9.2 Authorization Boundary + +- Merkle proofs provide **auditability**, not authorization. A valid + merkle inclusion proof means the credential issuance event was recorded + in the governance tree; it does not assert that the event was correct or + authorized. Authorization decisions MUST be based on SAT scope and + Accord policy, not on merkle proof validity alone. +- The `governance-epoch` extension enables stale-state detection but MUST + NOT be used as the sole authorization signal. A stale epoch indicates + that governance state may have changed since issuance; the server + SHOULD require re-attestation in this case. + +### 9.3 Parsing Safety + +Extension values are strings, not executable code. Implementations MUST +NOT evaluate extension values as code, shell commands, SQL, or any other +executable format. JSON values MUST be parsed with a standard JSON parser; +hand-rolled parsers are strongly discouraged. + +### 9.4 Size Constraints + +The total size of all Shellstream extension values (keys and values +combined) SHOULD NOT exceed 4096 bytes (4 KB). Exceeding this limit may +cause compatibility issues with certain SSH implementations that impose +limits on certificate size. Implementations MAY reject certificates whose +total `@guildhouse.io` extension payload exceeds this threshold. + +### 9.5 Replay and Freshness + +SSH certificates have their own validity period (valid-after to +valid-before). Shellstream extensions inherit this validity window. +Implementations SHOULD treat extension values as valid only within the +certificate's validity period. The `governance-epoch` extension provides +an additional freshness signal beyond the certificate's temporal validity. + +## 10. Compatibility + +### 10.1 SSH Protocol Compatibility + +All Shellstream extensions use the standard SSH certificate extension +format: a string key mapping to a string value. This is fully compatible +with OpenSSH and any SSH implementation that conforms to the +PROTOCOL.certkeys specification. + +### 10.2 Transparent Degradation + +SSH servers that do not understand Shellstream extensions will ignore them +entirely. This is standard OpenSSH behavior for unrecognized extensions. +Certificates bearing Shellstream extensions remain valid SSH certificates +and can be used for authentication on non-Guildhouse SSH servers (though +governance-based authorization will not be available). + +### 10.3 Additive Evolution + +Extensions are additive. New `@guildhouse.io` extensions can be +introduced in future revisions of this specification without breaking +existing parsers, per the forward compatibility rule in Section 8.4. + +### 10.4 Version Negotiation + +Version negotiation is implicit via extension presence. There is no +explicit version field. If a future revision deprecates or changes the +semantics of an extension, it MUST do so under a new extension name. The +original extension name retains its original semantics indefinitely. + +## 11. References + +- **OpenSSH PROTOCOL.certkeys** -- OpenSSH certificate format and + extension mechanism. + https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.certkeys + +- **RFC 2119** -- Key words for use in RFCs to Indicate Requirement + Levels. Bradner, S. March 1997. + https://datatracker.ietf.org/doc/html/rfc2119 + +- **RFC 3629** -- UTF-8, a transformation format of ISO 10646. Yergeau, + F. November 2003. + https://datatracker.ietf.org/doc/html/rfc3629 + +- **RFC 4122** -- A Universally Unique IDentifier (UUID) URN Namespace. + Leach, P., Mealling, M., and R. Salz. July 2005. + https://datatracker.ietf.org/doc/html/rfc4122 + +- **RFC 4251** -- The Secure Shell (SSH) Protocol Architecture. Ylonen, + T. and C. Lonvick, Ed. January 2006. + https://datatracker.ietf.org/doc/html/rfc4251 + +- **RFC 4648** -- The Base16, Base32, and Base64 Data Encodings. + Josefsson, S. October 2006. + https://datatracker.ietf.org/doc/html/rfc4648 + +- **RFC 8785** -- JSON Canonicalization Scheme (JCS). Rundgren, B., + Jordan, B., and S. Erdtman. June 2020. + https://datatracker.ietf.org/doc/html/rfc8785 diff --git a/specs/spiffe-ssh-svid.md b/specs/spiffe-ssh-svid.md new file mode 100644 index 0000000..278ba5e --- /dev/null +++ b/specs/spiffe-ssh-svid.md @@ -0,0 +1,534 @@ +# SSH-SVID: SSH Certificates with SPIFFE Identity + +## Abstract + +This document defines the SSH SPIFFE Verifiable Identity Document (SSH-SVID), a standard OpenSSH certificate type whose subject identity is derived from a SPIFFE ID. An SSH-SVID binds a SPIFFE identity to an SSH public key via a certificate signed by a SPIFFE-managed SSH Certificate Authority, enabling workloads to authenticate to SSH servers using the same identity framework that governs their X.509 and JWT credentials. This specification extends the SPIFFE standard into the SSH certificate domain, providing automated, short-lived, attestation-backed SSH credentials without requiring modifications to the SSH protocol or server implementations. + +## Status + +**Draft Specification** -- Version 0.1.0 + +This document is a draft and is not yet approved by any standards body. It is published for review and comment by the SPIFFE community. Distribution of this document is unlimited. + +## Table of Contents + +1. [Terminology](#1-terminology) +2. [Introduction](#2-introduction) +3. [Certificate Format](#3-certificate-format) +4. [Issuance Flow](#4-issuance-flow) +5. [Trust Model](#5-trust-model) +6. [Key Management](#6-key-management) +7. [Certificate Lifetime and Rotation](#7-certificate-lifetime-and-rotation) +8. [Security Considerations](#8-security-considerations) +9. [Compatibility](#9-compatibility) +10. [References](#10-references) + +## Notational Conventions + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119][rfc2119] and [RFC 8174][rfc8174] when, and only when, they appear in all capitals, as shown here. + +## 1. Terminology + +**SSH-SVID**: An OpenSSH certificate that encodes a SPIFFE ID as its principal identity. An SSH-SVID is a SPIFFE Verifiable Identity Document expressed in the SSH certificate format. + +**SPIFFE ID**: A URI of the form `spiffe:///` that uniquely identifies a workload within a trust domain, as defined by the [SPIFFE specification][spiffe-spec]. + +**Trust Domain**: A SPIFFE administrative realm identified by the authority component of a SPIFFE ID (e.g., `example.org` in `spiffe://example.org/web-server`). All identities within a trust domain share a common root of trust. + +**SPIRE**: The SPIFFE Runtime Environment, a production-ready reference implementation of the SPIFFE APIs that performs node and workload attestation and issues SVIDs. + +**Workload API**: The SPIFFE-defined API (typically exposed over a Unix domain socket) through which workloads obtain their SVIDs and trust bundles. Defined in the [SPIFFE Workload API specification][spiffe-workload-api]. + +**X.509-SVID**: A SPIFFE Verifiable Identity Document encoded as an X.509 certificate, as defined by the [SPIFFE X.509-SVID specification][x509-svid-spec]. + +**JWT-SVID**: A SPIFFE Verifiable Identity Document encoded as a JSON Web Token, as defined by the [SPIFFE JWT-SVID specification][jwt-svid-spec]. + +**SSH Certificate Authority (CA)**: A cryptographic key pair whose private key signs SSH certificates and whose public key is distributed to relying parties for certificate verification. In the context of this specification, the SSH CA is managed by the SPIRE Server. + +**SSH User Certificate**: An OpenSSH certificate of type `SSH_CERT_TYPE_USER` (value 1) that authenticates a user (or workload) to an SSH server, as defined by the [OpenSSH certificate format][openssh-cert]. + +**SSH Host Certificate**: An OpenSSH certificate of type `SSH_CERT_TYPE_HOST` (value 2) that authenticates an SSH server to a connecting client. While this specification focuses on user certificates, the trust model described herein applies to host certificates by analogy. + +## 2. Introduction + +SSH is the de facto standard for remote access to Unix systems and is widely used for workload-to-workload communication, configuration management, and operational automation. Despite its ubiquity, SSH authentication remains overwhelmingly reliant on static, long-lived key pairs that are manually provisioned, distributed, and revoked. + +This static key management model introduces several problems: + +- **Key sprawl**: Long-lived SSH keys accumulate across infrastructure with no reliable inventory or lifecycle management. +- **Manual rotation**: Key rotation requires human intervention or bespoke automation, leading to keys that persist far beyond their intended lifetime. +- **Weak identity binding**: An SSH public key identifies a cryptographic key, not a workload. There is no standard mechanism to assert that a given SSH key belongs to a specific service, pod, or process. +- **No attestation**: Traditional SSH key distribution trusts the provisioning process implicitly. There is no runtime verification that the entity presenting a key is the workload it claims to be. +- **Siloed trust**: SSH trust (via `authorized_keys` and `known_hosts`) is managed independently from the workload identity systems used for service mesh, mTLS, and API authentication. + +The SPIFFE framework addresses these problems for X.509 and JWT credentials by providing attestation-backed, automatically rotated, short-lived identity documents. However, the SPIFFE standard does not currently define an SSH certificate type, leaving SSH as a gap in the unified workload identity model. + +SSH-SVID closes this gap. By encoding a SPIFFE ID as the principal of a standard OpenSSH certificate, SSH-SVID enables: + +- Automatic SSH credential issuance through the existing SPIRE Workload API +- Runtime workload attestation before any SSH credential is granted +- Short-lived certificates that eliminate the need for key revocation infrastructure +- A unified identity across mTLS (X.509-SVID), bearer tokens (JWT-SVID), and SSH (SSH-SVID) +- Trust bundle federation across organizational boundaries, including SSH CA keys + +This specification defines the certificate format, issuance flow, trust model, key management requirements, and security considerations for SSH-SVIDs. + +## 3. Certificate Format + +### 3.1 Certificate Type + +An SSH-SVID MUST be an OpenSSH user certificate as defined by the [OpenSSH certificate key format][openssh-cert]. + +The certificate key type MUST be one of the following: + +- `ssh-ed25519-cert-v01@openssh.com` (REQUIRED support) +- `sk-ssh-ed25519-cert-v01@openssh.com` (OPTIONAL; for hardware-backed keys) + +Implementations MAY support `ecdsa-sha2-nistp256-cert-v01@openssh.com` for interoperability. Implementations MUST NOT issue SSH-SVIDs using RSA key types. + +The certificate type field MUST be set to `SSH_CERT_TYPE_USER` (value 1). + +### 3.2 Key ID + +The Key ID field of the SSH certificate MUST contain the SPIFFE ID of the workload, encoded as a UTF-8 string in its canonical URI form: + +``` +spiffe:/// +``` + +The Key ID MUST NOT contain query parameters, fragments, or trailing slashes. The Key ID MUST conform to the SPIFFE ID format defined in [Section 2 of the SPIFFE specification][spiffe-spec]. + +### 3.3 Valid Principals + +The Valid Principals field MUST include the workload's SPIFFE ID as the first entry. + +The Valid Principals field MAY include additional principals derived from the workload's SPIRE registration entry. These additional principals enable mapping to local Unix accounts or roles on the target SSH server. + +Example Valid Principals for a workload registered as `spiffe://example.org/ns/prod/sa/web-server`: + +``` +spiffe://example.org/ns/prod/sa/web-server +web-server +deployer +``` + +If no additional principals are configured in the workload registration, the Valid Principals field MUST contain exactly one entry: the SPIFFE ID. + +### 3.4 Serial Number + +The Serial Number field MUST be a monotonically increasing 64-bit unsigned integer, unique per SSH CA signing key. The serial number MUST NOT be reused for the lifetime of the CA key. + +Serial numbers are used for audit logging and MAY be used for revocation via `RevokedKeys` in OpenSSH's `KRL` (Key Revocation List) format in exceptional circumstances, although the short-lived nature of SSH-SVIDs (see [Section 7](#7-certificate-lifetime-and-rotation)) makes explicit revocation unnecessary under normal operation. + +### 3.5 Validity Period + +The `valid after` and `valid before` fields MUST be set to constrain the certificate's lifetime. The requirements for these values are specified in [Section 7](#7-certificate-lifetime-and-rotation). + +Implementations MUST NOT issue SSH-SVIDs with an empty validity period (i.e., valid from the Unix epoch to the maximum time value). + +### 3.6 Critical Options + +Critical options constrain the certificate's use. An SSH server MUST reject the certificate if it encounters an unrecognized critical option. + +The following critical options apply to SSH-SVIDs: + +- **`source-address`**: SHOULD be set when the workload's network location is known to the issuing SPIRE Server. The value MUST be a comma-separated list of CIDR address ranges from which the certificate is valid. When set, the SSH server MUST reject connections from addresses outside the specified ranges. + +- **`force-command`**: MAY be set to restrict the certificate to a specific command, based on the workload registration entry. + +Implementations MUST NOT set critical options that are not defined by [OpenSSH][openssh-cert] or by a companion specification explicitly referenced from the workload's SPIRE registration. + +### 3.7 Extensions + +Extensions grant capabilities to the certificate holder. An SSH server that does not recognize an extension MUST ignore it. + +SSH-SVIDs SHOULD include the following standard OpenSSH extensions, unless the workload's registration entry explicitly restricts them: + +- `permit-pty` +- `permit-user-rc` + +SSH-SVIDs MAY include additional standard extensions: + +- `permit-X11-forwarding` +- `permit-agent-forwarding` +- `permit-port-forwarding` +- `no-touch-required` (for `sk-` key types) + +SSH-SVIDs MAY include vendor-specific extensions prefixed with a domain namespace. In particular, the Shellstream system defines extensions under the `shellstream@guildhouse.io` namespace, as specified in the companion Shellstream specification. Vendor extensions MUST use the `@` format to avoid collision. + +### 3.8 Certificate Encoding + +The complete SSH-SVID certificate structure, using the `ssh-ed25519-cert-v01@openssh.com` type as an example, is as follows: + +``` +string "ssh-ed25519-cert-v01@openssh.com" +string nonce +string pk ; Ed25519 public key +uint64 serial ; monotonically increasing +uint32 type ; SSH_CERT_TYPE_USER (1) +string key id ; SPIFFE ID +string valid principals ; SPIFFE ID + optional additional +uint64 valid after ; certificate start time +uint64 valid before ; certificate expiry time +string critical options ; source-address, etc. +string extensions ; permit-pty, vendor extensions, etc. +string reserved ; empty +string signature key ; SSH CA public key +string signature ; CA signature over the certificate +``` + +This structure is identical to standard OpenSSH certificates. The SPIFFE identity is carried entirely within the existing Key ID and Valid Principals fields. + +## 4. Issuance Flow + +SSH-SVID issuance follows the existing SPIRE attestation and credential delivery model, extended with an SSH certificate signing capability. + +### 4.1 Flow Overview + +``` ++-----------+ +--------------+ +--------------+ +--------+ +| | (1) | | (3) | | (4) | | +| Workload +-------> SPIRE Agent +-------> SPIRE Server +-------> SSH CA | +| | | | | | | | +| | <-----+ | <-----+ | <-----+ | +| | (6) | | (5) | | (4) | | ++-----------+ +--------------+ +--------------+ +--------+ + (2) + Workload Attestation +``` + +### 4.2 Detailed Steps + +**Step 1: SSH-SVID Request** + +The workload requests an SSH-SVID from the SPIRE Agent via the Workload API. The request is made over a Unix domain socket. The workload provides its SSH public key (or requests that the Agent generate an ephemeral key pair on its behalf). + +The Workload API endpoint for SSH-SVID issuance is an extension to the standard SPIFFE Workload API: + +``` +rpc FetchSSHSVID(FetchSSHSVIDRequest) returns (FetchSSHSVIDResponse) +``` + +The request MUST include the workload's public key. The request MAY include requested principals beyond the SPIFFE ID. + +**Step 2: Workload Attestation** + +The SPIRE Agent identifies the calling workload using kernel-level attestation (e.g., Unix PID, Kubernetes pod identity, or Docker container labels). The Agent verifies that the calling process matches a registered workload entry in the SPIRE Server. + +This step is identical to workload attestation for X.509-SVIDs and JWT-SVIDs. No SSH-specific attestation is required. + +**Step 3: Agent-to-Server Request** + +The SPIRE Agent forwards the SSH-SVID request to the SPIRE Server over the Agent-Server mTLS channel. The request includes: + +- The workload's SPIFFE ID (determined by attestation) +- The workload's SSH public key +- Any additional requested principals + +The Agent-to-Server RPC is analogous to the existing X.509-SVID minting RPC: + +``` +rpc MintSSHSVID(MintSSHSVIDRequest) returns (MintSSHSVIDResponse) +``` + +**Step 4: Certificate Generation** + +The SPIRE Server generates the SSH certificate: + +1. The Server validates that the SPIFFE ID is registered and that the requested principals are authorized for the workload. +2. The Server invokes the **CredentialComposer** plugin chain, which populates the certificate fields according to this specification and the workload's registration entry. +3. The Server signs the certificate using the SSH CA private key, managed by the **KeyManager** plugin. + +The CredentialComposer plugin is the extension point where operators can customize certificate fields (extensions, critical options, additional principals) without modifying the SPIRE Server core. + +**Step 5: Certificate Delivery to Agent** + +The SPIRE Server returns the signed SSH certificate to the SPIRE Agent over the existing mTLS channel. The response includes: + +- The signed SSH-SVID certificate (encoded per [Section 3.8](#38-certificate-encoding)) +- The SSH CA trust bundle (the CA public key and any federated CA public keys) + +**Step 6: Certificate Delivery to Workload** + +The SPIRE Agent delivers the SSH-SVID to the workload via the Workload API Unix domain socket. The response includes: + +- The signed SSH certificate +- The corresponding private key (if the Agent generated the key pair) +- The SSH CA trust bundle for known_hosts configuration +- The certificate's expiry time + +The workload MAY write the certificate to disk (e.g., for use with `ssh -o CertificateFile=`) or hold it in memory for programmatic use. + +### 4.3 Workload API Extension + +The SSH-SVID Workload API extension defines the following messages: + +```protobuf +message FetchSSHSVIDRequest { + bytes public_key = 1; // SSH public key (Ed25519) + repeated string principals = 2; // Additional requested principals +} + +message FetchSSHSVIDResponse { + repeated SSHSVID svids = 1; + repeated SSHTrustBundle trust_bundles = 2; +} + +message SSHSVID { + string spiffe_id = 1; + bytes certificate = 2; // Signed OpenSSH certificate + bytes private_key = 3; // Private key (if Agent-generated) + int64 expires_at = 4; // Unix timestamp +} + +message SSHTrustBundle { + string trust_domain = 1; + repeated bytes ca_public_keys = 2; // SSH CA public keys +} +``` + +The full protobuf definitions are specified in the companion `ssh-credential-composer` plugin specification. + +## 5. Trust Model + +### 5.1 SSH CA as Part of the SPIRE Trust Bundle + +The SSH CA public key MUST be included in the SPIRE trust bundle for the trust domain. This extends the existing trust bundle structure, which already contains X.509 root certificates and JWT signing keys, to include SSH CA public keys. + +A trust bundle with SSH-SVID support contains: + +- X.509 root certificates (for X.509-SVID validation) +- JWT signing public keys (for JWT-SVID validation) +- SSH CA public keys (for SSH-SVID validation) + +### 5.2 Trust Bundle Federation + +When two trust domains are federated, each domain's trust bundle MAY include SSH CA public keys from the other domain. This enables cross-domain SSH-SVID validation: a workload in `trust-domain-a` can authenticate to an SSH server in `trust-domain-b` if the server trusts `trust-domain-a`'s SSH CA key. + +Federation of SSH CA keys follows the same mechanism as X.509 root certificate federation in SPIRE, using the SPIFFE Federation API. + +### 5.3 SSH Server Configuration + +SSH servers that accept SSH-SVIDs MUST be configured with the SSH CA public key(s) from the SPIRE trust bundle. This configuration uses standard OpenSSH server directives: + +**`TrustedUserCAKeys`**: Points to a file containing the SSH CA public key(s). This file MUST be kept current with the SPIRE trust bundle. Implementations SHOULD use a sidecar or agent process that watches the SPIRE trust bundle and updates this file automatically. + +``` +# /etc/ssh/sshd_config +TrustedUserCAKeys /etc/ssh/spire_ca_keys +``` + +**`AuthorizedPrincipalsFile`**: Maps SPIFFE IDs to local Unix accounts. This file determines which SPIFFE IDs are authorized to access which local accounts. + +``` +# /etc/ssh/authorized_principals/%u +# For user "deploy": +spiffe://example.org/ns/prod/sa/web-server +spiffe://example.org/ns/prod/sa/ci-runner +``` + +When `AuthorizedPrincipalsFile` is configured, the SSH server validates that the certificate's Valid Principals include an entry that matches a line in the principals file for the target Unix account. + +Alternatively, if the SSH server's `AuthorizedPrincipalsCommand` directive is available (OpenSSH 8.0+), implementations MAY use a command that queries the SPIRE trust bundle dynamically to resolve SPIFFE ID-to-account mappings. + +### 5.4 Certificate Chain Model + +SSH certificates use a single-level trust model: each certificate is signed directly by the CA key. There are no intermediate certificate authorities in the SSH certificate format. Consequently, the SSH CA public key present in the SPIRE trust bundle directly validates all SSH-SVIDs issued within that trust domain. + +This single-level model simplifies validation but requires careful CA key management (see [Section 6](#6-key-management)). + +## 6. Key Management + +### 6.1 CA Key Lifecycle + +The SSH CA key pair is managed by the SPIRE Server's **KeyManager** plugin. The KeyManager is responsible for generating, storing, and making available the CA private key for certificate signing operations. + +Implementations SHOULD use a KeyManager that stores CA keys in hardware security modules (HSMs) or secure enclaves. The companion `substrate-keymanager` specification defines a KeyManager implementation suitable for production deployments. + +The SSH CA key MUST NOT be exported from the KeyManager in plaintext. All signing operations MUST be performed within the KeyManager boundary. + +### 6.2 Key Types + +The following key types are defined for SSH CA keys: + +| Key Type | Status | Notes | +|---|---|---| +| Ed25519 | REQUIRED | All implementations MUST support Ed25519 CA keys | +| ECDSA P-256 | OPTIONAL | MAY be supported for interoperability | +| RSA | NOT RECOMMENDED | RSA SHOULD NOT be used due to larger key/signature sizes and weaker security margins at equivalent key lengths | + +The SSH CA key type determines the certificate signature algorithm. An Ed25519 CA key produces `ssh-ed25519` signatures; an ECDSA P-256 CA key produces `ecdsa-sha2-nistp256` signatures. + +### 6.3 CA Key Rotation + +SPIRE manages CA key rotation through the **upstream authority** mechanism. When a CA key rotation occurs: + +1. The new CA public key MUST be added to the trust bundle before any certificates are signed with the new CA private key. +2. Both the old and new CA public keys MUST be present in the trust bundle during the transition period. +3. The old CA public key MUST remain in the trust bundle until all certificates signed by the old key have expired. +4. SSH servers MUST receive the updated trust bundle (containing both CA public keys) before certificates signed by the new key are presented. + +The trust bundle propagation delay introduces a window during which a newly rotated CA key may not yet be trusted by all SSH servers. Implementations MUST account for this delay by ensuring the new key is in the trust bundle for at least one full certificate lifetime before the old key is removed. + +### 6.4 Workload Keys + +Workload SSH key pairs (the key pair bound to the certificate) MUST be ephemeral. A new key pair SHOULD be generated for each SSH-SVID request. + +Workload keys MUST be Ed25519. This requirement ensures small key sizes, fast signing, and consistent security properties across all SSH-SVIDs. + +Workload private keys MUST NOT be persisted to durable storage beyond the certificate's lifetime. If the SPIRE Agent generates the key pair on behalf of the workload, the Agent MUST delete its copy of the private key after delivering it to the workload. + +## 7. Certificate Lifetime and Rotation + +### 7.1 Lifetime Requirements + +SSH-SVIDs MUST be short-lived. The following constraints apply: + +| Parameter | Value | Requirement Level | +|---|---|---| +| Default TTL | 5 minutes | RECOMMENDED | +| Minimum TTL | 30 seconds | MUST NOT issue below | +| Maximum TTL | 1 hour | MUST NOT exceed | + +The `valid after` field MUST be set to the current time at issuance (with no more than 60 seconds of clock skew tolerance subtracted). + +The `valid before` field MUST be set to `valid after` plus the configured TTL. + +Operators MAY configure per-workload TTLs within the bounds above via SPIRE registration entries. + +### 7.2 Rotation + +Workloads MUST re-request SSH-SVIDs before the current certificate expires. The SPIRE Workload API handles rotation automatically when the workload uses the streaming `FetchSSHSVID` API variant. + +Certificates SHOULD be renewed when 50% of the certificate's lifetime has elapsed (the "half-life" renewal point). For a certificate with a 5-minute TTL, renewal SHOULD occur at 2 minutes 30 seconds after issuance. + +Implementations MUST tolerate brief periods where both the old and new certificates are valid (i.e., the validity periods overlap). This overlap is expected and does not constitute a security concern given the short lifetimes involved. + +### 7.3 Revocation + +SSH-SVIDs do not require a revocation infrastructure (CRL, OCSP, or equivalent) under normal operation. The short certificate lifetime ensures that a compromised certificate becomes unusable within minutes. + +In exceptional circumstances (e.g., a compromised workload key that must be revoked immediately), operators MAY use OpenSSH Key Revocation Lists (KRLs) distributed to SSH servers. KRL-based revocation is an operational concern outside the scope of this specification. + +The absence of revocation infrastructure is a deliberate design choice: it reduces operational complexity, eliminates availability dependencies on revocation endpoints, and aligns with the SPIFFE principle that short-lived credentials are preferable to long-lived credentials with revocation. + +## 8. Security Considerations + +### 8.1 Workload Attestation as Trust Anchor + +The security of SSH-SVIDs depends entirely on the correctness of SPIRE's workload attestation. If an attacker can spoof a workload's identity during attestation (e.g., by compromising the kernel, container runtime, or node agent), they can obtain SSH-SVIDs for arbitrary SPIFFE IDs. + +Operators MUST ensure that the workload attestation mechanism is appropriate for their threat model. Kubernetes-based attestation (using the TokenReview API and pod metadata) is RECOMMENDED for containerized workloads. Unix PID-based attestation SHOULD only be used when stronger mechanisms are unavailable. + +### 8.2 SPIFFE ID Visibility + +SSH-SVIDs do not provide confidentiality of the workload's SPIFFE ID. The SPIFFE ID is encoded in the certificate's Key ID and Valid Principals fields, both of which are transmitted in cleartext during SSH authentication (the certificate is sent before the encrypted channel is fully established in the SSH protocol). + +Operators SHOULD be aware that SPIFFE IDs may reveal information about internal service topology (e.g., namespace names, service account names). If this information is sensitive, operators SHOULD use opaque path components in SPIFFE IDs. + +### 8.3 Agent-to-Server Communication + +Communication between the SPIRE Agent and SPIRE Server MUST use mutual TLS (mTLS), authenticated with X.509-SVIDs. This is the default communication model in SPIRE. + +The Agent-to-Server channel carries SSH-SVID signing requests, which include the workload's public key and SPIFFE ID. Compromise of this channel would allow an attacker to request SSH-SVIDs for any workload the compromised Agent is authorized to attest. + +### 8.4 Workload API Socket Protection + +The Unix domain socket used for the Workload API MUST be protected by filesystem permissions. The socket SHOULD be accessible only to the workload processes that are entitled to receive SVIDs. + +In Kubernetes environments, the Workload API socket is typically mounted into the pod's filesystem via a `hostPath` or CSI volume. The SPIRE Agent MUST verify the connecting process's identity (via kernel-level attestation) regardless of filesystem permissions. + +### 8.5 Rate Limiting + +The SPIRE Server SHOULD enforce rate limits on SSH-SVID issuance to prevent abuse. Rate limits SHOULD be applied per workload registration entry (i.e., per SPIFFE ID). + +A RECOMMENDED rate limit is no more than 60 SSH-SVID issuances per SPIFFE ID per minute. This allows for normal rotation (approximately 1 request per renewal interval) while preventing runaway issuance loops. + +### 8.6 CA Key Compromise + +Compromise of the SSH CA private key allows an attacker to forge SSH-SVIDs for any SPIFFE ID in the trust domain. Mitigations include: + +- Storing CA keys in HSMs or secure enclaves (see [Section 6.1](#61-ca-key-lifecycle)) +- Monitoring certificate issuance logs for anomalous serial numbers or SPIFFE IDs +- Implementing CA key rotation on a regular schedule (see [Section 6.3](#63-ca-key-rotation)) +- Maintaining the ability to rapidly rotate the CA key and propagate the new trust bundle in response to a compromise + +### 8.7 Clock Skew + +SSH certificate validation depends on accurate system clocks. Clock skew between the issuing SPIRE Server and the validating SSH server can cause valid certificates to be rejected (if the server's clock is behind) or expired certificates to be accepted (if the server's clock is ahead). + +Implementations SHOULD account for clock skew by subtracting a small tolerance (no more than 60 seconds) from the `valid after` timestamp. Operators MUST ensure that all systems participating in SSH-SVID issuance and validation synchronize their clocks via NTP or an equivalent mechanism. + +## 9. Compatibility + +### 9.1 OpenSSH Server Compatibility + +SSH-SVIDs are standard OpenSSH certificates. Any OpenSSH server at version 8.0 or later can validate SSH-SVIDs without modification. The server requires only: + +- The SSH CA public key in `TrustedUserCAKeys` +- Principal-to-account mappings in `AuthorizedPrincipalsFile` or `AuthorizedPrincipalsCommand` + +No patches, plugins, or custom builds of OpenSSH are required. + +### 9.2 SSH Client Compatibility + +SSH-SVIDs can be used with any OpenSSH client (version 8.0+) via standard mechanisms: + +- **Certificate file**: `ssh -o CertificateFile=/path/to/ssh-svid-cert.pub` +- **Identity file**: `ssh -i /path/to/key` (when the certificate is in the same directory with the `-cert.pub` suffix, the client loads it automatically) +- **SSH agent**: `ssh-add /path/to/key` followed by `ssh-add -L` to verify the certificate is loaded + +### 9.3 SSH Agent Compatibility + +The standard `ssh-agent` can hold SSH-SVIDs. The SPIRE Agent or a helper process MAY load SSH-SVIDs into the workload's `ssh-agent` automatically, removing expired certificates and adding renewed ones as part of the rotation cycle. + +When using `ssh-agent`, the certificate lifetime constrains the agent's key lifetime. Implementations SHOULD use `ssh-add -t ` to set a key lifetime matching the certificate's remaining validity. + +### 9.4 Non-OpenSSH Implementations + +SSH-SVIDs are compatible with any SSH implementation that supports OpenSSH certificate validation, including but not limited to: + +- Dropbear (with certificate support enabled) +- libssh (version 0.9+) +- Paramiko (with certificate validation) +- Go's `golang.org/x/crypto/ssh` package + +Implementations that do not support OpenSSH certificates cannot validate SSH-SVIDs. This is a limitation of those implementations, not of this specification. + +## 10. References + +### Normative References + +- **[RFC 2119]** Bradner, S., "Key words for use in RFCs to Indicate Requirement Levels", BCP 14, RFC 2119, March 1997. https://www.rfc-editor.org/rfc/rfc2119 + +- **[RFC 8174]** Leiba, B., "Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words", BCP 14, RFC 8174, May 2017. https://www.rfc-editor.org/rfc/rfc8174 + +- **[SPIFFE Specification]** SPIFFE Project, "Secure Production Identity Framework for Everyone", v1.0. https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE.md + +- **[SPIFFE Workload API]** SPIFFE Project, "SPIFFE Workload API". https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Workload_API.md + +- **[X.509-SVID Specification]** SPIFFE Project, "X.509 SPIFFE Verifiable Identity Document". https://github.com/spiffe/spiffe/blob/main/standards/X509-SVID.md + +- **[JWT-SVID Specification]** SPIFFE Project, "JWT SPIFFE Verifiable Identity Document". https://github.com/spiffe/spiffe/blob/main/standards/JWT-SVID.md + +- **[OpenSSH Certificate Format]** OpenSSH Project, "PROTOCOL.certkeys". https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.certkeys + +### Informative References + +- **[RFC 4251]** Ylonen, T. and C. Lonvick, "The Secure Shell (SSH) Protocol Architecture", RFC 4251, January 2006. https://www.rfc-editor.org/rfc/rfc4251 + +- **[RFC 4252]** Ylonen, T. and C. Lonvick, "The Secure Shell (SSH) Authentication Protocol", RFC 4252, January 2006. https://www.rfc-editor.org/rfc/rfc4252 + +- **[RFC 4253]** Ylonen, T. and C. Lonvick, "The Secure Shell (SSH) Transport Layer Protocol", RFC 4253, January 2006. https://www.rfc-editor.org/rfc/rfc4253 + +- **[SPIRE Documentation]** SPIFFE Project, "SPIRE: the SPIFFE Runtime Environment". https://spiffe.io/docs/latest/spire-about/ + +- **[SPIFFE Trust Bundle]** SPIFFE Project, "SPIFFE Trust Domain and Bundle". https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Trust_Domain_and_Bundle.md + +[rfc2119]: https://www.rfc-editor.org/rfc/rfc2119 +[rfc8174]: https://www.rfc-editor.org/rfc/rfc8174 +[spiffe-spec]: https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE.md +[spiffe-workload-api]: https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Workload_API.md +[x509-svid-spec]: https://github.com/spiffe/spiffe/blob/main/standards/X509-SVID.md +[jwt-svid-spec]: https://github.com/spiffe/spiffe/blob/main/standards/JWT-SVID.md +[openssh-cert]: https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.certkeys diff --git a/test/fixtures/sample-oidc-token.json b/test/fixtures/sample-oidc-token.json new file mode 100644 index 0000000..61bf6c4 --- /dev/null +++ b/test/fixtures/sample-oidc-token.json @@ -0,0 +1,22 @@ +{ + "iss": "https://keycloak.guildhouse.example.org/realms/platform", + "sub": "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "aud": "spire", + "exp": 1735689600, + "iat": 1735686000, + "auth_time": 1735686000, + "email": "operator@guildhouse.coop", + "email_verified": true, + "preferred_username": "operator", + "groups": [ + "platform-engineers", + "tenant-alpha-admins" + ], + "realm_access": { + "roles": [ + "administrator", + "engineer" + ] + }, + "tenant_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" +} diff --git a/test/fixtures/sample-sat-scope.json b/test/fixtures/sample-sat-scope.json new file mode 100644 index 0000000..8661f6e --- /dev/null +++ b/test/fixtures/sample-sat-scope.json @@ -0,0 +1,5 @@ +{ + "registry_type": "oci", + "verbs": ["push", "pull"], + "resource_pattern": "tenant-alpha/*" +} diff --git a/test/fixtures/sample-ssh-cert-extensions.json b/test/fixtures/sample-ssh-cert-extensions.json new file mode 100644 index 0000000..e09ec3a --- /dev/null +++ b/test/fixtures/sample-ssh-cert-extensions.json @@ -0,0 +1,13 @@ +{ + "permit-pty": "", + "permit-user-rc": "", + "sat-scope@guildhouse.io": "{\"registry_type\":\"oci\",\"verbs\":[\"push\",\"pull\"],\"resource_pattern\":\"tenant-alpha/*\"}", + "sat-hash@guildhouse.io": "a3f2b8c1d4e5f67890abcdef1234567890abcdef1234567890abcdef12345678", + "tenant-id@guildhouse.io": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "roles@guildhouse.io": "administrator,engineer", + "ceremony-id@guildhouse.io": "11223344-5566-7788-99aa-bbccddeeff00", + "ceremony-type@guildhouse.io": "single_approval", + "merkle-root@guildhouse.io": "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", + "merkle-proof@guildhouse.io": "AQIDBAU=", + "governance-epoch@guildhouse.io": "42" +} diff --git a/test/fixtures/spire-test-config.hcl b/test/fixtures/spire-test-config.hcl new file mode 100644 index 0000000..04df255 --- /dev/null +++ b/test/fixtures/spire-test-config.hcl @@ -0,0 +1,12 @@ +# SPIRE test configuration for integration testing. +# +# This HCL file is used to test plugin configuration parsing. +# It represents the plugin_data block from a SPIRE server config. + +PluginConfig { + trust_domain = "test.example.org" + cluster_id = "test-cluster" + governance_addr = "localhost:50051" + ceremony_addr = "localhost:50052" + notary_addr = "localhost:50053" +}