From 353fda0855bedef4d50f33df4d292997de498b7f Mon Sep 17 00:00:00 2001 From: Alexandr Stelnykovych Date: Thu, 14 May 2026 17:25:33 +0300 Subject: [PATCH] feat(profile): re-key profiles when fingerprints change - Detect fingerprint changes and re-derive the profile ID - Migrate profile to new ID while preserving all settings and creation time - Update UI to handle profile navigation after fingerprint-triggered re-keying - Emit EventMigrated for history DB and connection re-attribution https://github.com/safing/portmaster/issues/1997 --- .../src/app/pages/app-view/app-view.ts | 20 ++++++- service/profile/database.go | 23 ++++++- service/profile/merge.go | 60 +++++++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/desktop/angular/src/app/pages/app-view/app-view.ts b/desktop/angular/src/app/pages/app-view/app-view.ts index 85a365a2..3ce445a5 100644 --- a/desktop/angular/src/app/pages/app-view/app-view.ts +++ b/desktop/angular/src/app/pages/app-view/app-view.ts @@ -275,15 +275,33 @@ export class AppViewComponent implements OnInit, OnDestroy { return; } + const source = this.appProfile.Source; + const id = this.appProfile.ID; + this.dialog .create(EditProfileDialog, { backdrop: true, autoclose: false, - data: `${this.appProfile.Source}/${this.appProfile.ID}`, + data: `${source}/${id}`, }) .onAction('deleted', () => { // navigate to the app overview if it has been deleted. this.router.navigate(['/app/']); + }) + .onAction('saved', () => { + // After save, the backend may have migrated the profile to a new ID + // (when fingerprints changed). Verify it still exists at the same ID; + // if not, navigate to the overview so the user can re-open the app + // and the component reinitializes with the correct new profile. + this.profileService.getAppProfile(`${source}/${id}`).subscribe({ + error: () => { + this.actionIndicator.info( + 'Profile ID Changed', + 'The fingerprint change caused the profile to be re-keyed. You can find the app in the app list to continue editing.' + ); + this.router.navigate(['/app/']); + }, + }); }); } diff --git a/service/profile/database.go b/service/profile/database.go index 82499cc7..0c75996e 100644 --- a/service/profile/database.go +++ b/service/profile/database.go @@ -2,6 +2,7 @@ package profile import ( "errors" + "fmt" "strings" "github.com/safing/portmaster/base/config" @@ -61,8 +62,26 @@ func startProfileUpdateChecker() error { return errors.New("subscription canceled") } - // Get active profile. scopedID := strings.TrimPrefix(r.Key(), ProfilesDBPath) + + // Check if fingerprints changed such that the profile ID needs to be + // re-derived. This must run before the activeProfile lookup so that it + // also applies when the app is not currently running. + if !r.Meta().IsDeleted() { + if receivedProfile, parseErr := EnsureProfile(r); parseErr == nil && !receivedProfile.savedInternally { + if len(receivedProfile.Fingerprints) > 0 && + DeriveProfileID(receivedProfile.Fingerprints) != receivedProfile.ID { + if renameErr := migrateProfileOnFingerprintChange(receivedProfile); renameErr != nil { + log.Errorf("profile: failed to rename profile %s after fingerprint change: %s", scopedID, renameErr) + } + // The rename saves a new profile and deletes the old one, each of + // which produces its own feed event. Skip further processing here. + continue profileFeed + } + } + } + + // Get active profile. activeProfile := getActiveProfile(scopedID) if activeProfile == nil { // Check if profile is being deleted. @@ -109,7 +128,7 @@ type databaseHook struct { database.HookBase } -// UsesPrePut implements the Hook interface and returns false. +// UsesPrePut implements the Hook interface and returns true. func (h *databaseHook) UsesPrePut() bool { return true } diff --git a/service/profile/merge.go b/service/profile/merge.go index d2c3f1c2..dafeb787 100644 --- a/service/profile/merge.go +++ b/service/profile/merge.go @@ -6,7 +6,9 @@ import ( "sync" "time" + "github.com/safing/portmaster/base/database" "github.com/safing/portmaster/base/database/record" + "github.com/safing/portmaster/base/log" "github.com/safing/portmaster/service/profile/binmeta" ) @@ -102,3 +104,61 @@ func addFingerprints(existing, add []Fingerprint, from string) []Fingerprint { return existing } + +// migrateProfileOnFingerprintChange creates a new profile whose ID is derived +// from the updated fingerprints, copies all settings from the old profile, +// deletes the old profile, and emits EventMigrated. This mirrors the pattern +// used by MergeProfiles and ensures that: +// - history DB entries are migrated (via EventMigrated → netquery handler) +// - active connections are re-attributed (via EventDelete → reAttributeConnections) +// - future connections find the profile via normal fingerprint matching +func migrateProfileOnFingerprintChange(old *Profile) error { + newDerivedID := DeriveProfileID(old.Fingerprints) + + // Abort if a profile with the target ID already exists — the user may have + // set fingerprints that conflict with another existing profile. + _, existsErr := profileDB.Get(ProfilesDBPath + MakeScopedID(old.Source, newDerivedID)) + if existsErr == nil { + log.Debugf("profile: skipping rename of %s: target ID %s already exists", old.ScopedID(), newDerivedID) + return nil + } + if !errors.Is(existsErr, database.ErrNotFound) { + return fmt.Errorf("failed to check for existing profile %s: %w", newDerivedID, existsErr) + } + + // Build the new profile. ID is left empty so New() derives it from Fingerprints. + newProfile := New(&Profile{ + Source: old.Source, + Name: old.Name, + Description: old.Description, + Warning: old.Warning, + WarningLastUpdated: old.WarningLastUpdated, + Homepage: old.Homepage, + Icon: old.Icon, + IconType: old.IconType, + Icons: old.Icons, + LinkedPath: old.LinkedPath, + PresentationPath: old.PresentationPath, + UsePresentationPath: old.UsePresentationPath, + Fingerprints: old.Fingerprints, + Config: old.Config, + LastEdited: time.Now().Unix(), + Internal: old.Internal, + }) + // Preserve the original creation timestamp (New() always overwrites it). + newProfile.Created = old.Created + + if err := newProfile.Save(); err != nil { + return fmt.Errorf("failed to save renamed profile: %w", err) + } + + if err := old.delete(); err != nil { + return fmt.Errorf("failed to delete old profile %s: %w", old.ScopedID(), err) + } + + module.EventMigrated.Submit([]string{old.ScopedID(), newProfile.ScopedID()}) + log.Infof("profile: renamed profile %q from %s to %s due to fingerprint change", + old.Name, old.ScopedID(), newProfile.ScopedID()) + + return nil +}