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:
parent
3dc3e9ee37
commit
420a4e2ea0
26 changed files with 1288 additions and 169 deletions
29
.github/workflows/ci.yaml
vendored
Normal file
29
.github/workflows/ci.yaml
vendored
Normal 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
613
AUDIT.md
Normal 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.1–6.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 152–154): "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 286–288): 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 14–22) 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 300–318) 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 205–223) ✓
|
||||
- Co-occurrence: `sat-scope` ↔ `sat-hash` (lines 226–231) ✓
|
||||
- Co-occurrence: `ceremony-id` ↔ `ceremony-type` (lines 257–262) ✓
|
||||
- Co-occurrence: `merkle-proof` → `merkle-root` (lines 292–294) ✓
|
||||
- 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 72–85). 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 37–137) ✓
|
||||
- Unknown extension handling: verified ignored (lines 139–155) ✓
|
||||
- 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 336–376) ✓
|
||||
- Base64: valid and invalid merkle-proof base64 (lines 399–436) ✓
|
||||
- Governance epoch: valid value, zero value (explicitly set), invalid string (lines 438–490) ✓
|
||||
- Nil input: both Encode(nil) and Validate(nil) tested (lines 492–504) ✓
|
||||
- UUID helper: 7 test cases including uppercase rejection (lines 522–541) ✓
|
||||
|
||||
**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 37–41 (SatScope struct), 46 (field type), 100–107 (Encode), 147–153 (Decode)
|
||||
**Spec:** `specs/shellstream-extensions.md` lines 137–154 (Section 6.1)
|
||||
|
||||
The spec states at lines 152–154: "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 13–22 (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 97–112 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 286–288 (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 247–254 (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.
|
||||
41
README.md
41
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
19
go.mod
|
|
@ -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
54
go.sum
Normal 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=
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
scope := &SatScope{}
|
||||
if err := json.Unmarshal([]byte(v), scope); err != nil {
|
||||
return nil, fmt.Errorf("shellstream: unmarshal sat-scope: %w", err)
|
||||
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.SatScopes = []*SatScope{scope}
|
||||
}
|
||||
ext.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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ package shellstream
|
|||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
|
@ -17,18 +20,19 @@ 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"},
|
||||
CeremonyID: "11223344-5566-7788-99aa-bbccddeeff00",
|
||||
CeremonyType: "single_approval",
|
||||
MerkleRoot: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
|
||||
MerkleProof: []byte{0x01, 0x02, 0x03, 0x04, 0x05},
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
19
test/fixtures/sample-ssh-cert-extensions.json
vendored
19
test/fixtures/sample-ssh-cert-extensions.json
vendored
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue