Skip to content

Commit bb97911

Browse files
SSD DDDclaude
authored andcommitted
test(rig): verify identity/MITM security over the wire + safety-number consistency
Single-process loopback could never test the identity crypto: it self-pairs (safety number over ONE identity, degenerate) and can't stage a MITM. The two-endpoint rig now can, given DISTINCT identities per instance — added TRINET_KC_ACCOUNT (keychain account suffix) and TRINET_PINS_KEY (pin-store suffix) so two local instances hold separate identities + separate TOFU pins (dev-only, inert in a shipping run; mirrored on both platforms to stay byte-identical). smoke/two_endpoint_security.sh, verified live: TEST 1 two honest peers (A,B) with distinct identities derive the SAME safety number (24514929508 == 24514929508) with ZERO false MITM — the real Signal-style check that single-process loopback could not do. TEST 2 A pins B, then an impostor M with a DIFFERENT identity answers at B's address; A flags "identity CHANGED — session refused" and decodes ZERO video from M — a real over-the-wire MITM detection of the pinning feature (#36), not a harness. Also: the MITM rejection now logs ONCE (on the false->true transition) instead of on every impostor handshake retry (the rig saw 65196 lines; now 2). The latch + session refusal are unchanged. Both platforms build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 97980fa commit bb97911

3 files changed

Lines changed: 77 additions & 10 deletions

File tree

phone/TriNetVideo/VideoPipeline.swift

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1642,7 +1642,16 @@ final class MeshCrypto {
16421642
// Long-term signing key in the KEYCHAIN (not a plaintext plist). Migrates a legacy
16431643
// UserDefaults key once; falls back to UserDefaults only if the Keychain is unavailable.
16441644
static let kcService = "com.trinet.identity"
1645-
static let kcAccount = "device-ed25519"
1645+
// TRINET_KC_ACCOUNT / TRINET_PINS_KEY give the two-endpoint rig distinct identities +
1646+
// pin stores per instance (Mac rig only; inert on iOS where the env vars are unset).
1647+
static let kcAccount: String = {
1648+
if let s = ProcessInfo.processInfo.environment["TRINET_KC_ACCOUNT"], !s.isEmpty { return "device-ed25519-" + s }
1649+
return "device-ed25519"
1650+
}()
1651+
static let pinsKey: String = {
1652+
if let s = ProcessInfo.processInfo.environment["TRINET_PINS_KEY"], !s.isEmpty { return "trinetPeerPins-" + s }
1653+
return "trinetPeerPins"
1654+
}()
16461655
private static func kcQuery() -> [String: Any] {
16471656
[kSecClass as String: kSecClassGenericPassword,
16481657
kSecAttrService as String: kcService, kSecAttrAccount as String: kcAccount]
@@ -1675,11 +1684,11 @@ final class MeshCrypto {
16751684
return key
16761685
}
16771686

1678-
private var pins: [String: Data] = (UserDefaults.standard.dictionary(forKey: "trinetPeerPins") as? [String: String] ?? [:])
1687+
private var pins: [String: Data] = (UserDefaults.standard.dictionary(forKey: MeshCrypto.pinsKey) as? [String: String] ?? [:])
16791688
.compactMapValues { Data(base64Encoded: $0) }
16801689
private func pin(_ ip: String, _ idPub: Data) {
16811690
pins[ip] = idPub
1682-
UserDefaults.standard.set(pins.mapValues { $0.base64EncodedString() }, forKey: "trinetPeerPins")
1691+
UserDefaults.standard.set(pins.mapValues { $0.base64EncodedString() }, forKey: MeshCrypto.pinsKey)
16831692
}
16841693

16851694
static func safetyNumber(_ a: Data, _ b: Data) -> String {
@@ -1741,8 +1750,8 @@ final class MeshCrypto {
17411750
return true
17421751
}
17431752
if let pinned = pins[peerIP], pinned != idPub {
1744-
mitmDetected = true
1745-
NSLog("TRINET: MITM — peer %@ identity CHANGED from the pinned key; session refused", peerIP)
1753+
if !mitmDetected { NSLog("TRINET: MITM — peer %@ identity CHANGED from the pinned key; session refused", peerIP) }
1754+
mitmDetected = true // latch; subsequent impostor handshakes still refused, not re-logged
17461755
return true
17471756
}
17481757
if pins[peerIP] == nil, !peerIP.isEmpty { pin(peerIP, idPub) }

phone/desktop/TriNetVideo/MeshCrypto.swift

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,17 @@ final class MeshCrypto {
8080
// identity + peers' pins stay valid); (3) first run — generate + store. If the Keychain
8181
// is unavailable (unsigned build), fall back to UserDefaults so the app still works.
8282
static let kcService = "com.trinet.identity"
83-
static let kcAccount = "device-ed25519"
83+
// TRINET_KC_ACCOUNT / TRINET_PINS_KEY give the two-endpoint rig DISTINCT identities +
84+
// pin stores per instance on one machine (so an over-the-wire MITM test is real). No-op
85+
// in a shipping run.
86+
static let kcAccount: String = {
87+
if let s = ProcessInfo.processInfo.environment["TRINET_KC_ACCOUNT"], !s.isEmpty { return "device-ed25519-" + s }
88+
return "device-ed25519"
89+
}()
90+
static let pinsKey: String = {
91+
if let s = ProcessInfo.processInfo.environment["TRINET_PINS_KEY"], !s.isEmpty { return "trinetPeerPins-" + s }
92+
return "trinetPeerPins"
93+
}()
8494
private static func kcQuery() -> [String: Any] {
8595
[kSecClass as String: kSecClassGenericPassword,
8696
kSecAttrService as String: kcService, kSecAttrAccount as String: kcAccount]
@@ -114,11 +124,11 @@ final class MeshCrypto {
114124
}
115125

116126
// Persisted TOFU pins: peer IP -> idPub. In-memory mirror for speed.
117-
private var pins: [String: Data] = (UserDefaults.standard.dictionary(forKey: "trinetPeerPins") as? [String: String] ?? [:])
127+
private var pins: [String: Data] = (UserDefaults.standard.dictionary(forKey: MeshCrypto.pinsKey) as? [String: String] ?? [:])
118128
.compactMapValues { Data(base64Encoded: $0) }
119129
private func pin(_ ip: String, _ idPub: Data) {
120130
pins[ip] = idPub
121-
UserDefaults.standard.set(pins.mapValues { $0.base64EncodedString() }, forKey: "trinetPeerPins")
131+
UserDefaults.standard.set(pins.mapValues { $0.base64EncodedString() }, forKey: MeshCrypto.pinsKey)
122132
}
123133

124134
// Short digit string over BOTH identity keys (order-independent) for out-of-band checks.
@@ -191,8 +201,8 @@ final class MeshCrypto {
191201
}
192202
// TOFU: a different idPub at a peer we've pinned is a MITM. Refuse the session.
193203
if let pinned = pins[peerIP], pinned != idPub {
194-
mitmDetected = true
195-
NSLog("TRINET: MITM — peer %@ identity CHANGED from the pinned key; session refused", peerIP)
204+
if !mitmDetected { NSLog("TRINET: MITM — peer %@ identity CHANGED from the pinned key; session refused", peerIP) }
205+
mitmDetected = true // latch; every subsequent impostor handshake is still refused, just not re-logged
196206
return true
197207
}
198208
if pins[peerIP] == nil, !peerIP.isEmpty { pin(peerIP, idPub) } // trust on first use

smoke/two_endpoint_security.sh

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#!/usr/bin/env bash
2+
# two_endpoint_security.sh — verify the identity/pinning security over REAL processes on
3+
# the two-endpoint rig. Single-process loopback can't test this: it self-pairs (safety
4+
# number over one identity, degenerate) and can't stage a MITM. Here each instance gets a
5+
# DISTINCT identity + pin store via TRINET_KC_ACCOUNT / TRINET_PINS_KEY.
6+
#
7+
# Test 1 two honest peers (A,B) derive the SAME safety number, no false MITM.
8+
# Test 2 A pins B, then an impostor M (different identity) answers at B's address ->
9+
# A flags a MITM and refuses the session (no media from M).
10+
set -u
11+
APP=/Applications/TriNetMonitor.app
12+
clean() { for s in "$@"; do
13+
security delete-generic-password -s com.trinet.identity -a "device-ed25519-$s" >/dev/null 2>&1
14+
defaults delete com.trinet.monitor "trinetPeerPins-$s" >/dev/null 2>&1
15+
done; }
16+
launch() { # log listen autocall kc pins [extra-env...]
17+
open -n --env TRINET_LOG="$1" --env TRINET_LISTEN="$2" --env TRINET_AUTOCALL="$3" \
18+
--env TRINET_KC_ACCOUNT="$4" --env TRINET_PINS_KEY="$5" "$APP"; }
19+
wait_s() { local e=$(( $(date +%s) + $1 )); while [ "$(date +%s)" -lt "$e" ]; do sleep 3; done; }
20+
kill_all() { pkill -9 -f "TriNetMonitor.app/Contents/MacOS" 2>/dev/null; sleep 2; }
21+
22+
kill_all; defaults delete com.trinet.monitor trinetRoom 2>/dev/null; clean A B M; rm -f /tmp/rig[ABM].log
23+
fail=0
24+
25+
echo "== TEST 1: two honest peers derive the SAME safety number =="
26+
launch /tmp/rigA.log 8000 127.0.0.1:8100 A A; sleep 1
27+
launch /tmp/rigB.log 8100 127.0.0.1:8000 B B
28+
wait_s 18; kill_all
29+
SNA=$(grep -aoE "safety number [0-9]+" /tmp/rigA.log 2>/dev/null | tail -1 | grep -oE "[0-9]+")
30+
SNB=$(grep -aoE "safety number [0-9]+" /tmp/rigB.log 2>/dev/null | tail -1 | grep -oE "[0-9]+")
31+
echo " A=$SNA B=$SNB"
32+
if [ -n "${SNA:-}" ] && [ "$SNA" = "${SNB:-}" ]; then echo " PASS (match)"; else echo " FAIL"; fail=1; fi
33+
if [ "$(grep -aic MITM /tmp/rigA.log)" = 0 ] && [ "$(grep -aic MITM /tmp/rigB.log)" = 0 ]; then
34+
echo " PASS (no false MITM)"; else echo " FAIL (false MITM)"; fail=1; fi
35+
36+
echo "== TEST 2: an impostor identity at the pinned peer is flagged as MITM =="
37+
# A keeps its pin of B from Test 1; M is a fresh distinct identity at B's port.
38+
clean M; rm -f /tmp/rigA.log /tmp/rigM.log
39+
launch /tmp/rigA.log 8000 127.0.0.1:8100 A A; sleep 1
40+
launch /tmp/rigM.log 8100 127.0.0.1:8000 M M
41+
wait_s 16; kill_all
42+
MITM=$(grep -aic MITM /tmp/rigA.log 2>/dev/null); VID=$(grep -aic "FIRST FRAME DECODED" /tmp/rigA.log 2>/dev/null)
43+
echo " A MITM-flagged=$MITM video-from-M=$VID"
44+
if [ "${MITM:-0}" -ge 1 ] && [ "${VID:-0}" = 0 ]; then echo " PASS (detected + refused)"; else echo " FAIL"; fail=1; fi
45+
46+
clean A B M
47+
echo; [ "$fail" = 0 ] && echo "ALL PASS" || echo "FAILURE(S)"
48+
exit $fail

0 commit comments

Comments
 (0)