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 }