Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions handshake.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,9 @@ func (hm *Manager) removeWaiter(nodeID uint32, target chan struct{}) {
// --- Trust persistence ---

type trustSnapshot struct {
Trusted []trustSnapshotEntry `json:"trusted"`
Pending []pendingSnapshotEntry `json:"pending,omitempty"`
Trusted []trustSnapshotEntry `json:"trusted"`
Pending []pendingSnapshotEntry `json:"pending,omitempty"`
Revoked []revokedSnapshotEntry `json:"revoked,omitempty"`
}

type trustSnapshotEntry struct {
Expand All @@ -286,6 +287,11 @@ type pendingSnapshotEntry struct {
ReceivedAt string `json:"received_at"`
}

type revokedSnapshotEntry struct {
NodeID uint32 `json:"node_id"`
Until string `json:"until"` // RFC3339 timestamp of cooldown expiry
}

func (hm *Manager) saveTrust() {
if hm.storePath == "" {
return
Expand All @@ -309,6 +315,15 @@ func (hm *Manager) saveTrust() {
ReceivedAt: p.ReceivedAt.Format(time.RFC3339),
})
}
for nodeID, until := range hm.revoked {
// Only persist entries whose cooldown hasn't expired yet.
if time.Now().Before(until) {
snap.Revoked = append(snap.Revoked, revokedSnapshotEntry{
NodeID: nodeID,
Until: until.Format(time.RFC3339),
})
}
}

// MarshalIndent on trustSnapshot is infallible: every field is a
// primitive (uint32/string/bool/uint16), pre-formatted via
Expand All @@ -325,7 +340,7 @@ func (hm *Manager) saveTrust() {
slog.Error("write trust state", "err", err)
return
}
slog.Debug("trust state saved", "peers", len(hm.trusted), "pending", len(hm.pending))
slog.Debug("trust state saved", "peers", len(hm.trusted), "pending", len(hm.pending), "revoked", len(snap.Revoked))
}

func (hm *Manager) loadTrust() {
Expand Down Expand Up @@ -363,7 +378,17 @@ func (hm *Manager) loadTrust() {
ReceivedAt: received,
}
}
slog.Info("loaded trust state", "peers", len(hm.trusted), "pending", len(hm.pending))
for _, e := range snap.Revoked {
until, err := time.Parse(time.RFC3339, e.Until)
if err != nil {
continue
}
// Only restore if the cooldown hasn't expired yet.
if time.Now().Before(until) {
hm.revoked[e.NodeID] = until
}
}
slog.Info("loaded trust state", "peers", len(hm.trusted), "pending", len(hm.pending), "revoked", len(hm.revoked))
}

// Start binds port 444 and begins handling handshake connections.
Expand Down
46 changes: 46 additions & 0 deletions zz_logic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,52 @@ func TestSaveLoadTrustRoundTripPreservesEntries(t *testing.T) {
}
}

func TestSaveLoadTrustRoundTripPreservesRevoked(t *testing.T) {
t.Parallel()
dir := t.TempDir()
idPath := filepath.Join(dir, "identity.json")
hm := newTestHM(t, idPath)
if hm.storePath == "" {
t.Fatal("storePath should be derived from IdentityPath")
}

// Add a revoked entry with a cooldown 10 minutes in the future.
futureCooldown := time.Now().Add(10 * time.Minute).Truncate(time.Second).UTC()
hm.revoked[13] = futureCooldown
hm.saveTrust()

// Load into a fresh manager — revoked[13] must survive the roundtrip.
hm2 := newTestHM(t, idPath)
until, ok := hm2.revoked[13]
if !ok {
t.Fatal("revoked[13] missing after load — restart resurrects revoked peers")
}
if !until.Equal(futureCooldown) {
t.Fatalf("revoked[13] = %v, want %v", until, futureCooldown)
}
}

func TestLoadTrustDropsExpiredRevokedCooldowns(t *testing.T) {
t.Parallel()
dir := t.TempDir()
idPath := filepath.Join(dir, "identity.json")
hm := newTestHM(t, idPath)
if hm.storePath == "" {
t.Fatal("storePath should be derived from IdentityPath")
}

// Add a revoked entry whose cooldown is already in the past.
pastCooldown := time.Now().Add(-1 * time.Minute).Truncate(time.Second).UTC()
hm.revoked[7] = pastCooldown
hm.saveTrust()

// Load — expired cooldown should be silently dropped.
hm2 := newTestHM(t, idPath)
if _, ok := hm2.revoked[7]; ok {
t.Fatal("revoked[7] should be dropped on load — cooldown already expired")
}
}

func TestLoadTrustMissingFileIsNoop(t *testing.T) {
t.Parallel()
dir := t.TempDir()
Expand Down
Loading