package main import ( "context" "crypto/sha256" "encoding/hex" "fmt" "log" "github.com/guildhouse-cooperative/guildhouse-spire-plugins/pkg/config" "github.com/guildhouse-cooperative/guildhouse-spire-plugins/pkg/governance" "github.com/guildhouse-cooperative/guildhouse-spire-plugins/pkg/shellstream" "github.com/guildhouse-cooperative/guildhouse-spire-plugins/pkg/sshcert" ) // SSHCredentialComposer implements the SPIRE CredentialComposer plugin interface. // // This is a merged plugin that handles both SSH certificate generation and // Shellstream extension injection. In SPIRE's model, CredentialComposer plugins // can modify credentials during the minting pipeline. // // The plugin: // - Creates an SSH user certificate with the SPIFFE ID as the primary principal // - Embeds Shellstream @guildhouse.dev extensions carrying governance metadata // - Signs the certificate using the SSH CA key (from KeyManager) // - Returns the certificate as part of the composed credential bundle // // This was originally designed as two separate plugins (ssh-svid-handler and // shellstream-composer) but merged because both are CredentialComposer plugins // performing conceptually one operation. type SSHCredentialComposer struct { builder *sshcert.Builder govClient *governance.Client config *config.PluginConfig } // Configure initializes the composer with SPIRE plugin configuration. func (c *SSHCredentialComposer) Configure(pluginConfig *config.PluginConfig) error { builder, err := sshcert.NewBuilder(sshcert.Config{ TrustDomain: pluginConfig.TrustDomain, }) if err != nil { return fmt.Errorf("ssh-credential-composer: builder: %w", err) } govClient, err := governance.NewClient(governance.Config{ GovernanceAddr: pluginConfig.GovernanceAddr, CeremonyAddr: pluginConfig.CeremonyAddr, NotaryAddr: pluginConfig.NotaryAddr, }) if err != nil { return fmt.Errorf("ssh-credential-composer: governance client: %w", err) } c.builder = builder c.govClient = govClient c.config = pluginConfig return nil } // ComposeServerSSHSVID composes an SSH-SVID with Shellstream governance extensions. // Called by SPIRE Server during credential minting. func (c *SSHCredentialComposer) ComposeServerSSHSVID(ctx context.Context, spiffeID, tenantID string, roles []string, satBytes []byte) ([]byte, error) { // Create a governance intent for this credential issuance. intent, err := c.govClient.CreateIntent(ctx, "credential", "issue", spiffeID, tenantID) if err != nil { log.Printf("ssh-credential-composer: governance intent failed (proceeding without): %v", err) // Non-fatal — compose without governance intent for availability. } // Compute SAT hash if SAT bytes are present. var satHash string var satScopes []*shellstream.SatScope if len(satBytes) > 0 { h := sha256.Sum256(satBytes) satHash = hex.EncodeToString(h[:]) // Default scope — will be refined by the SAT's actual scopes. satScopes = []*shellstream.SatScope{{ RegistryType: "credential", Verbs: []string{"read", "propose"}, ResourcePattern: "*", }} } // Build Shellstream extensions. extensions := &shellstream.ShellstreamExtensions{ TenantID: tenantID, Roles: roles, SatHash: satHash, SatScopes: satScopes, } if intent != nil && intent.IntentID != "" { extensions.GovernanceIntent = intent.IntentID } // Build the SSH certificate with extensions. certBytes, err := c.builder.Build(&sshcert.CertRequest{ SpiffeID: spiffeID, Extensions: extensions, }) if err != nil { return nil, fmt.Errorf("ssh-credential-composer: build certificate: %w", err) } // Notarize the credential issuance if governance intent succeeded. if intent != nil && intent.IntentID != "" && c.govClient != nil { certHash := sha256.Sum256(certBytes) fingerprint := hex.EncodeToString(certHash[:]) if notarizeErr := c.govClient.NotarizeCredentialEvent(ctx, governance.CredentialEvent{ EventType: "issue", IntentID: intent.IntentID, CredentialFingerprint: fingerprint, SpiffeID: spiffeID, TenantID: tenantID, }); notarizeErr != nil { log.Printf("ssh-credential-composer: notarization failed (non-fatal): %v", notarizeErr) } } return certBytes, nil }