178 lines
9.8 KiB
Swift
178 lines
9.8 KiB
Swift
import Foundation
|
|
import Testing
|
|
import NucleicProtocol
|
|
|
|
@testable import NucleicCore
|
|
|
|
/// Mesh "join": the roster-merge policy — auto-trust an introduced member, reject a forged
|
|
/// `(deviceID, key)` pair, never overwrite a pin from gossip, and honor the revocation-tombstone
|
|
/// ordering. Pure store-level, so these are hermetic.
|
|
@Suite struct MeshRosterMergeTests {
|
|
private let selfID = "self-host-id"
|
|
|
|
/// A self-consistent mac member (`deviceID == sha256(staticPublicKey)`), like a real host.
|
|
private func macMember(
|
|
label: String, pairedAt: Date, addresses: PeerAddresses? = nil
|
|
) -> (member: MeshMember, identity: DeviceIdentity) {
|
|
let identity = DeviceIdentity()
|
|
let member = MeshMember(
|
|
deviceID: identity.hostID, label: label, kind: .mac, capabilities: .hostAndAgents,
|
|
staticPublicKey: identity.staticPublicKey, addresses: addresses, pairedAt: pairedAt)
|
|
return (member, identity)
|
|
}
|
|
|
|
@Test func autoPinsIntroducedMacMember() async throws {
|
|
let store = InMemoryPairedDeviceStore()
|
|
let (c, cIdentity) = macMember(
|
|
label: "Mac C", pairedAt: Date(timeIntervalSince1970: 1_000),
|
|
addresses: PeerAddresses(lanHint: "c.local:5000"))
|
|
let changed = await MeshRosterMerge.apply(
|
|
MeshRosterPush(members: [c]), into: store, selfDeviceID: selfID)
|
|
|
|
#expect(changed)
|
|
let pinned = await store.device(cIdentity.hostID)
|
|
#expect(pinned?.kind == .mac)
|
|
#expect(pinned?.scope == .control)
|
|
#expect(pinned?.staticPublicKey == cIdentity.staticPublicKey)
|
|
#expect(pinned?.pairedAt == Date(timeIntervalSince1970: 1_000)) // origin birth preserved
|
|
#expect(pinned?.addresses?.lanHint == "c.local:5000")
|
|
}
|
|
|
|
/// A record whose `deviceID` isn't `sha256(staticPublicKey)` can't be a real mac identity —
|
|
/// reject it, so a compromised member can't inject a mac with a key it controls.
|
|
@Test func rejectsForgedMacMember() async throws {
|
|
let store = InMemoryPairedDeviceStore()
|
|
let forged = MeshMember(
|
|
deviceID: "not-a-real-hash", label: "Evil", kind: .mac, capabilities: .hostAndAgents,
|
|
staticPublicKey: Data(repeating: 0x09, count: 32), pairedAt: Date())
|
|
let changed = await MeshRosterMerge.apply(
|
|
MeshRosterPush(members: [forged]), into: store, selfDeviceID: selfID)
|
|
#expect(changed == false)
|
|
#expect(await store.device("not-a-real-hash") == nil)
|
|
}
|
|
|
|
/// A phone member (id isn't a key hash) is pinned without the hostID check — phones are visible
|
|
/// members, and a Mac never dials them (`reconcile` filters on `canHost`).
|
|
@Test func pinsPhoneMemberWithoutIntegrityCheck() async throws {
|
|
let store = InMemoryPairedDeviceStore()
|
|
let phone = MeshMember(
|
|
deviceID: "iphone-abc", label: "iPhone", kind: .iphone,
|
|
staticPublicKey: Data(repeating: 0x02, count: 32), pairedAt: Date())
|
|
_ = await MeshRosterMerge.apply(
|
|
MeshRosterPush(members: [phone]), into: store, selfDeviceID: selfID)
|
|
#expect(await store.device("iphone-abc")?.kind == .iphone)
|
|
}
|
|
|
|
/// A key change from gossip must never overwrite an existing pin — a genuine rotation needs a
|
|
/// fresh scan. Otherwise one bad push could lock you out of a peer forever.
|
|
@Test func neverOverwritesExistingPinFromGossip() async throws {
|
|
let store = InMemoryPairedDeviceStore()
|
|
let (c, cIdentity) = macMember(label: "Mac C", pairedAt: Date(timeIntervalSince1970: 1_000))
|
|
await store.upsert(PairedDevice(
|
|
deviceID: cIdentity.hostID, label: "Mac C", staticPublicKey: cIdentity.staticPublicKey,
|
|
scope: .control, pairedAt: Date(timeIntervalSince1970: 1_000), kind: .mac,
|
|
capabilities: .hostAndAgents))
|
|
// Same deviceID, a *different* key — a forged rotation.
|
|
let rotated = MeshMember(
|
|
deviceID: cIdentity.hostID, label: "Hijack", kind: .mac, capabilities: .hostAndAgents,
|
|
staticPublicKey: Data(repeating: 0xFF, count: 32), pairedAt: Date(timeIntervalSince1970: 2_000))
|
|
_ = await MeshRosterMerge.apply(
|
|
MeshRosterPush(members: [rotated]), into: store, selfDeviceID: selfID)
|
|
// Pin unchanged; the hijack label didn't take either.
|
|
#expect(await store.device(cIdentity.hostID)?.staticPublicKey == cIdentity.staticPublicKey)
|
|
#expect(await store.device(cIdentity.hostID)?.label == "Mac C")
|
|
_ = c
|
|
}
|
|
|
|
@Test func refreshesKnownMemberMetadata() async throws {
|
|
let store = InMemoryPairedDeviceStore()
|
|
let (c, cIdentity) = macMember(
|
|
label: "New Name", pairedAt: Date(timeIntervalSince1970: 1_000),
|
|
addresses: PeerAddresses(lanHint: "c.local:6000", updatedAt: Date(timeIntervalSince1970: 5_000)))
|
|
await store.upsert(PairedDevice(
|
|
deviceID: cIdentity.hostID, label: "Old Name", staticPublicKey: cIdentity.staticPublicKey,
|
|
scope: .control, pairedAt: Date(timeIntervalSince1970: 1_000), kind: .mac,
|
|
capabilities: .hostAndAgents,
|
|
addresses: PeerAddresses(lanHint: "c.local:1", updatedAt: Date(timeIntervalSince1970: 1_000))))
|
|
let changed = await MeshRosterMerge.apply(
|
|
MeshRosterPush(members: [c]), into: store, selfDeviceID: selfID)
|
|
#expect(changed)
|
|
#expect(await store.device(cIdentity.hostID)?.label == "New Name")
|
|
#expect(await store.device(cIdentity.hostID)?.addresses?.lanHint == "c.local:6000")
|
|
}
|
|
|
|
/// A tombstone suppresses a member whose membership predates the revocation — it stays dead and
|
|
/// isn't re-pinned by a stale gossip.
|
|
@Test func tombstoneDominatesStaleMember() async throws {
|
|
let store = InMemoryPairedDeviceStore()
|
|
let (c, cIdentity) = macMember(label: "Mac C", pairedAt: Date(timeIntervalSince1970: 1_000))
|
|
let push = MeshRosterPush(
|
|
members: [c],
|
|
tombstones: [MeshTombstone(deviceID: cIdentity.hostID, revokedAt: Date(timeIntervalSince1970: 2_000))])
|
|
_ = await MeshRosterMerge.apply(push, into: store, selfDeviceID: selfID)
|
|
#expect(await store.device(cIdentity.hostID) == nil)
|
|
#expect(await store.isTombstoned(cIdentity.hostID))
|
|
}
|
|
|
|
/// A deliberate re-join (member paired *after* the revocation) beats the tombstone and re-enters.
|
|
@Test func rePairedMemberBeatsTombstone() async throws {
|
|
let store = InMemoryPairedDeviceStore()
|
|
let (c, cIdentity) = macMember(label: "Mac C", pairedAt: Date(timeIntervalSince1970: 3_000))
|
|
let push = MeshRosterPush(
|
|
members: [c],
|
|
tombstones: [MeshTombstone(deviceID: cIdentity.hostID, revokedAt: Date(timeIntervalSince1970: 2_000))])
|
|
_ = await MeshRosterMerge.apply(push, into: store, selfDeviceID: selfID)
|
|
#expect(await store.device(cIdentity.hostID) != nil)
|
|
#expect(await store.isTombstoned(cIdentity.hostID) == false) // stale tombstone cleared
|
|
}
|
|
|
|
@Test func skipsSelfMemberAndSelfTombstone() async throws {
|
|
let store = InMemoryPairedDeviceStore()
|
|
let selfMember = MeshMember(
|
|
deviceID: selfID, label: "Me", kind: .mac, capabilities: .hostAndAgents,
|
|
staticPublicKey: Data(repeating: 0x03, count: 32), pairedAt: Date())
|
|
_ = await MeshRosterMerge.apply(
|
|
MeshRosterPush(
|
|
members: [selfMember],
|
|
tombstones: [MeshTombstone(deviceID: selfID, revokedAt: Date())]),
|
|
into: store, selfDeviceID: selfID)
|
|
#expect(await store.device(selfID) == nil)
|
|
#expect(await store.isTombstoned(selfID) == false) // we never tombstone ourselves
|
|
}
|
|
|
|
/// Freshness bookkeeping alone — a `lastSeenAt` bump (every hello anywhere produces one) or an
|
|
/// `addresses.updatedAt` restamp with identical dial fields — is persisted silently but reports
|
|
/// NO change: counting it re-gossiped the roster mesh-wide per hello and echoed between members.
|
|
@Test func freshnessOnlyUpdateIsPersistedButNotAChange() async throws {
|
|
let store = InMemoryPairedDeviceStore()
|
|
let identity = DeviceIdentity()
|
|
await store.upsert(PairedDevice(
|
|
deviceID: identity.hostID, label: "Mac C", staticPublicKey: identity.staticPublicKey,
|
|
scope: .control, pairedAt: Date(timeIntervalSince1970: 1_000),
|
|
lastSeenAt: Date(timeIntervalSince1970: 2_000), kind: .mac,
|
|
capabilities: .hostAndAgents,
|
|
addresses: PeerAddresses(lanHint: "c.local:6000", updatedAt: Date(timeIntervalSince1970: 2_000))))
|
|
let member = MeshMember(
|
|
deviceID: identity.hostID, label: "Mac C", kind: .mac, capabilities: .hostAndAgents,
|
|
staticPublicKey: identity.staticPublicKey,
|
|
addresses: PeerAddresses(lanHint: "c.local:6000", updatedAt: Date(timeIntervalSince1970: 9_000)),
|
|
pairedAt: Date(timeIntervalSince1970: 1_000),
|
|
lastSeenAt: Date(timeIntervalSince1970: 9_000))
|
|
let changed = await MeshRosterMerge.apply(
|
|
MeshRosterPush(members: [member]), into: store, selfDeviceID: selfID)
|
|
#expect(changed == false)
|
|
// …but the freshness metadata still landed.
|
|
#expect(await store.device(identity.hostID)?.lastSeenAt == Date(timeIntervalSince1970: 9_000))
|
|
}
|
|
|
|
/// Re-applying the same roster reports no change — the idempotence the gossip loop relies on to
|
|
/// converge instead of ping-ponging.
|
|
@Test func reMergeIsIdempotent() async throws {
|
|
let store = InMemoryPairedDeviceStore()
|
|
let (c, _) = macMember(label: "Mac C", pairedAt: Date(timeIntervalSince1970: 1_000))
|
|
let push = MeshRosterPush(members: [c])
|
|
#expect(await MeshRosterMerge.apply(push, into: store, selfDeviceID: selfID))
|
|
#expect(await MeshRosterMerge.apply(push, into: store, selfDeviceID: selfID) == false)
|
|
}
|
|
}
|