package credentialstore import ( "context" "database/sql" "errors" "fmt" "strings" "time" "github.com/google/uuid" ) var ( // StatusDraft is the initial state of a credential slot. A draft cannot // serve a route until a compatible binding validates its envelope key. StatusDraft = "draft" // ErrSlotNotFound is returned when a requested credential slot does not exist. ErrSlotNotFound = errors.New("credentialstore: slot not found") // ErrSlotAliasAlreadyExists is returned when a slot alias is already in use for a principal. ErrSlotAliasAlreadyExists = errors.New("credentialstore: slot alias already exists for principal") // ErrSlotRevoked is returned when attempting to modify a revoked slot. ErrSlotRevoked = errors.New("credentialstore: revoked slot cannot be modified") // ErrSlotNotActive is returned when an operation requires an active slot. ErrSlotNotActive = errors.New("credentialstore: slot is not active") ) const ( CredentialKindBearer = "bearer" CredentialKindAPIKey = "api_key" ) func normalizeCredentialPart(value string) string { return strings.ToLower(strings.TrimSpace(value)) } func normalizeAlias(value string) string { return strings.TrimSpace(value) } func knownCredentialKind(kind string) bool { switch kind { case CredentialKindBearer, CredentialKindAPIKey: return true default: return false } } // CreateSlotInput holds parameters for creating a new credential slot. // // SlotID is an additive, optional caller-generated UUID. When set it lets the // caller bind create-time secret AAD to a stable slot id before sealing; when // empty (the legacy zero value) the store generates the id. type CreateSlotInput struct { SlotID string PrincipalID string Vendor string CredentialKind string Alias string Envelope SecretEnvelope } // RotateSlotSecretInput holds parameters for rotating secret envelope on an existing slot. type RotateSlotSecretInput struct { PrincipalID string SlotID string CurrentRevision int64 Envelope SecretEnvelope } // CredentialSlotRecord represents a persisted credential slot row. type CredentialSlotRecord struct { ID string PrincipalID string Vendor string CredentialKind string Alias string Status string Revision int64 Envelope SecretEnvelope CreatedAt time.Time UpdatedAt time.Time RevokedAt *time.Time } // SlotRevisionRecord represents a persisted historical secret revision for a slot. type SlotRevisionRecord struct { ID string SlotID string Revision int64 Envelope SecretEnvelope CreatedAt time.Time } // CreateSlot creates a principal-scoped credential slot with an initial secret revision. func (s *Store) CreateSlot(ctx context.Context, in CreateSlotInput) (*CredentialSlotRecord, error) { if strings.TrimSpace(in.PrincipalID) == "" { return nil, fmt.Errorf("credentialstore: CreateSlot: principal_id is required") } vendor := normalizeCredentialPart(in.Vendor) if vendor == "" { return nil, fmt.Errorf("credentialstore: CreateSlot: vendor is required") } kind := normalizeCredentialPart(in.CredentialKind) if !knownCredentialKind(kind) { return nil, fmt.Errorf("credentialstore: CreateSlot: credential_kind is not recognized") } alias := normalizeAlias(in.Alias) if err := s.validateEnvelopeKey(ctx, in.Envelope); err != nil { return nil, err } slotID := strings.TrimSpace(in.SlotID) if slotID == "" { slotID = uuid.New().String() } else if _, err := uuid.Parse(slotID); err != nil { return nil, fmt.Errorf("credentialstore: CreateSlot: slot_id must be a valid UUID") } revisionID := uuid.New().String() now := time.Now().UTC() var record CredentialSlotRecord err := s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error { var pCount int if err := tx.QueryRowContext(ctx, s.bind(`SELECT COUNT(*) FROM principals WHERE id=?`), in.PrincipalID).Scan(&pCount); err != nil { return fmt.Errorf("credentialstore: check principal: %w", err) } if pCount == 0 { return fmt.Errorf("credentialstore: principal not found: %s", in.PrincipalID) } if alias != "" { var sCount int if err := tx.QueryRowContext(ctx, s.bind(`SELECT COUNT(*) FROM credential_slots WHERE principal_id=? AND alias=?`), in.PrincipalID, alias).Scan(&sCount); err != nil { return fmt.Errorf("credentialstore: check slot alias: %w", err) } if sCount > 0 { return ErrSlotAliasAlreadyExists } } var aliasArg any if alias != "" { aliasArg = alias } if _, err := tx.ExecContext(ctx, s.bind(` INSERT INTO credential_slots (id, principal_id, vendor, credential_kind, alias, status, revision, algorithm, key_id, key_version, nonce, ciphertext, aad, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), slotID, in.PrincipalID, vendor, kind, aliasArg, StatusDraft, 0, in.Envelope.Algorithm, in.Envelope.KeyID, in.Envelope.KeyVersion, in.Envelope.Nonce, in.Envelope.Ciphertext, in.Envelope.AAD, now, now, ); err != nil { return fmt.Errorf("credentialstore: insert slot: %w", err) } if _, err := tx.ExecContext(ctx, s.bind(` INSERT INTO credential_slot_revisions (id, slot_id, revision, algorithm, key_id, key_version, nonce, ciphertext, aad, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), revisionID, slotID, 0, in.Envelope.Algorithm, in.Envelope.KeyID, in.Envelope.KeyVersion, in.Envelope.Nonce, in.Envelope.Ciphertext, in.Envelope.AAD, now, ); err != nil { return fmt.Errorf("credentialstore: insert slot revision: %w", err) } if err := s.bumpProjectionGenerationTx(ctx, tx); err != nil { return err } record = CredentialSlotRecord{ ID: slotID, PrincipalID: in.PrincipalID, Vendor: vendor, CredentialKind: kind, Alias: alias, Status: StatusDraft, Revision: 0, Envelope: in.Envelope, CreatedAt: now, UpdatedAt: now, } return nil }) if err != nil { return nil, err } return &record, nil } // GetSlot retrieves a credential slot by principal ID and slot ID. func (s *Store) GetSlot(ctx context.Context, principalID, slotID string) (*CredentialSlotRecord, error) { var r CredentialSlotRecord var alias sql.NullString var createdAt, updatedAt string var revokedAt sql.NullString err := s.db.QueryRowContext(ctx, s.bind(` SELECT id, principal_id, vendor, credential_kind, alias, status, revision, algorithm, key_id, key_version, nonce, ciphertext, aad, created_at, updated_at, revoked_at FROM credential_slots WHERE principal_id=? AND id=? `), principalID, slotID).Scan( &r.ID, &r.PrincipalID, &r.Vendor, &r.CredentialKind, &alias, &r.Status, &r.Revision, &r.Envelope.Algorithm, &r.Envelope.KeyID, &r.Envelope.KeyVersion, &r.Envelope.Nonce, &r.Envelope.Ciphertext, &r.Envelope.AAD, &createdAt, &updatedAt, &revokedAt, ) if err != nil { if err == sql.ErrNoRows { return nil, ErrSlotNotFound } return nil, err } if alias.Valid { r.Alias = alias.String } r.CreatedAt, _ = parseTime(createdAt) r.UpdatedAt, _ = parseTime(updatedAt) if revokedAt.Valid { parsed, _ := parseTime(revokedAt.String) r.RevokedAt = &parsed } return &r, nil } // GetSlotByAlias retrieves a credential slot by principal ID and alias. func (s *Store) GetSlotByAlias(ctx context.Context, principalID, alias string) (*CredentialSlotRecord, error) { var r CredentialSlotRecord var aliasValue sql.NullString var createdAt, updatedAt string var revokedAt sql.NullString err := s.db.QueryRowContext(ctx, s.bind(` SELECT id, principal_id, vendor, credential_kind, alias, status, revision, algorithm, key_id, key_version, nonce, ciphertext, aad, created_at, updated_at, revoked_at FROM credential_slots WHERE principal_id=? AND alias=? `), principalID, normalizeAlias(alias)).Scan( &r.ID, &r.PrincipalID, &r.Vendor, &r.CredentialKind, &aliasValue, &r.Status, &r.Revision, &r.Envelope.Algorithm, &r.Envelope.KeyID, &r.Envelope.KeyVersion, &r.Envelope.Nonce, &r.Envelope.Ciphertext, &r.Envelope.AAD, &createdAt, &updatedAt, &revokedAt, ) if err != nil { if err == sql.ErrNoRows { return nil, ErrSlotNotFound } return nil, err } if aliasValue.Valid { r.Alias = aliasValue.String } r.CreatedAt, _ = parseTime(createdAt) r.UpdatedAt, _ = parseTime(updatedAt) if revokedAt.Valid { parsed, _ := parseTime(revokedAt.String) r.RevokedAt = &parsed } return &r, nil } // ListSlots returns all credential slots belonging to a principal. func (s *Store) ListSlots(ctx context.Context, principalID string) ([]CredentialSlotRecord, error) { rows, err := s.db.QueryContext(ctx, s.bind(` SELECT id, principal_id, vendor, credential_kind, alias, status, revision, algorithm, key_id, key_version, nonce, ciphertext, aad, created_at, updated_at, revoked_at FROM credential_slots WHERE principal_id=? ORDER BY alias `), principalID) if err != nil { return nil, err } defer rows.Close() var out []CredentialSlotRecord for rows.Next() { var r CredentialSlotRecord var alias sql.NullString var createdAt, updatedAt string var revokedAt sql.NullString if err := rows.Scan( &r.ID, &r.PrincipalID, &r.Vendor, &r.CredentialKind, &alias, &r.Status, &r.Revision, &r.Envelope.Algorithm, &r.Envelope.KeyID, &r.Envelope.KeyVersion, &r.Envelope.Nonce, &r.Envelope.Ciphertext, &r.Envelope.AAD, &createdAt, &updatedAt, &revokedAt, ); err != nil { return nil, err } if alias.Valid { r.Alias = alias.String } r.CreatedAt, _ = parseTime(createdAt) r.UpdatedAt, _ = parseTime(updatedAt) if revokedAt.Valid { parsed, _ := parseTime(revokedAt.String) r.RevokedAt = &parsed } out = append(out, r) } return out, rows.Err() } // RotateSlotSecret replaces the secret envelope on a slot using CAS on revision. func (s *Store) RotateSlotSecret(ctx context.Context, in RotateSlotSecretInput) (*CredentialSlotRecord, error) { if strings.TrimSpace(in.PrincipalID) == "" || strings.TrimSpace(in.SlotID) == "" { return nil, fmt.Errorf("credentialstore: RotateSlotSecret: principal_id and slot_id are required") } if err := s.validateEnvelopeKey(ctx, in.Envelope); err != nil { return nil, err } var updated CredentialSlotRecord err := s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error { var currentStatus string var currentRev int64 err := tx.QueryRowContext(ctx, s.bind(` SELECT status, revision FROM credential_slots WHERE principal_id=? AND id=? `), in.PrincipalID, in.SlotID).Scan(¤tStatus, ¤tRev) if err == sql.ErrNoRows { return ErrSlotNotFound } if err != nil { return err } if currentStatus == StatusRevoked { return ErrSlotRevoked } if currentRev != in.CurrentRevision { return fmt.Errorf("%w: have %d want %d", ErrRevisionMismatch, currentRev, in.CurrentRevision) } now := time.Now().UTC() nextRev := currentRev + 1 res, err := tx.ExecContext(ctx, s.bind(` UPDATE credential_slots SET revision=?, algorithm=?, key_id=?, key_version=?, nonce=?, ciphertext=?, aad=?, updated_at=? WHERE principal_id=? AND id=? AND revision=? `), nextRev, in.Envelope.Algorithm, in.Envelope.KeyID, in.Envelope.KeyVersion, in.Envelope.Nonce, in.Envelope.Ciphertext, in.Envelope.AAD, now, in.PrincipalID, in.SlotID, in.CurrentRevision, ) if err != nil { return err } affected, err := res.RowsAffected() if err != nil { return err } if affected != 1 { return fmt.Errorf("%w: failed to update slot revision", ErrRevisionMismatch) } revisionID := uuid.New().String() if _, err := tx.ExecContext(ctx, s.bind(` INSERT INTO credential_slot_revisions (id, slot_id, revision, algorithm, key_id, key_version, nonce, ciphertext, aad, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), revisionID, in.SlotID, nextRev, in.Envelope.Algorithm, in.Envelope.KeyID, in.Envelope.KeyVersion, in.Envelope.Nonce, in.Envelope.Ciphertext, in.Envelope.AAD, now, ); err != nil { return fmt.Errorf("credentialstore: insert slot revision: %w", err) } if err := s.loadSlotTx(ctx, tx, in.PrincipalID, in.SlotID, &updated); err != nil { return err } return s.bumpProjectionGenerationTx(ctx, tx) }) if err != nil { return nil, err } return &updated, nil } // DisableSlot disables an active credential slot using CAS on revision. func (s *Store) DisableSlot(ctx context.Context, principalID, slotID string, currentRevision int64) (*CredentialSlotRecord, error) { return s.casSlotStatus(ctx, principalID, slotID, currentRevision, []string{StatusActive}, StatusDisabled) } // EnableSlot enables a disabled credential slot using CAS on revision. The // stored envelope key and at least one non-revoked compatible binding must // still be valid before the slot can become active again. func (s *Store) EnableSlot(ctx context.Context, principalID, slotID string, currentRevision int64) (*CredentialSlotRecord, error) { return s.casSlotStatus(ctx, principalID, slotID, currentRevision, []string{StatusDisabled}, StatusActive) } // RevokeSlot revokes an active or disabled credential slot. Revocation is permanent. func (s *Store) RevokeSlot(ctx context.Context, principalID, slotID string, currentRevision int64) (*CredentialSlotRecord, error) { return s.casSlotStatus(ctx, principalID, slotID, currentRevision, []string{StatusDraft, StatusActive, StatusDisabled}, StatusRevoked) } func (s *Store) casSlotStatus(ctx context.Context, principalID, slotID string, currentRevision int64, fromStatuses []string, toStatus string) (*CredentialSlotRecord, error) { var updated CredentialSlotRecord err := s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error { if toStatus == StatusActive { var slot CredentialSlotRecord if err := s.loadSlotTx(ctx, tx, principalID, slotID, &slot); err != nil { if errors.Is(err, sql.ErrNoRows) { return ErrSlotNotFound } return err } if err := s.validateEnvelopeKey(ctx, slot.Envelope); err != nil { return err } ok, err := s.slotHasCompatibleBindingTx(ctx, tx, slot) if err != nil { return err } if !ok { return fmt.Errorf("%w: slot has no compatible binding", ErrSlotNotActive) } } now := time.Now().UTC() placeholders := strings.TrimRight(strings.Repeat("?,", len(fromStatuses)), ",") query := `UPDATE credential_slots SET status=?, revision=revision+1, updated_at=?, revoked_at=? WHERE principal_id=? AND id=? AND revision=? AND status IN (` + placeholders + `)` var revokedArg interface{} if toStatus == StatusRevoked { revokedArg = now } args := []any{toStatus, now, revokedArg, principalID, slotID, currentRevision} for _, st := range fromStatuses { args = append(args, st) } res, err := tx.ExecContext(ctx, s.bind(query), args...) if err != nil { return err } affected, err := res.RowsAffected() if err != nil { return err } if affected != 1 { return s.classifySlotTransitionMiss(ctx, tx, principalID, slotID, currentRevision, fromStatuses) } if err := s.loadSlotTx(ctx, tx, principalID, slotID, &updated); err != nil { return err } return s.bumpProjectionGenerationTx(ctx, tx) }) if err != nil { return nil, err } return &updated, nil } func (s *Store) classifySlotTransitionMiss(ctx context.Context, tx *sql.Tx, principalID, slotID string, currentRevision int64, fromStatuses []string) error { var status string var revision int64 err := tx.QueryRowContext(ctx, s.bind(` SELECT status, revision FROM credential_slots WHERE principal_id=? AND id=? `), principalID, slotID).Scan(&status, &revision) if err == sql.ErrNoRows { return ErrSlotNotFound } if err != nil { return err } if revision != currentRevision { return fmt.Errorf("%w: have %d want %d", ErrRevisionMismatch, revision, currentRevision) } if status == StatusRevoked { return ErrSlotRevoked } return fmt.Errorf("%w: have %s want one of %s", ErrSlotNotActive, status, strings.Join(fromStatuses, ",")) } func (s *Store) loadSlotTx(ctx context.Context, tx *sql.Tx, principalID, slotID string, target *CredentialSlotRecord) error { var alias sql.NullString var createdAt, updatedAt string var revokedAt sql.NullString err := tx.QueryRowContext(ctx, s.bind(` SELECT id, principal_id, vendor, credential_kind, alias, status, revision, algorithm, key_id, key_version, nonce, ciphertext, aad, created_at, updated_at, revoked_at FROM credential_slots WHERE principal_id=? AND id=? `), principalID, slotID).Scan( &target.ID, &target.PrincipalID, &target.Vendor, &target.CredentialKind, &alias, &target.Status, &target.Revision, &target.Envelope.Algorithm, &target.Envelope.KeyID, &target.Envelope.KeyVersion, &target.Envelope.Nonce, &target.Envelope.Ciphertext, &target.Envelope.AAD, &createdAt, &updatedAt, &revokedAt, ) if err != nil { return err } if alias.Valid { target.Alias = alias.String } target.CreatedAt, _ = parseTime(createdAt) target.UpdatedAt, _ = parseTime(updatedAt) if revokedAt.Valid { parsed, _ := parseTime(revokedAt.String) target.RevokedAt = &parsed } return nil } // ListSlotRevisions returns all historical secret envelope revisions for a slot. func (s *Store) ListSlotRevisions(ctx context.Context, principalID, slotID string) ([]SlotRevisionRecord, error) { // First verify slot ownership if _, err := s.GetSlot(ctx, principalID, slotID); err != nil { return nil, err } rows, err := s.db.QueryContext(ctx, s.bind(` SELECT id, slot_id, revision, algorithm, key_id, key_version, nonce, ciphertext, aad, created_at FROM credential_slot_revisions WHERE slot_id=? ORDER BY revision ASC `), slotID) if err != nil { return nil, err } defer rows.Close() var out []SlotRevisionRecord for rows.Next() { var r SlotRevisionRecord var createdAt string if err := rows.Scan( &r.ID, &r.SlotID, &r.Revision, &r.Envelope.Algorithm, &r.Envelope.KeyID, &r.Envelope.KeyVersion, &r.Envelope.Nonce, &r.Envelope.Ciphertext, &r.Envelope.AAD, &createdAt, ); err != nil { return nil, err } r.CreatedAt, _ = parseTime(createdAt) out = append(out, r) } return out, rows.Err() }