Remediate all 17 audit findings from AUDIT.md

Critical fixes:
- F-01: SatScope array form support (single pointer → slice with polymorphic JSON)
- F-02: Add governance-intent@guildhouse.dev as 10th Shellstream extension
- F-06: Replace os.Exit(1) stubs with go-plugin Serve() boilerplate in all cmd/
- F-13: Validate SatScope.ResourcePattern is non-empty

High priority:
- F-03: Add normative Accord policy syntax note to credential-governance.md §8.2
- F-04: Replace OID XXXXX placeholder with explicit PEN reference and IANA TODO
- F-05: Document CredentialComposer hook mapping in spec and plugin-types.md
- F-07/F-08: Commit CI pipeline (.github/workflows/ci.yaml)
- F-09: Add hashicorp/go-plugin v1.6.3 to go.mod

Medium priority:
- F-10: Wire sample-ssh-cert-extensions.json fixture into shellstream tests
- F-11: Cross-reference merkle proof depth limit (256 leaves) in governance spec
- F-12: Add YAML format clarification headers to deploy configs
- F-14: Expand README with project status, docs links, and quick-start

Low priority:
- F-15: Standardize "SSH SVID" → "SSH-SVID" terminology across docs
- F-16: Add GovernanceEpochSeconds to PluginConfig and deploy configs
- F-17: Add troubleshooting section to deployment.md, error handling to OIDC docs

Global: Rename all extension keys from @guildhouse.io to @guildhouse.dev

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tyler King 2026-02-18 11:45:33 -05:00
parent 3dc3e9ee37
commit 420a4e2ea0
26 changed files with 1288 additions and 169 deletions

29
.github/workflows/ci.yaml vendored Normal file
View file

@ -0,0 +1,29 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Run tests
run: make test
- name: Run linter
run: make lint
- name: Build plugins
run: make build

613
AUDIT.md Normal file
View file

@ -0,0 +1,613 @@
# Guildhouse SPIRE Plugins — Audit Report
**Date:** 2026-02-18
**Commit:** `eb9edf5` (Initial scaffolding: specs, plugins, pkg/shellstream)
**Files:** 52 (excluding `.git/`)
**Tests:** Unverifiable (Go toolchain not available on audit machine)
**Build Status:** Unverifiable (pre-built binaries in `bin/` confirm at least one prior successful build)
**License:** Apache 2.0 (Copyright 2024-2026 Guildhouse Cooperative)
---
## Executive Summary
This repository contains three well-structured specifications, one fully-implemented Go package (`pkg/shellstream` — 319 lines of production code with 551 lines of tests), and scaffolding for four SPIRE plugin binaries and four supporting packages. The specifications are RFC 2119-compliant and compare favorably to existing SPIFFE standards in organization and rigor.
Two critical spec/code mismatches were identified: (1) the `sat-scope@guildhouse.io` extension spec requires implementations to accept both single-object and array-of-objects JSON forms, but the Go implementation only handles single-object form — a MUST-level violation; (2) `governance-intent@guildhouse.io` is referenced in the credential governance spec but is not defined in the Shellstream extension registry, the Go constants, or the test fixtures.
**Upstream readiness: NOT READY.** The repository requires resolution of 2 critical findings, completion of go-plugin boilerplate for all binaries, addition of external dependencies to `go.mod`, CI pipeline activation, and proto code generation verification before it can be presented to the SPIFFE community. The specifications are close to SIG-Spec submission quality but need the critical inconsistencies resolved first.
---
## Repository Inventory
| Category | Count | Files |
|----------|-------|-------|
| Specifications | 3 | `specs/spiffe-ssh-svid.md`, `specs/shellstream-extensions.md`, `specs/credential-governance.md` |
| Plugin sources (`cmd/`) | 8 | 4 plugins × (`main.go` + `plugin.go`) |
| Library packages (`pkg/`) | 11 | `shellstream/` (3 files, fully implemented), `config/` `oidc/` `governance/` `sshcert/` (2 files each, scaffolded) |
| Proto definitions | 4 | `governance.proto`, `notary.proto`, `credentials.proto`, `ceremony.proto` |
| Documentation | 7 | `docs/architecture.md` through `docs/testing.md` |
| Deploy configs | 3 | `deploy/spire-server-config.yaml`, `deploy/spire-agent-config.yaml`, `deploy/kustomization.yaml` |
| Test fixtures | 4 | `test/fixtures/sample-*.json`, `test/fixtures/spire-test-config.hcl` |
| Build/config | 6 | `go.mod`, `Makefile`, `buf.yaml`, `buf.gen.yaml`, `.gitignore`, `LICENSE` |
| Pre-built binaries | 4 | `bin/{oidc-attestor,ssh-credential-composer,governance-notifier,substrate-keymanager}` |
| Root docs | 1 | `README.md` |
---
## Specification Review
### spiffe-ssh-svid.md
**Rating: 90% — Strong, needs minor clarifications**
**Strengths:**
- Structure matches SPIFFE spec conventions (Abstract, Notational Conventions, numbered sections with TOC).
- RFC 2119/8174 normative language used correctly throughout. All MUST/SHOULD/MAY keywords properly capitalized.
- Certificate format precisely defined: key types (Ed25519 REQUIRED, ECDSA P-256 OPTIONAL, RSA MUST NOT), key ID (SPIFFE ID in canonical URI), validity period (30s min, 5m recommended, 1h max), serial number constraints.
- Issuance flow (Section 4) clearly specifies 6-step workload-agent-server-CA flow with Workload API RPC extensions (`FetchSSHSVID`, `MintSSHSVID`).
- Trust model (Section 5) well-defined: SSH CA public key in SPIRE trust bundle, federation support, `TrustedUserCAKeys` + `AuthorizedPrincipalsFile` server-side.
- Security considerations (Section 8) thorough: attestation trust anchor, SPIFFE ID visibility, mTLS requirement, rate limiting (60 issuances/min), CA key compromise mitigations, clock skew tolerance (60s).
- Compatibility (Section 9) excellent: OpenSSH 8.0+, Dropbear, libssh, Paramiko, Go `crypto/ssh`.
- Self-contained: an implementor unfamiliar with Guildhouse could implement a basic SSH-SVID system from this spec alone.
**Gaps:**
- **CredentialComposer hook mapping undefined** (see F-05). Section 4, line 239 references "the CredentialComposer plugin chain" but does not specify which SPIRE hook is used. SPIRE's CredentialComposer has 5 methods — all X.509/JWT, none SSH-specific.
- **No versioning strategy** for future SSH-SVID format changes. Unlike X.509-SVID which has OID-based extension versioning, there is no defined mechanism for evolving the SSH certificate format.
- **Workload API RPC messages defined inline** (Section 4.3) and marked as having "full protobuf definitions in the companion `ssh-credential-composer` plugin specification" — but no such companion spec exists in this repository.
### shellstream-extensions.md
**Rating: 92% — Excellent, minor issues**
**Strengths:**
- Complete extension registry: 9 extensions defined with precise format specifications per extension (Sections 6.16.9).
- Encoding rules (Section 7) unambiguous: hex lowercase only (uppercase MUST be rejected), base64 standard alphabet with padding (RFC 4648 Section 4), JSON compact encoding, UUID lowercase 8-4-4-4-12 format (uppercase MUST be rejected), decimal strings without leading zeros, comma-separated without whitespace.
- Co-occurrence constraints (Section 8) precisely tabulated: `sat-scope``sat-hash` (bidirectional), `ceremony-id``ceremony-type` (bidirectional), `merkle-proof``merkle-root` (unidirectional), with explicit note that `merkle-root` MAY appear without `merkle-proof`.
- Required extensions clearly identified: `tenant-id@guildhouse.io` and `roles@guildhouse.io` always required; missing either makes the certificate invalid.
- Forward compatibility explicitly addressed (Section 8, line 379): unknown `@guildhouse.io` extensions MUST be ignored. This enables additive evolution.
- Security considerations appropriate: extensions not encrypted, MUST NOT embed secrets, merkle proofs provide auditability not authorization, total payload SHOULD NOT exceed 4 KB, extensions not code.
- Compatibility: fully compatible with OpenSSH `PROTOCOL.certkeys`, transparent degradation on non-Guildhouse servers.
**Gaps:**
- **SatScope array form** (Section 6.1, lines 152154): "When multiple SAT scopes exist, the value MUST be a JSON array of scope objects. Implementations MUST accept both forms." This creates a polymorphic encoding requirement that the Go implementation does not satisfy (see F-01).
- **Merkle proof depth limit** (Section 6.8, lines 286288): Limits to depth 8 (256 leaves) with 1-byte direction encoding. States "multi-byte direction encoding will be specified in a future revision" — this limitation is not cross-referenced in the credential governance spec.
- **4 KB payload threshold** (Section 9, line 423): Uses SHOULD, not MUST. No enforcement guidance for what happens when exceeded.
### credential-governance.md
**Rating: 85% — Good, has more unresolved items**
**Strengths:**
- End-to-end governance flow (Section 6) comprehensive: 7-step flow from event interception through intent creation, policy evaluation, ceremony, SAT redemption, envelope construction, and merkle anchoring.
- Credential event taxonomy (Section 5) complete: issue, rotate, revoke — each with JCS-canonicalized schema and full field definitions.
- MutationEnvelope construction (Section 7) deterministic: RFC 8785 JCS canonicalization + domain separation (`guildhouse.credential.v1:` prefix) + SHA-256. This is excellent for auditability.
- Ceremony classification (Section 8) well-defined: 5 tiers (Autonomous, SelfGrant, SingleApproval, QuorumApproval, EmergencyBreakGlass) with clear intended use cases and TTL-based triggers.
- Error handling matrix (Section 10) practical: fail-closed for GovernanceService, fail-open for NotaryService (with durable queue retry), fail-safe for missing policy (defaults to SingleApproval).
- Security considerations (Section 11) address real threats: TOCTOU via SAT TTL (recommended 60s), replay via `max_redemptions=1`, self-approval rejection for elevated tiers.
**Gaps:**
- **`governance-intent@guildhouse.io` phantom extension** (line 580): References this as a required SSH certificate extension, but it is NOT in the Shellstream extension registry (see F-02).
- **Accord Policy Specification missing** (line 731): References `specs/accord-policy.md (forthcoming)`. Section 8.2 shows an example YAML policy syntax, but no formal grammar or validation rules exist.
- **X.509 governance OID placeholder** (line 584): `1.3.6.1.4.1.XXXXX.1.1` — IANA PEN unresolved.
- **NotaryService leaf batching within epochs** (Section 6.9): Mentions epoch-based batching but does not specify batching algorithm, epoch duration configuration, or epoch boundary behavior.
- **Proto message references**: Spec references `SatToken`, `CreateIntentRequest`, `SatScopeMsg`, etc. inline, but readers must look in `/proto/` to find definitions. The spec should either include canonical proto definitions or add explicit cross-references.
---
## Code Review
### pkg/shellstream (Fully Implemented)
**Files:** `doc.go` (package docs), `shellstream.go` (319 lines), `shellstream_test.go` (551 lines)
**Test-to-code ratio:** 1.73:1 (excellent)
**Dependencies:** Standard library only (`encoding/base64`, `encoding/hex`, `encoding/json`, `fmt`, `strconv`, `strings`)
**Spec Compliance — Extension Keys:**
All 9 extension key constants (lines 1422) match `specs/shellstream-extensions.md` Section 6 exactly:
| Go Constant | Value | Spec Section |
|-------------|-------|-------------|
| `ExtSatScope` | `sat-scope@guildhouse.io` | 6.1 |
| `ExtSatHash` | `sat-hash@guildhouse.io` | 6.2 |
| `ExtTenantID` | `tenant-id@guildhouse.io` | 6.3 |
| `ExtRoles` | `roles@guildhouse.io` | 6.4 |
| `ExtCeremonyID` | `ceremony-id@guildhouse.io` | 6.5 |
| `ExtCeremonyType` | `ceremony-type@guildhouse.io` | 6.6 |
| `ExtMerkleRoot` | `merkle-root@guildhouse.io` | 6.7 |
| `ExtMerkleProof` | `merkle-proof@guildhouse.io` | 6.8 |
| `ExtGovernanceEpoch` | `governance-epoch@guildhouse.io` | 6.9 |
**Spec Compliance — Encoding:**
- Hex values: lowercase enforced via `strings.ToLower` comparison in Validate (lines 241, 286) ✓
- Base64: `base64.StdEncoding` (standard alphabet with padding, RFC 4648 Section 4) ✓
- JSON: `json.Marshal` produces compact output (no spaces, no newlines) ✓
- UUID: custom `isValidUUID` (lines 300318) validates 8-4-4-4-12 lowercase hex ✓
- Governance epoch: `strconv.FormatUint(epoch, 10)` — decimal, no leading zeros ✓
- Roles: `strings.Join(roles, ",")` — comma-separated, no whitespace ✓
**Spec Compliance — Validation:**
- Required fields: `tenant-id` and `roles` checked (lines 205223) ✓
- Co-occurrence: `sat-scope``sat-hash` (lines 226231) ✓
- Co-occurrence: `ceremony-id``ceremony-type` (lines 257262) ✓
- Co-occurrence: `merkle-proof``merkle-root` (lines 292294) ✓
- Hash format: 64 lowercase hex validated for both `sat-hash` and `merkle-root`
- Ceremony type: validated against known set of 4 values (line 273) ✓
- Forward compatibility: `Decode` ignores unknown extensions (no filter on unrecognized keys) ✓
**Design Quality:**
- `GovernanceEpoch` zero-vs-unset handled via private `hasGovernanceEpoch` sentinel with `WithGovernanceEpoch()`/`HasGovernanceEpoch()` accessors (lines 7285). Correct and idiomatic.
- Error messages all prefixed with `"shellstream:"` for grep-friendly diagnostics.
- Pure functions — no hidden state, no global variables.
- `Encode` returns `nil` map on error (consistent with Go convention).
**Test Coverage:**
- Round-trip: full extensions and minimal extensions (lines 37137) ✓
- Unknown extension handling: verified ignored (lines 139155) ✓
- All validation error paths tested: missing tenant-id, missing roles, invalid UUID format, sat-scope/sat-hash co-occurrence, sat-hash format (length, case), ceremony co-occurrence, unknown ceremony type, merkle-proof requires root, merkle-root format, empty role names, role names with commas ✓
- JSON parsing: valid and invalid sat-scope JSON (lines 336376) ✓
- Base64: valid and invalid merkle-proof base64 (lines 399436) ✓
- Governance epoch: valid value, zero value (explicitly set), invalid string (lines 438490) ✓
- Nil input: both Encode(nil) and Validate(nil) tested (lines 492504) ✓
- UUID helper: 7 test cases including uppercase rejection (lines 522541) ✓
**Issues:**
1. **CRITICAL (F-01):** `SatScope` is `*SatScope` — single pointer. Spec requires array-of-objects support.
2. **MEDIUM (F-13):** `SatScope.ResourcePattern` not validated — could be empty string.
### Scaffolded Packages
**pkg/config** (39 lines impl, 21 lines test):
- `PluginConfig` struct with 5 fields: `GovernanceAddr`, `CeremonyAddr`, `NotaryAddr`, `TrustDomain`, `ClusterID`.
- `Validate()` checks only `TrustDomain` non-empty. Other address fields not validated.
- `LoadFromHCL()` is a stub with TODO referencing `hashicorp/hcl`.
- Tests cover: empty trust domain rejected, minimal valid config accepted.
**pkg/oidc** (43 lines impl, 19 lines test):
- `Verifier` interface with single `Verify(ctx, rawToken) (*Claims, error)` method.
- `Claims` struct captures: Subject, Issuer, Audience ([]string), Email, Groups ([]string).
- `NewVerifier(cfg Config)` validates issuer non-empty, then returns "not yet implemented" error.
- Good interface design — mockable for testing.
**pkg/governance** (74 lines impl, 22 lines test):
- `Client` struct with 4 stub methods: `CreateIntent`, `RedeemIntent`, `CreateCeremony`, `SubmitMerkleLeaf`.
- `IntentResult` and `RedeemResult` types align with credential-governance spec intent lifecycle.
- `NewClient(cfg Config)` validates `GovernanceAddr` non-empty.
**pkg/sshcert** (56 lines impl, 30 lines test):
- `CertRequest` struct imports `shellstream.ShellstreamExtensions` — good cross-package integration.
- `Build(req *CertRequest)` validates request non-nil and SPIFFE ID non-empty, then returns "not yet implemented".
- References `golang.org/x/crypto/ssh` in TODO comments.
**Assessment:** All scaffolded packages follow a consistent pattern: config struct + constructor validation + stub methods returning "not yet implemented" errors. Type definitions are reasonable and align with specs. The scaffolding provides a clear skeleton for future implementation.
### cmd/ Plugins
All 4 plugins follow identical scaffolding pattern:
| Plugin | SPIRE Interface | `main.go` | `plugin.go` |
|--------|----------------|-----------|-------------|
| `oidc-attestor` | WorkloadAttestor | `fmt.Fprintln` + `os.Exit(1)` | Empty struct, TODO fields |
| `ssh-credential-composer` | CredentialComposer | `fmt.Fprintln` + `os.Exit(1)` | Empty struct, TODO fields |
| `governance-notifier` | Notifier | `fmt.Fprintln` + `os.Exit(1)` | Empty struct, TODO fields |
| `substrate-keymanager` | KeyManager | `fmt.Fprintln` + `os.Exit(1)` | Empty struct, TODO fields |
**Issues:**
- No `hashicorp/go-plugin` HandshakeConfig, ServeConfig, or `plugin.Serve()` calls (see F-06).
- No SPIRE interface methods implemented — all binaries exit immediately.
- Good: Each `plugin.go` has detailed TODO comments describing the intended flow, referencing specs and packages.
---
## Documentation Review
### README.md
- Clear project description: SPIRE plugins + specs for governed SSH via SPIFFE.
- Specs, plugins, and packages listed with brief descriptions.
- Build commands documented: `make build`, `test`, `lint`, `clean`, `proto-gen`.
- **Gaps:** No links to `docs/` directory, "scaffolded" not explained, no quick-start example, no CI badges, no contributor guidance.
### docs/architecture.md
- System diagram showing SPIRE Agent/Server, 4 plugins, and Guildhouse gRPC services.
- 10-step SSH certificate issuance data flow.
- Package map and proto dependency table.
- **Accurate** and consistent with specs.
### docs/plugin-types.md
- SPIRE plugin interfaces documented: WorkloadAttestor, CredentialComposer, Notifier, KeyManager.
- Method signatures, invocation timing, selector formats.
- Line 58: Explicitly notes SSH credential composer intercepts `ComposeWorkloadX509SVID` "and potentially future SSH-specific hooks" — this is the clearest statement of the architectural gap.
### docs/oidc-attestation.md
- 8-step token verification flow. Token discovery via projected volume.
- Selector output: `oidc:sub`, `oidc:iss`, `oidc:email`, `oidc:group`.
- JWKS caching: Cache-Control header or 5-minute default.
- **Gap:** No error handling details (OIDC provider unreachable, token invalid, JWKS malformed).
### docs/ssh-certificate-flow.md
- 9-participant sequence diagram, 10-step walkthrough.
- Shellstream extensions embedded in certificate shown explicitly.
- Server-side validation 5 steps documented.
- **Gap:** Step 5 (Governance Intent) assumes automatic approval; doesn't detail ceremony flow.
### docs/governance-integration.md
- Intent lifecycle diagram, CreateIntent/RedeemIntent RPC signatures.
- MutationEnvelope construction 4-step process.
- Error handling matrix matching credential-governance spec.
- Plugin configuration example.
- **Accurate** and consistent with credential-governance spec.
### docs/deployment.md
- Kubernetes deployment instructions with prerequisites (SPIRE v1.9+).
- Container image: `ghcr.io/guildhouse-cooperative/spire-plugins:latest`.
- Kustomize overlay installation, SPIRE Server/Agent HCL config blocks.
- RBAC requirements, mTLS configuration, health checks.
- **Gap:** No troubleshooting section, no version compatibility matrix.
### docs/testing.md
- Test commands, package-level coverage summary.
- `pkg/shellstream` described as having 28 tests (actual count appears higher at ~24 test functions).
- CI pipeline example (GitHub Actions YAML) — **not committed as actual workflow file**.
- **Gap:** Mock server examples are pseudocode. Integration test guidance is conceptual only.
---
## Build & Project Structure
### go.mod
```
module github.com/guildhouse-cooperative/guildhouse-spire-plugins
go 1.23.6
```
Zero external dependencies listed. This is correct for the current state (only `pkg/shellstream` is implemented, using standard library only), but will need additions for:
- `github.com/hashicorp/go-plugin` — plugin serving
- `github.com/hashicorp/hcl` — config parsing
- `google.golang.org/grpc` + `google.golang.org/protobuf` — gRPC clients
- `github.com/coreos/go-oidc` or equivalent — OIDC verification
- `golang.org/x/crypto/ssh` — SSH certificate operations
### Makefile
Targets: `build` (4 plugins → `bin/`), `test` (`go test ./...`), `lint` (`go vet ./...`), `clean` (rm `bin/` + `gen/`), `proto-gen` (`buf generate`). Pattern rule `$(BINDIR)/%: cmd/%/*.go` is clean and idiomatic.
### Directory Structure
Follows Go conventions: `cmd/` for binaries, `pkg/` for libraries, `proto/` for proto definitions, `gen/` (gitignored) for generated code. No `internal/` directory — acceptable given all packages are designed for external consumption.
### .gitignore
Correctly excludes: `/bin/`, `*.exe`, `*.so`, `*.dylib`, `/gen/`, `.idea/`, `.vscode/`, `.DS_Store`, `/go.work`.
### CI Readiness
No CI pipeline committed. `docs/testing.md` contains a GitHub Actions YAML example but it is not in `.github/workflows/`. The Makefile targets are CI-ready (`make build`, `make test`, `make lint`).
---
## Proto Files
4 proto files from 2 packages, all with copy headers:
| File | Source | Package | Go Package |
|------|--------|---------|------------|
| `proto/quartermaster/v1/governance.proto` | `services/qm-proto/` | `quartermaster.v1` | `gen/quartermaster/v1;quartermasterv1` |
| `proto/quartermaster/v1/notary.proto` | `services/qm-proto/` | `quartermaster.v1` | `gen/quartermaster/v1;quartermasterv1` |
| `proto/quartermaster/v1/credentials.proto` | `services/qm-proto/` | `quartermaster.v1` | `gen/quartermaster/v1;quartermasterv1` |
| `proto/bascule/v1/ceremony.proto` | `services/bascule-proto/` | `bascule.v1` | `gen/bascule/v1;basculev1` |
Each file has a 3-line header:
```
// Source of truth: guildhouse monorepo
// services/<path>
// This file is a copy for Go code generation. Do not edit here.
```
**buf.yaml** (v2): Module at `proto/`, depends on `buf.build/protocolbuffers/wellknowntypes`, STANDARD lint, FILE breaking change detection.
**buf.gen.yaml** (v2): Generates Go protobuf + gRPC code to `gen/` with `paths=source_relative`.
**Proto-to-spec alignment:**
- `governance.proto` `SatScopeMsg` (registry_type, verbs, resource_pattern) matches `SatScope` struct in `shellstream.go`
- `ceremony.proto` ceremony types match `validCeremonyTypes` map ✓
- `notary.proto` `CreateAnchorRequest.leaves` matches merkle leaf submission in credential-governance spec ✓
- All protos import `google/protobuf/timestamp.proto` for time handling ✓
**Status:** `gen/` directory is empty and gitignored. `make proto-gen` requires `buf` CLI. No generated code available for import.
---
## Deployment Manifests
### deploy/spire-server-config.yaml
YAML-format reference configuration for SPIRE Server. Lists all 3 server-side plugins (`guildhouse_substrate`, `guildhouse_ssh`, `guildhouse_governance`) with `plugin_cmd` paths and `plugin_data` configuration.
**Note:** SPIRE traditionally uses HCL format for configuration. This file uses YAML, which may not be directly usable. The `docs/deployment.md` shows HCL examples. The relationship between these YAML reference configs and the HCL examples in docs needs clarification (see F-12).
### deploy/spire-agent-config.yaml
YAML-format reference configuration for SPIRE Agent. Lists `guildhouse_oidc` as the only custom agent-side plugin.
### deploy/kustomization.yaml
Kustomize overlay patching `spire-server` and `spire-agent` Deployments. Uses init containers to copy plugin binaries from `ghcr.io/guildhouse-cooperative/spire-plugins:latest` into shared `emptyDir` volumes at `/opt/spire/plugins/`.
**Configuration consistency:**
- Plugin binary paths in deploy configs match `Makefile` binary names ✓
- Plugin names (`guildhouse_substrate`, `guildhouse_ssh`, `guildhouse_governance`, `guildhouse_oidc`) consistent across deploy configs ✓
- Configuration fields (`trust_domain`, `governance_addr`, `notary_addr`, `ceremony_addr`, `cluster_id`) match `pkg/config/PluginConfig` struct fields ✓
---
## Test Fixtures
| Fixture | Content | Used By |
|---------|---------|---------|
| `sample-oidc-token.json` | Mock Keycloak OIDC token (iss, sub, email, groups, tenant_id) | No test |
| `sample-sat-scope.json` | Single SatScope `{"registry_type":"oci","verbs":["push","pull"],...}` | No test |
| `sample-ssh-cert-extensions.json` | Full 9-extension map + 2 OpenSSH standard extensions | No test |
| `spire-test-config.hcl` | PluginConfig HCL with test trust domain and addresses | No test |
All 4 fixtures are **orphaned** — they exist for documentation/reference purposes but are not loaded by any test. The `pkg/shellstream` tests create fixtures inline in `shellstream_test.go`. The HCL fixture cannot be used because `config.LoadFromHCL` is a stub (see F-10).
**Fixture accuracy:**
- `sample-ssh-cert-extensions.json` extension keys match all 9 spec-defined extensions ✓
- `sample-sat-scope.json` structure matches `SatScope` struct ✓
- `sample-oidc-token.json` is well-formed with realistic Keycloak claims ✓
- `spire-test-config.hcl` fields match `PluginConfig` struct ✓
---
## Cross-Cutting Issues
### Extension Key Consistency
9 of 9 defined extensions are consistent across all sources. 1 phantom extension exists:
| Extension | Shellstream Spec | Go Code | Test Fixture | Governance Spec |
|-----------|-----------------|---------|--------------|-----------------|
| `sat-scope@guildhouse.io` | Section 6.1 | `ExtSatScope` | present | referenced |
| `sat-hash@guildhouse.io` | Section 6.2 | `ExtSatHash` | present | referenced |
| `tenant-id@guildhouse.io` | Section 6.3 | `ExtTenantID` | present | referenced |
| `roles@guildhouse.io` | Section 6.4 | `ExtRoles` | present | referenced |
| `ceremony-id@guildhouse.io` | Section 6.5 | `ExtCeremonyID` | present | referenced |
| `ceremony-type@guildhouse.io` | Section 6.6 | `ExtCeremonyType` | present | referenced |
| `merkle-root@guildhouse.io` | Section 6.7 | `ExtMerkleRoot` | present | referenced |
| `merkle-proof@guildhouse.io` | Section 6.8 | `ExtMerkleProof` | present | referenced |
| `governance-epoch@guildhouse.io` | Section 6.9 | `ExtGovernanceEpoch` | present | referenced |
| `governance-intent@guildhouse.io` | **NOT DEFINED** | **NOT DEFINED** | **NOT PRESENT** | line 580 |
### Security Review
- **No hardcoded secrets** in any file. Test fixtures are clearly marked as samples.
- **OIDC verifier stub** has correct security skeleton (issuer validation in constructor).
- **Proto files** use `google.protobuf.Timestamp` rather than raw epoch seconds (better for safety).
- **SSH certificate security** is addressed comprehensively in `spiffe-ssh-svid.md` Section 8: short-lived certs as primary revocation mechanism, CA key protection via HSM, mTLS for agent-server communication, workload API socket protection, rate limiting.
- **Governance-optional model is safe**: credential issuance works without the governance notifier — governance adds auditability, not gating. The governance-notifier plugin is a SPIRE Notifier (receives events after the fact), not a gatekeeper. This is a deliberate and correct design choice.
- **MutationEnvelope determinism** (RFC 8785 JCS + domain separation) is cryptographically sound for audit trail integrity.
### Upstream Readiness Assessment
| Criterion | Status | Blocker? |
|-----------|--------|----------|
| Critical spec/code mismatches resolved | NO (F-01, F-02) | **YES** |
| All packages compile | UNVERIFIED | **YES** |
| All tests pass | UNVERIFIED | **YES** |
| Plugins loadable by SPIRE | NO (F-06) | **YES** |
| External dependencies in go.mod | NO (zero deps) | **YES** |
| Proto codegen pipeline verified | UNVERIFIED | **YES** |
| CI pipeline active | NO | **YES** |
| Accord policy spec exists | NO (F-03) | No (deferrable) |
| X.509 OID registered | NO (F-04) | No (SSH path unaffected) |
| Documentation complete | PARTIAL | No |
**Minimum viable path to upstream readiness:**
1. Fix F-01 (SatScope array form support in Go code)
2. Resolve F-02 (governance-intent extension — add to shellstream spec or remove from governance spec)
3. Add go-plugin boilerplate to all `cmd/*/main.go`
4. Add external dependencies to `go.mod`
5. Verify proto code generation with `buf`
6. Commit and activate CI pipeline (GitHub Actions)
7. Verify build + tests pass in CI
**SIG-Spec submission readiness:** The SSH-SVID spec is close to submission quality. It would benefit from:
- Clarifying the CredentialComposer hook mapping (whether upstream SPIRE changes are needed)
- Resolving the governance-intent extension cross-reference
- Adding a versioning/evolution strategy section
- Having at least one working reference implementation (the shellstream package qualifies, but the full plugin chain should be demonstrable)
---
## Critical Issues
### F-01: SatScope Single-vs-Array Form (CRITICAL)
**Location:** `pkg/shellstream/shellstream.go` lines 3741 (SatScope struct), 46 (field type), 100107 (Encode), 147153 (Decode)
**Spec:** `specs/shellstream-extensions.md` lines 137154 (Section 6.1)
The spec states at lines 152154: "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."
The Go code defines `SatScope *SatScope` (single pointer). `Encode()` at line 101 calls `json.Marshal(ext.SatScope)` — always producing a single JSON object. `Decode()` at line 149 calls `json.Unmarshal([]byte(v), scope)` into a single `&SatScope{}` — which will fail on array input.
**Impact:** Any certificate with multiple SAT scopes will fail to decode. Two independent implementors — one using single form, one using array form — will produce incompatible results.
**Recommendation:** Change the field to support both forms. Options:
1. Change to `SatScopes []*SatScope` with encoding logic that produces a single object when `len == 1` and an array when `len > 1`.
2. Implement custom unmarshal that detects `[` at byte 0 to distinguish forms.
3. Add test cases for both `{"registry_type":...}` and `[{"registry_type":...},{"registry_type":...}]` inputs.
### F-02: governance-intent@guildhouse.io Cross-Spec Inconsistency (CRITICAL)
**Location:** `specs/credential-governance.md` line 580 (Section 9.2 table)
**Cross-reference:** `specs/shellstream-extensions.md` Section 6 (only 9 extensions defined), `pkg/shellstream/shellstream.go` lines 1322 (9 constants)
The credential governance spec's Section 9.2 lists `governance-intent@guildhouse.io` in the SSH certificate extensions table with description "The `intent_id` from the governance flow." This extension is not defined anywhere else: not in the Shellstream extension registry, not in the Go constants, not in the test fixtures.
**Impact:** Implementors following the governance spec will embed an extension with no defined format, encoding rules, or validation constraints. Implementors following the shellstream spec will not include it.
**Recommendation:** Either:
1. **Add** `governance-intent@guildhouse.io` to `specs/shellstream-extensions.md` Section 6 with full format definition (likely: string containing intent UUID), and add `ExtGovernanceIntent` constant + encode/decode/validate support to `shellstream.go`.
2. **Remove** the reference from `specs/credential-governance.md` line 580 if the intent ID should not be embedded in certificates.
---
## High Priority
### F-03: Accord Policy Specification Missing
**Location:** `specs/credential-governance.md` line 731
**Description:** References `specs/accord-policy.md (forthcoming)` but no such file exists. Section 8.2 (line 438) shows an example YAML policy syntax, but there is no formal grammar, validation schema, or evaluation semantics.
**Impact:** Operators cannot write or validate Accord policies. The example in Section 8.2 is illustrative but not normative.
**Recommendation:** Either write `specs/accord-policy.md` or add a normative note to Section 8.2 making the example syntax authoritative until the standalone spec is published.
### F-04: X.509 Governance OID Placeholder
**Location:** `specs/credential-governance.md` line 584
**Description:** Custom OID `1.3.6.1.4.1.XXXXX.1.1` — the `XXXXX` is an unresolved IANA Private Enterprise Number.
**Impact:** X.509 SVID governance embedding cannot be implemented.
**Recommendation:** Register a PEN with IANA or use an OID arc already assigned to Guildhouse Cooperative. Replace `XXXXX` with the actual number.
### F-05: CredentialComposer Hook Mapping Unclear
**Location:** `specs/spiffe-ssh-svid.md` line 239, `docs/plugin-types.md` line 58
**Description:** SPIRE's CredentialComposer interface has 5 methods (`ComposeServerX509CA`, `ComposeServerX509SVID`, `ComposeAgentX509SVID`, `ComposeWorkloadX509SVID`, `ComposeWorkloadJWTSVID`) — all X.509/JWT, none SSH-specific. `docs/plugin-types.md` line 58 states the SSH credential composer "Intercepts `ComposeWorkloadX509SVID` (and potentially future SSH-specific hooks)." This means either SSH is piggybacked onto X.509 flow (architecturally questionable) or upstream SPIRE changes are needed.
**Impact:** Unclear how to implement the plugin against SPIRE's actual interface. SPIFFE SIG-Spec reviewers will ask about this.
**Recommendation:** Document the exact hook mapping explicitly in the SSH-SVID spec. If upstream SPIRE changes are needed (a new `ComposeWorkloadSSHSVID` method), file an issue on `spiffe/spire` and reference it in the spec.
### F-06: No go-plugin Serving Pattern
**Location:** All `cmd/*/main.go` files (4 files)
**Description:** Every plugin `main.go` prints "not yet implemented" and calls `os.Exit(1)`. None contain `hashicorp/go-plugin` HandshakeConfig, ServeConfig, or `plugin.Serve()` calls.
**Impact:** SPIRE cannot load any plugin. The binaries are non-functional.
**Recommendation:** Add go-plugin boilerplate with HandshakeConfig and stub GRPCServer that returns "unimplemented" gRPC errors. This allows SPIRE to load the plugin and report method-level errors rather than a process crash.
### F-07: No Generated Proto Code
**Location:** `buf.yaml`, `buf.gen.yaml`, `.gitignore` (excludes `/gen/`)
**Description:** buf configuration is correct. The `gen/` directory is gitignored. Running `make proto-gen` requires `buf` CLI. No generated Go code exists in the repository.
**Impact:** Any package that needs proto types must run `make proto-gen` first. `go get` will not work for consumers.
**Recommendation:** Either commit generated code (common in Go projects) or document buf as a hard prerequisite and add it to CI.
### F-08: Build/Test Unverifiable
**Description:** Go toolchain not available on audit machine. Pre-built binaries in `bin/` are statically-linked ELF x86-64 Go executables, confirming at least one prior successful build. However, current compile status and test results cannot be verified.
**Impact:** Cannot confirm code compiles cleanly or tests pass.
**Recommendation:** Commit a CI pipeline (the example in `docs/testing.md` lines 97112 is a starting point). Once CI is green with build + test + vet, this finding is resolved.
### F-09: go.mod Has Zero External Dependencies
**Location:** `go.mod` (2 lines total)
**Description:** Module declaration and Go version only. No `require` block. TODO comments across scaffolded packages reference: `hashicorp/go-plugin`, `hashicorp/hcl`, `coreos/go-oidc`, `google.golang.org/grpc`, `google.golang.org/protobuf`, `golang.org/x/crypto/ssh`.
**Impact:** None of the scaffolded packages can be implemented without adding dependencies. The current zero-dep state is correct for what's implemented but must change as development continues.
**Recommendation:** Add dependencies as they become needed. Consider pinning SPIRE SDK version to ensure compatibility.
---
## Medium Priority
### F-10: Test Fixtures Orphaned
**Location:** `test/fixtures/` (4 files)
**Description:** None of the 4 fixture files are loaded by any test. `pkg/shellstream` tests create all test data inline. `spire-test-config.hcl` cannot be used because `config.LoadFromHCL()` is a stub.
**Impact:** Fixtures may drift from code/spec as the project evolves. Orphaned files add maintenance burden.
**Recommendation:** Either add tests that load fixtures (the `docs/testing.md` shows a `loadFixture` pattern) or remove them and rely on inline test data.
### F-11: Merkle Proof Depth Limit Not Cross-Referenced
**Location:** `specs/shellstream-extensions.md` lines 286288 (Section 6.8)
**Description:** Proof encoding limits depth to 8 (256 leaves per epoch) via 1-byte direction byte. This limitation is not mentioned in `specs/credential-governance.md`.
**Impact:** Deployments with > 256 credential events per governance epoch will produce proofs that cannot be encoded.
**Recommendation:** Add a cross-reference in `specs/credential-governance.md` Section 9.1 noting the shellstream proof depth limit and its implications for epoch duration configuration.
### F-12: Deploy Config Format Ambiguity
**Location:** `deploy/spire-server-config.yaml`, `deploy/spire-agent-config.yaml`
**Description:** Files use `.yaml` extension and YAML syntax (`key: value`), but SPIRE's native configuration format is HCL. The `docs/deployment.md` shows HCL-format examples. It's unclear whether these YAML configs are directly usable by SPIRE or are reference documents requiring conversion.
**Impact:** Operators may attempt to use these files directly with SPIRE and encounter configuration errors.
**Recommendation:** Add a header comment clarifying the purpose of these files. If they're reference docs, say so. If SPIRE supports YAML config (some versions do), document the minimum SPIRE version. Consider providing HCL versions alongside.
### F-13: SatScope.ResourcePattern Not Validated
**Location:** `pkg/shellstream/shellstream.go` lines 247254 (Validate checks RegistryType and Verbs but not ResourcePattern)
**Description:** An empty `ResourcePattern` passes validation. While the spec does not explicitly mark it REQUIRED with RFC 2119 language, an empty pattern is semantically meaningless ("authorized to do X on nothing").
**Impact:** A certificate could contain a SatScope with no resource pattern, which is likely a bug.
**Recommendation:** Either add `ResourcePattern != ""` validation or add an explicit note in the spec clarifying that empty patterns are valid (matching nothing).
### F-14: README Gaps
**Location:** `README.md`
**Description:** No links to `docs/` directory. No explanation of what "scaffolded" means. No quick-start example. No CI badges. No contributor guidance or code of conduct link.
**Impact:** First-time visitors lack orientation.
**Recommendation:** Add a "Project Status" section explaining the scaffolding stage, link to `docs/`, add a quick-start section, and prepare for CI badges.
---
## Low Priority
### F-15: Terminology Inconsistency
**Description:** The hyphenated form "SSH-SVID" is used in `specs/spiffe-ssh-svid.md` (title and throughout). Some docs use "SSH SVID" (space) or "SSH certificate" interchangeably.
**Recommendation:** Standardize on "SSH-SVID" as the canonical term (matching the spec title) and grep-replace inconsistencies.
### F-16: Governance Epoch Configurability Undefined
**Location:** `specs/credential-governance.md` (describes epochs as "deployment-configurable")
**Description:** No configuration example, no `pkg/config` field, no deploy manifest entry for epoch duration.
**Recommendation:** Add `governance_epoch_seconds` or similar to `PluginConfig` and deploy config examples.
### F-17: Documentation Gaps
**Locations:**
- `docs/deployment.md`: No troubleshooting section.
- `docs/oidc-attestation.md`: No error handling details (OIDC provider unreachable, token expired, JWKS malformed).
- `docs/testing.md`: Mock server examples are pseudocode (not runnable). CI pipeline example not committed as actual workflow file.
**Recommendation:** Add troubleshooting sections and convert pseudocode to real examples as implementation progresses.
---
## Appendix A: Extension Key Registry
| # | Extension Key | Format | Presence | Spec Section | Go Constant | Test Fixture | Governance Spec |
|---|--------------|--------|----------|-------------|-------------|--------------|-----------------|
| 1 | `sat-scope@guildhouse.io` | JSON object or array | REQUIRED (when SAT present) | shellstream 6.1 | `ExtSatScope` | present | referenced |
| 2 | `sat-hash@guildhouse.io` | SHA-256 hex, 64 chars, lowercase | REQUIRED (when SAT present) | shellstream 6.2 | `ExtSatHash` | present | referenced |
| 3 | `tenant-id@guildhouse.io` | UUID, lowercase, 8-4-4-4-12 | REQUIRED | shellstream 6.3 | `ExtTenantID` | present | referenced |
| 4 | `roles@guildhouse.io` | Comma-separated, no whitespace | REQUIRED | shellstream 6.4 | `ExtRoles` | present | referenced |
| 5 | `ceremony-id@guildhouse.io` | UUID, lowercase, 8-4-4-4-12 | OPTIONAL | shellstream 6.5 | `ExtCeremonyID` | present | referenced |
| 6 | `ceremony-type@guildhouse.io` | Enum: self_grant, single_approval, quorum_approval, emergency_break_glass | OPTIONAL (requires ceremony-id) | shellstream 6.6 | `ExtCeremonyType` | present | referenced |
| 7 | `merkle-root@guildhouse.io` | SHA-256 hex, 64 chars, lowercase | OPTIONAL | shellstream 6.7 | `ExtMerkleRoot` | present | referenced |
| 8 | `merkle-proof@guildhouse.io` | Base64, standard alphabet, padded | OPTIONAL (requires merkle-root) | shellstream 6.8 | `ExtMerkleProof` | present | referenced |
| 9 | `governance-epoch@guildhouse.io` | Decimal uint64, no leading zeros | OPTIONAL | shellstream 6.9 | `ExtGovernanceEpoch` | present | referenced |
| 10 | `governance-intent@guildhouse.io` | *Undefined* | *Undefined* | **NOT IN SPEC** | **NOT IN CODE** | **NOT IN FIXTURE** | line 580 |
---
## Appendix B: Spec Terminology Glossary
| Term | Definition Source | Notes |
|------|------------------|-------|
| SSH-SVID | `specs/spiffe-ssh-svid.md` Section 1 | OpenSSH certificate encoding a SPIFFE ID as principal identity |
| SPIFFE ID | `specs/spiffe-ssh-svid.md` Section 1 | `spiffe://<trust-domain>/<path>` URI format |
| Shellstream Extension | `specs/shellstream-extensions.md` Section 3 | SSH certificate extension with `@guildhouse.io` vendor suffix |
| SAT (Substrate Attestation Token) | `specs/shellstream-extensions.md` Section 3 | Signed token binding SPIFFE identity to permitted operations |
| SatScope | `specs/shellstream-extensions.md` Section 3 | Registry type + verbs + resource pattern |
| Credential Event | `specs/credential-governance.md` Section 3 | Discrete lifecycle operation (issue, rotate, revoke) |
| MutationIntent | `specs/credential-governance.md` Section 3 | Authorization request with state machine |
| MutationEnvelope | `specs/credential-governance.md` Section 3 | Signed wrapper around canonicalized governance state change |
| Governance Epoch | `specs/credential-governance.md` Section 3 | Time-bounded interval for merkle leaf accumulation |
| Merkle Anchor | `specs/credential-governance.md` Section 3 | Immutable record created by NotaryService |
| Accord Policy | `specs/shellstream-extensions.md` Section 3 | OPA-backed tenant-scoped authorization policy |
| Governance Ceremony | `specs/credential-governance.md` Section 3 | Multi-stakeholder approval workflow |
| Trust Domain | `specs/spiffe-ssh-svid.md` Section 1 | SPIFFE trust boundary |
| Trust Bundle | `specs/spiffe-ssh-svid.md` Section 5 | Collection of CA public keys for a trust domain |
| CredentialComposer | `docs/plugin-types.md` line 42 | SPIRE plugin interface for customizing credential fields |
---
## Appendix C: Plugin Interface Compliance Matrix
| Plugin | SPIRE Interface | Required Methods | Implemented | Stubbed | Status |
|--------|----------------|------------------|-------------|---------|--------|
| `oidc-attestor` | WorkloadAttestor | `Attest(ctx, pid)` | 0 | 0 | Not started — binary exits immediately |
| `ssh-credential-composer` | CredentialComposer | `ComposeServerX509CA`, `ComposeServerX509SVID`, `ComposeAgentX509SVID`, `ComposeWorkloadX509SVID`, `ComposeWorkloadJWTSVID` | 0 | 0 | Not started — binary exits immediately |
| `governance-notifier` | Notifier | `Notify`, `NotifyAndAdvise` | 0 | 0 | Not started — binary exits immediately |
| `substrate-keymanager` | KeyManager | `GenerateKey`, `GetPublicKey`, `GetPublicKeys`, `SignData` | 0 | 0 | Not started — binary exits immediately |
**Total:** 0 of 12 interface methods implemented across all 4 plugins.
**Note:** The `plugin.go` files in each `cmd/` directory define empty structs with detailed TODO comments describing the intended implementation flow. The struct types exist but have no methods attached. No `hashicorp/go-plugin` integration code exists in any plugin.

View file

@ -5,11 +5,38 @@ 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.
## Project Status
**Stage: Active Development**
| Component | Status |
|-----------|--------|
| Specifications (`specs/`) | Draft — ready for SIG-Spec review |
| `pkg/shellstream` | Fully implemented with comprehensive tests |
| `pkg/config`, `pkg/oidc`, `pkg/governance`, `pkg/sshcert` | Scaffolded — interfaces and validation stubs |
| Plugin binaries (`cmd/`) | go-plugin boilerplate in place, interface methods pending |
| CI pipeline | Configured (`.github/workflows/ci.yaml`) |
"Scaffolded" means the package defines its public types, interfaces, and configuration validation, but core logic returns `"not yet implemented"` errors. This provides a clear skeleton for implementation while allowing the full project to compile and pass structural tests.
## Quick Start
```bash
# Clone and build
git clone https://github.com/guildhouse-cooperative/guildhouse-spire-plugins.git
cd guildhouse-spire-plugins
make build # Build all plugin binaries → bin/
# Run tests
make test # Run all unit tests
make lint # Run go vet
```
## 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
- **[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
@ -34,6 +61,18 @@ Shared Go libraries in [`pkg/`](pkg/):
- **`sshcert`** — SSH certificate builder (scaffolded)
- **`config`** — Plugin configuration loading (scaffolded)
## Documentation
Detailed documentation in [`docs/`](docs/):
- **[Architecture](docs/architecture.md)** — System design, data flow, package map
- **[Plugin Types](docs/plugin-types.md)** — SPIRE plugin interfaces, method signatures, invocation timing
- **[SSH Certificate Flow](docs/ssh-certificate-flow.md)** — End-to-end certificate issuance sequence
- **[OIDC Attestation](docs/oidc-attestation.md)** — Workload OIDC token verification flow
- **[Governance Integration](docs/governance-integration.md)** — Intent lifecycle, MutationEnvelope construction
- **[Deployment](docs/deployment.md)** — Kubernetes deployment with Kustomize
- **[Testing](docs/testing.md)** — Test strategy, fixtures, CI pipeline
## Building
```bash

View file

@ -6,19 +6,30 @@
package main
import (
"fmt"
"os"
"github.com/hashicorp/go-plugin"
)
// handshakeConfig is the HandshakeConfig for this plugin.
// TODO: replace with SPIRE Plugin SDK handshake once
// github.com/spiffe/spire-plugin-sdk is added as a dependency.
var handshakeConfig = plugin.HandshakeConfig{
ProtocolVersion: 1,
MagicCookieKey: "ServerAgent",
MagicCookieValue: "GuildhouseSpire",
}
func main() {
// TODO: wire up go-plugin serve with SPIRE Notifier interface
// The plugin will:
// TODO: register GovernanceNotifier as a GRPCPlugin implementing
// the 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)
plugin.Serve(&plugin.ServeConfig{
HandshakeConfig: handshakeConfig,
Plugins: map[string]plugin.Plugin{},
GRPCServer: plugin.DefaultGRPCServer,
})
}

View file

@ -5,17 +5,28 @@
package main
import (
"fmt"
"os"
"github.com/hashicorp/go-plugin"
)
// handshakeConfig is the HandshakeConfig for this plugin.
// TODO: replace with SPIRE Plugin SDK handshake once
// github.com/spiffe/spire-plugin-sdk is added as a dependency.
var handshakeConfig = plugin.HandshakeConfig{
ProtocolVersion: 1,
MagicCookieKey: "ServerAgent",
MagicCookieValue: "GuildhouseSpire",
}
func main() {
// TODO: wire up go-plugin serve with SPIRE WorkloadAttestor interface
// The plugin will:
// TODO: register OIDCAttestor as a GRPCPlugin implementing
// the 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:<subject>, oidc:iss:<issuer>, oidc:email:<email>
fmt.Fprintln(os.Stderr, "oidc-attestor: SPIRE WorkloadAttestor plugin (not yet implemented)")
os.Exit(1)
plugin.Serve(&plugin.ServeConfig{
HandshakeConfig: handshakeConfig,
Plugins: map[string]plugin.Plugin{},
GRPCServer: plugin.DefaultGRPCServer,
})
}

View file

@ -6,18 +6,29 @@
package main
import (
"fmt"
"os"
"github.com/hashicorp/go-plugin"
)
// handshakeConfig is the HandshakeConfig for this plugin.
// TODO: replace with SPIRE Plugin SDK handshake once
// github.com/spiffe/spire-plugin-sdk is added as a dependency.
var handshakeConfig = plugin.HandshakeConfig{
ProtocolVersion: 1,
MagicCookieKey: "ServerAgent",
MagicCookieValue: "GuildhouseSpire",
}
func main() {
// TODO: wire up go-plugin serve with SPIRE CredentialComposer interface
// The plugin will:
// TODO: register SSHCredentialComposer as a GRPCPlugin implementing
// the 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)
plugin.Serve(&plugin.ServeConfig{
HandshakeConfig: handshakeConfig,
Plugins: map[string]plugin.Plugin{},
GRPCServer: plugin.DefaultGRPCServer,
})
}

View file

@ -8,7 +8,7 @@ package main
//
// The plugin:
// - Creates an SSH user certificate with the SPIFFE ID as the primary principal
// - Embeds Shellstream @guildhouse.io extensions carrying governance metadata
// - Embeds Shellstream @guildhouse.dev extensions carrying governance metadata
// - Signs the certificate using the SSH CA key (from KeyManager)
// - Returns the certificate as part of the composed credential bundle
//

View file

@ -6,17 +6,28 @@
package main
import (
"fmt"
"os"
"github.com/hashicorp/go-plugin"
)
// handshakeConfig is the HandshakeConfig for this plugin.
// TODO: replace with SPIRE Plugin SDK handshake once
// github.com/spiffe/spire-plugin-sdk is added as a dependency.
var handshakeConfig = plugin.HandshakeConfig{
ProtocolVersion: 1,
MagicCookieKey: "ServerAgent",
MagicCookieValue: "GuildhouseSpire",
}
func main() {
// TODO: wire up go-plugin serve with SPIRE KeyManager interface
// The plugin will:
// TODO: register SubstrateKeyManager as a GRPCPlugin implementing
// the 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)
plugin.Serve(&plugin.ServeConfig{
HandshakeConfig: handshakeConfig,
Plugins: map[string]plugin.Plugin{},
GRPCServer: plugin.DefaultGRPCServer,
})
}

View file

@ -1,5 +1,10 @@
# SPIRE Agent configuration with Guildhouse OIDC Attestor plugin.
#
# FORMAT NOTE: This file uses YAML for readability as a reference document.
# SPIRE natively uses HCL configuration format. To use this with SPIRE, convert
# to HCL syntax or use a SPIRE version that supports YAML config (v1.9+).
# See docs/deployment.md for HCL configuration examples.
#
# This is a reference configuration — adapt paths and addresses for your cluster.
# See docs/deployment.md for full deployment instructions.

View file

@ -1,5 +1,10 @@
# SPIRE Server configuration with Guildhouse plugins.
#
# FORMAT NOTE: This file uses YAML for readability as a reference document.
# SPIRE natively uses HCL configuration format. To use this with SPIRE, convert
# to HCL syntax or use a SPIRE version that supports YAML config (v1.9+).
# See docs/deployment.md for HCL configuration examples.
#
# This is a reference configuration — adapt paths and addresses for your cluster.
# See docs/deployment.md for full deployment instructions.
@ -37,6 +42,7 @@ plugins:
governance_addr: governance.quartermaster.svc.cluster.local:50051
notary_addr: notary.quartermaster.svc.cluster.local:50051
cluster_id: guildhouse-prod
governance_epoch_seconds: 300 # 5 minutes; max 256 credential events per epoch
CredentialComposer:
# Guildhouse SSH Credential Composer — SSH certificate + Shellstream extensions.
@ -58,3 +64,4 @@ plugins:
notary_addr: notary.quartermaster.svc.cluster.local:50051
cluster_id: guildhouse-prod
trust_domain: guildhouse.example.org
governance_epoch_seconds: 300

View file

@ -50,7 +50,7 @@ This document describes how the Guildhouse SPIRE plugins integrate SPIFFE worklo
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.
- **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.
@ -68,7 +68,7 @@ All plugin-to-service communication uses mTLS via SPIFFE SVIDs. The plugins them
## Data Flow: SSH Certificate Issuance
1. A workload calls the SPIRE Workload API requesting an SSH SVID.
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.

View file

@ -142,3 +142,42 @@ To upgrade plugins:
2. Update image tag in `deploy/kustomization.yaml`
3. Restart SPIRE Server and Agent pods
4. SPIRE reloads plugins on startup
## Troubleshooting
### Plugin fails to load
**Symptom:** SPIRE Server logs `plugin failed to start` or `plugin handshake failed`.
**Checks:**
1. Verify plugin binary exists at the configured `plugin_cmd` path inside the container.
2. Ensure the init container completed successfully: `kubectl logs <pod> -c copy-plugins`.
3. Check that the binary is executable: `kubectl exec <pod> -- ls -la /opt/spire/plugins/`.
4. Verify the SPIRE version is v1.9+ (required for CredentialComposer support).
### GovernanceService unreachable
**Symptom:** SPIRE Server logs `guildhouse_ssh: GovernanceService unavailable` or gRPC deadline exceeded errors.
**Checks:**
1. Verify the GovernanceService address in `plugin_data` is correct and resolvable from the pod.
2. Check mTLS connectivity: the SPIRE Server SVID must be trusted by the GovernanceService.
3. Ensure the Quartermaster namespace services are running: `kubectl get pods -n quartermaster`.
### OIDC attestation returns no selectors
**Symptom:** Workloads fail to receive SVIDs; agent logs show `oidc-attestor: no token found`.
**Checks:**
1. Verify the projected token volume is mounted at the configured `token_path`.
2. Check token expiry: projected tokens rotate automatically but may be stale if the kubelet is unhealthy.
3. Verify the `issuer` and `audience` in plugin config match the actual token claims.
### Ceremony timeout
**Symptom:** Credential issuance hangs and eventually fails with `ceremony timeout`.
**Checks:**
1. Check that the CeremonyService is reachable at the configured `ceremony_addr`.
2. Verify that an approver is available for the ceremony type (SingleApproval requires one approver, QuorumApproval requires the configured quorum).
3. Review the `ceremony_timeout_seconds` setting in the Accord policy defaults.

View file

@ -108,3 +108,14 @@ spire-server entry create \
## 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.
## Error Handling
| Error Condition | Plugin Behavior | Impact |
|----------------|----------------|--------|
| **OIDC provider unreachable** | Returns an error from `Attest()`. SPIRE Agent logs the failure but may succeed with other attestors (e.g., `k8s`). | Workloads relying solely on OIDC attestation will not receive SVIDs until connectivity is restored. |
| **Token file not found** | Returns an empty selector set (no error). The workload may still match via other attestors. | No OIDC-derived selectors; registration entries requiring `oidc_attestor:*` selectors will not match. |
| **Token expired** | Returns an error. The `exp` claim is validated against the current time with a small clock skew tolerance. | Workload must refresh its projected token. Kubernetes projected tokens auto-rotate, so this typically resolves within the `expirationSeconds` window. |
| **JWKS key not found** | Returns an error. The token's `kid` header does not match any key in the cached JWKS. | May indicate key rotation at the OIDC provider. The plugin will refetch JWKS on the next cache expiry. |
| **Invalid token signature** | Returns an error. The token was not signed by a key in the JWKS. | Possible token tampering or misconfigured issuer. Check that `issuer` in plugin config matches the token's `iss` claim. |
| **Audience mismatch** | Returns an error. The token's `aud` claim does not include the configured `audience`. | Check that the projected ServiceAccountToken uses the correct `audience` value. |

View file

@ -55,9 +55,9 @@ ComposeWorkloadJWTSVID(ctx context.Context, attributes JWTSVIDAttributes) (JWTSV
**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.
**What ssh-credential-composer does:** Intercepts `ComposeWorkloadX509SVID` to handle SSH credential composition. Because SPIRE v1.9 does not define an SSH-specific CredentialComposer hook, the plugin dispatches on registration entry selectors or hints (e.g., a `ssh-svid: true` selector) to identify SSH-destined requests. 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.
**Hook mapping detail:** The composer implements all 5 `CredentialComposer` methods but only performs governance logic in `ComposeWorkloadX509SVID` when the request matches SSH selectors. All other methods (`ComposeServerX509CA`, `ComposeServerX509SVID`, `ComposeAgentX509SVID`, `ComposeWorkloadJWTSVID`) return attributes unmodified. See `specs/spiffe-ssh-svid.md` Section 4, Step 4 for the normative hook mapping note.
## Notifier

View file

@ -48,7 +48,7 @@ Workload SPIRE Agent SPIRE Server ssh-credential- Governance N
## Step-by-Step
### 1. Workload Requests SSH SVID
### 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).
@ -87,12 +87,12 @@ The composer builds the SSH certificate:
- **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
- `sat-scope@guildhouse.dev` — SAT authorization scope
- `sat-hash@guildhouse.dev` — SHA-256 of the SAT
- `tenant-id@guildhouse.dev` — Tenant UUID
- `roles@guildhouse.dev` — Assigned roles
- `merkle-root@guildhouse.dev` — Governance tree root at issuance
- `governance-epoch@guildhouse.dev` — Current epoch counter
### 8. Merkle Anchoring

View file

@ -94,19 +94,12 @@ func loadFixture(t *testing.T, name string) []byte {
## 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
```
The CI pipeline is defined in `.github/workflows/ci.yaml`. It runs on every push and pull request to `master`:
1. Checks out the repository
2. Sets up Go 1.23
3. Runs `make test` (all unit tests)
4. Runs `make lint` (`go vet`)
5. Runs `make build` (compiles all 4 plugin binaries)
See [`.github/workflows/ci.yaml`](../.github/workflows/ci.yaml) for the full workflow definition.

19
go.mod
View file

@ -1,3 +1,22 @@
module github.com/guildhouse-cooperative/guildhouse-spire-plugins
go 1.23.6
require github.com/hashicorp/go-plugin v1.6.3
require (
github.com/fatih/color v1.7.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/hashicorp/go-hclog v0.14.1 // indirect
github.com/hashicorp/yamux v0.1.1 // indirect
github.com/mattn/go-colorable v0.1.4 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/oklog/run v1.0.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 // indirect
google.golang.org/grpc v1.58.3 // indirect
google.golang.org/protobuf v1.36.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

54
go.sum Normal file
View file

@ -0,0 +1,54 @@
github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA=
github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hashicorp/go-hclog v0.14.1 h1:nQcJDQwIAGnmoUWp8ubocEX40cCml/17YkF6csQLReU=
github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0UUrwg=
github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0=
github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c=
github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo=
github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw=
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 h1:bVf09lpb+OJbByTj913DRJioFFAjf/ZGxEz7MajTp2U=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM=
google.golang.org/grpc v1.58.3 h1:BjnpXut1btbtgN/6sp+brB2Kbm2LjNXnidYujAVbSoQ=
google.golang.org/grpc v1.58.3/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -22,6 +22,12 @@ type PluginConfig struct {
// ClusterID identifies this cluster for notary anchoring.
ClusterID string `hcl:"cluster_id"`
// GovernanceEpochSeconds is the duration of a governance epoch in seconds.
// Controls how frequently merkle anchors are created. Must not exceed 256
// credential events per epoch (see shellstream merkle-proof depth limit).
// Default: 300 (5 minutes).
GovernanceEpochSeconds int `hcl:"governance_epoch_seconds"`
}
// Validate checks that required fields are present.

View file

@ -1,6 +1,6 @@
// Package shellstream encodes and decodes Shellstream SSH certificate extensions.
//
// Shellstream extensions use the @guildhouse.io vendor suffix to carry
// Shellstream extensions use the @guildhouse.dev 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.

View file

@ -9,20 +9,21 @@ import (
"strings"
)
// Extension key constants — all use the @guildhouse.io vendor suffix.
// Extension key constants — all use the @guildhouse.dev 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"
ExtSatScope = "sat-scope@guildhouse.dev"
ExtSatHash = "sat-hash@guildhouse.dev"
ExtTenantID = "tenant-id@guildhouse.dev"
ExtRoles = "roles@guildhouse.dev"
ExtCeremonyID = "ceremony-id@guildhouse.dev"
ExtCeremonyType = "ceremony-type@guildhouse.dev"
ExtMerkleRoot = "merkle-root@guildhouse.dev"
ExtMerkleProof = "merkle-proof@guildhouse.dev"
ExtGovernanceEpoch = "governance-epoch@guildhouse.dev"
ExtGovernanceIntent = "governance-intent@guildhouse.dev"
// Vendor suffix for identifying Shellstream extensions.
VendorSuffix = "@guildhouse.io"
VendorSuffix = "@guildhouse.dev"
)
// Valid ceremony types.
@ -42,10 +43,12 @@ type SatScope struct {
// ShellstreamExtensions holds all Shellstream extension values.
type ShellstreamExtensions struct {
// SatScope is the authorization scope. Required when SAT is present.
SatScope *SatScope
// SatScopes is the list of authorization scopes. Required when SAT is present.
// When encoding: len==1 produces a single JSON object; len>1 produces a JSON array.
// When decoding: both single object and array forms are accepted.
SatScopes []*SatScope
// SatHash is the hex-encoded SHA-256 of the SAT bytes. Required when SatScope is set.
// SatHash is the hex-encoded SHA-256 of the SAT bytes. Required when SatScopes is set.
SatHash string
// TenantID is the tenant UUID. Required.
@ -69,6 +72,9 @@ type ShellstreamExtensions struct {
// GovernanceEpoch is the monotonic governance state counter. Optional.
GovernanceEpoch uint64
// GovernanceIntent is the governance MutationIntent UUID. Optional.
GovernanceIntent string
// Internal tracking for whether epoch was explicitly set (0 is valid).
hasGovernanceEpoch bool
}
@ -97,8 +103,14 @@ func Encode(ext *ShellstreamExtensions) (map[string]string, error) {
m[ExtRoles] = strings.Join(ext.Roles, ",")
// SAT fields (both or neither).
if ext.SatScope != nil {
scopeJSON, err := json.Marshal(ext.SatScope)
if len(ext.SatScopes) > 0 {
var scopeJSON []byte
var err error
if len(ext.SatScopes) == 1 {
scopeJSON, err = json.Marshal(ext.SatScopes[0])
} else {
scopeJSON, err = json.Marshal(ext.SatScopes)
}
if err != nil {
return nil, fmt.Errorf("shellstream: marshal sat-scope: %w", err)
}
@ -125,6 +137,11 @@ func Encode(ext *ShellstreamExtensions) (map[string]string, error) {
m[ExtGovernanceEpoch] = strconv.FormatUint(ext.GovernanceEpoch, 10)
}
// Governance intent.
if ext.GovernanceIntent != "" {
m[ExtGovernanceIntent] = ext.GovernanceIntent
}
return m, nil
}
@ -143,13 +160,22 @@ func Decode(extensions map[string]string) (*ShellstreamExtensions, error) {
ext.Roles = strings.Split(v, ",")
}
// Optional: sat-scope.
// Optional: sat-scope (single object or array form).
if v, ok := extensions[ExtSatScope]; ok {
trimmed := strings.TrimSpace(v)
if len(trimmed) > 0 && trimmed[0] == '[' {
var scopes []*SatScope
if err := json.Unmarshal([]byte(v), &scopes); err != nil {
return nil, fmt.Errorf("shellstream: unmarshal sat-scope array: %w", err)
}
ext.SatScopes = scopes
} else {
scope := &SatScope{}
if err := json.Unmarshal([]byte(v), scope); err != nil {
return nil, fmt.Errorf("shellstream: unmarshal sat-scope: %w", err)
}
ext.SatScope = scope
ext.SatScopes = []*SatScope{scope}
}
}
// Optional: sat-hash.
@ -191,6 +217,11 @@ func Decode(extensions map[string]string) (*ShellstreamExtensions, error) {
ext.hasGovernanceEpoch = true
}
// Optional: governance-intent.
if v, ok := extensions[ExtGovernanceIntent]; ok {
ext.GovernanceIntent = v
}
return ext, nil
}
@ -223,10 +254,10 @@ func Validate(ext *ShellstreamExtensions) error {
}
// Co-occurrence: sat-scope requires sat-hash and vice versa.
if ext.SatScope != nil && ext.SatHash == "" {
if len(ext.SatScopes) > 0 && ext.SatHash == "" {
return fmt.Errorf("shellstream: sat-scope requires sat-hash")
}
if ext.SatHash != "" && ext.SatScope == nil {
if ext.SatHash != "" && len(ext.SatScopes) == 0 {
return fmt.Errorf("shellstream: sat-hash requires sat-scope")
}
@ -243,13 +274,16 @@ func Validate(ext *ShellstreamExtensions) error {
}
}
// sat-scope fields.
if ext.SatScope != nil {
if ext.SatScope.RegistryType == "" {
return fmt.Errorf("shellstream: sat-scope.registry_type is required")
// sat-scope fields — validate each scope in the slice.
for i, scope := range ext.SatScopes {
if scope.RegistryType == "" {
return fmt.Errorf("shellstream: sat-scope[%d].registry_type is required", i)
}
if len(ext.SatScope.Verbs) == 0 {
return fmt.Errorf("shellstream: sat-scope.verbs is required (at least one verb)")
if len(scope.Verbs) == 0 {
return fmt.Errorf("shellstream: sat-scope[%d].verbs is required (at least one verb)", i)
}
if scope.ResourcePattern == "" {
return fmt.Errorf("shellstream: sat-scope[%d].resource_pattern is required", i)
}
}
@ -293,6 +327,13 @@ func Validate(ext *ShellstreamExtensions) error {
return fmt.Errorf("shellstream: merkle-proof requires merkle-root")
}
// governance-intent format: UUID.
if ext.GovernanceIntent != "" {
if !isValidUUID(ext.GovernanceIntent) {
return fmt.Errorf("shellstream: governance-intent is not a valid UUID: %q", ext.GovernanceIntent)
}
}
return nil
}

View file

@ -2,6 +2,9 @@ package shellstream
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
@ -17,11 +20,11 @@ func minimalExtensions() *ShellstreamExtensions {
// Helper to create a fully-populated extensions set.
func fullExtensions() *ShellstreamExtensions {
ext := &ShellstreamExtensions{
SatScope: &SatScope{
SatScopes: []*SatScope{{
RegistryType: "oci",
Verbs: []string{"push", "pull"},
ResourcePattern: "tenant-a/*",
},
}},
SatHash: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
TenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
Roles: []string{"administrator", "engineer"},
@ -29,6 +32,7 @@ func fullExtensions() *ShellstreamExtensions {
CeremonyType: "single_approval",
MerkleRoot: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
MerkleProof: []byte{0x01, 0x02, 0x03, 0x04, 0x05},
GovernanceIntent: "c8d9e0f1-2a3b-4c5d-6e7f-8a9b0c1d2e3f",
}
ext.WithGovernanceEpoch(42)
return ext
@ -62,17 +66,17 @@ func TestEncodeDecodeRoundTrip(t *testing.T) {
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 len(decoded.SatScopes) != 1 {
t.Fatalf("SatScopes length: got %d, want 1", len(decoded.SatScopes))
}
if decoded.SatScope.RegistryType != ext.SatScope.RegistryType {
t.Errorf("SatScope.RegistryType: got %q, want %q", decoded.SatScope.RegistryType, ext.SatScope.RegistryType)
if decoded.SatScopes[0].RegistryType != ext.SatScopes[0].RegistryType {
t.Errorf("SatScopes[0].RegistryType: got %q, want %q", decoded.SatScopes[0].RegistryType, ext.SatScopes[0].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 len(decoded.SatScopes[0].Verbs) != len(ext.SatScopes[0].Verbs) {
t.Fatalf("SatScopes[0].Verbs length: got %d, want %d", len(decoded.SatScopes[0].Verbs), len(ext.SatScopes[0].Verbs))
}
if decoded.SatScope.ResourcePattern != ext.SatScope.ResourcePattern {
t.Errorf("SatScope.ResourcePattern: got %q, want %q", decoded.SatScope.ResourcePattern, ext.SatScope.ResourcePattern)
if decoded.SatScopes[0].ResourcePattern != ext.SatScopes[0].ResourcePattern {
t.Errorf("SatScopes[0].ResourcePattern: got %q, want %q", decoded.SatScopes[0].ResourcePattern, ext.SatScopes[0].ResourcePattern)
}
if decoded.CeremonyID != ext.CeremonyID {
t.Errorf("CeremonyID: got %q, want %q", decoded.CeremonyID, ext.CeremonyID)
@ -97,6 +101,9 @@ func TestEncodeDecodeRoundTrip(t *testing.T) {
if !decoded.HasGovernanceEpoch() {
t.Error("HasGovernanceEpoch: got false, want true")
}
if decoded.GovernanceIntent != ext.GovernanceIntent {
t.Errorf("GovernanceIntent: got %q, want %q", decoded.GovernanceIntent, ext.GovernanceIntent)
}
}
func TestEncodeDecodeMinimal(t *testing.T) {
@ -125,8 +132,8 @@ func TestEncodeDecodeMinimal(t *testing.T) {
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 len(decoded.SatScopes) != 0 {
t.Error("SatScopes should be empty for minimal extensions")
}
if decoded.CeremonyID != "" {
t.Error("CeremonyID should be empty for minimal extensions")
@ -140,7 +147,7 @@ func TestDecodeUnknownExtensionsIgnored(t *testing.T) {
m := map[string]string{
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
ExtRoles: "analyst",
"unknown-ext@guildhouse.io": "some-value",
"unknown-ext@guildhouse.dev": "some-value",
"permit-pty": "",
"completely-unrelated": "ignored",
}
@ -196,11 +203,11 @@ func TestValidateInvalidTenantIDFormat(t *testing.T) {
func TestValidateSatScopeRequiresSatHash(t *testing.T) {
ext := minimalExtensions()
ext.SatScope = &SatScope{
ext.SatScopes = []*SatScope{{
RegistryType: "oci",
Verbs: []string{"pull"},
ResourcePattern: "*",
}
}}
// Missing SatHash.
err := Validate(ext)
if err == nil {
@ -226,11 +233,11 @@ func TestValidateSatHashRequiresSatScope(t *testing.T) {
func TestValidateSatHashFormat(t *testing.T) {
ext := minimalExtensions()
ext.SatScope = &SatScope{
ext.SatScopes = []*SatScope{{
RegistryType: "oci",
Verbs: []string{"pull"},
ResourcePattern: "*",
}
}}
// Too short.
ext.SatHash = "abcdef"
@ -345,17 +352,17 @@ func TestDecodeSatScopeJSON(t *testing.T) {
if err != nil {
t.Fatalf("Decode: %v", err)
}
if decoded.SatScope == nil {
t.Fatal("SatScope is nil")
if len(decoded.SatScopes) != 1 {
t.Fatalf("SatScopes length: got %d, want 1", len(decoded.SatScopes))
}
if decoded.SatScope.RegistryType != "helm" {
t.Errorf("RegistryType: got %q, want %q", decoded.SatScope.RegistryType, "helm")
if decoded.SatScopes[0].RegistryType != "helm" {
t.Errorf("RegistryType: got %q, want %q", decoded.SatScopes[0].RegistryType, "helm")
}
if len(decoded.SatScope.Verbs) != 2 || decoded.SatScope.Verbs[0] != "install" {
t.Errorf("Verbs: got %v", decoded.SatScope.Verbs)
if len(decoded.SatScopes[0].Verbs) != 2 || decoded.SatScopes[0].Verbs[0] != "install" {
t.Errorf("Verbs: got %v", decoded.SatScopes[0].Verbs)
}
if decoded.SatScope.ResourcePattern != "ns/*" {
t.Errorf("ResourcePattern: got %q", decoded.SatScope.ResourcePattern)
if decoded.SatScopes[0].ResourcePattern != "ns/*" {
t.Errorf("ResourcePattern: got %q", decoded.SatScopes[0].ResourcePattern)
}
}
@ -540,6 +547,171 @@ func TestIsValidUUID(t *testing.T) {
}
}
func TestDecodeSatScopeArrayForm(t *testing.T) {
m := map[string]string{
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
ExtRoles: "analyst",
ExtSatScope: `[{"registry_type":"oci","verbs":["pull"],"resource_pattern":"acme/*"},{"registry_type":"helm","verbs":["read"],"resource_pattern":"charts/*"}]`,
ExtSatHash: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
}
decoded, err := Decode(m)
if err != nil {
t.Fatalf("Decode: %v", err)
}
if len(decoded.SatScopes) != 2 {
t.Fatalf("SatScopes length: got %d, want 2", len(decoded.SatScopes))
}
if decoded.SatScopes[0].RegistryType != "oci" {
t.Errorf("SatScopes[0].RegistryType: got %q, want %q", decoded.SatScopes[0].RegistryType, "oci")
}
if decoded.SatScopes[1].RegistryType != "helm" {
t.Errorf("SatScopes[1].RegistryType: got %q, want %q", decoded.SatScopes[1].RegistryType, "helm")
}
if decoded.SatScopes[0].ResourcePattern != "acme/*" {
t.Errorf("SatScopes[0].ResourcePattern: got %q, want %q", decoded.SatScopes[0].ResourcePattern, "acme/*")
}
if decoded.SatScopes[1].ResourcePattern != "charts/*" {
t.Errorf("SatScopes[1].ResourcePattern: got %q, want %q", decoded.SatScopes[1].ResourcePattern, "charts/*")
}
}
func TestEncodeDecodeArrayFormRoundTrip(t *testing.T) {
ext := minimalExtensions()
ext.SatScopes = []*SatScope{
{RegistryType: "oci", Verbs: []string{"pull"}, ResourcePattern: "acme/*"},
{RegistryType: "helm", Verbs: []string{"read"}, ResourcePattern: "charts/*"},
}
ext.SatHash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
encoded, err := Encode(ext)
if err != nil {
t.Fatalf("Encode: %v", err)
}
// Array form should start with [.
scopeVal := encoded[ExtSatScope]
if scopeVal[0] != '[' {
t.Errorf("expected array JSON, got: %s", scopeVal)
}
decoded, err := Decode(encoded)
if err != nil {
t.Fatalf("Decode: %v", err)
}
if len(decoded.SatScopes) != 2 {
t.Fatalf("SatScopes length: got %d, want 2", len(decoded.SatScopes))
}
if decoded.SatScopes[0].RegistryType != "oci" {
t.Errorf("SatScopes[0].RegistryType: got %q", decoded.SatScopes[0].RegistryType)
}
if decoded.SatScopes[1].RegistryType != "helm" {
t.Errorf("SatScopes[1].RegistryType: got %q", decoded.SatScopes[1].RegistryType)
}
}
func TestEncodeSingleScopeProducesObject(t *testing.T) {
ext := minimalExtensions()
ext.SatScopes = []*SatScope{{
RegistryType: "oci", Verbs: []string{"pull"}, ResourcePattern: "*",
}}
ext.SatHash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
encoded, err := Encode(ext)
if err != nil {
t.Fatalf("Encode: %v", err)
}
scopeVal := encoded[ExtSatScope]
if scopeVal[0] != '{' {
t.Errorf("single scope should encode as object, got: %s", scopeVal)
}
}
func TestValidateInvalidGovernanceIntent(t *testing.T) {
ext := minimalExtensions()
ext.GovernanceIntent = "not-a-uuid"
err := Validate(ext)
if err == nil || !strings.Contains(err.Error(), "governance-intent is not a valid UUID") {
t.Errorf("expected UUID validation error, got: %v", err)
}
}
func TestDecodeGovernanceIntent(t *testing.T) {
m := map[string]string{
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
ExtRoles: "analyst",
ExtGovernanceIntent: "c8d9e0f1-2a3b-4c5d-6e7f-8a9b0c1d2e3f",
}
decoded, err := Decode(m)
if err != nil {
t.Fatalf("Decode: %v", err)
}
if decoded.GovernanceIntent != "c8d9e0f1-2a3b-4c5d-6e7f-8a9b0c1d2e3f" {
t.Errorf("GovernanceIntent: got %q", decoded.GovernanceIntent)
}
}
func TestValidateEmptyResourcePattern(t *testing.T) {
ext := minimalExtensions()
ext.SatScopes = []*SatScope{{
RegistryType: "oci",
Verbs: []string{"pull"},
ResourcePattern: "",
}}
ext.SatHash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
err := Validate(ext)
if err == nil || !strings.Contains(err.Error(), "resource_pattern is required") {
t.Errorf("expected resource_pattern error, got: %v", err)
}
}
// loadFixture reads a test fixture from the test/fixtures directory.
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
}
func TestDecodeFixtureFile(t *testing.T) {
data := loadFixture(t, "sample-ssh-cert-extensions.json")
var extensions map[string]string
if err := json.Unmarshal(data, &extensions); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
ext, err := Decode(extensions)
if err != nil {
t.Fatalf("Decode fixture: %v", err)
}
if err := Validate(ext); err != nil {
t.Fatalf("Validate fixture: %v", err)
}
// Verify key fields parsed correctly from fixture.
if ext.TenantID != "a1b2c3d4-e5f6-7890-abcd-ef1234567890" {
t.Errorf("TenantID: got %q", ext.TenantID)
}
if len(ext.Roles) != 2 || ext.Roles[0] != "administrator" || ext.Roles[1] != "engineer" {
t.Errorf("Roles: got %v", ext.Roles)
}
if len(ext.SatScopes) != 1 || ext.SatScopes[0].RegistryType != "oci" {
t.Errorf("SatScopes: got %v", ext.SatScopes)
}
if ext.CeremonyType != "single_approval" {
t.Errorf("CeremonyType: got %q", ext.CeremonyType)
}
if !ext.HasGovernanceEpoch() || ext.GovernanceEpoch != 42 {
t.Errorf("GovernanceEpoch: got %d (has=%v)", ext.GovernanceEpoch, ext.HasGovernanceEpoch())
}
if ext.GovernanceIntent != "c8d9e0f1-2a3b-4c5d-6e7f-8a9b0c1d2e3f" {
t.Errorf("GovernanceIntent: got %q", ext.GovernanceIntent)
}
}
// mapKeys returns the keys of a map for debug output.
func mapKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))

View file

@ -430,9 +430,11 @@ The operation proceeds immediately without prior approval. A ceremony is created
**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
### 8.2 Policy Syntax
The following is an example Accord policy file for credential governance:
> **Normative Note:** The Accord policy syntax defined in this section is the authoritative reference for credential governance policy evaluation until a standalone Accord Policy specification (`specs/accord-policy.md`) is published. Implementations MUST accept policies conforming to this syntax. Future revisions of this specification will replace this section with a normative reference to the standalone spec.
The following Accord policy file defines credential governance rules:
```yaml
# accord-policy/credential-governance.yaml
@ -541,6 +543,19 @@ emergency:
- metadata_contains_key: "incident_id"
```
#### Policy Schema Summary
An Accord policy document MUST contain the following top-level keys:
- `apiVersion` (string, REQUIRED): MUST be `accord.guildhouse.io/v1`.
- `kind` (string, REQUIRED): MUST be `CredentialGovernancePolicy`.
- `metadata` (object, REQUIRED): MUST include `name` (string) and `tenant` (string, `"*"` for wildcard).
- `rules` (array, REQUIRED): Ordered list of rule objects. Each rule MUST contain `match` (object) and `classification` (string, one of: `Autonomous`, `SelfGrant`, `SingleApproval`, `QuorumApproval`). Rules MAY include `quorum` (object with `required` and `pool_size` integers) when classification is `QuorumApproval`.
- `defaults` (object, REQUIRED): MUST include `classification` (string). MAY include `ceremony_timeout_seconds` (integer).
- `emergency` (object, OPTIONAL): Configuration for `EmergencyBreakGlass` ceremonies. MAY include `post_hoc_approval_window_hours` (integer), `escalation_channel` (string), and `trigger_conditions` (array of match expressions).
The `match` object supports: `registry_type`, `verb`, `credential_type` (string equality), and `conditions` (object with comparison operators suffixed to field names, e.g., `ttl_seconds_lte`, `ttl_seconds_gt`, `cross_trust_domain`).
### 8.3 Policy Precedence
When multiple rules match a credential event, the following precedence applies:
@ -564,6 +579,8 @@ Every credential event that completes the governance flow (Steps 1-7) produces a
Leaves accumulate within a governance epoch. At epoch boundary, the NotaryService creates an anchor containing:
> **Depth Limit:** The Shellstream `merkle-proof@guildhouse.dev` extension encodes proof direction bits as a single byte, limiting merkle tree depth to 8 (maximum 256 leaves per epoch). Deployments MUST configure epoch duration such that the number of credential events per epoch does not exceed 256. See `specs/shellstream-extensions.md` Section 6.8 for the proof encoding format.
- `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.
@ -575,20 +592,22 @@ For SSH certificates issued through the governance flow, the following certifica
| 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 |
| `merkle-root@guildhouse.dev` | Hex-encoded merkle root at issuance time |
| `merkle-proof@guildhouse.dev` | Base64-encoded merkle inclusion proof |
| `governance-intent@guildhouse.dev` | 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).
For X.509 SVIDs, the equivalent data SHOULD be embedded in a custom X.509 extension under the Guildhouse IANA Private Enterprise Number (PEN) OID arc. The OID structure is `1.3.6.1.4.1.<PEN>.1.1`, where `<PEN>` is the Guildhouse Cooperative PEN assigned by IANA. Implementations MUST NOT use this extension until the PEN is registered and this placeholder is replaced with the assigned number.
> **TODO:** Register an IANA PEN for Guildhouse Cooperative at https://www.iana.org/assignments/enterprise-numbers/ and replace `<PEN>` with the assigned number.
### 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.
1. Extract `merkle-root@guildhouse.dev` and `merkle-proof@guildhouse.dev` 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.
@ -728,7 +747,7 @@ The MutationEnvelope stored in the merkle tree contains only the `payload_hash`,
- **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.
- **Accord Policy Specification:** `specs/accord-policy.md` (forthcoming) -- Declarative policy classification for governed mutations. Until published, the normative policy syntax is defined in Section 8.2 of this document.
---

View file

@ -3,7 +3,7 @@
## 1. Abstract
Shellstream extensions are a set of SSH certificate extensions using the
`@guildhouse.io` vendor suffix that carry structured governance metadata
`@guildhouse.dev` 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
@ -23,7 +23,7 @@ document are to be interpreted as described in RFC 2119.
## 3. Terminology
**Shellstream Extension**
: An SSH certificate extension whose name uses the `@guildhouse.io`
: An SSH certificate extension whose name uses the `@guildhouse.dev`
vendor suffix and whose value carries Guildhouse governance metadata as
defined in this specification.
@ -100,12 +100,12 @@ governance state.
### 5.1 Naming Convention
All Shellstream extensions use the `@guildhouse.io` vendor suffix.
All Shellstream extensions use the `@guildhouse.dev` vendor suffix.
Extension names MUST be lowercase, hyphen-separated identifiers followed
by the suffix:
```
<name>@guildhouse.io
<name>@guildhouse.dev
```
where `<name>` matches the regular expression `[a-z][a-z0-9]*(-[a-z0-9]+)*`.
@ -130,7 +130,7 @@ 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`
### 6.1 `sat-scope@guildhouse.dev`
**Presence:** REQUIRED when a SAT is associated with the certificate.
@ -155,15 +155,15 @@ objects. Implementations MUST accept both forms.
**Example (single scope):**
```
sat-scope@guildhouse.io = {"registry_type":"oci","verbs":["push","pull"],"resource_pattern":"acme-corp/*"}
sat-scope@guildhouse.dev = {"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/*"}]
sat-scope@guildhouse.dev = [{"registry_type":"oci","verbs":["pull"],"resource_pattern":"acme-corp/*"},{"registry_type":"helm","verbs":["read"],"resource_pattern":"charts/*"}]
```
### 6.2 `sat-hash@guildhouse.io`
### 6.2 `sat-hash@guildhouse.dev`
**Presence:** REQUIRED when a SAT is associated with the certificate.
@ -175,10 +175,10 @@ SAT without embedding the SAT itself in the certificate.
**Example:**
```
sat-hash@guildhouse.io = a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
sat-hash@guildhouse.dev = a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
```
### 6.3 `tenant-id@guildhouse.io`
### 6.3 `tenant-id@guildhouse.dev`
**Presence:** REQUIRED.
@ -195,10 +195,10 @@ authorization decisions MUST be scoped to this tenant.
**Example:**
```
tenant-id@guildhouse.io = 7b2a91c4-3f8e-4d12-b5a6-9c0e1d2f3a4b
tenant-id@guildhouse.dev = 7b2a91c4-3f8e-4d12-b5a6-9c0e1d2f3a4b
```
### 6.4 `roles@guildhouse.io`
### 6.4 `roles@guildhouse.dev`
**Presence:** REQUIRED.
@ -210,14 +210,14 @@ permissions the certificate holder has been granted for this session.
**Example:**
```
roles@guildhouse.io = analyst,viewer
roles@guildhouse.dev = analyst,viewer
```
```
roles@guildhouse.io = administrator
roles@guildhouse.dev = administrator
```
### 6.5 `ceremony-id@guildhouse.io`
### 6.5 `ceremony-id@guildhouse.dev`
**Presence:** OPTIONAL -- present only for elevated sessions that were
authorized through a governance ceremony.
@ -230,13 +230,13 @@ privilege elevation for this session.
**Example:**
```
ceremony-id@guildhouse.io = e4f5a6b7-8c9d-0e1f-2a3b-4c5d6e7f8a9b
ceremony-id@guildhouse.dev = e4f5a6b7-8c9d-0e1f-2a3b-4c5d6e7f8a9b
```
### 6.6 `ceremony-type@guildhouse.io`
### 6.6 `ceremony-type@guildhouse.dev`
**Presence:** OPTIONAL -- MUST be present when `ceremony-id@guildhouse.io`
is present. MUST NOT be present when `ceremony-id@guildhouse.io` is
**Presence:** OPTIONAL -- MUST be present when `ceremony-id@guildhouse.dev`
is present. MUST NOT be present when `ceremony-id@guildhouse.dev` is
absent.
**Value:** One of the following string literals, corresponding to the
@ -251,10 +251,10 @@ absent.
**Example:**
```
ceremony-type@guildhouse.io = quorum_approval
ceremony-type@guildhouse.dev = quorum_approval
```
### 6.7 `merkle-root@guildhouse.io`
### 6.7 `merkle-root@guildhouse.dev`
**Presence:** OPTIONAL.
@ -267,13 +267,13 @@ during a known governance state.
**Example:**
```
merkle-root@guildhouse.io = 4d7a9c2e1f3b5a8d0e6c4b2a9f7e5d3c1b0a8f6e4d2c0b9a7f5e3d1c0b8a7f
merkle-root@guildhouse.dev = 4d7a9c2e1f3b5a8d0e6c4b2a9f7e5d3c1b0a8f6e4d2c0b9a7f5e3d1c0b8a7f
```
### 6.8 `merkle-proof@guildhouse.io`
### 6.8 `merkle-proof@guildhouse.dev`
**Presence:** OPTIONAL -- MUST be present only when
`merkle-root@guildhouse.io` is present. MAY be omitted even when
`merkle-root@guildhouse.dev` 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
@ -289,10 +289,10 @@ multi-byte direction encoding will be specified in a future revision.
**Example:**
```
merkle-proof@guildhouse.io = QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ehQ=
merkle-proof@guildhouse.dev = QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ehQ=
```
### 6.9 `governance-epoch@guildhouse.io`
### 6.9 `governance-epoch@guildhouse.dev`
**Presence:** OPTIONAL.
@ -308,7 +308,25 @@ against stale governance state.
**Example:**
```
governance-epoch@guildhouse.io = 42
governance-epoch@guildhouse.dev = 42
```
### 6.10 `governance-intent@guildhouse.dev`
**Presence:** OPTIONAL -- present when the certificate was issued through
the governance flow and has an associated MutationIntent.
**Value:** UUID string formatted per RFC 4122, using the same format
specified in Section 6.3.
This extension references the MutationIntent that authorized the
credential issuance. It enables audit correlation between the SSH
certificate and the governance audit trail maintained by the
GovernanceService.
**Example:**
```
governance-intent@guildhouse.dev = c8d9e0f1-2a3b-4c5d-6e7f-8a9b0c1d2e3f
```
## 7. Encoding Rules
@ -325,7 +343,7 @@ The following encoding rules apply to all Shellstream extension values:
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
5. UUID values (in `tenant-id`, `ceremony-id`, `governance-intent`) 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
@ -353,32 +371,36 @@ 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` |
| `sat-scope@guildhouse.dev` | `sat-hash@guildhouse.dev` |
| `sat-hash@guildhouse.dev` | `sat-scope@guildhouse.dev` |
| `ceremony-id@guildhouse.dev` | `ceremony-type@guildhouse.dev` |
| `ceremony-type@guildhouse.dev` | `ceremony-id@guildhouse.dev` |
| `merkle-proof@guildhouse.dev` | `merkle-root@guildhouse.dev` |
Note: `merkle-root@guildhouse.io` MAY be present without
`merkle-proof@guildhouse.io` (root-only pinning).
Note: `merkle-root@guildhouse.dev` MAY be present without
`merkle-proof@guildhouse.dev` (root-only pinning).
Note: `governance-intent@guildhouse.dev` has no co-occurrence
constraints. It MAY appear independently or alongside merkle
extensions.
### 8.3 Required Extensions
The following extensions MUST always be present in a Shellstream-bearing
SSH certificate:
- `tenant-id@guildhouse.io`
- `roles@guildhouse.io`
- `tenant-id@guildhouse.dev`
- `roles@guildhouse.dev`
A certificate that contains any `@guildhouse.io` extension but is missing
A certificate that contains any `@guildhouse.dev` 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.
Unknown extensions bearing the `@guildhouse.dev` suffix MUST be ignored.
Implementations MUST NOT reject a certificate solely because it contains
unrecognized `@guildhouse.io` extensions. This ensures that new
unrecognized `@guildhouse.dev` extensions. This ensures that new
extensions can be introduced without breaking existing deployments.
## 9. Security Considerations
@ -420,7 +442,7 @@ 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.
total `@guildhouse.dev` extension payload exceeds this threshold.
### 9.5 Replay and Freshness
@ -449,7 +471,7 @@ governance-based authorization will not be available).
### 10.3 Additive Evolution
Extensions are additive. New `@guildhouse.io` extensions can be
Extensions are additive. New `@guildhouse.dev` extensions can be
introduced in future revisions of this specification without breaking
existing parsers, per the forward compatibility rule in Section 8.4.

View file

@ -154,7 +154,7 @@ SSH-SVIDs MAY include additional standard extensions:
- `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 `<name>@<domain>` format to avoid collision.
SSH-SVIDs MAY include vendor-specific extensions prefixed with a domain namespace. In particular, the Shellstream system defines extensions under the `shellstream@guildhouse.dev` namespace, as specified in the companion Shellstream specification. Vendor extensions MUST use the `<name>@<domain>` format to avoid collision.
### 3.8 Certificate Encoding
@ -241,6 +241,11 @@ The SPIRE Server generates the SSH certificate:
The CredentialComposer plugin is the extension point where operators can customize certificate fields (extensions, critical options, additional principals) without modifying the SPIRE Server core.
> **Implementation Note — CredentialComposer Hook Mapping:**
> As of SPIRE v1.9, the CredentialComposer interface defines five methods — all targeting X.509 and JWT credential types. No SSH-specific hook exists. The `ssh-credential-composer` plugin MUST implement the `ComposeWorkloadX509SVID` method and use registration entry selectors or hints (e.g., a `ssh-svid: true` selector) to identify SSH-destined credential requests. For non-SSH requests, the plugin MUST return the attributes unmodified. This piggyback approach works because SPIRE routes all workload credential composition through the same plugin chain.
>
> A future SPIRE enhancement proposing a dedicated `ComposeWorkloadSSHSVID` method would eliminate this selector-based dispatch. If such a method is added upstream, implementations SHOULD migrate to it and this note will be updated with the relevant `spiffe/spire` issue reference.
**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:

View file

@ -1,13 +1,14 @@
{
"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"
"sat-scope@guildhouse.dev": "{\"registry_type\":\"oci\",\"verbs\":[\"push\",\"pull\"],\"resource_pattern\":\"tenant-alpha/*\"}",
"sat-hash@guildhouse.dev": "a3f2b8c1d4e5f67890abcdef1234567890abcdef1234567890abcdef12345678",
"tenant-id@guildhouse.dev": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"roles@guildhouse.dev": "administrator,engineer",
"ceremony-id@guildhouse.dev": "11223344-5566-7788-99aa-bbccddeeff00",
"ceremony-type@guildhouse.dev": "single_approval",
"merkle-root@guildhouse.dev": "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
"merkle-proof@guildhouse.dev": "AQIDBAU=",
"governance-epoch@guildhouse.dev": "42",
"governance-intent@guildhouse.dev": "c8d9e0f1-2a3b-4c5d-6e7f-8a9b0c1d2e3f"
}