guildhouse-spire-plugins/pkg/shellstream/shellstream.go
Tyler King a58d548518 feat: network-policy extension, governance lifecycle, audit remediation
- Network-policy SPIRE plugin extension
- Governance event notification with merkle anchoring
- Shellstream specs for consent channels + HFL embedded ABI
- All 17 audit findings from AUDIT.md remediated
- SSH credential composer + substrate key manager updates
- Test coverage for config + sshcert packages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:54:46 -04:00

418 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"
// MaxMerkleProofBase64Len is the maximum base64 length for a merkle proof.
// 8 levels × 32 bytes = 256 bytes → ~344 base64 chars. Use 512 for margin.
MaxMerkleProofBase64Len = 512
// MaxExtensionValueLen is the maximum length of any single extension value.
MaxExtensionValueLen = 4096
// MaxTotalExtensionsLen is the maximum combined length of all extension keys+values.
MaxTotalExtensionsLen = 16384
)
// 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.
//
// SECURITY: This field MUST be set by the SSH Credential Composer from the
// actual CreateIntentResponse.intent_id, not from external input. Verifiers
// SHOULD cross-check this value against the GovernanceService to confirm
// the intent exists and corresponds to this credential's issuance.
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.
// Size limits are enforced to prevent DoS via oversized extension values.
func Decode(extensions map[string]string) (*ShellstreamExtensions, error) {
// Total size check (S-05: prevent DoS via oversized extensions).
total := 0
for k, v := range extensions {
total += len(k) + len(v)
}
if total > MaxTotalExtensionsLen {
return nil, fmt.Errorf("shellstream: total extension size %d exceeds maximum %d", total, MaxTotalExtensionsLen)
}
ext := &ShellstreamExtensions{}
// Required: tenant-id.
if v, ok := extensions[ExtTenantID]; ok {
if err := checkValueLen(ExtTenantID, v); err != nil {
return nil, err
}
ext.TenantID = v
}
// Required: roles (S-11: sanitize — trim whitespace, filter empty strings).
if v, ok := extensions[ExtRoles]; ok && v != "" {
if err := checkValueLen(ExtRoles, v); err != nil {
return nil, err
}
raw := strings.Split(v, ",")
roles := make([]string, 0, len(raw))
for _, r := range raw {
trimmed := strings.TrimSpace(r)
if trimmed != "" {
roles = append(roles, trimmed)
}
}
if len(roles) == 0 {
return nil, fmt.Errorf("shellstream: roles extension present but contains no valid roles")
}
ext.Roles = roles
}
// Optional: sat-scope (single object or array form).
if v, ok := extensions[ExtSatScope]; ok {
if err := checkValueLen(ExtSatScope, v); err != nil {
return nil, err
}
trimmed := strings.TrimSpace(v)
if len(trimmed) > 0 && trimmed[0] == '[' {
var scopes []*SatScope
if err := json.Unmarshal([]byte(v), &scopes); err != nil {
return nil, fmt.Errorf("shellstream: unmarshal sat-scope array: %w", err)
}
ext.SatScopes = scopes
} else {
scope := &SatScope{}
if err := json.Unmarshal([]byte(v), scope); err != nil {
return nil, fmt.Errorf("shellstream: unmarshal sat-scope: %w", err)
}
ext.SatScopes = []*SatScope{scope}
}
}
// Optional: sat-hash.
if v, ok := extensions[ExtSatHash]; ok {
if err := checkValueLen(ExtSatHash, v); err != nil {
return nil, err
}
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 (S-05: size limit before base64 decode).
if v, ok := extensions[ExtMerkleProof]; ok {
if len(v) > MaxMerkleProofBase64Len {
return nil, fmt.Errorf("shellstream: merkle-proof length %d exceeds maximum %d", len(v), MaxMerkleProofBase64Len)
}
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
}
// checkValueLen validates that a single extension value does not exceed the maximum.
func checkValueLen(key, value string) error {
if len(value) > MaxExtensionValueLen {
return fmt.Errorf("shellstream: %s length %d exceeds maximum %d", key, len(value), MaxExtensionValueLen)
}
return 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
}