diff --git a/Sources/Stormo/Security/IdentityCertificate.swift b/Sources/Stormo/Security/IdentityCertificate.swift index 7b364bc..d727f42 100644 --- a/Sources/Stormo/Security/IdentityCertificate.swift +++ b/Sources/Stormo/Security/IdentityCertificate.swift @@ -29,6 +29,31 @@ public enum IdentityCertificate { private static let hundredYears: TimeInterval = 100 * 365.25 * 24 * 60 * 60 private static let oneDay: TimeInterval = 24 * 60 * 60 + /// A random 20-byte serial number, generated without going through + /// `Certificate.SerialNumber()`. + /// + /// That convenience initialiser funnels into swift-certificates' + /// `@inlinable` generic `RandomNumberGenerator.bytes(count:)`. The + /// specialisation the compiler emits for it is **miscompiled under Thread + /// Sanitizer**: the instrumentation ends up treating a freshly generated + /// random value as a memory address, and the process takes a SEGV on that + /// garbage pointer inside `__tsan::MemoryAccess` (reported, misleadingly, + /// against `Certificate.SerialNumber.init`). It is a hard process abort on + /// the iOS Simulator, which made a TSan-enabled suite impossible to run in + /// a host app. + /// + /// Generating the bytes here and handing them to the explicit + /// `init(bytes:)` overload is semantically identical — 20 random bytes, + /// ASN.1-normalised by that initialiser — while staying out of the + /// miscompiled specialisation. + static func randomSerialNumber() -> Certificate.SerialNumber { + var bytes = [UInt8](repeating: 0, count: 20) + for index in bytes.indices { + bytes[index] = UInt8.random(in: UInt8.min...UInt8.max) + } + return Certificate.SerialNumber(bytes: bytes) + } + /// Builds the self-signed `Certificate` for `identity`. Pure — no keychain, /// no I/O — so it is unit-testable without entitlements. public static func makeCertificate(for identity: PeerIdentity) throws -> Certificate { @@ -39,7 +64,7 @@ public enum IdentityCertificate { return try Certificate( version: .v3, - serialNumber: Certificate.SerialNumber(), + serialNumber: Self.randomSerialNumber(), publicKey: Certificate.PublicKey(identity.key.publicKey), notValidBefore: Date(timeIntervalSinceNow: -oneDay), notValidAfter: Date(timeIntervalSinceNow: hundredYears), diff --git a/Tests/StormoTests/IdentitySecurityTests.swift b/Tests/StormoTests/IdentitySecurityTests.swift index 5a0ba11..6e4ae7e 100644 --- a/Tests/StormoTests/IdentitySecurityTests.swift +++ b/Tests/StormoTests/IdentitySecurityTests.swift @@ -11,6 +11,44 @@ import Security @Suite("Step 2 — Identity & TLS") struct IdentitySecurityTests { + // MARK: - Serial numbers + + @Test("serial numbers are 20 random bytes and differ between certificates") + func serialNumberIsRandomAndSized() { + // `Certificate.SerialNumber` normalises to ASN.1 INTEGER form, which + // drops leading zero bytes — so 20 is the ceiling, not an invariant. + let serials = (0..<32).map { _ in IdentityCertificate.randomSerialNumber() } + + for serial in serials { + #expect(!serial.bytes.isEmpty) + #expect(serial.bytes.count <= 20) + } + + // 32 draws of ~20 random bytes colliding is impossible short of the + // generator being broken (which is what a miscompiled RNG path would + // look like). + let distinct = Set(serials.map { Array($0.bytes) }) + #expect(distinct.count == serials.count) + } + + @Test("certificate generation is reentrant across concurrent tasks") + func concurrentCertificateGeneration() async throws { + // Regression guard for the Thread Sanitizer SEGV: concurrent + // certificate construction used to abort the process outright on the + // iOS Simulator, and reported a Swift access race on macOS. + let identity = PeerIdentity(name: "Concurrent") + try await withThrowingTaskGroup(of: Int.self) { group in + for _ in 0..<8 { + group.addTask { + try IdentityCertificate.makeCertificateDER(for: identity).count + } + } + for try await size in group { + #expect(size > 0) + } + } + } + // MARK: - libp2p PeerID encoding (DD-8) @Test("keyHash is a 34-byte sha2-256 multihash (0x12 0x20 + digest)")