Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/tofu-ttl.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@resciencelab/dap": patch
---

Add TOFU binding TTL (default 7 days) to limit key compromise exposure window
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 44 additions & 0 deletions src/peer-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,14 @@ export function pruneStale(maxAgeMs: number, protectedIds: string[] = []): numbe
return pruned
}

const DEFAULT_TOFU_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7 days

let _tofuTtlMs: number = DEFAULT_TOFU_TTL_MS

export function setTofuTtl(days: number): void {
_tofuTtlMs = days * 24 * 60 * 60 * 1000
}

export function tofuVerifyAndCache(agentId: string, publicKey: string): boolean {
const now = Date.now()
const existing = store.peers[agentId]
Expand All @@ -175,6 +183,7 @@ export function tofuVerifyAndCache(agentId: string, publicKey: string): boolean
capabilities: [],
firstSeen: now,
lastSeen: now,
tofuCachedAt: now,
source: "gossip",
}
saveImmediate()
Expand All @@ -183,6 +192,17 @@ export function tofuVerifyAndCache(agentId: string, publicKey: string): boolean

if (!existing.publicKey) {
existing.publicKey = publicKey
existing.tofuCachedAt = now
existing.lastSeen = now
saveImmediate()
return true
}

// TTL check: if binding has expired, accept new key as fresh TOFU
if (existing.tofuCachedAt && now - existing.tofuCachedAt > _tofuTtlMs) {
console.log(`[p2p:db] TOFU TTL expired for ${agentId} — accepting new key`)
existing.publicKey = publicKey
existing.tofuCachedAt = now
existing.lastSeen = now
saveImmediate()
return true
Expand All @@ -193,10 +213,34 @@ export function tofuVerifyAndCache(agentId: string, publicKey: string): boolean
}

existing.lastSeen = now
if (!existing.tofuCachedAt) existing.tofuCachedAt = now
save()
return true
}

export function tofuReplaceKey(agentId: string, newPublicKey: string): void {
const now = Date.now()
const existing = store.peers[agentId]
if (existing) {
existing.publicKey = newPublicKey
existing.tofuCachedAt = now
existing.lastSeen = now
} else {
store.peers[agentId] = {
agentId,
publicKey: newPublicKey,
alias: "",
endpoints: [],
capabilities: [],
firstSeen: now,
lastSeen: now,
tofuCachedAt: now,
source: "gossip",
}
}
saveImmediate()
}

/** Extract a reachable address from a peer's endpoints for a given transport. */
export function getEndpointAddress(peer: DiscoveredPeerRecord, transport: string): string | null {
const ep = peer.endpoints
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export interface PeerRecord {
}

export interface DiscoveredPeerRecord extends PeerRecord {
tofuCachedAt?: number // timestamp when TOFU binding was first established
discoveredVia?: string
source: "manual" | "bootstrap" | "gossip"
version?: string
Expand All @@ -85,6 +86,7 @@ export interface PluginConfig {
bootstrap_peers?: string[]
discovery_interval_ms?: number
startup_delay_ms?: number
tofu_ttl_days?: number
}

// ── Key rotation (future) ───────────────────────────────────────────────────
Expand Down
75 changes: 74 additions & 1 deletion test/agentid-peer-db.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import assert from "node:assert/strict"
import * as fs from "fs"
import * as path from "path"
import * as os from "os"
import { initDb, upsertPeer, upsertDiscoveredPeer, listPeers, getPeer, removePeer, flushDb, tofuVerifyAndCache, getPeerIds, pruneStale, getEndpointAddress } from "../dist/peer-db.js"
import { initDb, upsertPeer, upsertDiscoveredPeer, listPeers, getPeer, removePeer, flushDb, tofuVerifyAndCache, tofuReplaceKey, setTofuTtl, getPeerIds, pruneStale, getEndpointAddress } from "../dist/peer-db.js"
import { generateIdentity } from "../dist/identity.js"

let tmpDir
Expand Down Expand Up @@ -81,6 +81,79 @@ describe("peer-db (agentId-keyed)", () => {
assert.ok(getPeer(id2.agentId))
})

it("TOFU: tofuCachedAt is set on first cache", () => {
const id = generateIdentity()
const before = Date.now()
tofuVerifyAndCache(id.agentId, id.publicKey)
const peer = getPeer(id.agentId)
assert.ok(peer)
assert.ok(typeof peer.tofuCachedAt === "number")
assert.ok(peer.tofuCachedAt >= before)
})

it("TOFU TTL: accepts new key after expiry", () => {
const id1 = generateIdentity()
const id2 = generateIdentity()

// Set a very short TTL (1ms) for testing
setTofuTtl(1 / (24 * 60 * 60 * 1000)) // 1ms expressed in days

// Cache first key
assert.equal(tofuVerifyAndCache(id1.agentId, id1.publicKey), true)

// Backdate tofuCachedAt to simulate expiry
const peer = getPeer(id1.agentId)
peer.tofuCachedAt = Date.now() - 100 // 100ms ago, well past 1ms TTL

// A different key should now be accepted
assert.equal(tofuVerifyAndCache(id1.agentId, id2.publicKey), true)
const updated = getPeer(id1.agentId)
assert.equal(updated.publicKey, id2.publicKey)

// Restore default TTL
setTofuTtl(7)
})

it("TOFU TTL: rejects new key before expiry", () => {
const id1 = generateIdentity()
const id2 = generateIdentity()

// Set a long TTL (100 days)
setTofuTtl(100)

assert.equal(tofuVerifyAndCache(id1.agentId, id1.publicKey), true)

// Key is fresh — different key must be rejected
assert.equal(tofuVerifyAndCache(id1.agentId, id2.publicKey), false)

// Restore default TTL
setTofuTtl(7)
})

it("tofuReplaceKey replaces existing binding", () => {
const id1 = generateIdentity()
const id2 = generateIdentity()

tofuVerifyAndCache(id1.agentId, id1.publicKey)
tofuReplaceKey(id1.agentId, id2.publicKey)

const peer = getPeer(id1.agentId)
assert.equal(peer.publicKey, id2.publicKey)
assert.ok(peer.tofuCachedAt)

// New key should now verify correctly
assert.equal(tofuVerifyAndCache(id1.agentId, id2.publicKey), true)
})

it("tofuReplaceKey creates new record if peer not found", () => {
const id = generateIdentity()
tofuReplaceKey(id.agentId, id.publicKey)
const peer = getPeer(id.agentId)
assert.ok(peer)
assert.equal(peer.publicKey, id.publicKey)
assert.ok(peer.tofuCachedAt)
})

it("getEndpointAddress returns best address for transport", () => {
const id = generateIdentity()
upsertDiscoveredPeer(id.agentId, id.publicKey, {
Expand Down