diff --git a/internal/security/coverage_test.go b/internal/security/coverage_test.go index 32ddb4b8..9d598738 100644 --- a/internal/security/coverage_test.go +++ b/internal/security/coverage_test.go @@ -1,6 +1,7 @@ package security import ( + "net" "os" "path/filepath" "testing" @@ -111,7 +112,7 @@ func TestGetMACAddress_ReturnsNonEmpty(t *testing.T) { t.Parallel() mac, err := GetMACAddress() if err != nil { - t.Skipf("no physical NIC available: %v", err) + t.Skipf("no NIC available: %v", err) } if mac == "" { t.Fatal("expected non-empty MAC address") @@ -122,6 +123,85 @@ func TestGetMACAddress_ReturnsNonEmpty(t *testing.T) { } } +func TestSelectMAC_PhysicalPreferred(t *testing.T) { + t.Parallel() + ifaces := []net.Interface{ + {Index: 1, Name: "lo", Flags: net.FlagLoopback, HardwareAddr: nil}, + {Index: 2, Name: "eth0", Flags: net.FlagUp, HardwareAddr: net.HardwareAddr{0x02, 0x42, 0xac, 0x11, 0x00, 0x02}}, // Docker virtual + {Index: 3, Name: "enp0s3", Flags: net.FlagUp, HardwareAddr: net.HardwareAddr{0x14, 0x98, 0x77, 0xab, 0xcd, 0xef}}, // physical + } + mac, err := selectMAC(ifaces) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mac != "14:98:77:ab:cd:ef" { + t.Fatalf("expected physical MAC, got %s", mac) + } +} + +func TestSelectMAC_VirtualFallback(t *testing.T) { + t.Parallel() + // Docker container scenario: only loopback + Docker veth + ifaces := []net.Interface{ + {Index: 1, Name: "lo", Flags: net.FlagLoopback, HardwareAddr: nil}, + {Index: 2, Name: "eth0", Flags: net.FlagUp, HardwareAddr: net.HardwareAddr{0x02, 0x42, 0xac, 0x11, 0x00, 0x02}}, + } + mac, err := selectMAC(ifaces) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mac != "02:42:ac:11:00:02" { + t.Fatalf("expected Docker virtual MAC, got %s", mac) + } +} + +func TestSelectMAC_MultipleVirtualPicksFirst(t *testing.T) { + t.Parallel() + ifaces := []net.Interface{ + {Index: 1, Name: "lo", Flags: net.FlagLoopback}, + {Index: 2, Name: "eth0", Flags: net.FlagUp, HardwareAddr: net.HardwareAddr{0x02, 0x42, 0xbb, 0x00, 0x00, 0x01}}, + {Index: 3, Name: "eth1", Flags: net.FlagUp, HardwareAddr: net.HardwareAddr{0x02, 0x42, 0xaa, 0x00, 0x00, 0x01}}, + } + mac, err := selectMAC(ifaces) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Lexicographically: 02:42:aa:... < 02:42:bb:... + if mac != "02:42:aa:00:00:01" { + t.Fatalf("expected lexicographically first virtual MAC, got %s", mac) + } +} + +func TestSelectMAC_NoInterfaces(t *testing.T) { + t.Parallel() + _, err := selectMAC(nil) + if err == nil { + t.Fatal("expected error for empty interface list") + } +} + +func TestSelectMAC_OnlyLoopback(t *testing.T) { + t.Parallel() + ifaces := []net.Interface{ + {Index: 1, Name: "lo", Flags: net.FlagLoopback, HardwareAddr: net.HardwareAddr{0x00, 0x00, 0x00, 0x00, 0x00, 0x00}}, + } + _, err := selectMAC(ifaces) + if err == nil { + t.Fatal("expected error when only loopback exists") + } +} + +func TestSelectMAC_NoHardwareAddr(t *testing.T) { + t.Parallel() + ifaces := []net.Interface{ + {Index: 1, Name: "tun0", Flags: net.FlagUp, HardwareAddr: nil}, + } + _, err := selectMAC(ifaces) + if err == nil { + t.Fatal("expected error for interfaces without hardware address") + } +} + // ─── storage.go ──────────────────────────────────────────────────────── func TestDeleteEncryptedData(t *testing.T) { diff --git a/internal/security/fingerprint.go b/internal/security/fingerprint.go index d4d56025..f5021082 100644 --- a/internal/security/fingerprint.go +++ b/internal/security/fingerprint.go @@ -43,14 +43,10 @@ func isVirtualMAC(mac string) bool { return false } -// GetMACAddress returns the first physical (non-loopback, non-virtual) MAC -// address, sorted lexicographically for determinism. -func GetMACAddress() (string, error) { - ifaces, err := net.Interfaces() - if err != nil { - return "", fmt.Errorf("enumerating network interfaces: %w", err) - } - var candidates []string +// selectMAC picks the best MAC from the given interface list. +// Physical NICs are preferred; virtual MACs are used as fallback. +func selectMAC(ifaces []net.Interface) (string, error) { + var physical, virtual []string for _, iface := range ifaces { if iface.Flags&net.FlagLoopback != 0 { continue @@ -61,13 +57,30 @@ func GetMACAddress() (string, error) { } s := strings.ToLower(hw.String()) if isVirtualMAC(s) { - continue + virtual = append(virtual, s) + } else { + physical = append(physical, s) } - candidates = append(candidates, s) } - if len(candidates) == 0 { - return "", fmt.Errorf("no physical NIC MAC address found") + if len(physical) > 0 { + slices.Sort(physical) + return physical[0], nil } - slices.Sort(candidates) - return candidates[0], nil + if len(virtual) > 0 { + slices.Sort(virtual) + return virtual[0], nil + } + return "", fmt.Errorf("no NIC MAC address found (no network interfaces available)") +} + +// GetMACAddress returns the first physical (non-loopback, non-virtual) MAC +// address, sorted lexicographically for determinism. If no physical NIC is +// found (e.g. inside a Docker container), it falls back to the first +// non-loopback virtual MAC address so that token encryption still works. +func GetMACAddress() (string, error) { + ifaces, err := net.Interfaces() + if err != nil { + return "", fmt.Errorf("enumerating network interfaces: %w", err) + } + return selectMAC(ifaces) }