mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25:20 +00:00
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>
177 lines
7.2 KiB
Swift
177 lines
7.2 KiB
Swift
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])) == [])
|
|
}
|
|
}
|