e75c8b37c8
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
129 lines
5.5 KiB
Go
129 lines
5.5 KiB
Go
package lifecycle
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/kandev/kandev/internal/secrets"
|
|
)
|
|
|
|
// controlServerCredentialSecretName is the fixed secret name for the
|
|
// installation's single standalone control-server ownership credential. It
|
|
// is never looked up by name -- only by the ID recorded in
|
|
// models.ControlServerRecord.CredentialSecretID -- so a fixed, non-unique
|
|
// name is safe.
|
|
const controlServerCredentialSecretName = "agent-survival-control-server-credential"
|
|
|
|
const controlServerCredentialSecretIDPrefix = "kandev-runtime:control-server:"
|
|
|
|
// storeControlServerCredential durably stores the control server's ownership
|
|
// credential and returns the secret ID that
|
|
// models.ControlServerRecord.CredentialSecretID should reference. Storing
|
|
// the token itself in the record would put a bearer credential for a
|
|
// service that executes commands in a user's worktree into the database in
|
|
// clear (design 01, "Ownership identity and credential") -- the record
|
|
// holds only this reference.
|
|
//
|
|
// existingSecretID, when non-empty, updates that secret in place so an
|
|
// ordinary credential rotation reuses the same reference and the record's
|
|
// CredentialSecretID never has to change. When the referenced secret is
|
|
// absent (e.g. deleted out of band), this falls back to creating a new one
|
|
// rather than failing: refusing to store a rotated credential would leave
|
|
// the backend unable to ever adopt this server again. When the referenced
|
|
// secret is present but its current value cannot be read (a transient
|
|
// failure, not absence), this also falls back to creating a new one rather
|
|
// than overwriting it: see tryReuseCredentialSecret.
|
|
func storeControlServerCredential(ctx context.Context, store secrets.SecretStore, existingSecretID, token string) (string, error) {
|
|
if store == nil {
|
|
return "", errors.New("secret store is unavailable")
|
|
}
|
|
if token == "" {
|
|
return "", errors.New("control server credential is required")
|
|
}
|
|
|
|
// Older installations used a generated, user-visible secret ID. Do not
|
|
// update that row during rotation because it would expose the control-server
|
|
// bearer credential through the user secret APIs. New and migrated rows use
|
|
// the internal runtime prefix and remain hidden by UserVisibleStore.
|
|
if isControlServerCredentialSecretID(existingSecretID) {
|
|
reused, err := tryReuseCredentialSecret(ctx, store, existingSecretID, token)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if reused {
|
|
return existingSecretID, nil
|
|
}
|
|
}
|
|
|
|
secret := &secrets.SecretWithValue{
|
|
Secret: secrets.Secret{
|
|
ID: controlServerCredentialSecretIDPrefix + uuid.NewString(),
|
|
Name: controlServerCredentialSecretName,
|
|
},
|
|
Value: token,
|
|
}
|
|
if err := store.Create(ctx, secret); err != nil {
|
|
return "", err
|
|
}
|
|
return secret.ID, nil
|
|
}
|
|
|
|
func isControlServerCredentialSecretID(id string) bool {
|
|
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(id)), controlServerCredentialSecretIDPrefix)
|
|
}
|
|
|
|
// tryReuseCredentialSecret overwrites existingSecretID in place with token
|
|
// and reports whether it did.
|
|
//
|
|
// It first reveals the secret's current value to prove the row is genuinely
|
|
// present and readable before overwriting it: Update alone cannot tell
|
|
// "absent" apart from "present but transiently unreadable" -- it blindly
|
|
// overwrites either way, since it never re-decrypts the prior value.
|
|
// Overwriting a slot whose value cannot currently be read would destroy
|
|
// whatever credential is there over what may be only a transient failure,
|
|
// which AC-EXECUTORS-CONTROL-OWNERSHIP-001.9 forbids ("shall not delete the
|
|
// stored credential" on a transient read error, "because the failure is on
|
|
// this side and destroying a secret over a transient read error is
|
|
// unrecoverable").
|
|
//
|
|
// A genuinely absent secret, or one that fails to reveal for any other
|
|
// reason, both report (false, nil) rather than an error: the caller falls
|
|
// back to creating a fresh secret instead of failing the whole operation, so
|
|
// a transient blip reading the OLD credential's row never blocks durably
|
|
// recording a new one (leaving one orphaned row behind is an acceptable cost
|
|
// next to destroying a credential that might still be needed). Only a
|
|
// failure of the Update call itself -- the row having just been proven
|
|
// present and readable, then failing to write -- is surfaced as an error.
|
|
func tryReuseCredentialSecret(ctx context.Context, store secrets.SecretStore, existingSecretID, token string) (bool, error) {
|
|
if _, err := revealGlobalSecret(ctx, store, existingSecretID); err != nil {
|
|
return false, nil
|
|
}
|
|
if err := store.Update(ctx, existingSecretID, &secrets.UpdateSecretRequest{Value: &token}); err != nil {
|
|
if errors.Is(err, secrets.ErrNotFound) {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// revealControlServerCredential retrieves the decrypted ownership credential
|
|
// referenced by a control-server record. An empty reference is rejected
|
|
// before any store contact, distinct from a not-found lookup, so a caller
|
|
// can tell "record has no credential" apart from "stored reference is
|
|
// stale" -- design 02's failure table treats these differently
|
|
// (credential-unavailable vs authentication failure).
|
|
func revealControlServerCredential(ctx context.Context, store secrets.SecretStore, secretID string) (string, error) {
|
|
if secretID == "" {
|
|
return "", errors.New("control server credential reference is empty")
|
|
}
|
|
value, err := revealGlobalSecret(ctx, store, secretID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("reveal control server credential: %w", err)
|
|
}
|
|
return value, nil
|
|
}
|