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
This commit is contained in:
Alexandr Stelnykovych
2026-05-14 17:25:33 +03:00
parent e0507ca85b
commit 353fda0855
3 changed files with 100 additions and 3 deletions
@@ -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/']);
},
});
});
}
+21 -2
View File
@@ -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
}
+60
View File
@@ -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
}