- 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>
418 lines
12 KiB
Go
418 lines
12 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"
|
||
|
||
// 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
|
||
}
|