Ctrl K
ComunidadGuías…

model/passkey_option.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package model

import (
	"crypto/hmac"
	"errors"
	"maps"
	"slices"
	"strings"
	"sync"

	"github.com/QuantumNous/new-api/common"
	"github.com/QuantumNous/new-api/setting/system_setting"
	"gorm.io/gorm"
	"gorm.io/gorm/clause"
)

var passkeyOptionMutex sync.Mutex
var errPasskeyDomainPreview = errors.New("passkey domain preview")
var ErrPasskeyDomainRemovalConfirmation = errors.New("Review the affected Passkeys and confirm the domain removal.")

// PasskeyDomainChange is both the preview and the authoritative saved result.
// Unknown credentials may belong to a removed domain; they are not a known loss.
type PasskeyDomainChange struct {
	RPID                 string   `json:"rp_id"`
	LegacyRPIDs          string   `json:"legacy_rp_ids"`
	Origins              string   `json:"origins"`
	PreviousRPID         string   `json:"previous_rp_id"`
	EffectiveRPID        string   `json:"effective_rp_id"`
	RemovedRPIDs         []string `json:"removed_rp_ids"`
	AffectedCredentials  int64    `json:"affected_credentials"`
	UnknownCredentials   int64    `json:"unknown_credentials"`
	ConfirmationRequired bool     `json:"confirmation_required"`
	RemovalConfirmation  string   `json:"removal_confirmation"`
}

type PasskeyDomainRemovalError struct {
	Change *PasskeyDomainChange
}

func (e *PasskeyDomainRemovalError) Error() string {
	return ErrPasskeyDomainRemovalConfirmation.Error()
}
func (e *PasskeyDomainRemovalError) Unwrap() error { return ErrPasskeyDomainRemovalConfirmation }

// ServerAddress and origins participate because either can change an implicit RP ID.
func IsPasskeyDomainOption(key string) bool {
	return key == "passkey.rp_id" || key == "passkey.legacy_rp_ids" || key == "passkey.origins" || key == "ServerAddress"
}

// lockPasskeyDomainSettings serializes trust changes and credential writes across
// nodes. Callers also hold passkeyOptionMutex for SQLite and local cache ordering.
// Always acquire these option rows before any user/session/credential row locks.
func lockPasskeyDomainSettings(tx *gorm.DB) (system_setting.PasskeySettings, string, error) {
	common.OptionMapRWMutex.RLock()
	settings, serverAddress := *system_setting.GetPasskeySettings(), system_setting.ServerAddress
	common.OptionMapRWMutex.RUnlock()
	rows := []Option{
		{Key: "ServerAddress", Value: serverAddress},
		{Key: "passkey.legacy_rp_ids", Value: settings.LegacyRPIDs},
		{Key: "passkey.origins", Value: settings.Origins},
		{Key: "passkey.rp_id", Value: settings.RPID},
	}
	// The first write also acquires SQLite's writer lock before any reads.
	if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&rows).Error; err != nil {
		return settings, serverAddress, err
	}
	for i := range rows {
		if err := lockForUpdate(tx).Where(&Option{Key: rows[i].Key}).First(&rows[i]).Error; err != nil {
			return settings, serverAddress, err
		}
	}
	serverAddress = rows[0].Value
	settings.LegacyRPIDs, settings.Origins, settings.RPID = rows[1].Value, rows[2].Value, rows[3].Value
	return settings, serverAddress, nil
}

func validatePasskeyRPIDWithTx(tx *gorm.DB, rpID string) error {
	settings, serverAddress, err := lockPasskeyDomainSettings(tx)
	if err != nil {
		return err
	}
	// Preserve the existing request-derived default only when no domain or
	// origin has ever been configured. Finish still checks the signed origin.
	if settings.RPID == "" && settings.LegacyRPIDs == "" && settings.Origins == "" && serverAddress == "" {
		return nil
	}
	if !slices.Contains(settings.WithDefaults(serverAddress).RelyingPartyIDs(), rpID) {
		return system_setting.ErrPasskeyRPIDUnavailable
	}
	return nil
}

// UpdatePasskeyDomainOptions keeps every supplied option in one transaction,
// including unrelated keys from UpdateOptionsBulk. Preview rolls back even the
// initial default rows and never publishes a local configuration change.
func UpdatePasskeyDomainOptions(values map[string]string, preview bool, confirmation string) (*PasskeyDomainChange, error) {
	passkeyOptionMutex.Lock()
	defer passkeyOptionMutex.Unlock()
	values = maps.Clone(values)
	for key, value := range values {
		if err := validateOptionValue(key, value); err != nil {
			return nil, err
		}
	}
	var change *PasskeyDomainChange
	err := DB.Transaction(func(tx *gorm.DB) error {
		settings, serverAddress, err := lockPasskeyDomainSettings(tx)
		if err != nil {
			return err
		}
		previous := settings.WithDefaults(serverAddress)
		before := [4]string{serverAddress, settings.RPID, settings.LegacyRPIDs, settings.Origins}
		if value, changed := values["ServerAddress"]; changed {
			serverAddress = value
		}
		if value, changed := values["passkey.origins"]; changed {
			origins := []string{}
			for _, origin := range strings.FieldsFunc(value, func(c rune) bool { return c == ',' || c == '\n' || c == '\r' }) {
				origin = strings.TrimSpace(origin)
				if origin != "" && !slices.Contains(origins, origin) {
					origins = append(origins, origin)
				}
			}
			settings.Origins = strings.Join(origins, ",")
		} else if before[0] != serverAddress && (settings.Origins == "" || settings.Origins == "[]") {
			// Retain the previously trusted implicit website as well as the new
			// default, so retaining its RP ID does not leave its keys unusable.
			origins := []string{}
			for _, origin := range []string{previous.Origins, settings.WithDefaults(serverAddress).Origins} {
				if origin != "" && !slices.Contains(origins, origin) {
					origins = append(origins, origin)
				}
			}
			settings.Origins = strings.Join(origins, ",")
		}
		if value, changed := values["passkey.rp_id"]; changed {
			settings.RPID = strings.TrimSpace(value)
			if settings.RPID != "" {
				settings.RPID, err = system_setting.NormalizePasskeyRPID(settings.RPID, settings.WithDefaults(serverAddress).Origins)
				if err != nil {
					return err
				}
			}
		}
		if value, changed := values["passkey.legacy_rp_ids"]; changed {
			settings.LegacyRPIDs = value
		}
		effective := settings.WithDefaults(serverAddress)
		legacy, err := system_setting.ParsePasskeyRPIDs(settings.LegacyRPIDs, effective.Origins)
		if err != nil {
			return err
		}
		oldRPID, nextRPID := previous.EffectiveRPID(), effective.EffectiveRPID()
		_, previousDomainError := system_setting.NormalizePasskeyRPID(oldRPID, previous.Origins)
		if previousDomainError == nil && oldRPID != nextRPID && !slices.Contains(legacy, oldRPID) {
			legacy = append(legacy, oldRPID)
		}
		settings.LegacyRPIDs = strings.Join(legacy, ",")
		// Validate retained single-label domains against the complete proposed
		// origin set too. Do not normalize their historical hash inputs.
		if _, err := system_setting.ParsePasskeyRPIDs(settings.LegacyRPIDs, effective.Origins); err != nil {
			return err
		}
		// ServerAddress also supports non-WebAuthn deployments (for example an
		// IP during initial setup). Restrict explicit RP IDs, not that URL.
		if settings.RPID != "" {
			if _, err := system_setting.NormalizePasskeyRPID(nextRPID, effective.Origins); err != nil {
				return err
			}
		}
		change = &PasskeyDomainChange{
			RPID: settings.RPID, LegacyRPIDs: settings.LegacyRPIDs, Origins: settings.Origins,
			PreviousRPID: oldRPID, EffectiveRPID: nextRPID, RemovedRPIDs: []string{},
		}
		nextIDs := settings.WithDefaults(serverAddress).RelyingPartyIDs()
		for _, id := range previous.RelyingPartyIDs() {
			if !slices.Contains(nextIDs, id) {
				change.RemovedRPIDs = append(change.RemovedRPIDs, id)
			}
		}
		if len(change.RemovedRPIDs) > 0 {
			// MySQL may compare/group text case-insensitively. Select only
			// candidate RP values, then count exact bytes without credential IDs.
			rows, err := tx.Model(&PasskeyCredential{}).Select("rp_id").
				Where("rp_id IS NULL OR rp_id = ? OR rp_id IN ?", "", change.RemovedRPIDs).Rows()
			if err != nil {
				return err
			}
			defer rows.Close()
			for rows.Next() {
				var rpID *string
				if err := rows.Scan(&rpID); err != nil {
					return err
				}
				if rpID == nil || *rpID == "" {
					change.UnknownCredentials++
				} else if slices.Contains(change.RemovedRPIDs, *rpID) {
					change.AffectedCredentials++
				}
			}
			if err := rows.Err(); err != nil {
				return err
			}
			if err := rows.Close(); err != nil {
				return err
			}
		}
		change.ConfirmationRequired = change.AffectedCredentials > 0 || change.UnknownCredentials > 0
		// This is an intent/freshness binding, not an authentication credential.
		// Root authorization remains mandatory. No map iteration affects its hash.
		payload, err := common.Marshal(struct {
			Before         [4]string
			After          [4]string
			Removed        []string
			Known, Unknown int64
		}{before, [4]string{serverAddress, settings.RPID, settings.LegacyRPIDs, settings.Origins}, change.RemovedRPIDs, change.AffectedCredentials, change.UnknownCredentials})
		if err != nil {
			return err
		}
		change.RemovalConfirmation = common.GenerateHMACWithKey([]byte("passkey-domains-v1:"+common.SessionSecret), string(payload))
		if preview {
			return errPasskeyDomainPreview
		}
		if (change.ConfirmationRequired || confirmation != "") && !hmac.Equal([]byte(confirmation), []byte(change.RemovalConfirmation)) {
			return &PasskeyDomainRemovalError{Change: change}
		}
		values["passkey.rp_id"], values["passkey.legacy_rp_ids"], values["passkey.origins"] = settings.RPID, settings.LegacyRPIDs, settings.Origins
		values["ServerAddress"] = serverAddress
		keys := slices.Sorted(maps.Keys(values))
		for _, key := range keys {
			if err := tx.Save(&Option{Key: key, Value: values[key]}).Error; err != nil {
				return err
			}
		}
		return nil
	})
	if errors.Is(err, errPasskeyDomainPreview) {
		return change, nil
	}
	if err != nil {
		return change, err
	}
	applyPasskeyDomainOptions(values)
	for key, value := range values {
		if !IsPasskeyDomainOption(key) {
			if err := updateOptionMap(key, value); err != nil {
				return change, err
			}
		}
	}
	return change, nil
}

func applyPasskeyDomainOptions(values map[string]string) {
	common.OptionMapRWMutex.Lock()
	defer common.OptionMapRWMutex.Unlock()
	if common.OptionMap == nil {
		common.OptionMap = make(map[string]string)
	}
	for _, key := range []string{"ServerAddress", "passkey.legacy_rp_ids", "passkey.origins", "passkey.rp_id"} {
		if value, ok := values[key]; ok {
			common.OptionMap[key] = value
			if key == "ServerAddress" {
				system_setting.ServerAddress = value
			} else {
				handleConfigUpdate(key, value)
			}
		}
	}
}