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 }