guildhouse-spire-plugins/pkg/shellstream/shellstream.go
Tyler King 420a4e2ea0 Remediate all 17 audit findings from AUDIT.md
Critical fixes:
- F-01: SatScope array form support (single pointer → slice with polymorphic JSON)
- F-02: Add governance-intent@guildhouse.dev as 10th Shellstream extension
- F-06: Replace os.Exit(1) stubs with go-plugin Serve() boilerplate in all cmd/
- F-13: Validate SatScope.ResourcePattern is non-empty

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:45:33 -05:00

359 lines
10 KiB
Go

package shellstream
import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"strconv"
"strings"
)
// Extension key constants — all use the @guildhouse.dev vendor suffix.
const (
ExtSatScope = "sat-scope@guildhouse.dev"
ExtSatHash = "sat-hash@guildhouse.dev"
ExtTenantID = "tenant-id@guildhouse.dev"
ExtRoles = "roles@guildhouse.dev"
ExtCeremonyID = "ceremony-id@guildhouse.dev"
ExtCeremonyType = "ceremony-type@guildhouse.dev"
ExtMerkleRoot = "merkle-root@guildhouse.dev"
ExtMerkleProof = "merkle-proof@guildhouse.dev"
ExtGovernanceEpoch = "governance-epoch@guildhouse.dev"
ExtGovernanceIntent = "governance-intent@guildhouse.dev"
// Vendor suffix for identifying Shellstream extensions.
VendorSuffix = "@guildhouse.dev"
)
// 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 {
// SatScopes is the list of authorization scopes. Required when SAT is present.
// When encoding: len==1 produces a single JSON object; len>1 produces a JSON array.
// When decoding: both single object and array forms are accepted.
SatScopes []*SatScope
// SatHash is the hex-encoded SHA-256 of the SAT bytes. Required when SatScopes 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
// GovernanceIntent is the governance MutationIntent UUID. Optional.
GovernanceIntent string
// 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 len(ext.SatScopes) > 0 {
var scopeJSON []byte
var err error
if len(ext.SatScopes) == 1 {
scopeJSON, err = json.Marshal(ext.SatScopes[0])
} else {
scopeJSON, err = json.Marshal(ext.SatScopes)
}
if err != nil {
return nil, fmt.Errorf("shellstream: marshal sat-scope: %w", err)
}
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)
}
// Governance intent.
if ext.GovernanceIntent != "" {
m[ExtGovernanceIntent] = ext.GovernanceIntent
}
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 (single object or array form).
if v, ok := extensions[ExtSatScope]; ok {
trimmed := strings.TrimSpace(v)
if len(trimmed) > 0 && trimmed[0] == '[' {
var scopes []*SatScope
if err := json.Unmarshal([]byte(v), &scopes); err != nil {
return nil, fmt.Errorf("shellstream: unmarshal sat-scope array: %w", err)
}
ext.SatScopes = scopes
} else {
scope := &SatScope{}
if err := json.Unmarshal([]byte(v), scope); err != nil {
return nil, fmt.Errorf("shellstream: unmarshal sat-scope: %w", err)
}
ext.SatScopes = []*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
}
// Optional: governance-intent.
if v, ok := extensions[ExtGovernanceIntent]; ok {
ext.GovernanceIntent = v
}
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 len(ext.SatScopes) > 0 && ext.SatHash == "" {
return fmt.Errorf("shellstream: sat-scope requires sat-hash")
}
if ext.SatHash != "" && len(ext.SatScopes) == 0 {
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 — validate each scope in the slice.
for i, scope := range ext.SatScopes {
if scope.RegistryType == "" {
return fmt.Errorf("shellstream: sat-scope[%d].registry_type is required", i)
}
if len(scope.Verbs) == 0 {
return fmt.Errorf("shellstream: sat-scope[%d].verbs is required (at least one verb)", i)
}
if scope.ResourcePattern == "" {
return fmt.Errorf("shellstream: sat-scope[%d].resource_pattern is required", i)
}
}
// 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")
}
// governance-intent format: UUID.
if ext.GovernanceIntent != "" {
if !isValidUUID(ext.GovernanceIntent) {
return fmt.Errorf("shellstream: governance-intent is not a valid UUID: %q", ext.GovernanceIntent)
}
}
return nil
}
// 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
}