guildhouse-spire-plugins/pkg/shellstream/shellstream.go

318 lines
8.8 KiB
Go

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
}