mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 20:45:19 +00:00
Transitive verification: vouch for verified peers over Noise
When a Noise session establishes with a peer I verified and that peer advertises the .vouch capability, send signed attestations (up to 16, most recently verified first, at most once per peer per 24h) for the OTHER fingerprints I verified. Receivers accept vouches only from senders they verified themselves, verify the Ed25519 signature against the sender's announce-bound signing key, and surface the result as a new derived trust tier: vouched (unfilled seal) between casual and trusted. Protocol: - NoisePayloadType.vouch = 0x12 carries a batch of TLV attestations: voucheeFingerprint (32B), voucheeSigningKey (32B), timestamp (uint64 ms BE), Ed25519 signature over "bitchat-vouch-v1" | fingerprint | signingKey | timestamp. The voucher is implicit in the authenticated session. - PeerCapabilities.localSupported now advertises .vouch. Storage (SecureIdentityStateManager / IdentityCache): - vouches keyed by vouchee, capped at 8 vouchers each; validity is recomputed on read (voucher still verified-by-me, < 30 days old), so unverifying a voucher retires their vouches without cascade deletes. - New IdentityCache fields are Optional so pre-existing encrypted caches decode cleanly; TrustLevel.vouched is inserted mid-ladder but raw values are strings, so persisted values are unaffected (and vouched itself is never persisted). - Panic wipe clears vouch state with the rest of the identity cache. UI: unfilled checkmark.seal badge in the mesh peer list (filled seal stays exclusive to verified) and a "vouched for by N people you verified" section with voucher names in FingerprintView; VoiceOver labels and xcstrings entries included. Tests: attestation encode/decode + signature (forged/tampered/expired), accept-policy gates, batch cap, trust-level derivation incl. voucher invalidation, persistence compat, and coordinator exchange/accept policies. Full macOS suite: 1088 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import bitchat
|
||||
|
||||
struct VouchAttestationTests {
|
||||
private let voucherKey = Curve25519.Signing.PrivateKey()
|
||||
|
||||
private func makeAttestation(
|
||||
fingerprint: Data = Data(repeating: 0xAA, count: 32),
|
||||
signingKey: Data = Data(repeating: 0xBB, count: 32),
|
||||
timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
signedBy key: Curve25519.Signing.PrivateKey? = nil
|
||||
) throws -> VouchAttestation {
|
||||
let signer = key ?? voucherKey
|
||||
return try #require(
|
||||
VouchAttestation.build(
|
||||
voucheeFingerprint: fingerprint,
|
||||
voucheeSigningKey: signingKey,
|
||||
timestampMs: timestampMs,
|
||||
sign: { try? signer.signature(for: $0) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func roundTripsAndVerifiesSignature() throws {
|
||||
let attestation = try makeAttestation()
|
||||
let encoded = try #require(attestation.encode())
|
||||
let decoded = try #require(VouchAttestation.decode(from: encoded))
|
||||
|
||||
#expect(decoded == attestation)
|
||||
#expect(decoded.voucheeFingerprintHex == String(repeating: "aa", count: 32))
|
||||
#expect(decoded.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation))
|
||||
}
|
||||
|
||||
@Test
|
||||
func decodeSkipsUnknownTLVsAndRejectsMalformedInput() throws {
|
||||
let attestation = try makeAttestation()
|
||||
var encoded = try #require(attestation.encode())
|
||||
|
||||
// Unknown TLV appended: skipped for forward compatibility.
|
||||
encoded.append(contentsOf: [0x7F, 0x02, 0x01, 0x02])
|
||||
#expect(VouchAttestation.decode(from: encoded) == attestation)
|
||||
|
||||
// Truncation and missing fields are rejected.
|
||||
#expect(VouchAttestation.decode(from: encoded.dropLast()) == nil)
|
||||
#expect(VouchAttestation.decode(from: Data([0x01, 0x20])) == nil)
|
||||
#expect(VouchAttestation.decode(from: Data()) == nil)
|
||||
|
||||
// Wrong field sizes are rejected.
|
||||
var wrongSize = Data([0x01, 0x10])
|
||||
wrongSize.append(Data(repeating: 0xAA, count: 16))
|
||||
#expect(VouchAttestation.decode(from: wrongSize) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func buildRejectsWrongKeyAndFingerprintSizes() {
|
||||
let sign: (Data) -> Data? = { try? self.voucherKey.signature(for: $0) }
|
||||
#expect(VouchAttestation.build(
|
||||
voucheeFingerprint: Data(repeating: 0xAA, count: 16),
|
||||
voucheeSigningKey: Data(repeating: 0xBB, count: 32),
|
||||
sign: sign
|
||||
) == nil)
|
||||
#expect(VouchAttestation.build(
|
||||
voucheeFingerprint: Data(repeating: 0xAA, count: 32),
|
||||
voucheeSigningKey: Data(repeating: 0xBB, count: 16),
|
||||
sign: sign
|
||||
) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func forgedSignatureFailsVerification() throws {
|
||||
let attestation = try makeAttestation()
|
||||
let otherKey = Curve25519.Signing.PrivateKey()
|
||||
|
||||
// Verifying against a key that didn't sign fails.
|
||||
#expect(!attestation.verifySignature(voucherSigningKey: otherKey.publicKey.rawRepresentation))
|
||||
|
||||
// An attestation signed by an imposter fails against the real key.
|
||||
let forged = try makeAttestation(signedBy: otherKey)
|
||||
#expect(!forged.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation))
|
||||
#expect(!attestation.verifySignature(voucherSigningKey: Data(repeating: 0x01, count: 3)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func tamperedFieldsFailVerification() throws {
|
||||
let attestation = try makeAttestation()
|
||||
let publicKey = voucherKey.publicKey.rawRepresentation
|
||||
|
||||
var tamperedFingerprint = attestation.voucheeFingerprint
|
||||
tamperedFingerprint[0] ^= 0xFF
|
||||
let tampered = VouchAttestation(
|
||||
voucheeFingerprint: tamperedFingerprint,
|
||||
voucheeSigningKey: attestation.voucheeSigningKey,
|
||||
timestampMs: attestation.timestampMs,
|
||||
signature: attestation.signature
|
||||
)
|
||||
#expect(!tampered.verifySignature(voucherSigningKey: publicKey))
|
||||
|
||||
let backdated = VouchAttestation(
|
||||
voucheeFingerprint: attestation.voucheeFingerprint,
|
||||
voucheeSigningKey: attestation.voucheeSigningKey,
|
||||
timestampMs: attestation.timestampMs - 1,
|
||||
signature: attestation.signature
|
||||
)
|
||||
#expect(!backdated.verifySignature(voucherSigningKey: publicKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
func expiryWindowIsEnforced() throws {
|
||||
let now = Date()
|
||||
let fresh = try makeAttestation(timestampMs: UInt64(now.timeIntervalSince1970 * 1000))
|
||||
#expect(!fresh.isExpired(now: now))
|
||||
|
||||
let thirtyOneDaysAgo = now.addingTimeInterval(-31 * 24 * 60 * 60)
|
||||
let expired = try makeAttestation(timestampMs: UInt64(thirtyOneDaysAgo.timeIntervalSince1970 * 1000))
|
||||
#expect(expired.isExpired(now: now))
|
||||
|
||||
let farFuture = now.addingTimeInterval(2 * 60 * 60)
|
||||
let fromTheFuture = try makeAttestation(timestampMs: UInt64(farFuture.timeIntervalSince1970 * 1000))
|
||||
#expect(fromTheFuture.isExpired(now: now))
|
||||
|
||||
// A verified-but-expired attestation still has a valid signature; the
|
||||
// two checks are independent gates.
|
||||
#expect(expired.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation))
|
||||
}
|
||||
|
||||
@Test
|
||||
func batchRoundTripsAndEnforcesCap() throws {
|
||||
let attestations = try (0..<3).map { index in
|
||||
try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32))
|
||||
}
|
||||
let payload = try #require(VouchAttestation.encodeList(attestations))
|
||||
#expect(VouchAttestation.decodeList(from: payload) == attestations)
|
||||
|
||||
#expect(VouchAttestation.encodeList([]) == nil)
|
||||
|
||||
let tooMany = try (0..<17).map { index in
|
||||
try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32))
|
||||
}
|
||||
#expect(VouchAttestation.encodeList(tooMany) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func decodeListIgnoresEntriesBeyondCapAndMalformedEntries() throws {
|
||||
let attestations = try (0..<17).map { index in
|
||||
try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32))
|
||||
}
|
||||
// Hand-build an oversized batch that lies about its count.
|
||||
var payload = Data([UInt8(attestations.count)])
|
||||
for attestation in attestations {
|
||||
let encoded = try #require(attestation.encode())
|
||||
payload.append(UInt8(encoded.count >> 8))
|
||||
payload.append(UInt8(encoded.count & 0xFF))
|
||||
payload.append(encoded)
|
||||
}
|
||||
let decoded = VouchAttestation.decodeList(from: payload)
|
||||
#expect(decoded.count == VouchAttestation.maxBatchCount)
|
||||
#expect(decoded == Array(attestations.prefix(VouchAttestation.maxBatchCount)))
|
||||
|
||||
// A malformed middle entry is dropped without killing the batch.
|
||||
let good = try makeAttestation()
|
||||
let goodEncoded = try #require(good.encode())
|
||||
var mixed = Data([2])
|
||||
mixed.append(contentsOf: [0x00, 0x03, 0xDE, 0xAD, 0xBE])
|
||||
mixed.append(UInt8(goodEncoded.count >> 8))
|
||||
mixed.append(UInt8(goodEncoded.count & 0xFF))
|
||||
mixed.append(goodEncoded)
|
||||
#expect(VouchAttestation.decodeList(from: mixed) == [good])
|
||||
|
||||
#expect(VouchAttestation.decodeList(from: Data()) == [])
|
||||
#expect(VouchAttestation.decodeList(from: Data([5])) == [])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user