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>
359 lines
10 KiB
Go
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
|
|
}
|