Initial scaffolding: specs, plugins, pkg/shellstream

This commit is contained in:
Tyler King 2026-02-18 10:47:09 -05:00
commit 3dc3e9ee37
47 changed files with 4936 additions and 0 deletions

31
.gitignore vendored Normal file
View file

@ -0,0 +1,31 @@
# Binaries
/bin/
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary
*.test
# Output of go coverage
*.out
# Go workspace
go.work
go.work.sum
# Generated proto code
/gen/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db

190
LICENSE Normal file
View file

@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2024-2026 Guildhouse Cooperative
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

30
Makefile Normal file
View file

@ -0,0 +1,30 @@
MODULE := github.com/guildhouse-cooperative/guildhouse-spire-plugins
BINDIR := bin
PLUGINS := \
oidc-attestor \
ssh-credential-composer \
governance-notifier \
substrate-keymanager
.PHONY: all build test lint clean proto-gen
all: build
build: $(addprefix $(BINDIR)/,$(PLUGINS))
$(BINDIR)/%: cmd/%/*.go
@mkdir -p $(BINDIR)
go build -o $@ ./cmd/$*
test:
go test ./...
lint:
go vet ./...
clean:
rm -rf $(BINDIR) gen/
proto-gen:
buf generate

59
README.md Normal file
View file

@ -0,0 +1,59 @@
# Guildhouse SPIRE Plugins
SPIRE plugins and specifications for governed SSH access via SPIFFE identity.
This repository extends the [SPIFFE](https://spiffe.io/) ecosystem with SSH certificate
issuance, governance-aware credential lifecycle management, and Guildhouse platform integration.
## Specifications
The primary deliverables are three formal specifications in [`specs/`](specs/):
- **[SPIFFE SSH SVID](specs/spiffe-ssh-svid.md)** — Defines SSH certificates whose identity derives from SPIFFE IDs
- **[Shellstream Extensions](specs/shellstream-extensions.md)** — Vendor-suffixed SSH certificate extensions for governance metadata
- **[Credential Governance](specs/credential-governance.md)** — Credential lifecycle events as governed mutations with merkle anchoring
## Plugins
Four SPIRE plugins in [`cmd/`](cmd/):
| Plugin | SPIRE Type | Runs In | Purpose |
|--------|-----------|---------|---------|
| `oidc-attestor` | WorkloadAttestor | Agent | OIDC token verification, claim-to-selector mapping |
| `ssh-credential-composer` | CredentialComposer | Server | SSH certificate generation with Shellstream extensions |
| `governance-notifier` | Notifier | Server | Credential event notification, merkle anchoring |
| `substrate-keymanager` | KeyManager | Server | Governance-aware signing key management |
## Packages
Shared Go libraries in [`pkg/`](pkg/):
- **`shellstream`** — Encode/decode Shellstream SSH certificate extensions (fully implemented)
- **`oidc`** — OIDC token verification (scaffolded)
- **`governance`** — GovernanceService/CeremonyService gRPC client (scaffolded)
- **`sshcert`** — SSH certificate builder (scaffolded)
- **`config`** — Plugin configuration loading (scaffolded)
## Building
```bash
make build # Build all plugin binaries
make test # Run tests
make lint # Run go vet
make clean # Remove build artifacts
```
## Proto Code Generation
Proto files in `proto/` are copies from the [Guildhouse](https://github.com/guildhouse-cooperative/guildhouse)
monorepo. To regenerate Go bindings:
```bash
make proto-gen
```
Requires [buf](https://buf.build/docs/installation) to be installed.
## License
Apache License 2.0 — see [LICENSE](LICENSE).

8
buf.gen.yaml Normal file
View file

@ -0,0 +1,8 @@
version: v2
plugins:
- remote: buf.build/protocolbuffers/go
out: gen
opt: paths=source_relative
- remote: buf.build/grpc/go
out: gen
opt: paths=source_relative

11
buf.yaml Normal file
View file

@ -0,0 +1,11 @@
version: v2
modules:
- path: proto
deps:
- buf.build/protocolbuffers/wellknowntypes
lint:
use:
- STANDARD
breaking:
use:
- FILE

View file

@ -0,0 +1,24 @@
// Governance Notifier — SPIRE Notifier plugin.
//
// Runs in SPIRE Server. Notifies the Guildhouse GovernanceService of credential
// lifecycle events (issue, rotate, revoke) and submits MutationEnvelopes to the
// NotaryService for merkle anchoring.
package main
import (
"fmt"
"os"
)
func main() {
// TODO: wire up go-plugin serve with SPIRE Notifier interface
// The plugin will:
// 1. Receive credential lifecycle notifications from SPIRE Server
// 2. Construct a CreateIntentRequest for the credential event
// 3. Call GovernanceService.CreateIntent
// 4. If ceremony required, monitor CeremonyService for resolution
// 5. Construct MutationEnvelope (RFC 8785 JCS → domain-separated SHA-256)
// 6. Submit merkle leaf to NotaryService.CreateAnchor
fmt.Fprintln(os.Stderr, "governance-notifier: SPIRE Notifier plugin (not yet implemented)")
os.Exit(1)
}

View file

@ -0,0 +1,21 @@
package main
// GovernanceNotifier implements the SPIRE Notifier plugin interface.
//
// SPIRE Server calls Notify() on credential lifecycle events. This plugin
// bridges those events into the Guildhouse governance framework:
//
// 1. Credential issued → CreateIntent(registry_type="credential", verb="issue")
// 2. Credential rotated → CreateIntent(registry_type="credential", verb="rotate")
// 3. Credential revoked → CreateIntent(registry_type="credential", verb="revoke")
//
// For each event, the plugin also constructs a MutationEnvelope containing
// the event payload (JCS-canonicalized) and submits the SHA-256 hash as a
// merkle leaf to the NotaryService for audit anchoring.
//
// See specs/credential-governance.md for the full specification.
type GovernanceNotifier struct {
// TODO: add fields
// - governance.Client for GovernanceService/CeremonyService/NotaryService
// - config for cluster ID, trust domain
}

21
cmd/oidc-attestor/main.go Normal file
View file

@ -0,0 +1,21 @@
// OIDC Attestor — SPIRE WorkloadAttestor plugin.
//
// Runs in SPIRE Agent. Verifies OIDC tokens presented by workloads
// and maps their claims to SPIRE selectors for registration matching.
package main
import (
"fmt"
"os"
)
func main() {
// TODO: wire up go-plugin serve with SPIRE WorkloadAttestor interface
// The plugin will:
// 1. Receive a workload PID from SPIRE Agent
// 2. Read the workload's OIDC token (from filesystem or environment)
// 3. Verify the token using pkg/oidc
// 4. Return selectors: oidc:sub:<subject>, oidc:iss:<issuer>, oidc:email:<email>
fmt.Fprintln(os.Stderr, "oidc-attestor: SPIRE WorkloadAttestor plugin (not yet implemented)")
os.Exit(1)
}

View file

@ -0,0 +1,18 @@
package main
// OIDCAttestor implements the SPIRE WorkloadAttestor plugin interface.
//
// When SPIRE Agent needs to attest a workload, it calls Attest() with the
// workload's process ID. This plugin reads the workload's OIDC token and
// returns selectors based on the verified claims.
//
// Selectors produced:
// - oidc:sub:<subject> — OIDC subject claim
// - oidc:iss:<issuer> — OIDC issuer
// - oidc:email:<email> — OIDC email claim (if present)
// - oidc:group:<group> — One per OIDC group claim (if present)
type OIDCAttestor struct {
// TODO: add fields
// - oidc.Verifier for token validation
// - config for token discovery path
}

View file

@ -0,0 +1,23 @@
// SSH Credential Composer — SPIRE CredentialComposer plugin.
//
// Runs in SPIRE Server. Intercepts SVID minting to generate SSH certificates
// with Shellstream governance extensions. Handles both SSH certificate creation
// and governance metadata injection in a single plugin.
package main
import (
"fmt"
"os"
)
func main() {
// TODO: wire up go-plugin serve with SPIRE CredentialComposer interface
// The plugin will:
// 1. Receive SVID minting request from SPIRE Server
// 2. Generate an SSH certificate with the SPIFFE ID as principal
// 3. Encode Shellstream extensions (sat-scope, tenant-id, roles, etc.)
// 4. Sign the certificate with the SSH CA key
// 5. Return the composed credential
fmt.Fprintln(os.Stderr, "ssh-credential-composer: SPIRE CredentialComposer plugin (not yet implemented)")
os.Exit(1)
}

View file

@ -0,0 +1,23 @@
package main
// SSHCredentialComposer implements the SPIRE CredentialComposer plugin interface.
//
// This is a merged plugin that handles both SSH certificate generation and
// Shellstream extension injection. In SPIRE's model, CredentialComposer plugins
// can modify credentials during the minting pipeline.
//
// The plugin:
// - Creates an SSH user certificate with the SPIFFE ID as the primary principal
// - Embeds Shellstream @guildhouse.io extensions carrying governance metadata
// - Signs the certificate using the SSH CA key (from KeyManager)
// - Returns the certificate as part of the composed credential bundle
//
// This was originally designed as two separate plugins (ssh-svid-handler and
// shellstream-composer) but merged because both are CredentialComposer plugins
// performing conceptually one operation.
type SSHCredentialComposer struct {
// TODO: add fields
// - sshcert.Builder for certificate construction
// - governance.Client for fetching current governance state
// - config for trust domain, default TTL, etc.
}

View file

@ -0,0 +1,22 @@
// Substrate KeyManager — SPIRE KeyManager plugin.
//
// Runs in SPIRE Server. Manages signing keys with governance-aware rotation.
// Key rotation events require ceremony approval when the Accord policy demands it,
// ensuring that CA key changes are governed mutations.
package main
import (
"fmt"
"os"
)
func main() {
// TODO: wire up go-plugin serve with SPIRE KeyManager interface
// The plugin will:
// 1. Generate and store signing keys (Ed25519 for SSH, ECDSA for X.509)
// 2. Provide signing operations to SPIRE Server
// 3. On key rotation: create a governance intent and await ceremony approval
// 4. Submit key rotation events to NotaryService for merkle anchoring
fmt.Fprintln(os.Stderr, "substrate-keymanager: SPIRE KeyManager plugin (not yet implemented)")
os.Exit(1)
}

View file

@ -0,0 +1,21 @@
package main
// SubstrateKeyManager implements the SPIRE KeyManager plugin interface.
//
// SPIRE Server uses KeyManager plugins to generate, store, and use signing
// keys for SVID issuance. This plugin adds governance awareness:
//
// - Key generation: Standard Ed25519/ECDSA key generation
// - Key storage: Keys stored in memory (ephemeral) or filesystem (persistent)
// - Key rotation: Triggers a governance ceremony when Accord policy requires it
// - Audit: Key lifecycle events (generate, rotate, destroy) are merkle-anchored
//
// The governance integration ensures that CA key changes (which affect all
// issued SVIDs) are treated as high-impact governed mutations, typically
// requiring quorum approval.
type SubstrateKeyManager struct {
// TODO: add fields
// - key store (in-memory or filesystem)
// - governance.Client for ceremony-gated rotation
// - config for key algorithm, rotation policy
}

68
deploy/kustomization.yaml Normal file
View file

@ -0,0 +1,68 @@
# Kustomize overlay for deploying Guildhouse SPIRE plugins.
#
# This overlay patches the base SPIRE deployment to include plugin binaries
# and configuration. Apply on top of the standard SPIRE Helm chart or
# kustomize base.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources: []
# Plugin binaries are distributed as a container image.
# Mount them into the SPIRE server/agent pods via an init container.
patches:
- target:
kind: Deployment
name: spire-server
patch: |
apiVersion: apps/v1
kind: Deployment
metadata:
name: spire-server
spec:
template:
spec:
initContainers:
- name: guildhouse-plugins
image: ghcr.io/guildhouse-cooperative/spire-plugins:latest
command: ["cp", "-r", "/plugins/", "/opt/spire/plugins/"]
volumeMounts:
- name: plugins
mountPath: /opt/spire/plugins
containers:
- name: spire-server
volumeMounts:
- name: plugins
mountPath: /opt/spire/plugins
readOnly: true
volumes:
- name: plugins
emptyDir: {}
- target:
kind: Deployment
name: spire-agent
patch: |
apiVersion: apps/v1
kind: Deployment
metadata:
name: spire-agent
spec:
template:
spec:
initContainers:
- name: guildhouse-plugins
image: ghcr.io/guildhouse-cooperative/spire-plugins:latest
command: ["cp", "/plugins/oidc-attestor", "/opt/spire/plugins/"]
volumeMounts:
- name: plugins
mountPath: /opt/spire/plugins
containers:
- name: spire-agent
volumeMounts:
- name: plugins
mountPath: /opt/spire/plugins
readOnly: true
volumes:
- name: plugins
emptyDir: {}

View file

@ -0,0 +1,36 @@
# SPIRE Agent configuration with Guildhouse OIDC Attestor plugin.
#
# This is a reference configuration — adapt paths and addresses for your cluster.
# See docs/deployment.md for full deployment instructions.
agent:
data_dir: /var/lib/spire/agent
log_level: INFO
server_address: spire-server.spire.svc.cluster.local
server_port: 8081
socket_path: /run/spire/sockets/agent.sock
trust_domain: guildhouse.example.org
plugins:
NodeAttestor:
k8s_psat:
plugin_data:
cluster: guildhouse
KeyManager:
memory:
plugin_data: {}
WorkloadAttestor:
# Standard Kubernetes workload attestation.
k8s:
plugin_data:
skip_kubelet_verification: false
# Guildhouse OIDC attestation — verifies workload OIDC tokens.
guildhouse_oidc:
plugin_cmd: /opt/spire/plugins/oidc-attestor
plugin_data:
issuer: https://keycloak.guildhouse.example.org/realms/platform
audience: spire
token_path: /var/run/secrets/oidc/token

View file

@ -0,0 +1,60 @@
# SPIRE Server configuration with Guildhouse plugins.
#
# This is a reference configuration — adapt paths and addresses for your cluster.
# See docs/deployment.md for full deployment instructions.
server:
bind_address: 0.0.0.0
bind_port: 8081
data_dir: /var/lib/spire/server
log_level: INFO
trust_domain: guildhouse.example.org
ca_ttl: 24h
default_x509_svid_ttl: 1h
default_jwt_svid_ttl: 5m
plugins:
DataStore:
sql:
plugin_data:
database_type: sqlite3
connection_string: /var/lib/spire/server/datastore.sqlite3
NodeAttestor:
k8s_psat:
plugin_data:
clusters:
guildhouse:
service_account_allow_list:
- spire:spire-agent
KeyManager:
# Guildhouse Substrate KeyManager — governance-aware key management.
guildhouse_substrate:
plugin_cmd: /opt/spire/plugins/substrate-keymanager
plugin_data:
trust_domain: guildhouse.example.org
governance_addr: governance.quartermaster.svc.cluster.local:50051
notary_addr: notary.quartermaster.svc.cluster.local:50051
cluster_id: guildhouse-prod
CredentialComposer:
# Guildhouse SSH Credential Composer — SSH certificate + Shellstream extensions.
guildhouse_ssh:
plugin_cmd: /opt/spire/plugins/ssh-credential-composer
plugin_data:
trust_domain: guildhouse.example.org
governance_addr: governance.quartermaster.svc.cluster.local:50051
default_cert_ttl: 5m
max_cert_ttl: 1h
Notifier:
# Guildhouse Governance Notifier — credential lifecycle → governance events.
guildhouse_governance:
plugin_cmd: /opt/spire/plugins/governance-notifier
plugin_data:
governance_addr: governance.quartermaster.svc.cluster.local:50051
ceremony_addr: ceremony.bascule.svc.cluster.local:50052
notary_addr: notary.quartermaster.svc.cluster.local:50051
cluster_id: guildhouse-prod
trust_domain: guildhouse.example.org

114
docs/architecture.md Normal file
View file

@ -0,0 +1,114 @@
# Architecture Overview
This document describes how the Guildhouse SPIRE plugins integrate SPIFFE workload identity with Guildhouse's governance platform.
## System Diagram
```
+----------------------------------------------------------+
| Kubernetes Cluster |
| |
| +--------------------+ +-------------------------+ |
| | SPIRE Agent | | SPIRE Server | |
| | | | | |
| | +----------------+ | | +---------------------+ | |
| | | oidc-attestor | | | | ssh-credential- | | |
| | | (WorkloadAttr) | | | | composer (CredComp) | | |
| | +----------------+ | | +---------------------+ | |
| | | | | |
| +--------|-----------+ | +---------------------+ | |
| | | | governance-notifier | | |
| | | | (Notifier) | | |
| | | +---------------------+ | |
| | | | |
| +--------v-----------+ | +---------------------+ | |
| | Workloads | | | substrate-keymanager| | |
| | (present OIDC | | | (KeyManager) | | |
| | tokens for | | +---------------------+ | |
| | attestation) | +------|--------|----------+ |
| +--------------------+ | | |
| gRPC | gRPC | |
| (mTLS) | | (mTLS) |
| +-----------------------------+ | +------------------+
| | Quartermaster |<--+ | Bascule |
| | | | |
| | - GovernanceService | | - CeremonyServ |
| | (MutationIntents) | | (multi-party |
| | - NotaryService | | approval) |
| | (Merkle anchoring) | | |
| +-----------------------------+ +------------------+
+----------------------------------------------------------+
```
## Components
### SPIRE Agent Plugins
**oidc-attestor** (WorkloadAttestor) runs inside the SPIRE Agent. When a workload calls the Workload API to request an SVID, the agent invokes oidc-attestor to identify the workload. The plugin discovers an OIDC token from the workload's environment (file path or environment variable), verifies it against a JWKS endpoint, and returns selectors derived from token claims.
### SPIRE Server Plugins
Three plugins run inside the SPIRE Server:
- **ssh-credential-composer** (CredentialComposer) intercepts credential minting. When the server issues an SSH SVID, this plugin encodes Shellstream extensions into the SSH certificate's critical options, embedding governance metadata (intent ID, ceremony outcome, SAT hash) into the cert itself.
- **governance-notifier** (Notifier) receives SPIRE lifecycle events (bundle updates, registration entry changes, SVID rotations). On relevant events, it calls GovernanceService to create or update MutationIntents, and may trigger CeremonyService flows for operations requiring multi-stakeholder approval.
- **substrate-keymanager** (KeyManager) manages the signing keys used by the SPIRE Server. It stores key material with governance-aware lifecycle management, ensuring key rotations are captured as auditable intents.
## Guildhouse gRPC Services
All plugin-to-service communication uses mTLS via SPIFFE SVIDs. The plugins themselves hold SPIFFE identities and authenticate to Guildhouse services through mutual TLS.
| Service | Package | Plugin Consumers |
|---------|---------|-----------------|
| GovernanceService | `quartermaster.v1` | governance-notifier, ssh-credential-composer |
| NotaryService | `quartermaster.v1` | governance-notifier, ssh-credential-composer |
| CeremonyService | `bascule.v1` | governance-notifier |
## Data Flow: SSH Certificate Issuance
1. A workload calls the SPIRE Workload API requesting an SSH SVID.
2. The SPIRE Agent invokes **oidc-attestor**, which discovers and verifies the workload's OIDC token and returns selectors.
3. The agent matches selectors against registration entries and forwards the request to the SPIRE Server.
4. The server invokes **ssh-credential-composer** during credential minting.
5. The composer calls **GovernanceService.CreateIntent** to record the SSH cert issuance as a MutationIntent.
6. If the intent requires approval, **CeremonyService.CreateCeremony** is called and the flow blocks until approval.
7. The composer constructs a MutationEnvelope (RFC 8785 JCS canonicalization, domain-separated SHA-256 hash), which becomes a merkle leaf.
8. **NotaryService.CreateAnchor** anchors the envelope hash into the merkle tree.
9. The composer encodes Shellstream extensions into the SSH certificate's critical options (intent ID, SAT hash, ceremony result).
10. The signed SSH certificate is returned to the workload through the agent.
## Package Map
```
guildhouse-spire-plugins/
cmd/
oidc-attestor/ # WorkloadAttestor plugin binary
ssh-credential-composer/ # CredentialComposer plugin binary
governance-notifier/ # Notifier plugin binary
substrate-keymanager/ # KeyManager plugin binary
pkg/
shellstream/ # Encode/decode SSH cert Shellstream extensions
oidc/ # OIDC token discovery and verification
governance/ # GovernanceService + CeremonyService client
sshcert/ # SSH certificate construction helpers
config/ # Shared plugin configuration loading
proto/
quartermaster/v1/ # governance.proto, notary.proto, credentials.proto
bascule/v1/ # ceremony.proto
specs/ # Formal specifications (read-only reference)
deploy/ # Kubernetes manifests and Helm values
test/ # Integration tests and fixtures
```
## Proto Dependencies
| Proto File | Service | Package |
|-----------|---------|---------|
| `governance.proto` | GovernanceService | `quartermaster.v1` |
| `notary.proto` | NotaryService | `quartermaster.v1` |
| `credentials.proto` | Credential types | `quartermaster.v1` |
| `ceremony.proto` | CeremonyService | `bascule.v1` |
Generated Go code from these protos lives alongside the `.proto` files or in a generated output directory. The `pkg/governance` package wraps the raw gRPC stubs with higher-level client logic (retries, circuit breaking, intent lifecycle management).

144
docs/deployment.md Normal file
View file

@ -0,0 +1,144 @@
# Deployment Guide
Kubernetes deployment of Guildhouse SPIRE plugins.
## Prerequisites
- SPIRE Server and Agent deployed (v1.9+)
- Guildhouse Quartermaster services running (GovernanceService, NotaryService)
- Guildhouse Bascule services running (CeremonyService)
- mTLS connectivity between SPIRE and Guildhouse services via SPIFFE
## Plugin Distribution
Plugin binaries are packaged as a container image:
```
ghcr.io/guildhouse-cooperative/spire-plugins:latest
```
The image contains:
```
/plugins/
oidc-attestor
ssh-credential-composer
governance-notifier
substrate-keymanager
```
## Installation via Kustomize
Apply the overlay in `deploy/kustomization.yaml` on top of your SPIRE deployment:
```bash
kubectl apply -k deploy/
```
This patches the SPIRE Server and Agent Deployments to:
1. Add an init container that copies plugin binaries to a shared volume
2. Mount the plugin directory into the SPIRE containers
## SPIRE Server Configuration
Add plugin blocks to your SPIRE Server configuration (`server.conf`):
```hcl
plugins {
KeyManager "guildhouse_substrate" {
plugin_cmd = "/opt/spire/plugins/substrate-keymanager"
plugin_data {
trust_domain = "guildhouse.example.org"
governance_addr = "governance.quartermaster.svc.cluster.local:50051"
notary_addr = "notary.quartermaster.svc.cluster.local:50051"
cluster_id = "guildhouse-prod"
}
}
CredentialComposer "guildhouse_ssh" {
plugin_cmd = "/opt/spire/plugins/ssh-credential-composer"
plugin_data {
trust_domain = "guildhouse.example.org"
governance_addr = "governance.quartermaster.svc.cluster.local:50051"
default_cert_ttl = "5m"
max_cert_ttl = "1h"
}
}
Notifier "guildhouse_governance" {
plugin_cmd = "/opt/spire/plugins/governance-notifier"
plugin_data {
governance_addr = "governance.quartermaster.svc.cluster.local:50051"
ceremony_addr = "ceremony.bascule.svc.cluster.local:50052"
notary_addr = "notary.quartermaster.svc.cluster.local:50051"
cluster_id = "guildhouse-prod"
trust_domain = "guildhouse.example.org"
}
}
}
```
## SPIRE Agent Configuration
Add the OIDC attestor to your SPIRE Agent configuration (`agent.conf`):
```hcl
plugins {
WorkloadAttestor "guildhouse_oidc" {
plugin_cmd = "/opt/spire/plugins/oidc-attestor"
plugin_data {
issuer = "https://keycloak.guildhouse.example.org/realms/platform"
audience = "spire"
token_path = "/var/run/secrets/oidc/token"
}
}
}
```
## RBAC
The SPIRE Server ServiceAccount needs access to read Kubernetes Secrets (for webhook secrets) and ConfigMaps (for Accord policy):
```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: spire-server-guildhouse
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get"]
resourceNames: ["guildhouse-governance-certs"]
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "watch"]
```
## mTLS Configuration
Plugins authenticate to Guildhouse services using the SPIRE Server's own SVID. The plugin inherits the server's SPIFFE identity and uses it for mTLS.
No additional certificate configuration is needed — the plugin obtains its mTLS credentials from the SPIRE Server's Workload API socket.
## Health Checks
Each plugin logs its health status at startup. Monitor SPIRE Server logs for:
```
level=info msg="guildhouse_ssh: connected to GovernanceService"
level=info msg="guildhouse_governance: connected to CeremonyService"
level=info msg="guildhouse_substrate: key manager initialized"
```
Plugin failures surface as SPIRE Server errors during credential minting or event notification.
## Environment Variables
Plugins read configuration from HCL `plugin_data` blocks. No environment variables are required. The SPIRE plugin framework passes configuration directly.
## Upgrading
To upgrade plugins:
1. Build new plugin container image
2. Update image tag in `deploy/kustomization.yaml`
3. Restart SPIRE Server and Agent pods
4. SPIRE reloads plugins on startup

View file

@ -0,0 +1,139 @@
# Governance Integration
How plugins interact with GovernanceService, CeremonyService, and NotaryService.
## Services Overview
| Service | Proto Package | Purpose |
|---------|--------------|---------|
| GovernanceService | `quartermaster.v1` | MutationIntent lifecycle (create, redeem, revoke) |
| CeremonyService | `bascule.v1` | Multi-stakeholder approval workflows |
| NotaryService | `quartermaster.v1` | Merkle tree anchoring for audit |
All communication uses gRPC with mTLS via SPIFFE SVIDs.
## Intent Lifecycle
Every credential operation follows the intent lifecycle:
```
CreateIntent ──> [authorized] ──> RedeemIntent ──> SAT ──> Operation ──> Anchor
|
v
[ceremony_pending] ──> CeremonyService ──> [approved] ──> RedeemIntent
|
v
[denied] ──> Abort
```
### CreateIntent
```protobuf
rpc CreateIntent(CreateIntentRequest) returns (CreateIntentResponse);
```
Fields:
- `registry_type`: `"credential"` for all credential operations
- `verb`: `"issue"`, `"rotate"`, or `"revoke"`
- `artifact_scope`: JSON description of the credential parameters
- `tenant_id`: Owning tenant UUID
- `identity_claim`: OIDC token or external event claim
- `ttl_seconds`: Intent lifetime (default 300)
- `max_redemptions`: Always `1` for credential operations
- `idempotency_key`: `"{registry_type}:{verb}:{credential_id}"`
Response includes:
- `intent_id`: Unique identifier for the intent
- `ceremony_id`: Non-empty if a governance ceremony is required
- `denied`: True if the Accord policy immediately rejects the operation
### RedeemIntent
Called after the intent is authorized (immediately or after ceremony approval):
```protobuf
rpc RedeemIntent(RedeemIntentRequest) returns (RedeemIntentResponse);
```
Returns a `SatToken` containing:
- `sat_hash`: Cryptographic binding of the SAT contents
- `bearer_svid`: SPIFFE ID authorized to perform the operation
- `scopes`: `SatScopeMsg` entries defining permitted operations
- `issued_at` / `expires_at`: SAT validity window
## Ceremony Flow
When `CreateIntentResponse.ceremony_id` is non-empty:
1. Plugin monitors ceremony status via `CeremonyService.GetCeremony`
2. Approvers use `ApproveCeremony` or `DenyCeremony` (via Bascule shell or API)
3. On approval, GovernanceService transitions the intent to `authorized`
4. Plugin proceeds with `RedeemIntent`
Ceremony types (from Accord policy):
- **Autonomous**: No approval needed, intent authorized immediately
- **SelfGrant**: Requestor self-approves
- **SingleApproval**: One external approver required
- **QuorumApproval**: Multiple approvers required (configurable quorum)
- **EmergencyBreakGlass**: Proceed immediately, require post-hoc approval
## MutationEnvelope Construction
After the credential operation succeeds, the plugin constructs a MutationEnvelope for audit anchoring:
### Step 1: Canonicalize Payload
Serialize the credential event payload using RFC 8785 JSON Canonicalization Scheme (JCS):
- Sorted keys
- No whitespace
- Deterministic number formatting
### Step 2: Domain-Separated Hash
```
payload_hash = SHA-256("guildhouse.credential.v1:" + jcs_bytes)
```
The domain prefix prevents cross-protocol hash collisions.
### Step 3: Build Envelope
```json
{
"domain": "guildhouse.credential.v1",
"payload_hash": "<hex-sha256>",
"timestamp": "<rfc3339>",
"actor_svid": "<spiffe-id>",
"tenant_id": "<uuid>",
"event_type": "issue",
"intent_id": "<intent-id>",
"sat_hash": "<hex-sha256>"
}
```
### Step 4: Anchor
JCS-canonicalize the envelope, SHA-256 hash it, submit as a leaf to `NotaryService.CreateAnchor`.
## Error Handling
| Failure | Behavior |
|---------|----------|
| GovernanceService unreachable | **Fail closed** — credential operation fails |
| Ceremony timeout | Treat as denial, abort operation |
| NotaryService unreachable | Credential operation proceeds, queue leaf for retry |
| No Accord policy match | Default to SingleApproval (fail safe) |
## Plugin Configuration
All plugins share governance connection configuration:
```hcl
plugin_data {
governance_addr = "governance.quartermaster.svc.cluster.local:50051"
ceremony_addr = "ceremony.bascule.svc.cluster.local:50052"
notary_addr = "notary.quartermaster.svc.cluster.local:50051"
trust_domain = "guildhouse.example.org"
cluster_id = "guildhouse-prod"
}
```

110
docs/oidc-attestation.md Normal file
View file

@ -0,0 +1,110 @@
# OIDC Workload Attestation
How the `oidc-attestor` plugin identifies workloads via OIDC tokens.
## Flow
```
Workload SPIRE Agent oidc-attestor OIDC Provider
| | | |
| FetchX509SVID | | |
|----------------->| | |
| | Attest(pid=1234) | |
| |------------------->| |
| | | GET /.well-known/ |
| | | openid-configuration|
| | |-------------------->|
| | | JWKS endpoint |
| | |<--------------------|
| | | |
| | | Read token from |
| | | /var/run/secrets/ |
| | | oidc/token |
| | | |
| | | Verify signature |
| | | via JWKS |
| | | |
| | Selectors | |
| |<-------------------| |
| X509-SVID | | |
|<-----------------| | |
```
## Token Discovery
The plugin discovers the workload's OIDC token via a configurable path. In Kubernetes, the token is typically projected into the pod filesystem:
```yaml
# Pod spec
volumes:
- name: oidc-token
projected:
sources:
- serviceAccountToken:
audience: spire
expirationSeconds: 3600
path: token
containers:
- name: app
volumeMounts:
- name: oidc-token
mountPath: /var/run/secrets/oidc
readOnly: true
```
Plugin configuration:
```hcl
WorkloadAttestor "guildhouse_oidc" {
plugin_cmd = "/opt/spire/plugins/oidc-attestor"
plugin_data {
issuer = "https://keycloak.guildhouse.example.org/realms/platform"
audience = "spire"
token_path = "/var/run/secrets/oidc/token"
}
}
```
## Token Verification
1. Plugin reads the JWT from the configured path.
2. Fetches the OIDC discovery document from `{issuer}/.well-known/openid-configuration`.
3. Retrieves the JWKS from the `jwks_uri` in the discovery document.
4. Verifies the JWT signature using the matching key from the JWKS.
5. Validates standard claims: `iss` matches configured issuer, `aud` includes configured audience, `exp` is in the future.
## Selector Output
The plugin returns selectors that SPIRE uses to match workloads against registration entries:
| Selector | Source Claim | Example |
|----------|-------------|---------|
| `oidc_attestor:iss:<value>` | `iss` | `oidc_attestor:iss:https://keycloak.example.org/realms/platform` |
| `oidc_attestor:sub:<value>` | `sub` | `oidc_attestor:sub:f47ac10b-58cc-4372-a567-0e02b2c3d479` |
| `oidc_attestor:email:<value>` | `email` | `oidc_attestor:email:operator@guildhouse.coop` |
| `oidc_attestor:group:<value>` | `groups[]` | `oidc_attestor:group:platform-engineers` |
## Registration Entry Matching
To grant an SVID to workloads authenticated via OIDC:
```bash
spire-server entry create \
-spiffeID spiffe://guildhouse.io/ns/prod/sa/web-server \
-parentID spiffe://guildhouse.io/spire/agent/k8s_psat/guildhouse/... \
-selector oidc_attestor:sub:f47ac10b-58cc-4372-a567-0e02b2c3d479
```
Multiple selectors can be combined (AND logic):
```bash
spire-server entry create \
-spiffeID spiffe://guildhouse.io/ns/prod/sa/admin-tool \
-parentID spiffe://guildhouse.io/spire/agent/k8s_psat/guildhouse/... \
-selector oidc_attestor:iss:https://keycloak.example.org/realms/platform \
-selector oidc_attestor:group:platform-engineers
```
## JWKS Caching
The plugin caches JWKS responses for the duration specified by the `Cache-Control` header (or 5 minutes if not present). This avoids hitting the OIDC provider on every attestation.

115
docs/plugin-types.md Normal file
View file

@ -0,0 +1,115 @@
# SPIRE Plugin Types
This document describes the four SPIRE plugin interfaces implemented by this repository, their callback methods, invocation timing, and the Guildhouse plugin that implements each.
## Overview
SPIRE's plugin architecture uses the go-plugin (hashicorp/go-plugin) framework. Plugins are compiled as separate binaries and loaded by the SPIRE Agent or Server at startup via the plugin configuration in `server.conf` or `agent.conf`.
| Interface | Side | Guildhouse Plugin | Purpose |
|-----------|------|-------------------|---------|
| WorkloadAttestor | Agent | oidc-attestor | Identify workloads by OIDC token |
| CredentialComposer | Server | ssh-credential-composer | Modify credentials during minting |
| Notifier | Server | governance-notifier | React to SPIRE lifecycle events |
| KeyManager | Server | substrate-keymanager | Manage server signing keys |
## WorkloadAttestor
**Side:** SPIRE Agent
**Plugin:** `cmd/oidc-attestor`
**Interface methods:**
```go
Attest(ctx context.Context, pid int32) ([]*common.Selector, error)
```
**When called:** Every time a workload calls the Workload API (typically `FetchX509SVID` or `FetchJWTSVID`). The agent identifies the calling process by PID, then passes that PID to all registered WorkloadAttestor plugins. Each plugin returns zero or more selectors.
**What oidc-attestor does:** Given the workload PID, it discovers an OIDC token associated with that process (via a projected volume path or environment variable), validates the token signature against a JWKS endpoint, and returns selectors derived from token claims (issuer, subject, audience, custom claims).
**Selector format:**
```
oidc_attestor:iss:<issuer>
oidc_attestor:sub:<subject>
oidc_attestor:aud:<audience>
oidc_attestor:claim:<key>:<value>
```
## CredentialComposer
**Side:** SPIRE Server
**Plugin:** `cmd/ssh-credential-composer`
**Interface methods:**
```go
ComposeServerX509CA(ctx context.Context, attributes X509CAAttributes) (X509CAAttributes, error)
ComposeServerX509SVID(ctx context.Context, attributes X509SVIDAttributes) (X509SVIDAttributes, error)
ComposeAgentX509SVID(ctx context.Context, attributes X509SVIDAttributes) (X509SVIDAttributes, error)
ComposeWorkloadX509SVID(ctx context.Context, attributes X509SVIDAttributes) (X509SVIDAttributes, error)
ComposeWorkloadJWTSVID(ctx context.Context, attributes JWTSVIDAttributes) (JWTSVIDAttributes, error)
```
**When called:** During credential minting on the server. After the server decides to issue a credential (X.509 SVID, JWT SVID, or CA certificate), it passes the proposed attributes through all registered CredentialComposer plugins. Each plugin may modify the attributes before the credential is signed.
**What ssh-credential-composer does:** Intercepts `ComposeWorkloadX509SVID` (and potentially future SSH-specific hooks). It reads the SPIFFE ID and registration entry metadata, calls GovernanceService to create a MutationIntent for the issuance, constructs a MutationEnvelope, anchors it via NotaryService, then encodes the governance metadata as Shellstream extensions in the SSH certificate's critical options using the `pkg/shellstream` encoder.
**Key detail:** The composer does not modify X.509 SVIDs for non-SSH use cases. It checks registration entry selectors or hints to determine if the credential is destined for SSH usage before injecting extensions.
## Notifier
**Side:** SPIRE Server
**Plugin:** `cmd/governance-notifier`
**Interface methods:**
```go
Notify(ctx context.Context, event *NotifyRequest) (*NotifyResponse, error)
NotifyAndAdvise(ctx context.Context, event *NotifyAndAdviseRequest) (*NotifyAndAdviseResponse, error)
```
**When called:**
- `Notify`: After a bundle update or registration entry change has been committed. This is fire-and-forget; the server does not block on the response.
- `NotifyAndAdvise`: Before certain operations where the plugin can influence the outcome. The server waits for the response.
**Event types the plugin handles:**
- `BundleUpdated` — A trust bundle was rotated.
- `EntryUpdated` — A registration entry was created, updated, or deleted.
**What governance-notifier does:** On bundle rotation events, it creates a MutationIntent via GovernanceService to record the rotation as an auditable governance event. For sensitive entry changes (e.g., high-privilege SPIFFE IDs), it triggers a CeremonyService flow requiring multi-stakeholder approval. All events are anchored in the merkle tree via NotaryService.
## KeyManager
**Side:** SPIRE Server
**Plugin:** `cmd/substrate-keymanager`
**Interface methods:**
```go
GenerateKey(ctx context.Context, req *GenerateKeyRequest) (*GenerateKeyResponse, error)
GetPublicKey(ctx context.Context, req *GetPublicKeyRequest) (*GetPublicKeyResponse, error)
GetPublicKeys(ctx context.Context, req *GetPublicKeysRequest) (*GetPublicKeysResponse, error)
SignData(ctx context.Context, req *SignDataRequest) (*SignDataResponse, error)
```
**When called:**
- `GenerateKey`: When the server needs a new signing key (initial startup, key rotation).
- `GetPublicKey`/`GetPublicKeys`: When the server needs to retrieve existing key material.
- `SignData`: Every time the server signs a credential (SVID, CA cert).
**What substrate-keymanager does:** Manages the SPIRE Server's signing keys with governance-aware lifecycle. Key generation and rotation events are recorded as MutationIntents. The plugin stores keys in a configured backend (filesystem, Kubernetes Secret, or remote KMS) and ensures every key operation is traceable through the governance audit trail.
## Plugin Loading Order
SPIRE loads plugins in the order they appear in configuration. For correctness:
1. **substrate-keymanager** must load first (server needs signing keys before issuing anything).
2. **ssh-credential-composer** loads next (must be present before any credential minting).
3. **governance-notifier** loads last (reacts to events that other plugins may trigger).
On the agent side, **oidc-attestor** is loaded alongside any other workload attestors (e.g., the built-in `k8s` attestor). Multiple attestors can coexist; their selectors are unioned.

View file

@ -0,0 +1,122 @@
# SSH Certificate Issuance Flow
End-to-end flow from workload request to governed SSH session.
## Sequence
```
Workload SPIRE Agent SPIRE Server ssh-credential- Governance Notary
composer Service Service
| | | | | |
| FetchSSHSVID | | | | |
|-------------->| | | | |
| | Attest(pid) | | | |
| |--+ | | | |
| | | oidc-attestor | | |
| |<-+ | | | |
| | | | | |
| | MintSSHSVID | | | |
| |-------------->| | | |
| | | ComposeWorkloadCred | |
| | |-------------->| | |
| | | | | |
| | | | CreateIntent | |
| | | |-------------->| |
| | | | intent_id | |
| | | |<--------------| |
| | | | | |
| | | | RedeemIntent | |
| | | |-------------->| |
| | | | SAT | |
| | | |<--------------| |
| | | | | |
| | | | Build SSH cert with |
| | | | Shellstream extensions |
| | | | | |
| | | | CreateAnchor | |
| | | |------------------------------>|
| | | | merkle_root | |
| | | |<------------------------------|
| | | | | |
| | | SSH cert | | |
| | |<--------------| | |
| | SSH cert | | | |
| |<--------------| | | |
| SSH cert | | | | |
|<--------------| | | | |
```
## Step-by-Step
### 1. Workload Requests SSH SVID
The workload calls the SPIRE Workload API via Unix domain socket, requesting an SSH-SVID. It provides its SSH public key (Ed25519).
### 2. Agent Attests Workload
The SPIRE Agent identifies the calling process via kernel-level attestation. The `oidc-attestor` plugin verifies the workload's OIDC token and returns selectors. The agent matches selectors against registration entries.
### 3. Agent Forwards to Server
The agent sends a `MintSSHSVID` request to the SPIRE Server over the Agent-Server mTLS channel, including the workload's SPIFFE ID, public key, and requested principals.
### 4. CredentialComposer Pipeline
The SPIRE Server invokes the `ssh-credential-composer` plugin during credential minting.
### 5. Governance Intent
The composer calls `GovernanceService.CreateIntent` with:
- `registry_type`: `"credential"`
- `verb`: `"issue"`
- `artifact_scope`: JSON describing the SSH certificate parameters
- Identity from SPIRE attestation
If the Accord policy requires a ceremony, the flow blocks until approval.
### 6. SAT Issuance
The composer calls `GovernanceService.RedeemIntent` to obtain a SAT (Substrate Attestation Token) authorizing the credential issuance.
### 7. SSH Certificate Construction
The composer builds the SSH certificate:
- **Key ID**: SPIFFE ID
- **Principals**: SPIFFE ID + additional principals from registration
- **TTL**: Per-workload configuration (default 5 minutes)
- **Extensions**: Standard SSH extensions + Shellstream governance extensions
Shellstream extensions embedded:
- `sat-scope@guildhouse.io` — SAT authorization scope
- `sat-hash@guildhouse.io` — SHA-256 of the SAT
- `tenant-id@guildhouse.io` — Tenant UUID
- `roles@guildhouse.io` — Assigned roles
- `merkle-root@guildhouse.io` — Governance tree root at issuance
- `governance-epoch@guildhouse.io` — Current epoch counter
### 8. Merkle Anchoring
The composer constructs a MutationEnvelope (RFC 8785 JCS + domain-separated SHA-256) and submits the leaf hash to `NotaryService.CreateAnchor`.
### 9. Certificate Delivery
The signed SSH certificate flows back through the server → agent → workload chain. The workload receives the certificate, private key (if agent-generated), and SSH CA trust bundle.
## Using the Certificate
The workload uses the SSH-SVID to connect to governed hosts:
```bash
ssh -o CertificateFile=/tmp/ssh-svid-cert.pub \
-i /tmp/ssh-svid-key \
target-host.example.org
```
## Server-Side Validation
The SSH server:
1. Validates the certificate signature against `TrustedUserCAKeys` (from SPIRE trust bundle)
2. Checks the certificate validity period
3. Matches principals against `AuthorizedPrincipalsFile`
4. Reads Shellstream extensions for authorization decisions (tenant, roles, SAT scope)
5. Optionally verifies the merkle proof against the NotaryService for audit

112
docs/testing.md Normal file
View file

@ -0,0 +1,112 @@
# Testing Strategy
How to test the Guildhouse SPIRE plugins.
## Running Tests
```bash
make test # Run all tests
make lint # Run go vet
# Verbose output
go test -v ./...
# Single package
go test -v ./pkg/shellstream/...
# With coverage
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
```
## Test Structure
### Unit Tests (`pkg/`)
Each package has `*_test.go` files with unit tests:
| Package | Tests | Coverage Focus |
|---------|-------|---------------|
| `pkg/shellstream` | ~28 tests | Encode/decode round-trips, validation rules, format constraints, co-occurrence rules |
| `pkg/oidc` | 2 tests | Config validation (scaffolded) |
| `pkg/governance` | 2 tests | Client construction (scaffolded) |
| `pkg/sshcert` | 3 tests | Builder config validation (scaffolded) |
| `pkg/config` | 2 tests | Plugin config validation (scaffolded) |
### `pkg/shellstream` Tests (Fully Implemented)
The shellstream package has comprehensive tests covering:
- **Round-trip**: Encode → Decode produces identical values (full and minimal)
- **Required fields**: Missing tenant-id, missing roles produce validation errors
- **Format validation**: Invalid UUID, wrong hex length, uppercase hex, invalid base64
- **Co-occurrence**: sat-scope without sat-hash, ceremony-id without ceremony-type, merkle-proof without merkle-root
- **Parsing**: JSON sat-scope, comma-separated roles, base64 merkle proof, uint64 governance epoch
- **Edge cases**: Epoch value 0, nil extensions, unknown extensions ignored
- **Enum validation**: Unknown ceremony-type rejected
### Integration Test Approach (Future)
When plugin implementations are complete, integration tests will use mock gRPC servers:
```go
// Example mock GovernanceService
type mockGovernanceServer struct {
quartermasterv1.UnimplementedGovernanceServiceServer
intents map[string]*Intent
}
func (s *mockGovernanceServer) CreateIntent(ctx context.Context, req *quartermasterv1.CreateIntentRequest) (*quartermasterv1.CreateIntentResponse, error) {
// Return predictable responses for testing
}
```
Integration tests will:
1. Start mock gRPC servers for GovernanceService, CeremonyService, NotaryService
2. Create plugin instances pointed at the mock servers
3. Invoke plugin methods (Attest, Compose, Notify)
4. Verify the plugin made expected gRPC calls with correct parameters
5. Verify the plugin handled error conditions (unreachable service, denied intent, ceremony timeout)
## Test Fixtures
Test fixture files in `test/fixtures/`:
| File | Purpose |
|------|---------|
| `sample-oidc-token.json` | Example OIDC token payload for oidc-attestor testing |
| `sample-sat-scope.json` | Example SatScope for governance client testing |
| `sample-ssh-cert-extensions.json` | Example Shellstream extensions map for shellstream package testing |
| `spire-test-config.hcl` | Example SPIRE plugin configuration for config package testing |
Load fixtures in tests:
```go
func loadFixture(t *testing.T, name string) []byte {
t.Helper()
data, err := os.ReadFile(filepath.Join("../../test/fixtures", name))
if err != nil {
t.Fatalf("load fixture %s: %v", name, err)
}
return data
}
```
## CI Pipeline
```yaml
# .github/workflows/test.yaml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23'
- run: make test
- run: make lint
- run: make build
```

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module github.com/guildhouse-cooperative/guildhouse-spire-plugins
go 1.23.6

39
pkg/config/config.go Normal file
View file

@ -0,0 +1,39 @@
// Package config provides configuration loading for SPIRE plugins.
// SPIRE plugins receive configuration via HCL in the SPIRE server/agent config file.
package config
import (
"fmt"
)
// PluginConfig holds common configuration fields shared by all Guildhouse SPIRE plugins.
type PluginConfig struct {
// GovernanceAddr is the gRPC address of the GovernanceService.
GovernanceAddr string `hcl:"governance_addr"`
// CeremonyAddr is the gRPC address of the CeremonyService.
CeremonyAddr string `hcl:"ceremony_addr"`
// NotaryAddr is the gRPC address of the NotaryService.
NotaryAddr string `hcl:"notary_addr"`
// TrustDomain is the SPIFFE trust domain.
TrustDomain string `hcl:"trust_domain"`
// ClusterID identifies this cluster for notary anchoring.
ClusterID string `hcl:"cluster_id"`
}
// Validate checks that required fields are present.
func (c *PluginConfig) Validate() error {
if c.TrustDomain == "" {
return fmt.Errorf("config: trust_domain is required")
}
return nil
}
// LoadFromHCL parses plugin configuration from HCL bytes.
// TODO: implement — use hashicorp/hcl to parse configuration.
func LoadFromHCL(data []byte) (*PluginConfig, error) {
return nil, fmt.Errorf("config: LoadFromHCL not yet implemented")
}

21
pkg/config/config_test.go Normal file
View file

@ -0,0 +1,21 @@
package config
import (
"testing"
)
func TestValidateRequiresTrustDomain(t *testing.T) {
cfg := &PluginConfig{}
err := cfg.Validate()
if err == nil {
t.Fatal("expected error for empty trust domain")
}
}
func TestValidateAcceptsMinimalConfig(t *testing.T) {
cfg := &PluginConfig{TrustDomain: "example.org"}
err := cfg.Validate()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}

View file

@ -0,0 +1,74 @@
// Package governance provides a gRPC client for the Guildhouse GovernanceService
// and CeremonyService, used by SPIRE plugins to participate in governed mutations.
package governance
import (
"context"
"fmt"
)
// Config holds governance client configuration.
type Config struct {
// GovernanceAddr is the gRPC address of the GovernanceService.
GovernanceAddr string
// CeremonyAddr is the gRPC address of the CeremonyService.
CeremonyAddr string
// NotaryAddr is the gRPC address of the NotaryService.
NotaryAddr string
}
// IntentResult holds the result of a CreateIntent call.
type IntentResult struct {
IntentID string
CeremonyID string // non-empty if ceremony required
Denied bool
Error string
}
// RedeemResult holds the result of a RedeemIntent call.
type RedeemResult struct {
Success bool
SatHash []byte
Status string
Error string
}
// Client wraps gRPC clients for GovernanceService, CeremonyService, and NotaryService.
type Client struct {
config Config
}
// NewClient creates a governance client.
func NewClient(cfg Config) (*Client, error) {
if cfg.GovernanceAddr == "" {
return nil, fmt.Errorf("governance: governance address is required")
}
// TODO: implement — establish gRPC connections with mTLS
return &Client{config: cfg}, nil
}
// CreateIntent creates a MutationIntent for a credential operation.
func (c *Client) CreateIntent(ctx context.Context, registryType, verb, artifactScope, tenantID string) (*IntentResult, error) {
// TODO: implement — call GovernanceService.CreateIntent
return nil, fmt.Errorf("governance: CreateIntent not yet implemented")
}
// RedeemIntent redeems a MutationIntent to obtain a SAT.
func (c *Client) RedeemIntent(ctx context.Context, intentID string) (*RedeemResult, error) {
// TODO: implement — call GovernanceService.RedeemIntent
return nil, fmt.Errorf("governance: RedeemIntent not yet implemented")
}
// CreateCeremony creates a governance ceremony.
func (c *Client) CreateCeremony(ctx context.Context, ceremonyType, intentID string, requiredApprovals uint32) (string, error) {
// TODO: implement — call CeremonyService.CreateCeremony
return "", fmt.Errorf("governance: CreateCeremony not yet implemented")
}
// SubmitMerkleLeaf submits a credential event as a merkle leaf to the NotaryService.
func (c *Client) SubmitMerkleLeaf(ctx context.Context, clusterID string, leaf []byte) (string, error) {
// TODO: implement — call NotaryService.CreateAnchor
return "", fmt.Errorf("governance: SubmitMerkleLeaf not yet implemented")
}

View file

@ -0,0 +1,22 @@
package governance
import (
"testing"
)
func TestNewClientRequiresAddress(t *testing.T) {
_, err := NewClient(Config{})
if err == nil {
t.Fatal("expected error for empty governance address")
}
}
func TestNewClientAcceptsValidConfig(t *testing.T) {
c, err := NewClient(Config{GovernanceAddr: "localhost:50051"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if c == nil {
t.Fatal("client should not be nil")
}
}

43
pkg/oidc/oidc.go Normal file
View file

@ -0,0 +1,43 @@
// Package oidc provides OIDC token verification for SPIRE workload attestation.
package oidc
import (
"context"
"fmt"
)
// Config holds OIDC verifier configuration.
type Config struct {
// Issuer is the expected OIDC issuer URL.
Issuer string
// Audience is the expected token audience.
Audience string
// JWKSURL overrides automatic OIDC discovery for the JWKS endpoint.
JWKSURL string
}
// Claims represents the verified claims from an OIDC token.
type Claims struct {
Subject string
Issuer string
Audience []string
Email string
Groups []string
}
// Verifier validates OIDC tokens and extracts claims.
type Verifier interface {
// Verify validates the token and returns the claims.
Verify(ctx context.Context, rawToken string) (*Claims, error)
}
// NewVerifier creates an OIDC token verifier from the given configuration.
func NewVerifier(cfg Config) (Verifier, error) {
if cfg.Issuer == "" {
return nil, fmt.Errorf("oidc: issuer is required")
}
// TODO: implement — fetch OIDC discovery document, configure JWKS validation
return nil, fmt.Errorf("oidc: not yet implemented")
}

19
pkg/oidc/oidc_test.go Normal file
View file

@ -0,0 +1,19 @@
package oidc
import (
"testing"
)
func TestNewVerifierRequiresIssuer(t *testing.T) {
_, err := NewVerifier(Config{})
if err == nil {
t.Fatal("expected error for empty issuer")
}
}
func TestNewVerifierNotYetImplemented(t *testing.T) {
_, err := NewVerifier(Config{Issuer: "https://accounts.example.com"})
if err == nil {
t.Fatal("expected not-yet-implemented error")
}
}

9
pkg/shellstream/doc.go Normal file
View file

@ -0,0 +1,9 @@
// Package shellstream encodes and decodes Shellstream SSH certificate extensions.
//
// Shellstream extensions use the @guildhouse.io vendor suffix to carry
// governance metadata in SSH certificates. This package provides Encode,
// Decode, and Validate functions that operate on the extension map within
// an OpenSSH certificate.
//
// See specs/shellstream-extensions.md for the full specification.
package shellstream

View file

@ -0,0 +1,318 @@
package shellstream
import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"strconv"
"strings"
)
// Extension key constants — all use the @guildhouse.io vendor suffix.
const (
ExtSatScope = "sat-scope@guildhouse.io"
ExtSatHash = "sat-hash@guildhouse.io"
ExtTenantID = "tenant-id@guildhouse.io"
ExtRoles = "roles@guildhouse.io"
ExtCeremonyID = "ceremony-id@guildhouse.io"
ExtCeremonyType = "ceremony-type@guildhouse.io"
ExtMerkleRoot = "merkle-root@guildhouse.io"
ExtMerkleProof = "merkle-proof@guildhouse.io"
ExtGovernanceEpoch = "governance-epoch@guildhouse.io"
// Vendor suffix for identifying Shellstream extensions.
VendorSuffix = "@guildhouse.io"
)
// Valid ceremony types.
var validCeremonyTypes = map[string]bool{
"self_grant": true,
"single_approval": true,
"quorum_approval": true,
"emergency_break_glass": true,
}
// SatScope represents a Substrate Attestation Token scope.
type SatScope struct {
RegistryType string `json:"registry_type"`
Verbs []string `json:"verbs"`
ResourcePattern string `json:"resource_pattern"`
}
// ShellstreamExtensions holds all Shellstream extension values.
type ShellstreamExtensions struct {
// SatScope is the authorization scope. Required when SAT is present.
SatScope *SatScope
// SatHash is the hex-encoded SHA-256 of the SAT bytes. Required when SatScope is set.
SatHash string
// TenantID is the tenant UUID. Required.
TenantID string
// Roles is the list of role names. Required (at least one).
Roles []string
// CeremonyID is the governance ceremony UUID. Optional (elevated sessions only).
CeremonyID string
// CeremonyType is the ceremony type. Required when CeremonyID is set.
CeremonyType string
// MerkleRoot is the hex-encoded governance merkle root at issuance. Optional.
MerkleRoot string
// MerkleProof is the binary inclusion proof. Optional, requires MerkleRoot.
MerkleProof []byte
// GovernanceEpoch is the monotonic governance state counter. Optional.
GovernanceEpoch uint64
// Internal tracking for whether epoch was explicitly set (0 is valid).
hasGovernanceEpoch bool
}
// WithGovernanceEpoch sets the governance epoch and marks it as explicitly set.
func (e *ShellstreamExtensions) WithGovernanceEpoch(epoch uint64) {
e.GovernanceEpoch = epoch
e.hasGovernanceEpoch = true
}
// HasGovernanceEpoch returns true if the governance epoch was explicitly set.
func (e *ShellstreamExtensions) HasGovernanceEpoch() bool {
return e.hasGovernanceEpoch
}
// Encode serializes ShellstreamExtensions into an SSH certificate extensions map.
func Encode(ext *ShellstreamExtensions) (map[string]string, error) {
if ext == nil {
return nil, fmt.Errorf("shellstream: nil extensions")
}
m := make(map[string]string)
// Required fields.
m[ExtTenantID] = ext.TenantID
m[ExtRoles] = strings.Join(ext.Roles, ",")
// SAT fields (both or neither).
if ext.SatScope != nil {
scopeJSON, err := json.Marshal(ext.SatScope)
if err != nil {
return nil, fmt.Errorf("shellstream: marshal sat-scope: %w", err)
}
m[ExtSatScope] = string(scopeJSON)
m[ExtSatHash] = ext.SatHash
}
// Ceremony fields.
if ext.CeremonyID != "" {
m[ExtCeremonyID] = ext.CeremonyID
m[ExtCeremonyType] = ext.CeremonyType
}
// Merkle fields.
if ext.MerkleRoot != "" {
m[ExtMerkleRoot] = ext.MerkleRoot
}
if len(ext.MerkleProof) > 0 {
m[ExtMerkleProof] = base64.StdEncoding.EncodeToString(ext.MerkleProof)
}
// Governance epoch.
if ext.hasGovernanceEpoch {
m[ExtGovernanceEpoch] = strconv.FormatUint(ext.GovernanceEpoch, 10)
}
return m, nil
}
// Decode parses an SSH certificate extensions map into ShellstreamExtensions.
// Unknown extensions (including non-Shellstream keys) are silently ignored.
func Decode(extensions map[string]string) (*ShellstreamExtensions, error) {
ext := &ShellstreamExtensions{}
// Required: tenant-id.
if v, ok := extensions[ExtTenantID]; ok {
ext.TenantID = v
}
// Required: roles.
if v, ok := extensions[ExtRoles]; ok && v != "" {
ext.Roles = strings.Split(v, ",")
}
// Optional: sat-scope.
if v, ok := extensions[ExtSatScope]; ok {
scope := &SatScope{}
if err := json.Unmarshal([]byte(v), scope); err != nil {
return nil, fmt.Errorf("shellstream: unmarshal sat-scope: %w", err)
}
ext.SatScope = scope
}
// Optional: sat-hash.
if v, ok := extensions[ExtSatHash]; ok {
ext.SatHash = v
}
// Optional: ceremony-id.
if v, ok := extensions[ExtCeremonyID]; ok {
ext.CeremonyID = v
}
// Optional: ceremony-type.
if v, ok := extensions[ExtCeremonyType]; ok {
ext.CeremonyType = v
}
// Optional: merkle-root.
if v, ok := extensions[ExtMerkleRoot]; ok {
ext.MerkleRoot = v
}
// Optional: merkle-proof.
if v, ok := extensions[ExtMerkleProof]; ok {
proof, err := base64.StdEncoding.DecodeString(v)
if err != nil {
return nil, fmt.Errorf("shellstream: decode merkle-proof: %w", err)
}
ext.MerkleProof = proof
}
// Optional: governance-epoch.
if v, ok := extensions[ExtGovernanceEpoch]; ok {
epoch, err := strconv.ParseUint(v, 10, 64)
if err != nil {
return nil, fmt.Errorf("shellstream: parse governance-epoch: %w", err)
}
ext.GovernanceEpoch = epoch
ext.hasGovernanceEpoch = true
}
return ext, nil
}
// Validate checks that a ShellstreamExtensions value satisfies all format
// constraints and co-occurrence rules from the specification.
func Validate(ext *ShellstreamExtensions) error {
if ext == nil {
return fmt.Errorf("shellstream: nil extensions")
}
// Required: tenant-id.
if ext.TenantID == "" {
return fmt.Errorf("shellstream: tenant-id is required")
}
if !isValidUUID(ext.TenantID) {
return fmt.Errorf("shellstream: tenant-id is not a valid UUID: %q", ext.TenantID)
}
// Required: roles (at least one).
if len(ext.Roles) == 0 {
return fmt.Errorf("shellstream: roles is required (at least one role)")
}
for _, r := range ext.Roles {
if r == "" {
return fmt.Errorf("shellstream: empty role name")
}
if strings.ContainsAny(r, ", ") {
return fmt.Errorf("shellstream: role name must not contain commas or spaces: %q", r)
}
}
// Co-occurrence: sat-scope requires sat-hash and vice versa.
if ext.SatScope != nil && ext.SatHash == "" {
return fmt.Errorf("shellstream: sat-scope requires sat-hash")
}
if ext.SatHash != "" && ext.SatScope == nil {
return fmt.Errorf("shellstream: sat-hash requires sat-scope")
}
// sat-hash format: 64 lowercase hex characters.
if ext.SatHash != "" {
if len(ext.SatHash) != 64 {
return fmt.Errorf("shellstream: sat-hash must be 64 hex characters, got %d", len(ext.SatHash))
}
if _, err := hex.DecodeString(ext.SatHash); err != nil {
return fmt.Errorf("shellstream: sat-hash is not valid hex: %w", err)
}
if ext.SatHash != strings.ToLower(ext.SatHash) {
return fmt.Errorf("shellstream: sat-hash must be lowercase")
}
}
// sat-scope fields.
if ext.SatScope != nil {
if ext.SatScope.RegistryType == "" {
return fmt.Errorf("shellstream: sat-scope.registry_type is required")
}
if len(ext.SatScope.Verbs) == 0 {
return fmt.Errorf("shellstream: sat-scope.verbs is required (at least one verb)")
}
}
// Co-occurrence: ceremony-id requires ceremony-type.
if ext.CeremonyID != "" && ext.CeremonyType == "" {
return fmt.Errorf("shellstream: ceremony-id requires ceremony-type")
}
if ext.CeremonyType != "" && ext.CeremonyID == "" {
return fmt.Errorf("shellstream: ceremony-type requires ceremony-id")
}
// ceremony-id format: UUID.
if ext.CeremonyID != "" {
if !isValidUUID(ext.CeremonyID) {
return fmt.Errorf("shellstream: ceremony-id is not a valid UUID: %q", ext.CeremonyID)
}
}
// ceremony-type: must be a known value.
if ext.CeremonyType != "" {
if !validCeremonyTypes[ext.CeremonyType] {
return fmt.Errorf("shellstream: unknown ceremony-type: %q", ext.CeremonyType)
}
}
// merkle-root format: 64 lowercase hex characters.
if ext.MerkleRoot != "" {
if len(ext.MerkleRoot) != 64 {
return fmt.Errorf("shellstream: merkle-root must be 64 hex characters, got %d", len(ext.MerkleRoot))
}
if _, err := hex.DecodeString(ext.MerkleRoot); err != nil {
return fmt.Errorf("shellstream: merkle-root is not valid hex: %w", err)
}
if ext.MerkleRoot != strings.ToLower(ext.MerkleRoot) {
return fmt.Errorf("shellstream: merkle-root must be lowercase")
}
}
// Co-occurrence: merkle-proof requires merkle-root.
if len(ext.MerkleProof) > 0 && ext.MerkleRoot == "" {
return fmt.Errorf("shellstream: merkle-proof requires merkle-root")
}
return nil
}
// isValidUUID checks if a string is a valid RFC 4122 UUID (lowercase, hyphenated).
func isValidUUID(s string) bool {
// Format: 8-4-4-4-12 = 36 characters.
if len(s) != 36 {
return false
}
for i, c := range s {
switch i {
case 8, 13, 18, 23:
if c != '-' {
return false
}
default:
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
return false
}
}
}
return true
}

View file

@ -0,0 +1,550 @@
package shellstream
import (
"encoding/base64"
"strings"
"testing"
)
// Helper to create a minimal valid extensions set.
func minimalExtensions() *ShellstreamExtensions {
return &ShellstreamExtensions{
TenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
Roles: []string{"analyst"},
}
}
// Helper to create a fully-populated extensions set.
func fullExtensions() *ShellstreamExtensions {
ext := &ShellstreamExtensions{
SatScope: &SatScope{
RegistryType: "oci",
Verbs: []string{"push", "pull"},
ResourcePattern: "tenant-a/*",
},
SatHash: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
TenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
Roles: []string{"administrator", "engineer"},
CeremonyID: "11223344-5566-7788-99aa-bbccddeeff00",
CeremonyType: "single_approval",
MerkleRoot: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
MerkleProof: []byte{0x01, 0x02, 0x03, 0x04, 0x05},
}
ext.WithGovernanceEpoch(42)
return ext
}
func TestEncodeDecodeRoundTrip(t *testing.T) {
ext := fullExtensions()
encoded, err := Encode(ext)
if err != nil {
t.Fatalf("Encode: %v", err)
}
decoded, err := Decode(encoded)
if err != nil {
t.Fatalf("Decode: %v", err)
}
// Verify all fields round-trip.
if decoded.TenantID != ext.TenantID {
t.Errorf("TenantID: got %q, want %q", decoded.TenantID, ext.TenantID)
}
if len(decoded.Roles) != len(ext.Roles) {
t.Fatalf("Roles length: got %d, want %d", len(decoded.Roles), len(ext.Roles))
}
for i, r := range decoded.Roles {
if r != ext.Roles[i] {
t.Errorf("Roles[%d]: got %q, want %q", i, r, ext.Roles[i])
}
}
if decoded.SatHash != ext.SatHash {
t.Errorf("SatHash: got %q, want %q", decoded.SatHash, ext.SatHash)
}
if decoded.SatScope == nil {
t.Fatal("SatScope: got nil")
}
if decoded.SatScope.RegistryType != ext.SatScope.RegistryType {
t.Errorf("SatScope.RegistryType: got %q, want %q", decoded.SatScope.RegistryType, ext.SatScope.RegistryType)
}
if len(decoded.SatScope.Verbs) != len(ext.SatScope.Verbs) {
t.Fatalf("SatScope.Verbs length: got %d, want %d", len(decoded.SatScope.Verbs), len(ext.SatScope.Verbs))
}
if decoded.SatScope.ResourcePattern != ext.SatScope.ResourcePattern {
t.Errorf("SatScope.ResourcePattern: got %q, want %q", decoded.SatScope.ResourcePattern, ext.SatScope.ResourcePattern)
}
if decoded.CeremonyID != ext.CeremonyID {
t.Errorf("CeremonyID: got %q, want %q", decoded.CeremonyID, ext.CeremonyID)
}
if decoded.CeremonyType != ext.CeremonyType {
t.Errorf("CeremonyType: got %q, want %q", decoded.CeremonyType, ext.CeremonyType)
}
if decoded.MerkleRoot != ext.MerkleRoot {
t.Errorf("MerkleRoot: got %q, want %q", decoded.MerkleRoot, ext.MerkleRoot)
}
if len(decoded.MerkleProof) != len(ext.MerkleProof) {
t.Fatalf("MerkleProof length: got %d, want %d", len(decoded.MerkleProof), len(ext.MerkleProof))
}
for i, b := range decoded.MerkleProof {
if b != ext.MerkleProof[i] {
t.Errorf("MerkleProof[%d]: got %x, want %x", i, b, ext.MerkleProof[i])
}
}
if decoded.GovernanceEpoch != ext.GovernanceEpoch {
t.Errorf("GovernanceEpoch: got %d, want %d", decoded.GovernanceEpoch, ext.GovernanceEpoch)
}
if !decoded.HasGovernanceEpoch() {
t.Error("HasGovernanceEpoch: got false, want true")
}
}
func TestEncodeDecodeMinimal(t *testing.T) {
ext := minimalExtensions()
encoded, err := Encode(ext)
if err != nil {
t.Fatalf("Encode: %v", err)
}
// Only tenant-id and roles should be present.
if len(encoded) != 2 {
t.Errorf("encoded map length: got %d, want 2 (keys: %v)", len(encoded), mapKeys(encoded))
}
if _, ok := encoded[ExtTenantID]; !ok {
t.Error("missing tenant-id")
}
if _, ok := encoded[ExtRoles]; !ok {
t.Error("missing roles")
}
decoded, err := Decode(encoded)
if err != nil {
t.Fatalf("Decode: %v", err)
}
if decoded.TenantID != ext.TenantID {
t.Errorf("TenantID: got %q, want %q", decoded.TenantID, ext.TenantID)
}
if decoded.SatScope != nil {
t.Error("SatScope should be nil for minimal extensions")
}
if decoded.CeremonyID != "" {
t.Error("CeremonyID should be empty for minimal extensions")
}
if decoded.HasGovernanceEpoch() {
t.Error("HasGovernanceEpoch should be false for minimal extensions")
}
}
func TestDecodeUnknownExtensionsIgnored(t *testing.T) {
m := map[string]string{
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
ExtRoles: "analyst",
"unknown-ext@guildhouse.io": "some-value",
"permit-pty": "",
"completely-unrelated": "ignored",
}
decoded, err := Decode(m)
if err != nil {
t.Fatalf("Decode: %v", err)
}
if decoded.TenantID != "a1b2c3d4-e5f6-7890-abcd-ef1234567890" {
t.Errorf("TenantID: got %q", decoded.TenantID)
}
}
func TestValidateRequiredTenantID(t *testing.T) {
ext := &ShellstreamExtensions{
Roles: []string{"analyst"},
}
err := Validate(ext)
if err == nil {
t.Fatal("expected error for missing tenant-id")
}
if !strings.Contains(err.Error(), "tenant-id is required") {
t.Errorf("unexpected error: %v", err)
}
}
func TestValidateRequiredRoles(t *testing.T) {
ext := &ShellstreamExtensions{
TenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
}
err := Validate(ext)
if err == nil {
t.Fatal("expected error for missing roles")
}
if !strings.Contains(err.Error(), "roles is required") {
t.Errorf("unexpected error: %v", err)
}
}
func TestValidateInvalidTenantIDFormat(t *testing.T) {
ext := &ShellstreamExtensions{
TenantID: "not-a-uuid",
Roles: []string{"analyst"},
}
err := Validate(ext)
if err == nil {
t.Fatal("expected error for invalid UUID")
}
if !strings.Contains(err.Error(), "not a valid UUID") {
t.Errorf("unexpected error: %v", err)
}
}
func TestValidateSatScopeRequiresSatHash(t *testing.T) {
ext := minimalExtensions()
ext.SatScope = &SatScope{
RegistryType: "oci",
Verbs: []string{"pull"},
ResourcePattern: "*",
}
// Missing SatHash.
err := Validate(ext)
if err == nil {
t.Fatal("expected error for sat-scope without sat-hash")
}
if !strings.Contains(err.Error(), "sat-scope requires sat-hash") {
t.Errorf("unexpected error: %v", err)
}
}
func TestValidateSatHashRequiresSatScope(t *testing.T) {
ext := minimalExtensions()
ext.SatHash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
// Missing SatScope.
err := Validate(ext)
if err == nil {
t.Fatal("expected error for sat-hash without sat-scope")
}
if !strings.Contains(err.Error(), "sat-hash requires sat-scope") {
t.Errorf("unexpected error: %v", err)
}
}
func TestValidateSatHashFormat(t *testing.T) {
ext := minimalExtensions()
ext.SatScope = &SatScope{
RegistryType: "oci",
Verbs: []string{"pull"},
ResourcePattern: "*",
}
// Too short.
ext.SatHash = "abcdef"
err := Validate(ext)
if err == nil || !strings.Contains(err.Error(), "64 hex characters") {
t.Errorf("expected 64-char error, got: %v", err)
}
// Uppercase.
ext.SatHash = "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789"
err = Validate(ext)
if err == nil || !strings.Contains(err.Error(), "lowercase") {
t.Errorf("expected lowercase error, got: %v", err)
}
// Valid.
ext.SatHash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
err = Validate(ext)
if err != nil {
t.Errorf("unexpected error for valid sat-hash: %v", err)
}
}
func TestValidateCeremonyCooccurrence(t *testing.T) {
ext := minimalExtensions()
// ceremony-id without ceremony-type.
ext.CeremonyID = "11223344-5566-7788-99aa-bbccddeeff00"
err := Validate(ext)
if err == nil || !strings.Contains(err.Error(), "ceremony-id requires ceremony-type") {
t.Errorf("expected co-occurrence error, got: %v", err)
}
// ceremony-type without ceremony-id.
ext.CeremonyID = ""
ext.CeremonyType = "single_approval"
err = Validate(ext)
if err == nil || !strings.Contains(err.Error(), "ceremony-type requires ceremony-id") {
t.Errorf("expected co-occurrence error, got: %v", err)
}
}
func TestValidateUnknownCeremonyType(t *testing.T) {
ext := minimalExtensions()
ext.CeremonyID = "11223344-5566-7788-99aa-bbccddeeff00"
ext.CeremonyType = "unknown_type"
err := Validate(ext)
if err == nil || !strings.Contains(err.Error(), "unknown ceremony-type") {
t.Errorf("expected unknown ceremony-type error, got: %v", err)
}
}
func TestValidateMerkleProofRequiresRoot(t *testing.T) {
ext := minimalExtensions()
ext.MerkleProof = []byte{0x01, 0x02}
// Missing MerkleRoot.
err := Validate(ext)
if err == nil || !strings.Contains(err.Error(), "merkle-proof requires merkle-root") {
t.Errorf("expected co-occurrence error, got: %v", err)
}
}
func TestValidateMerkleRootFormat(t *testing.T) {
ext := minimalExtensions()
// Too short.
ext.MerkleRoot = "abcdef"
err := Validate(ext)
if err == nil || !strings.Contains(err.Error(), "64 hex characters") {
t.Errorf("expected 64-char error, got: %v", err)
}
// Uppercase.
ext.MerkleRoot = "FEDCBA9876543210FEDCBA9876543210FEDCBA9876543210FEDCBA9876543210"
err = Validate(ext)
if err == nil || !strings.Contains(err.Error(), "lowercase") {
t.Errorf("expected lowercase error, got: %v", err)
}
}
func TestValidateEmptyRoleName(t *testing.T) {
ext := &ShellstreamExtensions{
TenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
Roles: []string{"analyst", ""},
}
err := Validate(ext)
if err == nil || !strings.Contains(err.Error(), "empty role name") {
t.Errorf("expected empty role error, got: %v", err)
}
}
func TestValidateRoleWithComma(t *testing.T) {
ext := &ShellstreamExtensions{
TenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
Roles: []string{"analyst,viewer"},
}
err := Validate(ext)
if err == nil || !strings.Contains(err.Error(), "commas or spaces") {
t.Errorf("expected comma error, got: %v", err)
}
}
func TestDecodeSatScopeJSON(t *testing.T) {
m := map[string]string{
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
ExtRoles: "analyst",
ExtSatScope: `{"registry_type":"helm","verbs":["install","upgrade"],"resource_pattern":"ns/*"}`,
ExtSatHash: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
}
decoded, err := Decode(m)
if err != nil {
t.Fatalf("Decode: %v", err)
}
if decoded.SatScope == nil {
t.Fatal("SatScope is nil")
}
if decoded.SatScope.RegistryType != "helm" {
t.Errorf("RegistryType: got %q, want %q", decoded.SatScope.RegistryType, "helm")
}
if len(decoded.SatScope.Verbs) != 2 || decoded.SatScope.Verbs[0] != "install" {
t.Errorf("Verbs: got %v", decoded.SatScope.Verbs)
}
if decoded.SatScope.ResourcePattern != "ns/*" {
t.Errorf("ResourcePattern: got %q", decoded.SatScope.ResourcePattern)
}
}
func TestDecodeInvalidSatScopeJSON(t *testing.T) {
m := map[string]string{
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
ExtRoles: "analyst",
ExtSatScope: "not-valid-json",
}
_, err := Decode(m)
if err == nil {
t.Fatal("expected error for invalid JSON")
}
if !strings.Contains(err.Error(), "unmarshal sat-scope") {
t.Errorf("unexpected error: %v", err)
}
}
func TestDecodeRolesCommaParsing(t *testing.T) {
m := map[string]string{
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
ExtRoles: "administrator,engineer,analyst",
}
decoded, err := Decode(m)
if err != nil {
t.Fatalf("Decode: %v", err)
}
if len(decoded.Roles) != 3 {
t.Fatalf("Roles length: got %d, want 3", len(decoded.Roles))
}
expected := []string{"administrator", "engineer", "analyst"}
for i, r := range decoded.Roles {
if r != expected[i] {
t.Errorf("Roles[%d]: got %q, want %q", i, r, expected[i])
}
}
}
func TestDecodeMerkleProofBase64(t *testing.T) {
proof := []byte{0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04}
m := map[string]string{
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
ExtRoles: "analyst",
ExtMerkleRoot: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
ExtMerkleProof: base64.StdEncoding.EncodeToString(proof),
}
decoded, err := Decode(m)
if err != nil {
t.Fatalf("Decode: %v", err)
}
if len(decoded.MerkleProof) != len(proof) {
t.Fatalf("MerkleProof length: got %d, want %d", len(decoded.MerkleProof), len(proof))
}
for i, b := range decoded.MerkleProof {
if b != proof[i] {
t.Errorf("MerkleProof[%d]: got %x, want %x", i, b, proof[i])
}
}
}
func TestDecodeInvalidMerkleProofBase64(t *testing.T) {
m := map[string]string{
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
ExtRoles: "analyst",
ExtMerkleProof: "not-valid-base64!!!",
}
_, err := Decode(m)
if err == nil {
t.Fatal("expected error for invalid base64")
}
if !strings.Contains(err.Error(), "decode merkle-proof") {
t.Errorf("unexpected error: %v", err)
}
}
func TestDecodeGovernanceEpoch(t *testing.T) {
m := map[string]string{
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
ExtRoles: "analyst",
ExtGovernanceEpoch: "12345",
}
decoded, err := Decode(m)
if err != nil {
t.Fatalf("Decode: %v", err)
}
if decoded.GovernanceEpoch != 12345 {
t.Errorf("GovernanceEpoch: got %d, want 12345", decoded.GovernanceEpoch)
}
if !decoded.HasGovernanceEpoch() {
t.Error("HasGovernanceEpoch: got false, want true")
}
}
func TestDecodeGovernanceEpochZero(t *testing.T) {
m := map[string]string{
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
ExtRoles: "analyst",
ExtGovernanceEpoch: "0",
}
decoded, err := Decode(m)
if err != nil {
t.Fatalf("Decode: %v", err)
}
if decoded.GovernanceEpoch != 0 {
t.Errorf("GovernanceEpoch: got %d, want 0", decoded.GovernanceEpoch)
}
if !decoded.HasGovernanceEpoch() {
t.Error("HasGovernanceEpoch: got false, want true (epoch 0 is valid)")
}
}
func TestDecodeInvalidGovernanceEpoch(t *testing.T) {
m := map[string]string{
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
ExtRoles: "analyst",
ExtGovernanceEpoch: "not-a-number",
}
_, err := Decode(m)
if err == nil {
t.Fatal("expected error for invalid epoch")
}
if !strings.Contains(err.Error(), "parse governance-epoch") {
t.Errorf("unexpected error: %v", err)
}
}
func TestEncodeNilReturnsError(t *testing.T) {
_, err := Encode(nil)
if err == nil {
t.Fatal("expected error for nil extensions")
}
}
func TestValidateNilReturnsError(t *testing.T) {
err := Validate(nil)
if err == nil {
t.Fatal("expected error for nil extensions")
}
}
func TestValidateFullExtensions(t *testing.T) {
ext := fullExtensions()
err := Validate(ext)
if err != nil {
t.Fatalf("unexpected validation error: %v", err)
}
}
func TestValidateMinimalExtensions(t *testing.T) {
ext := minimalExtensions()
err := Validate(ext)
if err != nil {
t.Fatalf("unexpected validation error: %v", err)
}
}
func TestIsValidUUID(t *testing.T) {
tests := []struct {
input string
want bool
}{
{"a1b2c3d4-e5f6-7890-abcd-ef1234567890", true},
{"00000000-0000-0000-0000-000000000000", true},
{"A1B2C3D4-E5F6-7890-ABCD-EF1234567890", false}, // uppercase
{"a1b2c3d4e5f6-7890-abcd-ef1234567890", false}, // missing hyphen
{"too-short", false},
{"", false},
{"a1b2c3d4-e5f6-7890-abcd-ef12345678901", false}, // too long
}
for _, tt := range tests {
got := isValidUUID(tt.input)
if got != tt.want {
t.Errorf("isValidUUID(%q) = %v, want %v", tt.input, got, tt.want)
}
}
}
// mapKeys returns the keys of a map for debug output.
func mapKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}

56
pkg/sshcert/sshcert.go Normal file
View file

@ -0,0 +1,56 @@
// Package sshcert builds SSH certificates with Shellstream extensions,
// bridging SPIFFE identity and Guildhouse governance metadata.
package sshcert
import (
"fmt"
"github.com/guildhouse-cooperative/guildhouse-spire-plugins/pkg/shellstream"
)
// Config holds SSH certificate builder configuration.
type Config struct {
// TrustDomain is the SPIFFE trust domain.
TrustDomain string
}
// CertRequest describes an SSH certificate to build.
type CertRequest struct {
// SpiffeID is the workload's SPIFFE ID (used as principal).
SpiffeID string
// Extensions are the Shellstream governance extensions to embed.
Extensions *shellstream.ShellstreamExtensions
// ValidSeconds is the certificate lifetime in seconds.
ValidSeconds uint64
// Principals are additional SSH principals beyond the SPIFFE ID.
Principals []string
}
// Builder creates SSH certificates with Shellstream extensions.
type Builder struct {
config Config
}
// NewBuilder creates an SSH certificate builder.
func NewBuilder(cfg Config) (*Builder, error) {
if cfg.TrustDomain == "" {
return nil, fmt.Errorf("sshcert: trust domain is required")
}
return &Builder{config: cfg}, nil
}
// Build creates an SSH certificate from the request.
// TODO: implement — create golang.org/x/crypto/ssh.Certificate with Shellstream extensions.
func (b *Builder) Build(req *CertRequest) ([]byte, error) {
if req == nil {
return nil, fmt.Errorf("sshcert: nil request")
}
if req.SpiffeID == "" {
return nil, fmt.Errorf("sshcert: spiffe ID is required")
}
// TODO: implement — generate key pair, build certificate, sign with CA key
return nil, fmt.Errorf("sshcert: Build not yet implemented")
}

View file

@ -0,0 +1,30 @@
package sshcert
import (
"testing"
)
func TestNewBuilderRequiresTrustDomain(t *testing.T) {
_, err := NewBuilder(Config{})
if err == nil {
t.Fatal("expected error for empty trust domain")
}
}
func TestNewBuilderAcceptsValidConfig(t *testing.T) {
b, err := NewBuilder(Config{TrustDomain: "example.org"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if b == nil {
t.Fatal("builder should not be nil")
}
}
func TestBuildRequiresSpiffeID(t *testing.T) {
b, _ := NewBuilder(Config{TrustDomain: "example.org"})
_, err := b.Build(&CertRequest{})
if err == nil {
t.Fatal("expected error for empty spiffe ID")
}
}

View file

@ -0,0 +1,162 @@
// Source of truth: guildhouse monorepo
// services/bascule-proto/proto/bascule/v1/ceremony.proto
// This file is a copy for Go code generation. Do not edit here.
syntax = "proto3";
package bascule.v1;
option go_package = "github.com/guildhouse-cooperative/guildhouse-spire-plugins/gen/bascule/v1;basculev1";
import "google/protobuf/timestamp.proto";
// Governance Ceremony Service multi-stakeholder approval flows
// triggered by Accord policy when a mutation requires human sign-off.
service CeremonyService {
// Create a new governance ceremony.
rpc CreateCeremony (CreateCeremonyRequest) returns (CreateCeremonyResponse);
// Record an approval or denial on a pending ceremony.
rpc ApproveCeremony (ApproveCeremonyRequest) returns (ApproveCeremonyResponse);
// Deny a pending ceremony.
rpc DenyCeremony (DenyCeremonyRequest) returns (DenyCeremonyResponse);
// Cancel a pending ceremony (requestor or admin).
rpc CancelCeremony (CancelCeremonyRequest) returns (CancelCeremonyResponse);
// Get the current status of a ceremony.
rpc GetCeremony (GetCeremonyRequest) returns (GetCeremonyResponse);
// List pending ceremonies, optionally filtered.
rpc ListPendingCeremonies (ListPendingCeremoniesRequest) returns (ListPendingCeremoniesResponse);
// Get the resolution proof for a completed ceremony.
rpc GetCeremonyProof (GetCeremonyProofRequest) returns (GetCeremonyProofResponse);
}
// --- Create ---
message CreateCeremonyRequest {
string ceremony_type = 1; // "single_approval", "quorum_approval", etc.
CeremonySubjectMsg subject = 2;
uint32 required_approvals = 3;
repeated string approver_roles = 4;
uint32 ttl_hours = 5; // 0 = default (24h)
string intent_id = 6; // optional linked MutationIntent
string run_id = 7; // optional linked pipeline run
uint64 pr_number = 8; // optional linked PR
string remote_name = 9; // optional remote name
}
message CreateCeremonyResponse {
string ceremony_id = 1;
string status = 2; // "pending" or "approved" (for self-grant)
google.protobuf.Timestamp expires_at = 3;
string error = 4;
}
// --- Approve ---
message ApproveCeremonyRequest {
string ceremony_id = 1;
string approver_identity = 2;
string approver_role = 3;
string comment = 4;
}
message ApproveCeremonyResponse {
bool success = 1;
string status = 2; // updated status after approval
string error = 3;
}
// --- Deny ---
message DenyCeremonyRequest {
string ceremony_id = 1;
string approver_identity = 2;
string approver_role = 3;
string comment = 4;
}
message DenyCeremonyResponse {
bool success = 1;
string status = 2;
string error = 3;
}
// --- Cancel ---
message CancelCeremonyRequest {
string ceremony_id = 1;
}
message CancelCeremonyResponse {
bool success = 1;
string error = 2;
}
// --- Get ---
message GetCeremonyRequest {
string ceremony_id = 1;
}
message GetCeremonyResponse {
string ceremony_id = 1;
string ceremony_type = 2;
CeremonySubjectMsg subject = 3;
string status = 4;
uint32 required_approvals = 5;
uint32 current_approvals = 6;
repeated CeremonyApprovalMsg approvals = 7;
google.protobuf.Timestamp created_at = 8;
google.protobuf.Timestamp expires_at = 9;
string intent_id = 10;
string run_id = 11;
uint64 pr_number = 12;
string remote_name = 13;
string error = 14;
}
// --- List Pending ---
message ListPendingCeremoniesRequest {
string intent_id = 1; // optional filter
}
message ListPendingCeremoniesResponse {
repeated GetCeremonyResponse ceremonies = 1;
}
// --- Proof ---
message GetCeremonyProofRequest {
string ceremony_id = 1;
}
message GetCeremonyProofResponse {
string ceremony_id = 1;
string status = 2;
string proof_hash = 3;
repeated CeremonyApprovalMsg approvals = 4;
google.protobuf.Timestamp resolved_at = 5;
string error = 6;
}
// --- Shared messages ---
message CeremonySubjectMsg {
string subject_type = 1; // "mutation_intent", "pipeline_merge", "schematic_publish", "custom"
string reference_id = 2; // intent_id, run_id, "name:version", or custom ref
string description = 3; // human-readable label
map<string, string> metadata = 4; // extra fields
}
message CeremonyApprovalMsg {
string approver_identity = 1;
string approver_role = 2;
string decision = 3; // "approve" or "deny"
string comment = 4;
google.protobuf.Timestamp decided_at = 5;
}

View file

@ -0,0 +1,77 @@
// Source of truth: guildhouse monorepo
// services/qm-proto/proto/quartermaster/v1/credentials.proto
// This file is a copy for Go code generation. Do not edit here.
syntax = "proto3";
package quartermaster.v1;
option go_package = "github.com/guildhouse-cooperative/guildhouse-spire-plugins/gen/quartermaster/v1;quartermasterv1";
import "google/protobuf/timestamp.proto";
service QuartermasterCredentials {
rpc ProvisionDatabase (ProvisionDatabaseRequest) returns (ProvisionDatabaseResponse);
rpc RotateCredential (RotateCredentialRequest) returns (RotateCredentialResponse);
rpc RevokeCredential (RevokeCredentialRequest) returns (RevokeCredentialResponse);
rpc GetCredentialRef (GetCredentialRefRequest) returns (GetCredentialRefResponse);
rpc ListCredentials (ListCredentialsRequest) returns (ListCredentialsResponse);
}
message ProvisionDatabaseRequest {
string cluster_id = 1;
string service_name = 2;
string database_name = 3;
}
message ProvisionDatabaseResponse {
string credential_id = 1;
string secret_ref = 2;
string secret_namespace = 3;
google.protobuf.Timestamp issued_at = 4;
bytes merkle_leaf = 5;
}
message RotateCredentialRequest {
string credential_id = 1;
}
message RotateCredentialResponse {
string new_credential_id = 1;
string secret_ref = 2;
google.protobuf.Timestamp issued_at = 3;
bytes merkle_leaf = 4;
}
message RevokeCredentialRequest {
string credential_id = 1;
}
message RevokeCredentialResponse {
google.protobuf.Timestamp revoked_at = 1;
}
message GetCredentialRefRequest {
string credential_id = 1;
}
message GetCredentialRefResponse {
string credential_id = 1;
string cluster_id = 2;
string service_name = 3;
string credential_type = 4;
string username = 5;
string database_name = 6;
string secret_ref = 7;
string secret_namespace = 8;
google.protobuf.Timestamp issued_at = 9;
google.protobuf.Timestamp expires_at = 10;
bool revoked = 11;
}
message ListCredentialsRequest {
string cluster_id = 1;
}
message ListCredentialsResponse {
repeated GetCredentialRefResponse credentials = 1;
}

View file

@ -0,0 +1,125 @@
// Source of truth: guildhouse monorepo
// services/qm-proto/proto/quartermaster/v1/governance.proto
// This file is a copy for Go code generation. Do not edit here.
syntax = "proto3";
package quartermaster.v1;
option go_package = "github.com/guildhouse-cooperative/guildhouse-spire-plugins/gen/quartermaster/v1;quartermasterv1";
import "google/protobuf/timestamp.proto";
// Governance service for intent lifecycle and SAT issuance.
service GovernanceService {
// Create a MutationIntent called by application at user-request time.
rpc CreateIntent(CreateIntentRequest) returns (CreateIntentResponse);
// Redeem a MutationIntent called by worker at execution time.
rpc RedeemIntent(RedeemIntentRequest) returns (RedeemIntentResponse);
// Revoke a MutationIntent called to cancel pending authorization.
rpc RevokeIntent(RevokeIntentRequest) returns (RevokeIntentResponse);
// Query intents for a tenant (admin/audit use).
rpc ListIntents(ListIntentsRequest) returns (ListIntentsResponse);
}
message CreateIntentRequest {
string registry_type = 1;
string verb = 2;
string artifact_scope = 3;
string tenant_id = 4;
// Identity claim one of these should be set.
oneof identity_claim {
string oidc_token = 5;
ExternalEventClaim external_event = 6;
}
uint32 ttl_seconds = 7;
uint32 max_redemptions = 8;
string idempotency_key = 9;
}
message ExternalEventClaim {
string source = 1;
string event_id = 2;
string event_type = 3;
string verification = 4;
}
message CreateIntentResponse {
string intent_id = 1;
google.protobuf.Timestamp expires_at = 2;
bytes intent_hash = 3;
string error = 4;
bool denied = 5;
string denial_reason = 6;
// If a governance ceremony is required, this field contains the
// ceremony ID. The intent status is "ceremony_pending" and cannot
// be redeemed until the ceremony resolves.
string ceremony_id = 7;
}
message RedeemIntentRequest {
string intent_id = 1;
}
message RedeemIntentResponse {
bool success = 1;
SatToken sat = 2;
int32 remaining_redemptions = 3;
string status = 4;
string error = 5;
}
message SatToken {
bytes sat_hash = 1;
string bearer_svid = 2;
repeated SatScopeMsg scopes = 3;
google.protobuf.Timestamp issued_at = 4;
google.protobuf.Timestamp expires_at = 5;
bytes signature = 6;
bytes sat_bytes = 7;
}
message SatScopeMsg {
string registry_type = 1;
repeated string verbs = 2;
string resource_pattern = 3;
}
message RevokeIntentRequest {
string intent_id = 1;
}
message RevokeIntentResponse {
bool success = 1;
string error = 2;
}
message ListIntentsRequest {
string tenant_id = 1;
string status_filter = 2;
int32 limit = 3;
}
message ListIntentsResponse {
repeated IntentSummary intents = 1;
}
message IntentSummary {
string intent_id = 1;
string registry_type = 2;
string verb = 3;
string artifact_scope = 4;
string tenant_id = 5;
string claim_type = 6;
string claim_subject = 7;
string status = 8;
int32 max_redemptions = 9;
int32 redeemed_count = 10;
google.protobuf.Timestamp authorized_at = 11;
google.protobuf.Timestamp expires_at = 12;
}

View file

@ -0,0 +1,54 @@
// Source of truth: guildhouse monorepo
// services/qm-proto/proto/quartermaster/v1/notary.proto
// This file is a copy for Go code generation. Do not edit here.
syntax = "proto3";
package quartermaster.v1;
option go_package = "github.com/guildhouse-cooperative/guildhouse-spire-plugins/gen/quartermaster/v1;quartermasterv1";
import "google/protobuf/timestamp.proto";
service QuartermasterNotary {
rpc CreateAnchor (CreateAnchorRequest) returns (CreateAnchorResponse);
rpc GetLatestAnchor (GetLatestAnchorRequest) returns (GetLatestAnchorResponse);
rpc VerifyInclusion (VerifyInclusionRequest) returns (VerifyInclusionResponse);
}
message CreateAnchorRequest {
string cluster_id = 1;
repeated bytes leaves = 2;
int64 etcd_revision = 3; // 0 means not set
}
message CreateAnchorResponse {
string anchor_id = 1;
bytes merkle_root = 2;
bytes previous_root = 3;
int32 leaf_count = 4;
google.protobuf.Timestamp time = 5;
}
message GetLatestAnchorRequest {
string cluster_id = 1;
}
message GetLatestAnchorResponse {
string anchor_id = 1;
string cluster_id = 2;
bytes merkle_root = 3;
bytes previous_root = 4;
int64 etcd_revision = 5;
int32 leaf_count = 6;
google.protobuf.Timestamp time = 7;
}
message VerifyInclusionRequest {
string anchor_id = 1;
bytes leaf = 2;
repeated bytes proof = 3;
}
message VerifyInclusionResponse {
bool valid = 1;
}

View file

@ -0,0 +1,735 @@
# Credential Governance Specification
**Version:** 0.1.0-draft
**Date:** 2026-02-18
**Authors:** Guildhouse Cooperative
---
## 1. Abstract
This specification defines the integration between SPIRE credential lifecycle events and the Guildhouse governance framework. Credential operations (issuance, rotation, revocation) are modeled as governed mutations, subject to Accord policy classification, optional ceremony approval, and merkle-anchored audit recording. The goal is to bring every credential operation under a unified governance model that provides authorization control, multi-stakeholder approval where required, and an immutable, verifiable audit trail.
## 2. Status
**Draft specification.** This document is a working draft and is subject to change. It has not yet been ratified by the Guildhouse governance body. Normative requirements use the key words defined in RFC 2119.
### 2.1 Key Words
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119).
## 3. Terminology
**Credential Event**
A discrete lifecycle operation on a credential resource: issuance, rotation, or revocation. Each credential event is the atomic unit of governance for this specification.
**Governed Mutation**
Any state change within the Guildhouse platform that is subject to intent authorization, policy evaluation, and audit recording. A credential event becomes a governed mutation when it enters the governance flow.
**MutationIntent**
An authorization request registered with the GovernanceService (`quartermaster.v1.GovernanceService.CreateIntent`). Represents a declared intention to perform a mutation. An intent transitions through states: `pending` -> `ceremony_pending` | `authorized` -> `redeemed` | `expired` | `denied`.
**MutationEnvelope**
The universal wrapper for governed mutations. Contains the domain-separated hash of the canonicalized event payload, actor identity, timestamp, and tenant context. Serves as the merkle leaf input for audit anchoring.
**SAT (Substrate Attestation Token)**
The authorization token issued upon successful intent redemption (`quartermaster.v1.SatToken`). Carries `SatScopeMsg` entries specifying `registry_type`, `verbs`, and `resource_pattern`. The SAT is the bearer credential that authorizes the actual credential operation to proceed.
**Accord Policy**
A declarative policy evaluated by the Accord policy engine that classifies mutations and determines their ceremony requirements. Accord policies map `(registry_type, verb, artifact_scope)` tuples to ceremony classification levels.
**Ceremony**
A multi-stakeholder approval flow managed by the CeremonyService (`bascule.v1.CeremonyService`). Ceremonies require one or more identified approvers to authorize a mutation before the corresponding intent becomes redeemable.
**Merkle Anchor**
An immutable record created by the NotaryService (`quartermaster.v1.NotaryService.CreateAnchor`) that commits a batch of mutation envelope hashes into a merkle tree. Each anchor contains a `merkle_root`, `previous_root`, and the tree's leaf data.
**Governance Epoch**
A time-bounded interval during which merkle leaves accumulate before being committed in an anchor. The epoch boundary triggers anchor creation. Epoch duration is deployment-configurable.
**Trust Domain**
A SPIFFE trust domain (e.g., `spiffe://guildhouse.io`) that defines the boundary of identity authority. Cross-trust-domain credential operations carry elevated governance requirements.
**Credential Lifecycle**
The complete sequence of states a credential passes through: creation (issuance), active use, rotation (replacement), and termination (revocation or expiry). Each state transition that involves an active operation maps to a credential event.
## 4. Introduction
Traditional credential management -- certificates, SSH keys, database passwords, API tokens -- treats lifecycle operations as administrative actions executed by privileged operators with no formal governance beyond access control. In a multi-tenant, multi-stakeholder environment, credential operations carry security implications that extend beyond the immediate operator:
- An SSH certificate minted for a workload in Tenant A may grant access to shared infrastructure.
- A database credential rotation affects downstream services that depend on the credential.
- An emergency revocation of a CA signing key impacts every workload in the trust domain.
These operations require varying levels of scrutiny. Routine certificate issuance for short-lived workload identities needs no human approval, while CA key rotation demands multi-stakeholder consensus.
This specification bridges SPIRE's credential lifecycle operations with the Guildhouse governance model by:
1. Defining a canonical schema for each credential event type.
2. Mapping credential events to governed mutations via the GovernanceService intent lifecycle.
3. Leveraging Accord policy to classify events into ceremony tiers.
4. Anchoring every credential event in the NotaryService merkle tree for immutable audit.
The result is that every credential operation -- from the most routine certificate mint to an emergency CA revocation -- passes through a uniform governance pipeline with policy-appropriate controls and a verifiable audit trail.
## 5. Credential Events
Each credential lifecycle operation maps to a typed credential event. Events are the input to the governance pipeline and the payload of the resulting MutationEnvelope.
### 5.1 Issue
An issue event occurs when a new credential is created. Examples include: an SSH certificate minted by the ssh-credential-composer, a database credential provisioned by the substrate-keymanager, or an OIDC token issued by the oidc-attestor.
**Canonical Fields:**
| Field | Type | Required | Description |
|----------------------|----------|----------|---------------------------------------------------------------|
| `event_type` | string | REQUIRED | Fixed value: `"issue"` |
| `credential_type` | string | REQUIRED | Type identifier (e.g., `"ssh_user_cert"`, `"db_password"`, `"x509_svid"`) |
| `subject_spiffe_id` | string | REQUIRED | SPIFFE ID of the workload receiving the credential |
| `tenant_id` | string | REQUIRED | UUID of the owning tenant |
| `scope` | string | REQUIRED | Scope descriptor (e.g., host pattern, database name) |
| `requestor_identity` | string | REQUIRED | SPIFFE ID or OIDC subject of the entity requesting issuance |
| `credential_id` | string | REQUIRED | Unique identifier assigned to the new credential |
| `ttl_seconds` | uint32 | REQUIRED | Requested time-to-live for the credential |
| `metadata` | object | OPTIONAL | Additional type-specific metadata (e.g., key algorithm, extensions) |
**Example (JCS-canonicalized):**
```json
{"credential_id":"cred-a1b2c3","credential_type":"ssh_user_cert","event_type":"issue","metadata":{"extensions":["permit-pty"],"key_algorithm":"ed25519"},"requestor_identity":"spiffe://guildhouse.io/ns/platform/sa/operator","scope":"*.staging.internal","subject_spiffe_id":"spiffe://guildhouse.io/ns/tenant-acme/sa/web-server","tenant_id":"f47ac10b-58cc-4372-a567-0e02b2c3d479","ttl_seconds":3600}
```
### 5.2 Rotate
A rotate event occurs when an existing credential is replaced with a new one. Rotation may be scheduled (automated lifecycle), manual (operator-initiated), or emergency (compromise response).
**Canonical Fields:**
| Field | Type | Required | Description |
|------------------------|----------|----------|-----------------------------------------------------------------|
| `event_type` | string | REQUIRED | Fixed value: `"rotate"` |
| `old_credential_id` | string | REQUIRED | Identifier of the credential being replaced |
| `new_credential_type` | string | REQUIRED | Type identifier for the replacement credential |
| `subject_spiffe_id` | string | REQUIRED | SPIFFE ID of the workload whose credential is rotating |
| `tenant_id` | string | REQUIRED | UUID of the owning tenant |
| `rotation_reason` | string | REQUIRED | One of: `"scheduled"`, `"manual"`, `"compromised"` |
| `requestor_identity` | string | REQUIRED | SPIFFE ID or OIDC subject of the entity requesting rotation |
| `new_credential_id` | string | REQUIRED | Unique identifier assigned to the replacement credential |
| `metadata` | object | OPTIONAL | Additional type-specific metadata |
**Example (JCS-canonicalized):**
```json
{"event_type":"rotate","metadata":{"key_algorithm":"ed25519"},"new_credential_id":"cred-d4e5f6","new_credential_type":"ssh_user_cert","old_credential_id":"cred-a1b2c3","requestor_identity":"spiffe://guildhouse.io/ns/platform/sa/rotation-controller","rotation_reason":"scheduled","subject_spiffe_id":"spiffe://guildhouse.io/ns/tenant-acme/sa/web-server","tenant_id":"f47ac10b-58cc-4372-a567-0e02b2c3d479"}
```
### 5.3 Revoke
A revoke event occurs when a credential is invalidated before its natural expiry. Revocation is an irreversible operation within a governance epoch.
**Canonical Fields:**
| Field | Type | Required | Description |
|------------------------|----------|----------|-----------------------------------------------------------------|
| `event_type` | string | REQUIRED | Fixed value: `"revoke"` |
| `credential_id` | string | REQUIRED | Identifier of the credential being revoked |
| `credential_type` | string | REQUIRED | Type identifier of the credential being revoked |
| `subject_spiffe_id` | string | REQUIRED | SPIFFE ID of the workload whose credential is being revoked |
| `tenant_id` | string | REQUIRED | UUID of the owning tenant |
| `revocation_reason` | string | REQUIRED | Human-readable reason for revocation |
| `requestor_identity` | string | REQUIRED | SPIFFE ID or OIDC subject of the entity requesting revocation |
| `metadata` | object | OPTIONAL | Additional context (e.g., incident ID, CVE reference) |
**Example (JCS-canonicalized):**
```json
{"credential_id":"cred-a1b2c3","credential_type":"ssh_user_cert","event_type":"revoke","metadata":{"incident_id":"INC-2026-0042"},"requestor_identity":"spiffe://guildhouse.io/ns/platform/sa/security-responder","revocation_reason":"Private key compromised per INC-2026-0042","subject_spiffe_id":"spiffe://guildhouse.io/ns/tenant-acme/sa/web-server","tenant_id":"f47ac10b-58cc-4372-a567-0e02b2c3d479"}
```
### 5.4 Schema Validation
All credential event payloads MUST conform to the canonical field definitions above. Implementations MUST reject events with missing REQUIRED fields. Implementations MUST ignore unknown fields during canonicalization but SHOULD preserve them in non-canonical storage for forward compatibility.
## 6. Governance Integration Flow
This section defines the end-to-end flow by which a credential lifecycle event becomes a governed mutation.
### 6.1 Flow Overview
```
Credential Lifecycle Event
|
v
+----------------------------+
| governance-notifier plugin |
| (intercepts event) |
+----------------------------+
|
Step 2: CreateIntentRequest
|
v
+----------------------------+
| GovernanceService |
| (evaluates Accord policy)|
+----------------------------+
/ \
no ceremony ceremony required
required (ceremony_id returned)
| |
| Step 5: Ceremony
| |
| v
| +----------------------------+
| | CeremonyService |
| | (approval flow) |
| +----------------------------+
| / \
| approved denied
| | |
| | v
| | Operation blocked.
| | Intent denied.
v v
Step 6: RedeemIntent
|
v
+----------------------------+
| SAT issued |
| Credential op proceeds |
+----------------------------+
|
Step 7: MutationEnvelope
|
v
+----------------------------+
| NotaryService |
| (merkle anchoring) |
+----------------------------+
|
v
Audit record committed.
```
### 6.2 Step 1: Event Interception
The `governance-notifier` plugin (running as a SPIRE Server plugin or sidecar) intercepts credential lifecycle events at the point of origin. The plugin MUST intercept the event before the credential operation is executed. The event is the trigger for the governance flow; the credential operation is deferred until governance authorization completes.
For SSH certificate issuance, the interception point is the `ssh-credential-composer` plugin's `MintCredential` path. For credential rotation, it is the rotation controller's scheduling loop. For revocation, it is the revocation API endpoint.
### 6.3 Step 2: Intent Creation
The governance-notifier plugin constructs a `CreateIntentRequest` with the following field mappings:
| `CreateIntentRequest` field | Value |
|-----------------------------|-----------------------------------------------------------------------|
| `registry_type` | `"credential"` |
| `verb` | One of: `"issue"`, `"rotate"`, `"revoke"` |
| `artifact_scope` | JCS-canonicalized JSON of the credential event payload (Section 5) |
| `tenant_id` | The `tenant_id` from the credential event |
| `identity_claim` | `oidc_token` from OIDC attestation, or `external_event` for automated operations |
| `ttl_seconds` | Plugin-configurable; SHOULD default to 300 seconds |
| `max_redemptions` | `1` (credential events are single-use) |
| `idempotency_key` | `SHA-256(registry_type + ":" + verb + ":" + credential_id)` |
The plugin MUST set `max_redemptions` to `1` for all credential events. Credential operations are not idempotent at the infrastructure level (a second SSH certificate mint produces a distinct certificate), and the intent MUST NOT be reusable.
The `idempotency_key` ensures that duplicate event deliveries (e.g., from retry logic) map to the same intent rather than creating parallel authorization flows.
### 6.4 Step 3: Accord Policy Evaluation
The GovernanceService evaluates the intent against the Accord policy engine. The policy receives the `(registry_type, verb, artifact_scope)` tuple and returns a classification that determines the ceremony requirement. Policy evaluation is synchronous with the `CreateIntent` call.
If the Accord policy denies the operation outright (e.g., a forbidden credential type for the tenant), the `CreateIntentResponse` MUST have `denied = true` and `denial_reason` populated. The plugin MUST abort the credential operation.
### 6.5 Step 4a: No Ceremony Required (Autonomous / SelfGrant)
If the Accord policy classifies the event as `Autonomous` or `SelfGrant`, the `CreateIntentResponse` returns with `ceremony_id` empty and the intent in `authorized` status. The plugin proceeds directly to Step 6 (RedeemIntent).
For `SelfGrant` classification, the GovernanceService records the requestor's identity as the implicit approver. No external approval action is required, but the self-grant is recorded in the audit trail.
### 6.6 Step 4b: Ceremony Required
If the Accord policy classifies the event as `SingleApproval`, `QuorumApproval`, or `EmergencyBreakGlass`, the `CreateIntentResponse` returns with a non-empty `ceremony_id`. The intent status is `ceremony_pending` and cannot be redeemed until the ceremony resolves.
### 6.7 Step 5: Ceremony Approval Flow
When a ceremony is required, the following sub-flow executes:
1. The GovernanceService has already called `CeremonyService.CreateCeremony` internally during intent creation.
2. The governance-notifier plugin polls or streams the ceremony status via `CeremonyService.GetCeremony` using the `ceremony_id`.
3. Eligible approvers are notified through the platform's notification system (out of scope for this specification).
4. Approvers call `CeremonyService.ApproveCeremony` (or `DenyCeremony`) with their authenticated identity.
5. When the ceremony reaches its approval threshold:
- **Approved:** The GovernanceService transitions the intent from `ceremony_pending` to `authorized`. The plugin proceeds to Step 6.
- **Denied:** The GovernanceService transitions the intent to `denied`. The plugin MUST abort the credential operation and return an error to the caller.
The plugin MUST implement a configurable timeout for ceremony polling. If the ceremony does not resolve within the timeout, the plugin MUST treat this as a denial and abort the credential operation. The RECOMMENDED default timeout is 600 seconds (10 minutes) for interactive operations and 3600 seconds (1 hour) for operations that can tolerate asynchronous approval.
### 6.8 Step 6: Intent Redemption
The plugin calls `GovernanceService.RedeemIntent` with the `intent_id` received in Step 2.
On success, the `RedeemIntentResponse` contains a `SatToken` with:
- `bearer_svid`: The SPIFFE ID authorized to perform the credential operation.
- `scopes`: A `SatScopeMsg` entry with `registry_type = "credential"`, `verbs = ["{issue|rotate|revoke}"]`, and `resource_pattern` matching the credential event scope.
- `issued_at` / `expires_at`: The SAT validity window.
- `sat_hash`: The cryptographic binding of the SAT contents.
The plugin MUST verify that the SAT's `bearer_svid` matches the plugin's own SVID. The plugin MUST NOT proceed with the credential operation if the SAT has expired (`expires_at < now()`).
### 6.9 Step 7: MutationEnvelope Construction and Anchoring
After the credential operation completes successfully, the plugin constructs a MutationEnvelope (Section 7) and submits it to the NotaryService for merkle anchoring.
The plugin MUST construct the envelope after the credential operation succeeds, not before. The envelope records the fact that the operation occurred, not merely that it was authorized.
The plugin calls `NotaryService.CreateAnchor` with the envelope's merkle leaf hash. If the NotaryService batches leaves within governance epochs, the leaf is queued for the next anchor. The plugin SHOULD NOT block the credential operation on anchor confirmation (Section 10 defines the retry semantics for unreachable NotaryService).
## 7. MutationEnvelope Construction
This section defines the deterministic process for constructing a MutationEnvelope from a credential event.
### 7.1 Payload Canonicalization
The credential event payload (as defined in Section 5) MUST be serialized using [RFC 8785 JSON Canonicalization Scheme (JCS)](https://www.rfc-editor.org/rfc/rfc8785). JCS produces deterministic JSON output with the following properties:
- Object keys are sorted lexicographically by Unicode code point.
- No insignificant whitespace.
- Numbers are serialized in their shortest form without trailing zeros.
- Strings use minimal escape sequences.
Implementations MUST use a JCS-compliant serializer. Hand-rolled JSON serialization is not acceptable, as subtle differences (e.g., Unicode normalization, number formatting) break hash determinism.
The JCS output is the `jcs_bytes` value used in subsequent steps.
### 7.2 Domain Separation
The payload hash uses domain separation to prevent cross-protocol hash collisions. The hash input is constructed as:
```
payload_hash = SHA-256("guildhouse.credential.v1:" || jcs_bytes)
```
Where:
- `"guildhouse.credential.v1:"` is the domain separation prefix, encoded as UTF-8 bytes. The trailing colon is part of the prefix.
- `||` denotes byte concatenation.
- `jcs_bytes` is the JCS-canonicalized event payload from Section 7.1.
The domain prefix `guildhouse.credential.v1` is specific to this specification. Other governed mutation types (e.g., registry mutations, configuration changes) use their own domain prefixes. This ensures that a credential event payload cannot collide with a registry mutation payload even if they happen to contain identical JSON.
The `payload_hash` is encoded as lowercase hexadecimal for inclusion in the envelope.
### 7.3 Envelope Fields
The MutationEnvelope is a JSON object with the following fields:
| Field | Type | Description |
|----------------|--------|--------------------------------------------------------------|
| `domain` | string | Fixed value: `"guildhouse.credential.v1"` |
| `payload_hash` | string | Lowercase hex-encoded SHA-256 from Section 7.2 |
| `timestamp` | string | RFC 3339 timestamp with UTC offset (`Z` suffix) |
| `actor_svid` | string | SPIFFE ID of the entity that performed the credential operation |
| `tenant_id` | string | UUID of the owning tenant |
| `event_type` | string | One of: `"issue"`, `"rotate"`, `"revoke"` |
| `intent_id` | string | The intent ID from the governance flow |
| `sat_hash` | string | Lowercase hex-encoded hash of the SAT that authorized the operation |
**Example:**
```json
{
"domain": "guildhouse.credential.v1",
"payload_hash": "a3f2b8c1d4e5f67890abcdef1234567890abcdef1234567890abcdef12345678",
"timestamp": "2026-02-18T14:30:00Z",
"actor_svid": "spiffe://guildhouse.io/ns/platform/sa/ssh-credential-composer",
"tenant_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"event_type": "issue",
"intent_id": "intent-x7y8z9",
"sat_hash": "b4c3d2e1f0a9876543210fedcba9876543210fedcba9876543210fedcba98765"
}
```
### 7.4 Merkle Leaf Construction
The merkle leaf is derived from the envelope itself:
1. The envelope JSON object (Section 7.3) is serialized using JCS (RFC 8785).
2. The JCS output is hashed: `leaf_hash = SHA-256(jcs_envelope_bytes)`.
3. The `leaf_hash` is submitted to `NotaryService.CreateAnchor`.
Note: The merkle leaf hash does NOT use domain separation. Domain separation is applied at the payload level (Section 7.2). The envelope is already scoped by its `domain` field, and the leaf is always interpreted in the context of the governance merkle tree.
### 7.5 Determinism Guarantee
Given identical inputs (same credential event payload, same timestamp, same actor), the MutationEnvelope construction MUST produce identical output bytes and identical leaf hashes. Implementations MUST NOT include non-deterministic data (e.g., random nonces, system-local timestamps with varying precision) in the envelope.
The `timestamp` field MUST be truncated to whole seconds (no fractional seconds) to ensure cross-implementation consistency.
## 8. Ceremony Classification
The Accord policy engine classifies credential events into ceremony tiers based on the event type, credential type, scope, and tenant context. This section defines the standard classification levels and their intended use.
### 8.1 Classification Levels
#### 8.1.1 Autonomous
No human approval is required. The GovernanceService authorizes the intent immediately upon policy evaluation.
**Intended use:**
- Routine short-lived SSH certificate issuance (TTL <= 8 hours)
- Scheduled credential rotation with `rotation_reason = "scheduled"`
- X.509 SVID minting for registered workloads
**Rationale:** These operations are high-frequency, low-risk, and automated. Requiring human approval would create an operational bottleneck without meaningful security benefit.
#### 8.1.2 SelfGrant
The requestor implicitly approves their own operation. No external approval is required, but the self-grant is recorded distinctly in the audit trail.
**Intended use:**
- Manual credential rotation (`rotation_reason = "manual"`) for services the requestor owns
- Database credential provisioning for the requestor's own tenant
- Long-lived SSH certificate issuance (TTL > 8 hours, <= 30 days)
**Rationale:** The requestor has legitimate authority over these operations, but the explicit self-grant classification enables audit differentiation from fully automated operations.
#### 8.1.3 SingleApproval
One approver (distinct from the requestor) MUST authorize the operation via the CeremonyService.
**Intended use:**
- Cross-tenant credential access (credential scope spans multiple tenants)
- Non-standard certificate parameters (unusual extensions, elevated privileges)
- SSH certificate issuance with TTL > 30 days
**Rationale:** These operations have elevated risk or cross-boundary implications that warrant a second pair of eyes.
#### 8.1.4 QuorumApproval
Multiple approvers MUST authorize the operation. The quorum size is policy-configurable (default: 2 of 3).
**Intended use:**
- CA signing key rotation
- Emergency credential revocation of high-value credentials
- Cross-trust-domain credential operations
- Bulk credential revocation (> 10 credentials in a single operation)
**Rationale:** These operations have platform-wide or cross-domain impact. Multi-stakeholder consensus prevents unilateral action.
#### 8.1.5 EmergencyBreakGlass
The operation proceeds immediately without prior approval. A ceremony is created post-hoc and MUST be approved within a configured window (default: 24 hours). If post-hoc approval is not obtained, an escalation alert is generated.
**Intended use:**
- Active compromise response requiring immediate credential revocation
- Emergency CA key rotation during a security incident
- Time-critical credential operations where approval delay would cause greater harm than the operation itself
**Rationale:** Security incidents cannot wait for approval flows. The break-glass mechanism balances immediate response with accountability by requiring after-the-fact justification.
### 8.2 Policy Example
The following is an example Accord policy file for credential governance:
```yaml
# accord-policy/credential-governance.yaml
apiVersion: accord.guildhouse.io/v1
kind: CredentialGovernancePolicy
metadata:
name: default-credential-policy
tenant: "*" # applies to all tenants unless overridden
rules:
# Routine SSH certificate issuance — no approval needed
- match:
registry_type: credential
verb: issue
credential_type: ssh_user_cert
conditions:
ttl_seconds_lte: 28800 # <= 8 hours
classification: Autonomous
# Long-lived SSH certificates — self-grant
- match:
registry_type: credential
verb: issue
credential_type: ssh_user_cert
conditions:
ttl_seconds_gt: 28800
ttl_seconds_lte: 2592000 # <= 30 days
classification: SelfGrant
# Very long-lived SSH certificates — requires approval
- match:
registry_type: credential
verb: issue
credential_type: ssh_user_cert
conditions:
ttl_seconds_gt: 2592000
classification: SingleApproval
# Scheduled rotation — no approval
- match:
registry_type: credential
verb: rotate
rotation_reason: scheduled
classification: Autonomous
# Manual rotation — self-grant
- match:
registry_type: credential
verb: rotate
rotation_reason: manual
classification: SelfGrant
# Compromise-driven rotation — quorum
- match:
registry_type: credential
verb: rotate
rotation_reason: compromised
classification: QuorumApproval
quorum:
required: 2
pool_size: 3
# Standard revocation — single approval
- match:
registry_type: credential
verb: revoke
classification: SingleApproval
# Cross-trust-domain operations — quorum
- match:
registry_type: credential
conditions:
cross_trust_domain: true
classification: QuorumApproval
quorum:
required: 2
pool_size: 3
# X.509 SVID minting — autonomous
- match:
registry_type: credential
verb: issue
credential_type: x509_svid
classification: Autonomous
# Database credential provisioning — self-grant
- match:
registry_type: credential
verb: issue
credential_type: db_password
classification: SelfGrant
defaults:
# If no rule matches, require single approval (fail-safe)
classification: SingleApproval
ceremony_timeout_seconds: 600
emergency:
# Break-glass configuration
classification: EmergencyBreakGlass
post_hoc_approval_window_hours: 24
escalation_channel: platform-security
trigger_conditions:
- revocation_reason_contains: "compromise"
- revocation_reason_contains: "incident"
- metadata_contains_key: "incident_id"
```
### 8.3 Policy Precedence
When multiple rules match a credential event, the following precedence applies:
1. Tenant-specific policies override the wildcard (`"*"`) tenant policy.
2. More specific `match` criteria (more fields specified) take precedence over less specific rules.
3. If two rules have equal specificity, the rule appearing later in the file takes precedence.
4. If no rule matches, the `defaults.classification` applies. Implementations MUST default to `SingleApproval` if no default is configured (Section 10).
### 8.4 Emergency Override
A credential event MAY be elevated to `EmergencyBreakGlass` classification if it matches the `emergency.trigger_conditions`. Emergency classification overrides the normal rule match. The emergency trigger evaluation runs before normal rule matching.
Implementations MUST log emergency break-glass activations at a severity level of `WARN` or higher.
## 9. Audit Trail
### 9.1 Merkle Anchoring
Every credential event that completes the governance flow (Steps 1-7) produces a merkle leaf in the governance tree. The leaf hash is derived from the MutationEnvelope as defined in Section 7.4.
Leaves accumulate within a governance epoch. At epoch boundary, the NotaryService creates an anchor containing:
- `merkle_root`: The root hash of the merkle tree for this epoch.
- `previous_root`: The merkle root of the preceding anchor, forming an append-only chain.
- `leaf_count`: The number of leaves in this epoch.
- `epoch_start` / `epoch_end`: The time boundaries of the epoch.
### 9.2 Certificate-Embedded Audit References
For SSH certificates issued through the governance flow, the following certificate extensions MUST be embedded:
| Extension | Value |
|---------------------------------|--------------------------------------------|
| `merkle-root@guildhouse.io` | Hex-encoded merkle root at issuance time |
| `merkle-proof@guildhouse.io` | Base64-encoded merkle inclusion proof |
| `governance-intent@guildhouse.io` | The `intent_id` from the governance flow |
These extensions enable offline verification: given a certificate, a verifier can extract the merkle root and proof, then call `NotaryService.VerifyInclusion` to confirm that the credential issuance event was recorded in the governance audit trail.
For X.509 SVIDs, the equivalent data SHOULD be embedded in a custom X.509 extension with OID `1.3.6.1.4.1.XXXXX.1.1` (OID to be registered).
### 9.3 Audit Queries
The following audit queries MUST be supported:
**Verify a credential's governance record:**
1. Extract `merkle-root@guildhouse.io` and `merkle-proof@guildhouse.io` from the certificate.
2. Call `NotaryService.VerifyInclusion(merkle_root, leaf_hash, proof)`.
3. If inclusion is verified, the credential issuance event is confirmed to exist in the governance audit trail.
**Retrieve a credential's governance history:**
1. Query `GovernanceService.ListIntents` with `tenant_id` and `status_filter`.
2. Cross-reference intent records with NotaryService anchors by `intent_id`.
3. Reconstruct the complete governance chain for the credential lifecycle.
**Verify audit chain integrity:**
1. Call `NotaryService.GetLatestAnchor` to obtain the current anchor.
2. Walk the `previous_root` chain backward to verify continuity.
3. For each anchor, verify that the `merkle_root` is consistent with the known leaves.
### 9.4 Audit Chain Continuity
Each anchor's `previous_root` field MUST reference the `merkle_root` of the immediately preceding anchor. This forms an append-only linked list of audit states.
The first anchor in the chain (genesis anchor) MUST have `previous_root` set to the zero hash (`0x0000...0000`, 32 zero bytes).
Implementations MUST reject any anchor where `previous_root` does not match the `merkle_root` of the stored preceding anchor. This prevents history rewriting.
### 9.5 Retention
Anchors are immutable once created. Implementations MUST NOT delete or modify existing anchors.
Leaf data (the MutationEnvelope content, as opposed to the leaf hash) retention is policy-dependent. Implementations SHOULD retain leaf data for at least the lifetime of the longest-lived credential type in the deployment. Implementations MAY archive leaf data to cold storage after a configurable retention period, provided the leaf hashes remain available for inclusion verification.
## 10. Error Handling
This section defines the behavior when components in the governance flow are unavailable or return errors.
### 10.1 GovernanceService Unreachable
If the governance-notifier plugin cannot reach the GovernanceService (network error, timeout, or non-retryable gRPC status), the credential operation MUST fail. This is a **fail-closed** policy.
**Rationale:** Credential operations without governance authorization bypass the entire security model. Allowing ungoverned credential operations, even temporarily, creates an audit gap and potential for abuse.
The plugin SHOULD retry with exponential backoff (initial delay 100ms, max delay 10s, max retries 5) before declaring the GovernanceService unreachable. The plugin MUST surface the failure to the caller with a clear error message indicating governance unavailability.
### 10.2 Ceremony Timeout
If a ceremony does not reach approval or denial within the configured timeout:
1. The intent remains in `ceremony_pending` status.
2. The plugin MUST treat the timeout as a denial and abort the credential operation.
3. The plugin SHOULD call `GovernanceService.RevokeIntent` to clean up the pending intent.
4. The plugin MUST log the timeout event at `WARN` severity.
The intent's own `ttl_seconds` provides an additional backstop: even if the plugin fails to revoke the intent, the GovernanceService MUST expire intents that exceed their TTL.
### 10.3 NotaryService Unreachable
If the NotaryService is unreachable after the credential operation has completed:
1. The credential operation MUST proceed. The credential has already been issued/rotated/revoked; failing at this point would leave the system in an inconsistent state.
2. The MutationEnvelope MUST be persisted to local durable storage (e.g., an on-disk queue).
3. The plugin MUST retry anchoring with exponential backoff until the NotaryService becomes available.
4. The plugin MUST flag the credential as "anchoring-pending" in any status reporting.
5. The plugin SHOULD emit a metric (`governance_anchoring_pending_total`) for monitoring.
**Rationale:** Unlike the GovernanceService (which provides authorization), the NotaryService provides audit recording. A temporary audit gap is preferable to failing a legitimately authorized credential operation. The retry mechanism ensures eventual consistency of the audit trail.
### 10.4 Accord Policy Missing
If the Accord policy engine has no matching rule for a credential event's `(registry_type, verb, credential_type)` tuple, the GovernanceService MUST default to `SingleApproval` classification. This is a **fail-safe** policy.
**Rationale:** An unconfigured credential type is more likely an oversight than an intentionally ungoverned operation. Requiring at least single approval ensures that novel credential types receive human review.
### 10.5 Duplicate Intent (Idempotency Key Collision)
If a `CreateIntentRequest` is received with an `idempotency_key` that matches an existing, non-expired intent:
1. If the existing intent is in `authorized` or `ceremony_pending` status, the GovernanceService MUST return the existing intent's `intent_id` and `ceremony_id` (if applicable). A new intent MUST NOT be created.
2. If the existing intent is in `redeemed`, `expired`, or `denied` status, the GovernanceService MUST create a new intent (the idempotency window has passed).
This behavior ensures that retry logic in the governance-notifier plugin does not create parallel authorization flows for the same credential event.
## 11. Security Considerations
### 11.1 Plugin Trust Boundary
The governance-notifier plugin runs inside SPIRE Server's trust boundary (as a server plugin) or as a colocated sidecar with access to the SPIRE Server's Unix domain socket. Its gRPC calls to the GovernanceService MUST use mTLS with the SPIRE Server's own SVID as the client certificate.
The plugin MUST NOT accept or relay credentials from external sources. The identity claim in the `CreateIntentRequest` MUST be derived from SPIRE's own attestation data, not from caller-supplied headers or tokens.
### 11.2 Hash Determinism and Idempotency
MutationEnvelope hashes are deterministic: given the same credential event payload, timestamp, and actor, the same hash is always produced. This property enables:
- **Deduplication:** Duplicate events produce duplicate hashes, which can be detected.
- **Verification:** A verifier can independently reconstruct the expected hash from the event data and compare it to the anchored leaf.
Implementations MUST use a JCS library that passes the [RFC 8785 test vectors](https://www.rfc-editor.org/rfc/rfc8785#appendix-B). Non-conformant JCS implementations will produce different hashes, breaking verification.
### 11.3 Append-Only Audit Trail
The merkle tree anchored by the NotaryService is append-only. Once a leaf hash is included in an anchor, it cannot be removed or modified without invalidating the merkle root (and all subsequent roots in the chain).
Implementations MUST NOT provide an API to delete anchors or modify leaf data. The only write operations are `CreateAnchor` (append) and the internal leaf accumulation within an epoch.
### 11.4 Ceremony Identity Verification
Approvers participating in a ceremony MUST be authenticated via OIDC or SPIFFE identity. Self-asserted identity (e.g., a username/password without federated verification) MUST NOT be accepted for ceremony approval.
The CeremonyService MUST verify that the approver's identity is distinct from the requestor's identity for `SingleApproval` and `QuorumApproval` classifications. Self-approval MUST be rejected for these tiers. (For `SelfGrant`, the requestor is the implicit approver by definition.)
### 11.5 Time-of-Check / Time-of-Use (TOCTOU)
The SAT issued upon intent redemption has a bounded lifetime (`issued_at` to `expires_at`). The credential operation MUST complete within the SAT lifetime. If the SAT expires before the credential operation completes, the operation MUST be aborted.
The RECOMMENDED SAT TTL for credential operations is 60 seconds. This window is sufficient for the credential operation itself (certificate signing, database credential provisioning) but short enough to limit the window of vulnerability if the SAT is intercepted.
Implementations MUST check `expires_at` immediately before executing the credential operation, not only at the time the SAT is received.
### 11.6 Replay Protection
Each MutationIntent has the following replay protection mechanisms:
- **`max_redemptions`:** Set to `1` for all credential events (Section 6.3). The intent can only be redeemed once.
- **`idempotency_key`:** Derived from the credential event's identity (`registry_type + verb + credential_id`). Duplicate submissions within the intent TTL window return the existing intent rather than creating a new one (Section 10.5).
- **`ttl_seconds`:** The intent expires after its TTL, preventing stale intents from being redeemed after the operational context has changed.
The combination of single-use redemption, idempotency keying, and TTL expiration provides defense-in-depth against replay attacks.
### 11.7 Confidentiality of Credential Event Data
The `artifact_scope` field in `CreateIntentRequest` contains the JCS-canonicalized credential event payload, which may include sensitive information (e.g., SPIFFE IDs, tenant identifiers, credential scopes). The gRPC channel between the governance-notifier plugin and the GovernanceService MUST be encrypted (mTLS, per Section 11.1).
The MutationEnvelope stored in the merkle tree contains only the `payload_hash`, not the raw payload. The raw credential event data SHOULD be stored separately with appropriate access controls, linked to the envelope by `intent_id`.
## 12. References
- **SPIFFE Specification:** [https://spiffe.io/docs/latest/spiffe-about/overview/](https://spiffe.io/docs/latest/spiffe-about/overview/) -- The Secure Production Identity Framework for Everyone.
- **RFC 8785:** [https://www.rfc-editor.org/rfc/rfc8785](https://www.rfc-editor.org/rfc/rfc8785) -- JSON Canonicalization Scheme (JCS).
- **RFC 3339:** [https://www.rfc-editor.org/rfc/rfc3339](https://www.rfc-editor.org/rfc/rfc3339) -- Date and Time on the Internet: Timestamps.
- **RFC 2119:** [https://www.rfc-editor.org/rfc/rfc2119](https://www.rfc-editor.org/rfc/rfc2119) -- Key words for use in RFCs to Indicate Requirement Levels.
- **GovernanceService proto:** `quartermaster/v1/governance.proto` -- `CreateIntent`, `RedeemIntent`, `RevokeIntent`, `ListIntents`.
- **CeremonyService proto:** `bascule/v1/ceremony.proto` -- `CreateCeremony`, `ApproveCeremony`, `DenyCeremony`, `GetCeremony`.
- **NotaryService proto:** `quartermaster/v1/notary.proto` -- `CreateAnchor`, `GetLatestAnchor`, `VerifyInclusion`.
- **Accord Policy Specification:** `specs/accord-policy.md` (forthcoming) -- Declarative policy classification for governed mutations.
---
*End of specification.*

View file

@ -0,0 +1,491 @@
# Shellstream SSH Certificate Extensions
## 1. Abstract
Shellstream extensions are a set of SSH certificate extensions using the
`@guildhouse.io` vendor suffix that carry structured governance metadata
within SSH certificates issued by SPIRE. These extensions encode
authorization scope, tenant context, governance ceremony references, and
merkle audit proofs, enabling SSH servers to make fine-grained
authorization decisions based on Guildhouse platform state without
requiring separate API calls at connection time.
## 2. Status
**Draft Specification** -- This document is a working draft and subject to
change. It defines the normative behavior that conforming implementations
MUST follow once finalized.
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT",
"SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this
document are to be interpreted as described in RFC 2119.
## 3. Terminology
**Shellstream Extension**
: An SSH certificate extension whose name uses the `@guildhouse.io`
vendor suffix and whose value carries Guildhouse governance metadata as
defined in this specification.
**SSH Certificate Extension**
: A key-value string pair embedded in an OpenSSH certificate as defined
in the OpenSSH PROTOCOL.certkeys document. Extensions are advisory and
do not restrict the certificate holder; they convey additional
information to the server.
**Vendor Suffix**
: The `@domain` portion of an extension name that designates the
namespace owner, following the OpenSSH convention for avoiding
collisions between independently defined extensions.
**SAT (Substrate Attestation Token)**
: A signed token issued by the Quartermaster credential service that
binds a SPIFFE identity to a set of permitted operations on a specific
registry type. The SAT is the primary authorization artifact in the
Guildhouse platform.
**SatScope**
: A structured object within a SAT that defines the registry type,
permitted verbs, and resource pattern for which the token grants
access.
**Governance Ceremony**
: A structured approval workflow managed by the Bascule CeremonyService
that authorizes privilege elevation. Ceremony types range from
self-granted to quorum-based approval.
**Merkle Anchor**
: A cryptographic commitment (merkle tree root hash) representing the
complete governance state at a point in time. Each governance mutation
is recorded as a leaf in the tree.
**MutationEnvelope**
: A signed wrapper around a governance state change that is recorded as a
leaf in the governance merkle tree.
**Accord Policy**
: A tenant-scoped authorization policy evaluated by the Accord policy
engine (backed by OPA) that defines roles, permissions, and ceremony
requirements for a tenant.
**Trust Domain**
: A SPIFFE trust domain (e.g., `guildhouse.io`) that defines the
boundary within which SPIFFE identities and their associated
attestations are valid.
## 4. Introduction
SSH certificates, as defined by OpenSSH's PROTOCOL.certkeys, support
arbitrary extensions as key-value string pairs. By convention, vendor-
specific extensions use the `name@domain` format to avoid collisions with
extensions defined by other parties or by future OpenSSH releases.
Shellstream extensions embed Guildhouse governance metadata into SSH
certificates so that SSH servers can make authorization decisions based on
platform state without issuing separate API calls. When SPIRE issues an
SSH certificate for a Guildhouse workload or user, the certificate may
include Shellstream extensions that describe:
- **What** the holder is authorized to do (SAT scope).
- **Who** the holder is acting as (tenant identity, roles).
- **Why** elevated access was granted (ceremony reference).
- **When** the governance state was captured (merkle root, epoch).
This decoupling of authorization metadata from runtime API lookups enables
offline authorization decisions, improves latency, and creates a
cryptographically verifiable audit trail linking SSH sessions to
governance state.
## 5. Extension Format
### 5.1 Naming Convention
All Shellstream extensions use the `@guildhouse.io` vendor suffix.
Extension names MUST be lowercase, hyphen-separated identifiers followed
by the suffix:
```
<name>@guildhouse.io
```
where `<name>` matches the regular expression `[a-z][a-z0-9]*(-[a-z0-9]+)*`.
### 5.2 Value Encoding
Extension values are UTF-8 strings, as constrained by the SSH certificate
extension format (RFC 4251 string type). All values MUST be valid UTF-8.
Binary data MUST be encoded as follows:
- Cryptographic hashes: lowercase hexadecimal encoding.
- Proofs and opaque binary payloads: base64 encoding using the standard
alphabet with padding (RFC 4648 Section 4).
JSON-structured values MUST use compact encoding with no unnecessary
whitespace (no spaces after colons or commas, no newlines or
indentation).
## 6. Extension Registry
This section defines each Shellstream extension. Extensions marked
REQUIRED MUST be present in every Shellstream-bearing SSH certificate.
Extensions marked OPTIONAL MAY be omitted.
### 6.1 `sat-scope@guildhouse.io`
**Presence:** REQUIRED when a SAT is associated with the certificate.
**Value:** A JSON object (or JSON array of objects when multiple scopes
exist) with the following structure:
```json
{"registry_type":"<type>","verbs":["<verb>",...],"resource_pattern":"<pattern>"}
```
**Fields:**
| Field | Type | Description |
|--------------------|-----------------|-----------------------------------------------------------------------------|
| `registry_type` | string | Identifies the registry kind. Values include `"oci"`, `"helm"`, `"git"`, `"database"`. |
| `verbs` | array of string | Permitted operations. Examples: `["read","write"]`, `["push","pull"]`. |
| `resource_pattern` | string | Glob pattern for resource matching. Examples: `"tenant-a/*"`, `"ns/repo:*"`. |
When a single SAT scope exists, the value MUST be a single JSON object.
When multiple SAT scopes exist, the value MUST be a JSON array of scope
objects. Implementations MUST accept both forms.
**Example (single scope):**
```
sat-scope@guildhouse.io = {"registry_type":"oci","verbs":["push","pull"],"resource_pattern":"acme-corp/*"}
```
**Example (multiple scopes):**
```
sat-scope@guildhouse.io = [{"registry_type":"oci","verbs":["pull"],"resource_pattern":"acme-corp/*"},{"registry_type":"helm","verbs":["read"],"resource_pattern":"charts/*"}]
```
### 6.2 `sat-hash@guildhouse.io`
**Presence:** REQUIRED when a SAT is associated with the certificate.
**Value:** Lowercase hex-encoded SHA-256 hash of the raw SAT bytes.
The value MUST be exactly 64 hexadecimal characters (`[0-9a-f]{64}`).
This hash is used for audit correlation -- it links the SSH session to the
SAT without embedding the SAT itself in the certificate.
**Example:**
```
sat-hash@guildhouse.io = a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
```
### 6.3 `tenant-id@guildhouse.io`
**Presence:** REQUIRED.
**Value:** UUID string formatted per RFC 4122, using lowercase hexadecimal
digits with hyphens in the 8-4-4-4-12 grouping.
The value MUST match the regular expression:
```
[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}
```
This extension identifies the tenant context for the SSH session. All
authorization decisions MUST be scoped to this tenant.
**Example:**
```
tenant-id@guildhouse.io = 7b2a91c4-3f8e-4d12-b5a6-9c0e1d2f3a4b
```
### 6.4 `roles@guildhouse.io`
**Presence:** REQUIRED.
**Value:** Comma-separated list of role names with no whitespace. Each
role name MUST match `[a-z][a-z0-9_]*`.
Roles are defined by the tenant's Accord policy and represent the set of
permissions the certificate holder has been granted for this session.
**Example:**
```
roles@guildhouse.io = analyst,viewer
```
```
roles@guildhouse.io = administrator
```
### 6.5 `ceremony-id@guildhouse.io`
**Presence:** OPTIONAL -- present only for elevated sessions that were
authorized through a governance ceremony.
**Value:** UUID string formatted per RFC 4122, using the same format
specified in Section 6.3.
This extension references the governance ceremony that authorized the
privilege elevation for this session.
**Example:**
```
ceremony-id@guildhouse.io = e4f5a6b7-8c9d-0e1f-2a3b-4c5d6e7f8a9b
```
### 6.6 `ceremony-type@guildhouse.io`
**Presence:** OPTIONAL -- MUST be present when `ceremony-id@guildhouse.io`
is present. MUST NOT be present when `ceremony-id@guildhouse.io` is
absent.
**Value:** One of the following string literals, corresponding to the
`CeremonyType` enum from the Bascule CeremonyService:
| Value | Description |
|-------------------------|---------------------------------------------------------------------|
| `self_grant` | The holder approved their own elevation (permitted by policy). |
| `single_approval` | A single designated approver authorized the elevation. |
| `quorum_approval` | A quorum of approvers collectively authorized the elevation. |
| `emergency_break_glass` | Emergency access granted outside normal approval flow, with enhanced audit. |
**Example:**
```
ceremony-type@guildhouse.io = quorum_approval
```
### 6.7 `merkle-root@guildhouse.io`
**Presence:** OPTIONAL.
**Value:** Lowercase hex-encoded SHA-256 merkle root hash. The value MUST
be exactly 64 hexadecimal characters (`[0-9a-f]{64}`).
This is the root of the governance merkle tree at the time the certificate
was issued. It allows offline verification that the certificate was issued
during a known governance state.
**Example:**
```
merkle-root@guildhouse.io = 4d7a9c2e1f3b5a8d0e6c4b2a9f7e5d3c1b0a8f6e4d2c0b9a7f5e3d1c0b8a7f
```
### 6.8 `merkle-proof@guildhouse.io`
**Presence:** OPTIONAL -- MUST be present only when
`merkle-root@guildhouse.io` is present. MAY be omitted even when
`merkle-root` is present (the root alone is useful for epoch pinning).
**Value:** Base64-encoded (standard alphabet with padding, per RFC 4648
Section 4) serialized inclusion proof.
**Proof wire format:** The proof is a byte sequence consisting of
concatenated 32-byte SHA-256 sibling hashes followed by a single final
byte encoding the direction bits. Each bit in the direction byte indicates
whether the corresponding sibling is on the left (0) or right (1) of the
path, with the least-significant bit corresponding to the first sibling.
This limits proofs to trees of depth 8 (256 leaves). For deeper trees, a
multi-byte direction encoding will be specified in a future revision.
**Example:**
```
merkle-proof@guildhouse.io = QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ehQ=
```
### 6.9 `governance-epoch@guildhouse.io`
**Presence:** OPTIONAL.
**Value:** Decimal string representation of an unsigned 64-bit integer.
The value MUST match `[1-9][0-9]*|0` (no leading zeros except for the
value zero itself).
The governance epoch is a monotonically increasing counter that increments
with each governance state change. It enables stale-state detection
without requiring full merkle verification: if a server's last-known epoch
is greater than the certificate's epoch, the certificate was issued
against stale governance state.
**Example:**
```
governance-epoch@guildhouse.io = 42
```
## 7. Encoding Rules
The following encoding rules apply to all Shellstream extension values:
1. All values MUST be valid UTF-8 (RFC 3629).
2. Hash values (in `sat-hash`, `merkle-root`) MUST be lowercase
hexadecimal (`[0-9a-f]+`). Uppercase hex digits MUST be rejected.
3. Base64 values (in `merkle-proof`) MUST use the standard alphabet
(`A-Z`, `a-z`, `0-9`, `+`, `/`) with `=` padding as specified in
RFC 4648 Section 4. URL-safe base64 MUST NOT be used.
4. JSON values (in `sat-scope`) MUST use compact encoding: no spaces
after `:` or `,`, no newlines, no indentation. Parsers SHOULD accept
non-compact JSON for robustness but generators MUST produce compact
JSON.
5. UUID values (in `tenant-id`, `ceremony-id`) MUST be lowercase with
hyphens in 8-4-4-4-12 format. Uppercase UUID strings MUST be
rejected.
6. Numeric values (in `governance-epoch`) MUST be decimal strings
without leading zeros (except for the literal `"0"`).
7. Comma-separated values (in `roles`) MUST NOT contain whitespace
between items.
## 8. Validation Requirements
Implementations receiving SSH certificates with Shellstream extensions
MUST validate the following constraints before using extension values for
authorization or audit purposes:
### 8.1 Structural Validation
- Each extension value MUST conform to the format specified in its
registry entry (Section 6).
- Values that fail format validation MUST be treated as absent. The
server MAY log a warning but MUST NOT terminate the SSH connection
solely due to a malformed extension value.
### 8.2 Co-occurrence Constraints
The following co-occurrence rules MUST be enforced:
| If present | Then MUST also be present |
|-------------------------------|--------------------------------|
| `sat-scope@guildhouse.io` | `sat-hash@guildhouse.io` |
| `sat-hash@guildhouse.io` | `sat-scope@guildhouse.io` |
| `ceremony-id@guildhouse.io` | `ceremony-type@guildhouse.io` |
| `ceremony-type@guildhouse.io` | `ceremony-id@guildhouse.io` |
| `merkle-proof@guildhouse.io` | `merkle-root@guildhouse.io` |
Note: `merkle-root@guildhouse.io` MAY be present without
`merkle-proof@guildhouse.io` (root-only pinning).
### 8.3 Required Extensions
The following extensions MUST always be present in a Shellstream-bearing
SSH certificate:
- `tenant-id@guildhouse.io`
- `roles@guildhouse.io`
A certificate that contains any `@guildhouse.io` extension but is missing
either `tenant-id` or `roles` MUST be treated as invalid. The server
SHOULD reject the session.
### 8.4 Forward Compatibility
Unknown extensions bearing the `@guildhouse.io` suffix MUST be ignored.
Implementations MUST NOT reject a certificate solely because it contains
unrecognized `@guildhouse.io` extensions. This ensures that new
extensions can be introduced without breaking existing deployments.
## 9. Security Considerations
### 9.1 Extension Visibility
SSH certificate extensions are not encrypted. Any entity that can read the
certificate (including intermediate proxies, logging infrastructure, and
the SSH server) can read extension values. Accordingly:
- Implementations MUST NOT embed secrets, bearer tokens, private keys, or
other sensitive credentials in extension values.
- The `sat-hash` extension contains a SHA-256 hash of the SAT, not the
SAT itself. The SAT MUST be validated through a separate, secure
channel.
### 9.2 Authorization Boundary
- Merkle proofs provide **auditability**, not authorization. A valid
merkle inclusion proof means the credential issuance event was recorded
in the governance tree; it does not assert that the event was correct or
authorized. Authorization decisions MUST be based on SAT scope and
Accord policy, not on merkle proof validity alone.
- The `governance-epoch` extension enables stale-state detection but MUST
NOT be used as the sole authorization signal. A stale epoch indicates
that governance state may have changed since issuance; the server
SHOULD require re-attestation in this case.
### 9.3 Parsing Safety
Extension values are strings, not executable code. Implementations MUST
NOT evaluate extension values as code, shell commands, SQL, or any other
executable format. JSON values MUST be parsed with a standard JSON parser;
hand-rolled parsers are strongly discouraged.
### 9.4 Size Constraints
The total size of all Shellstream extension values (keys and values
combined) SHOULD NOT exceed 4096 bytes (4 KB). Exceeding this limit may
cause compatibility issues with certain SSH implementations that impose
limits on certificate size. Implementations MAY reject certificates whose
total `@guildhouse.io` extension payload exceeds this threshold.
### 9.5 Replay and Freshness
SSH certificates have their own validity period (valid-after to
valid-before). Shellstream extensions inherit this validity window.
Implementations SHOULD treat extension values as valid only within the
certificate's validity period. The `governance-epoch` extension provides
an additional freshness signal beyond the certificate's temporal validity.
## 10. Compatibility
### 10.1 SSH Protocol Compatibility
All Shellstream extensions use the standard SSH certificate extension
format: a string key mapping to a string value. This is fully compatible
with OpenSSH and any SSH implementation that conforms to the
PROTOCOL.certkeys specification.
### 10.2 Transparent Degradation
SSH servers that do not understand Shellstream extensions will ignore them
entirely. This is standard OpenSSH behavior for unrecognized extensions.
Certificates bearing Shellstream extensions remain valid SSH certificates
and can be used for authentication on non-Guildhouse SSH servers (though
governance-based authorization will not be available).
### 10.3 Additive Evolution
Extensions are additive. New `@guildhouse.io` extensions can be
introduced in future revisions of this specification without breaking
existing parsers, per the forward compatibility rule in Section 8.4.
### 10.4 Version Negotiation
Version negotiation is implicit via extension presence. There is no
explicit version field. If a future revision deprecates or changes the
semantics of an extension, it MUST do so under a new extension name. The
original extension name retains its original semantics indefinitely.
## 11. References
- **OpenSSH PROTOCOL.certkeys** -- OpenSSH certificate format and
extension mechanism.
https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.certkeys
- **RFC 2119** -- Key words for use in RFCs to Indicate Requirement
Levels. Bradner, S. March 1997.
https://datatracker.ietf.org/doc/html/rfc2119
- **RFC 3629** -- UTF-8, a transformation format of ISO 10646. Yergeau,
F. November 2003.
https://datatracker.ietf.org/doc/html/rfc3629
- **RFC 4122** -- A Universally Unique IDentifier (UUID) URN Namespace.
Leach, P., Mealling, M., and R. Salz. July 2005.
https://datatracker.ietf.org/doc/html/rfc4122
- **RFC 4251** -- The Secure Shell (SSH) Protocol Architecture. Ylonen,
T. and C. Lonvick, Ed. January 2006.
https://datatracker.ietf.org/doc/html/rfc4251
- **RFC 4648** -- The Base16, Base32, and Base64 Data Encodings.
Josefsson, S. October 2006.
https://datatracker.ietf.org/doc/html/rfc4648
- **RFC 8785** -- JSON Canonicalization Scheme (JCS). Rundgren, B.,
Jordan, B., and S. Erdtman. June 2020.
https://datatracker.ietf.org/doc/html/rfc8785

534
specs/spiffe-ssh-svid.md Normal file
View file

@ -0,0 +1,534 @@
# SSH-SVID: SSH Certificates with SPIFFE Identity
## Abstract
This document defines the SSH SPIFFE Verifiable Identity Document (SSH-SVID), a standard OpenSSH certificate type whose subject identity is derived from a SPIFFE ID. An SSH-SVID binds a SPIFFE identity to an SSH public key via a certificate signed by a SPIFFE-managed SSH Certificate Authority, enabling workloads to authenticate to SSH servers using the same identity framework that governs their X.509 and JWT credentials. This specification extends the SPIFFE standard into the SSH certificate domain, providing automated, short-lived, attestation-backed SSH credentials without requiring modifications to the SSH protocol or server implementations.
## Status
**Draft Specification** -- Version 0.1.0
This document is a draft and is not yet approved by any standards body. It is published for review and comment by the SPIFFE community. Distribution of this document is unlimited.
## Table of Contents
1. [Terminology](#1-terminology)
2. [Introduction](#2-introduction)
3. [Certificate Format](#3-certificate-format)
4. [Issuance Flow](#4-issuance-flow)
5. [Trust Model](#5-trust-model)
6. [Key Management](#6-key-management)
7. [Certificate Lifetime and Rotation](#7-certificate-lifetime-and-rotation)
8. [Security Considerations](#8-security-considerations)
9. [Compatibility](#9-compatibility)
10. [References](#10-references)
## Notational Conventions
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119][rfc2119] and [RFC 8174][rfc8174] when, and only when, they appear in all capitals, as shown here.
## 1. Terminology
**SSH-SVID**: An OpenSSH certificate that encodes a SPIFFE ID as its principal identity. An SSH-SVID is a SPIFFE Verifiable Identity Document expressed in the SSH certificate format.
**SPIFFE ID**: A URI of the form `spiffe://<trust-domain>/<path>` that uniquely identifies a workload within a trust domain, as defined by the [SPIFFE specification][spiffe-spec].
**Trust Domain**: A SPIFFE administrative realm identified by the authority component of a SPIFFE ID (e.g., `example.org` in `spiffe://example.org/web-server`). All identities within a trust domain share a common root of trust.
**SPIRE**: The SPIFFE Runtime Environment, a production-ready reference implementation of the SPIFFE APIs that performs node and workload attestation and issues SVIDs.
**Workload API**: The SPIFFE-defined API (typically exposed over a Unix domain socket) through which workloads obtain their SVIDs and trust bundles. Defined in the [SPIFFE Workload API specification][spiffe-workload-api].
**X.509-SVID**: A SPIFFE Verifiable Identity Document encoded as an X.509 certificate, as defined by the [SPIFFE X.509-SVID specification][x509-svid-spec].
**JWT-SVID**: A SPIFFE Verifiable Identity Document encoded as a JSON Web Token, as defined by the [SPIFFE JWT-SVID specification][jwt-svid-spec].
**SSH Certificate Authority (CA)**: A cryptographic key pair whose private key signs SSH certificates and whose public key is distributed to relying parties for certificate verification. In the context of this specification, the SSH CA is managed by the SPIRE Server.
**SSH User Certificate**: An OpenSSH certificate of type `SSH_CERT_TYPE_USER` (value 1) that authenticates a user (or workload) to an SSH server, as defined by the [OpenSSH certificate format][openssh-cert].
**SSH Host Certificate**: An OpenSSH certificate of type `SSH_CERT_TYPE_HOST` (value 2) that authenticates an SSH server to a connecting client. While this specification focuses on user certificates, the trust model described herein applies to host certificates by analogy.
## 2. Introduction
SSH is the de facto standard for remote access to Unix systems and is widely used for workload-to-workload communication, configuration management, and operational automation. Despite its ubiquity, SSH authentication remains overwhelmingly reliant on static, long-lived key pairs that are manually provisioned, distributed, and revoked.
This static key management model introduces several problems:
- **Key sprawl**: Long-lived SSH keys accumulate across infrastructure with no reliable inventory or lifecycle management.
- **Manual rotation**: Key rotation requires human intervention or bespoke automation, leading to keys that persist far beyond their intended lifetime.
- **Weak identity binding**: An SSH public key identifies a cryptographic key, not a workload. There is no standard mechanism to assert that a given SSH key belongs to a specific service, pod, or process.
- **No attestation**: Traditional SSH key distribution trusts the provisioning process implicitly. There is no runtime verification that the entity presenting a key is the workload it claims to be.
- **Siloed trust**: SSH trust (via `authorized_keys` and `known_hosts`) is managed independently from the workload identity systems used for service mesh, mTLS, and API authentication.
The SPIFFE framework addresses these problems for X.509 and JWT credentials by providing attestation-backed, automatically rotated, short-lived identity documents. However, the SPIFFE standard does not currently define an SSH certificate type, leaving SSH as a gap in the unified workload identity model.
SSH-SVID closes this gap. By encoding a SPIFFE ID as the principal of a standard OpenSSH certificate, SSH-SVID enables:
- Automatic SSH credential issuance through the existing SPIRE Workload API
- Runtime workload attestation before any SSH credential is granted
- Short-lived certificates that eliminate the need for key revocation infrastructure
- A unified identity across mTLS (X.509-SVID), bearer tokens (JWT-SVID), and SSH (SSH-SVID)
- Trust bundle federation across organizational boundaries, including SSH CA keys
This specification defines the certificate format, issuance flow, trust model, key management requirements, and security considerations for SSH-SVIDs.
## 3. Certificate Format
### 3.1 Certificate Type
An SSH-SVID MUST be an OpenSSH user certificate as defined by the [OpenSSH certificate key format][openssh-cert].
The certificate key type MUST be one of the following:
- `ssh-ed25519-cert-v01@openssh.com` (REQUIRED support)
- `sk-ssh-ed25519-cert-v01@openssh.com` (OPTIONAL; for hardware-backed keys)
Implementations MAY support `ecdsa-sha2-nistp256-cert-v01@openssh.com` for interoperability. Implementations MUST NOT issue SSH-SVIDs using RSA key types.
The certificate type field MUST be set to `SSH_CERT_TYPE_USER` (value 1).
### 3.2 Key ID
The Key ID field of the SSH certificate MUST contain the SPIFFE ID of the workload, encoded as a UTF-8 string in its canonical URI form:
```
spiffe://<trust-domain>/<path>
```
The Key ID MUST NOT contain query parameters, fragments, or trailing slashes. The Key ID MUST conform to the SPIFFE ID format defined in [Section 2 of the SPIFFE specification][spiffe-spec].
### 3.3 Valid Principals
The Valid Principals field MUST include the workload's SPIFFE ID as the first entry.
The Valid Principals field MAY include additional principals derived from the workload's SPIRE registration entry. These additional principals enable mapping to local Unix accounts or roles on the target SSH server.
Example Valid Principals for a workload registered as `spiffe://example.org/ns/prod/sa/web-server`:
```
spiffe://example.org/ns/prod/sa/web-server
web-server
deployer
```
If no additional principals are configured in the workload registration, the Valid Principals field MUST contain exactly one entry: the SPIFFE ID.
### 3.4 Serial Number
The Serial Number field MUST be a monotonically increasing 64-bit unsigned integer, unique per SSH CA signing key. The serial number MUST NOT be reused for the lifetime of the CA key.
Serial numbers are used for audit logging and MAY be used for revocation via `RevokedKeys` in OpenSSH's `KRL` (Key Revocation List) format in exceptional circumstances, although the short-lived nature of SSH-SVIDs (see [Section 7](#7-certificate-lifetime-and-rotation)) makes explicit revocation unnecessary under normal operation.
### 3.5 Validity Period
The `valid after` and `valid before` fields MUST be set to constrain the certificate's lifetime. The requirements for these values are specified in [Section 7](#7-certificate-lifetime-and-rotation).
Implementations MUST NOT issue SSH-SVIDs with an empty validity period (i.e., valid from the Unix epoch to the maximum time value).
### 3.6 Critical Options
Critical options constrain the certificate's use. An SSH server MUST reject the certificate if it encounters an unrecognized critical option.
The following critical options apply to SSH-SVIDs:
- **`source-address`**: SHOULD be set when the workload's network location is known to the issuing SPIRE Server. The value MUST be a comma-separated list of CIDR address ranges from which the certificate is valid. When set, the SSH server MUST reject connections from addresses outside the specified ranges.
- **`force-command`**: MAY be set to restrict the certificate to a specific command, based on the workload registration entry.
Implementations MUST NOT set critical options that are not defined by [OpenSSH][openssh-cert] or by a companion specification explicitly referenced from the workload's SPIRE registration.
### 3.7 Extensions
Extensions grant capabilities to the certificate holder. An SSH server that does not recognize an extension MUST ignore it.
SSH-SVIDs SHOULD include the following standard OpenSSH extensions, unless the workload's registration entry explicitly restricts them:
- `permit-pty`
- `permit-user-rc`
SSH-SVIDs MAY include additional standard extensions:
- `permit-X11-forwarding`
- `permit-agent-forwarding`
- `permit-port-forwarding`
- `no-touch-required` (for `sk-` key types)
SSH-SVIDs MAY include vendor-specific extensions prefixed with a domain namespace. In particular, the Shellstream system defines extensions under the `shellstream@guildhouse.io` namespace, as specified in the companion Shellstream specification. Vendor extensions MUST use the `<name>@<domain>` format to avoid collision.
### 3.8 Certificate Encoding
The complete SSH-SVID certificate structure, using the `ssh-ed25519-cert-v01@openssh.com` type as an example, is as follows:
```
string "ssh-ed25519-cert-v01@openssh.com"
string nonce
string pk ; Ed25519 public key
uint64 serial ; monotonically increasing
uint32 type ; SSH_CERT_TYPE_USER (1)
string key id ; SPIFFE ID
string valid principals ; SPIFFE ID + optional additional
uint64 valid after ; certificate start time
uint64 valid before ; certificate expiry time
string critical options ; source-address, etc.
string extensions ; permit-pty, vendor extensions, etc.
string reserved ; empty
string signature key ; SSH CA public key
string signature ; CA signature over the certificate
```
This structure is identical to standard OpenSSH certificates. The SPIFFE identity is carried entirely within the existing Key ID and Valid Principals fields.
## 4. Issuance Flow
SSH-SVID issuance follows the existing SPIRE attestation and credential delivery model, extended with an SSH certificate signing capability.
### 4.1 Flow Overview
```
+-----------+ +--------------+ +--------------+ +--------+
| | (1) | | (3) | | (4) | |
| Workload +-------> SPIRE Agent +-------> SPIRE Server +-------> SSH CA |
| | | | | | | |
| | <-----+ | <-----+ | <-----+ |
| | (6) | | (5) | | (4) | |
+-----------+ +--------------+ +--------------+ +--------+
(2)
Workload Attestation
```
### 4.2 Detailed Steps
**Step 1: SSH-SVID Request**
The workload requests an SSH-SVID from the SPIRE Agent via the Workload API. The request is made over a Unix domain socket. The workload provides its SSH public key (or requests that the Agent generate an ephemeral key pair on its behalf).
The Workload API endpoint for SSH-SVID issuance is an extension to the standard SPIFFE Workload API:
```
rpc FetchSSHSVID(FetchSSHSVIDRequest) returns (FetchSSHSVIDResponse)
```
The request MUST include the workload's public key. The request MAY include requested principals beyond the SPIFFE ID.
**Step 2: Workload Attestation**
The SPIRE Agent identifies the calling workload using kernel-level attestation (e.g., Unix PID, Kubernetes pod identity, or Docker container labels). The Agent verifies that the calling process matches a registered workload entry in the SPIRE Server.
This step is identical to workload attestation for X.509-SVIDs and JWT-SVIDs. No SSH-specific attestation is required.
**Step 3: Agent-to-Server Request**
The SPIRE Agent forwards the SSH-SVID request to the SPIRE Server over the Agent-Server mTLS channel. The request includes:
- The workload's SPIFFE ID (determined by attestation)
- The workload's SSH public key
- Any additional requested principals
The Agent-to-Server RPC is analogous to the existing X.509-SVID minting RPC:
```
rpc MintSSHSVID(MintSSHSVIDRequest) returns (MintSSHSVIDResponse)
```
**Step 4: Certificate Generation**
The SPIRE Server generates the SSH certificate:
1. The Server validates that the SPIFFE ID is registered and that the requested principals are authorized for the workload.
2. The Server invokes the **CredentialComposer** plugin chain, which populates the certificate fields according to this specification and the workload's registration entry.
3. The Server signs the certificate using the SSH CA private key, managed by the **KeyManager** plugin.
The CredentialComposer plugin is the extension point where operators can customize certificate fields (extensions, critical options, additional principals) without modifying the SPIRE Server core.
**Step 5: Certificate Delivery to Agent**
The SPIRE Server returns the signed SSH certificate to the SPIRE Agent over the existing mTLS channel. The response includes:
- The signed SSH-SVID certificate (encoded per [Section 3.8](#38-certificate-encoding))
- The SSH CA trust bundle (the CA public key and any federated CA public keys)
**Step 6: Certificate Delivery to Workload**
The SPIRE Agent delivers the SSH-SVID to the workload via the Workload API Unix domain socket. The response includes:
- The signed SSH certificate
- The corresponding private key (if the Agent generated the key pair)
- The SSH CA trust bundle for known_hosts configuration
- The certificate's expiry time
The workload MAY write the certificate to disk (e.g., for use with `ssh -o CertificateFile=`) or hold it in memory for programmatic use.
### 4.3 Workload API Extension
The SSH-SVID Workload API extension defines the following messages:
```protobuf
message FetchSSHSVIDRequest {
bytes public_key = 1; // SSH public key (Ed25519)
repeated string principals = 2; // Additional requested principals
}
message FetchSSHSVIDResponse {
repeated SSHSVID svids = 1;
repeated SSHTrustBundle trust_bundles = 2;
}
message SSHSVID {
string spiffe_id = 1;
bytes certificate = 2; // Signed OpenSSH certificate
bytes private_key = 3; // Private key (if Agent-generated)
int64 expires_at = 4; // Unix timestamp
}
message SSHTrustBundle {
string trust_domain = 1;
repeated bytes ca_public_keys = 2; // SSH CA public keys
}
```
The full protobuf definitions are specified in the companion `ssh-credential-composer` plugin specification.
## 5. Trust Model
### 5.1 SSH CA as Part of the SPIRE Trust Bundle
The SSH CA public key MUST be included in the SPIRE trust bundle for the trust domain. This extends the existing trust bundle structure, which already contains X.509 root certificates and JWT signing keys, to include SSH CA public keys.
A trust bundle with SSH-SVID support contains:
- X.509 root certificates (for X.509-SVID validation)
- JWT signing public keys (for JWT-SVID validation)
- SSH CA public keys (for SSH-SVID validation)
### 5.2 Trust Bundle Federation
When two trust domains are federated, each domain's trust bundle MAY include SSH CA public keys from the other domain. This enables cross-domain SSH-SVID validation: a workload in `trust-domain-a` can authenticate to an SSH server in `trust-domain-b` if the server trusts `trust-domain-a`'s SSH CA key.
Federation of SSH CA keys follows the same mechanism as X.509 root certificate federation in SPIRE, using the SPIFFE Federation API.
### 5.3 SSH Server Configuration
SSH servers that accept SSH-SVIDs MUST be configured with the SSH CA public key(s) from the SPIRE trust bundle. This configuration uses standard OpenSSH server directives:
**`TrustedUserCAKeys`**: Points to a file containing the SSH CA public key(s). This file MUST be kept current with the SPIRE trust bundle. Implementations SHOULD use a sidecar or agent process that watches the SPIRE trust bundle and updates this file automatically.
```
# /etc/ssh/sshd_config
TrustedUserCAKeys /etc/ssh/spire_ca_keys
```
**`AuthorizedPrincipalsFile`**: Maps SPIFFE IDs to local Unix accounts. This file determines which SPIFFE IDs are authorized to access which local accounts.
```
# /etc/ssh/authorized_principals/%u
# For user "deploy":
spiffe://example.org/ns/prod/sa/web-server
spiffe://example.org/ns/prod/sa/ci-runner
```
When `AuthorizedPrincipalsFile` is configured, the SSH server validates that the certificate's Valid Principals include an entry that matches a line in the principals file for the target Unix account.
Alternatively, if the SSH server's `AuthorizedPrincipalsCommand` directive is available (OpenSSH 8.0+), implementations MAY use a command that queries the SPIRE trust bundle dynamically to resolve SPIFFE ID-to-account mappings.
### 5.4 Certificate Chain Model
SSH certificates use a single-level trust model: each certificate is signed directly by the CA key. There are no intermediate certificate authorities in the SSH certificate format. Consequently, the SSH CA public key present in the SPIRE trust bundle directly validates all SSH-SVIDs issued within that trust domain.
This single-level model simplifies validation but requires careful CA key management (see [Section 6](#6-key-management)).
## 6. Key Management
### 6.1 CA Key Lifecycle
The SSH CA key pair is managed by the SPIRE Server's **KeyManager** plugin. The KeyManager is responsible for generating, storing, and making available the CA private key for certificate signing operations.
Implementations SHOULD use a KeyManager that stores CA keys in hardware security modules (HSMs) or secure enclaves. The companion `substrate-keymanager` specification defines a KeyManager implementation suitable for production deployments.
The SSH CA key MUST NOT be exported from the KeyManager in plaintext. All signing operations MUST be performed within the KeyManager boundary.
### 6.2 Key Types
The following key types are defined for SSH CA keys:
| Key Type | Status | Notes |
|---|---|---|
| Ed25519 | REQUIRED | All implementations MUST support Ed25519 CA keys |
| ECDSA P-256 | OPTIONAL | MAY be supported for interoperability |
| RSA | NOT RECOMMENDED | RSA SHOULD NOT be used due to larger key/signature sizes and weaker security margins at equivalent key lengths |
The SSH CA key type determines the certificate signature algorithm. An Ed25519 CA key produces `ssh-ed25519` signatures; an ECDSA P-256 CA key produces `ecdsa-sha2-nistp256` signatures.
### 6.3 CA Key Rotation
SPIRE manages CA key rotation through the **upstream authority** mechanism. When a CA key rotation occurs:
1. The new CA public key MUST be added to the trust bundle before any certificates are signed with the new CA private key.
2. Both the old and new CA public keys MUST be present in the trust bundle during the transition period.
3. The old CA public key MUST remain in the trust bundle until all certificates signed by the old key have expired.
4. SSH servers MUST receive the updated trust bundle (containing both CA public keys) before certificates signed by the new key are presented.
The trust bundle propagation delay introduces a window during which a newly rotated CA key may not yet be trusted by all SSH servers. Implementations MUST account for this delay by ensuring the new key is in the trust bundle for at least one full certificate lifetime before the old key is removed.
### 6.4 Workload Keys
Workload SSH key pairs (the key pair bound to the certificate) MUST be ephemeral. A new key pair SHOULD be generated for each SSH-SVID request.
Workload keys MUST be Ed25519. This requirement ensures small key sizes, fast signing, and consistent security properties across all SSH-SVIDs.
Workload private keys MUST NOT be persisted to durable storage beyond the certificate's lifetime. If the SPIRE Agent generates the key pair on behalf of the workload, the Agent MUST delete its copy of the private key after delivering it to the workload.
## 7. Certificate Lifetime and Rotation
### 7.1 Lifetime Requirements
SSH-SVIDs MUST be short-lived. The following constraints apply:
| Parameter | Value | Requirement Level |
|---|---|---|
| Default TTL | 5 minutes | RECOMMENDED |
| Minimum TTL | 30 seconds | MUST NOT issue below |
| Maximum TTL | 1 hour | MUST NOT exceed |
The `valid after` field MUST be set to the current time at issuance (with no more than 60 seconds of clock skew tolerance subtracted).
The `valid before` field MUST be set to `valid after` plus the configured TTL.
Operators MAY configure per-workload TTLs within the bounds above via SPIRE registration entries.
### 7.2 Rotation
Workloads MUST re-request SSH-SVIDs before the current certificate expires. The SPIRE Workload API handles rotation automatically when the workload uses the streaming `FetchSSHSVID` API variant.
Certificates SHOULD be renewed when 50% of the certificate's lifetime has elapsed (the "half-life" renewal point). For a certificate with a 5-minute TTL, renewal SHOULD occur at 2 minutes 30 seconds after issuance.
Implementations MUST tolerate brief periods where both the old and new certificates are valid (i.e., the validity periods overlap). This overlap is expected and does not constitute a security concern given the short lifetimes involved.
### 7.3 Revocation
SSH-SVIDs do not require a revocation infrastructure (CRL, OCSP, or equivalent) under normal operation. The short certificate lifetime ensures that a compromised certificate becomes unusable within minutes.
In exceptional circumstances (e.g., a compromised workload key that must be revoked immediately), operators MAY use OpenSSH Key Revocation Lists (KRLs) distributed to SSH servers. KRL-based revocation is an operational concern outside the scope of this specification.
The absence of revocation infrastructure is a deliberate design choice: it reduces operational complexity, eliminates availability dependencies on revocation endpoints, and aligns with the SPIFFE principle that short-lived credentials are preferable to long-lived credentials with revocation.
## 8. Security Considerations
### 8.1 Workload Attestation as Trust Anchor
The security of SSH-SVIDs depends entirely on the correctness of SPIRE's workload attestation. If an attacker can spoof a workload's identity during attestation (e.g., by compromising the kernel, container runtime, or node agent), they can obtain SSH-SVIDs for arbitrary SPIFFE IDs.
Operators MUST ensure that the workload attestation mechanism is appropriate for their threat model. Kubernetes-based attestation (using the TokenReview API and pod metadata) is RECOMMENDED for containerized workloads. Unix PID-based attestation SHOULD only be used when stronger mechanisms are unavailable.
### 8.2 SPIFFE ID Visibility
SSH-SVIDs do not provide confidentiality of the workload's SPIFFE ID. The SPIFFE ID is encoded in the certificate's Key ID and Valid Principals fields, both of which are transmitted in cleartext during SSH authentication (the certificate is sent before the encrypted channel is fully established in the SSH protocol).
Operators SHOULD be aware that SPIFFE IDs may reveal information about internal service topology (e.g., namespace names, service account names). If this information is sensitive, operators SHOULD use opaque path components in SPIFFE IDs.
### 8.3 Agent-to-Server Communication
Communication between the SPIRE Agent and SPIRE Server MUST use mutual TLS (mTLS), authenticated with X.509-SVIDs. This is the default communication model in SPIRE.
The Agent-to-Server channel carries SSH-SVID signing requests, which include the workload's public key and SPIFFE ID. Compromise of this channel would allow an attacker to request SSH-SVIDs for any workload the compromised Agent is authorized to attest.
### 8.4 Workload API Socket Protection
The Unix domain socket used for the Workload API MUST be protected by filesystem permissions. The socket SHOULD be accessible only to the workload processes that are entitled to receive SVIDs.
In Kubernetes environments, the Workload API socket is typically mounted into the pod's filesystem via a `hostPath` or CSI volume. The SPIRE Agent MUST verify the connecting process's identity (via kernel-level attestation) regardless of filesystem permissions.
### 8.5 Rate Limiting
The SPIRE Server SHOULD enforce rate limits on SSH-SVID issuance to prevent abuse. Rate limits SHOULD be applied per workload registration entry (i.e., per SPIFFE ID).
A RECOMMENDED rate limit is no more than 60 SSH-SVID issuances per SPIFFE ID per minute. This allows for normal rotation (approximately 1 request per renewal interval) while preventing runaway issuance loops.
### 8.6 CA Key Compromise
Compromise of the SSH CA private key allows an attacker to forge SSH-SVIDs for any SPIFFE ID in the trust domain. Mitigations include:
- Storing CA keys in HSMs or secure enclaves (see [Section 6.1](#61-ca-key-lifecycle))
- Monitoring certificate issuance logs for anomalous serial numbers or SPIFFE IDs
- Implementing CA key rotation on a regular schedule (see [Section 6.3](#63-ca-key-rotation))
- Maintaining the ability to rapidly rotate the CA key and propagate the new trust bundle in response to a compromise
### 8.7 Clock Skew
SSH certificate validation depends on accurate system clocks. Clock skew between the issuing SPIRE Server and the validating SSH server can cause valid certificates to be rejected (if the server's clock is behind) or expired certificates to be accepted (if the server's clock is ahead).
Implementations SHOULD account for clock skew by subtracting a small tolerance (no more than 60 seconds) from the `valid after` timestamp. Operators MUST ensure that all systems participating in SSH-SVID issuance and validation synchronize their clocks via NTP or an equivalent mechanism.
## 9. Compatibility
### 9.1 OpenSSH Server Compatibility
SSH-SVIDs are standard OpenSSH certificates. Any OpenSSH server at version 8.0 or later can validate SSH-SVIDs without modification. The server requires only:
- The SSH CA public key in `TrustedUserCAKeys`
- Principal-to-account mappings in `AuthorizedPrincipalsFile` or `AuthorizedPrincipalsCommand`
No patches, plugins, or custom builds of OpenSSH are required.
### 9.2 SSH Client Compatibility
SSH-SVIDs can be used with any OpenSSH client (version 8.0+) via standard mechanisms:
- **Certificate file**: `ssh -o CertificateFile=/path/to/ssh-svid-cert.pub`
- **Identity file**: `ssh -i /path/to/key` (when the certificate is in the same directory with the `-cert.pub` suffix, the client loads it automatically)
- **SSH agent**: `ssh-add /path/to/key` followed by `ssh-add -L` to verify the certificate is loaded
### 9.3 SSH Agent Compatibility
The standard `ssh-agent` can hold SSH-SVIDs. The SPIRE Agent or a helper process MAY load SSH-SVIDs into the workload's `ssh-agent` automatically, removing expired certificates and adding renewed ones as part of the rotation cycle.
When using `ssh-agent`, the certificate lifetime constrains the agent's key lifetime. Implementations SHOULD use `ssh-add -t <seconds>` to set a key lifetime matching the certificate's remaining validity.
### 9.4 Non-OpenSSH Implementations
SSH-SVIDs are compatible with any SSH implementation that supports OpenSSH certificate validation, including but not limited to:
- Dropbear (with certificate support enabled)
- libssh (version 0.9+)
- Paramiko (with certificate validation)
- Go's `golang.org/x/crypto/ssh` package
Implementations that do not support OpenSSH certificates cannot validate SSH-SVIDs. This is a limitation of those implementations, not of this specification.
## 10. References
### Normative References
- **[RFC 2119]** Bradner, S., "Key words for use in RFCs to Indicate Requirement Levels", BCP 14, RFC 2119, March 1997. https://www.rfc-editor.org/rfc/rfc2119
- **[RFC 8174]** Leiba, B., "Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words", BCP 14, RFC 8174, May 2017. https://www.rfc-editor.org/rfc/rfc8174
- **[SPIFFE Specification]** SPIFFE Project, "Secure Production Identity Framework for Everyone", v1.0. https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE.md
- **[SPIFFE Workload API]** SPIFFE Project, "SPIFFE Workload API". https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Workload_API.md
- **[X.509-SVID Specification]** SPIFFE Project, "X.509 SPIFFE Verifiable Identity Document". https://github.com/spiffe/spiffe/blob/main/standards/X509-SVID.md
- **[JWT-SVID Specification]** SPIFFE Project, "JWT SPIFFE Verifiable Identity Document". https://github.com/spiffe/spiffe/blob/main/standards/JWT-SVID.md
- **[OpenSSH Certificate Format]** OpenSSH Project, "PROTOCOL.certkeys". https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.certkeys
### Informative References
- **[RFC 4251]** Ylonen, T. and C. Lonvick, "The Secure Shell (SSH) Protocol Architecture", RFC 4251, January 2006. https://www.rfc-editor.org/rfc/rfc4251
- **[RFC 4252]** Ylonen, T. and C. Lonvick, "The Secure Shell (SSH) Authentication Protocol", RFC 4252, January 2006. https://www.rfc-editor.org/rfc/rfc4252
- **[RFC 4253]** Ylonen, T. and C. Lonvick, "The Secure Shell (SSH) Transport Layer Protocol", RFC 4253, January 2006. https://www.rfc-editor.org/rfc/rfc4253
- **[SPIRE Documentation]** SPIFFE Project, "SPIRE: the SPIFFE Runtime Environment". https://spiffe.io/docs/latest/spire-about/
- **[SPIFFE Trust Bundle]** SPIFFE Project, "SPIFFE Trust Domain and Bundle". https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Trust_Domain_and_Bundle.md
[rfc2119]: https://www.rfc-editor.org/rfc/rfc2119
[rfc8174]: https://www.rfc-editor.org/rfc/rfc8174
[spiffe-spec]: https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE.md
[spiffe-workload-api]: https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Workload_API.md
[x509-svid-spec]: https://github.com/spiffe/spiffe/blob/main/standards/X509-SVID.md
[jwt-svid-spec]: https://github.com/spiffe/spiffe/blob/main/standards/JWT-SVID.md
[openssh-cert]: https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.certkeys

22
test/fixtures/sample-oidc-token.json vendored Normal file
View file

@ -0,0 +1,22 @@
{
"iss": "https://keycloak.guildhouse.example.org/realms/platform",
"sub": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"aud": "spire",
"exp": 1735689600,
"iat": 1735686000,
"auth_time": 1735686000,
"email": "operator@guildhouse.coop",
"email_verified": true,
"preferred_username": "operator",
"groups": [
"platform-engineers",
"tenant-alpha-admins"
],
"realm_access": {
"roles": [
"administrator",
"engineer"
]
},
"tenant_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}

5
test/fixtures/sample-sat-scope.json vendored Normal file
View file

@ -0,0 +1,5 @@
{
"registry_type": "oci",
"verbs": ["push", "pull"],
"resource_pattern": "tenant-alpha/*"
}

View file

@ -0,0 +1,13 @@
{
"permit-pty": "",
"permit-user-rc": "",
"sat-scope@guildhouse.io": "{\"registry_type\":\"oci\",\"verbs\":[\"push\",\"pull\"],\"resource_pattern\":\"tenant-alpha/*\"}",
"sat-hash@guildhouse.io": "a3f2b8c1d4e5f67890abcdef1234567890abcdef1234567890abcdef12345678",
"tenant-id@guildhouse.io": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"roles@guildhouse.io": "administrator,engineer",
"ceremony-id@guildhouse.io": "11223344-5566-7788-99aa-bbccddeeff00",
"ceremony-type@guildhouse.io": "single_approval",
"merkle-root@guildhouse.io": "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
"merkle-proof@guildhouse.io": "AQIDBAU=",
"governance-epoch@guildhouse.io": "42"
}

12
test/fixtures/spire-test-config.hcl vendored Normal file
View file

@ -0,0 +1,12 @@
# SPIRE test configuration for integration testing.
#
# This HCL file is used to test plugin configuration parsing.
# It represents the plugin_data block from a SPIRE server config.
PluginConfig {
trust_domain = "test.example.org"
cluster_id = "test-cluster"
governance_addr = "localhost:50051"
ceremony_addr = "localhost:50052"
notary_addr = "localhost:50053"
}