diff --git a/Package.swift b/Package.swift index ee29e18..a5d681d 100644 --- a/Package.swift +++ b/Package.swift @@ -37,6 +37,7 @@ let package = Package( products: [ .library(name: "BSV", targets: ["BSV"]), .library(name: "BSVCompat", targets: ["BSVCompat"]), + .library(name: "BSVAirGap", targets: ["BSVAirGap"]), ] + modernPublicModules.map { module in .library(name: module, targets: [module]) }, @@ -76,6 +77,10 @@ let package = Package( .product(name: "CryptoExtras", package: "swift-crypto"), ] ), + .target( + name: "BSVAirGap", + dependencies: ["BSVCore", "BSVCrypto"] + ), .target( name: "BSVKeys", dependencies: [ @@ -172,6 +177,11 @@ let package = Package( dependencies: ["BSVCrypto", "BSVCore"], exclude: ["README.md"] ), + .testTarget( + name: "BSVAirGapTests", + dependencies: ["BSVAirGap", "BSVCore", "BSVCrypto"], + resources: [.copy("Fixtures")] + ), .testTarget( name: "BSVBigNumTests", dependencies: ["BSVBigNum"], diff --git a/Sources/BSVAirGap/AirGapConstants.swift b/Sources/BSVAirGap/AirGapConstants.swift new file mode 100644 index 0000000..af0bceb --- /dev/null +++ b/Sources/BSVAirGap/AirGapConstants.swift @@ -0,0 +1,29 @@ +/// BRC-141 version-1 wire and resource limits. +public enum AirGap { + /// ASCII prefix on every encoded part. + public static let prefix = "air-gap:" + + /// Version byte in the binary header. + public static let wireVersion: UInt8 = 1 + + /// Size of the session identifier in bytes. + public static let sessionIDByteCount = 8 + + /// Default source-block size. + public static let defaultBlockBytes = 1_200 + + /// Largest source-block size permitted by version 1. + public static let maximumBlockBytes = 2_048 + + /// Largest message permitted by version 1. + public static let maximumMessageBytes = 65_536 + + /// Consecutive parts of one foreign session required before switching. + public static let sessionSwitchPartCount = 3 + + package static let headerByteCount = 23 + package static let maximumBlockCount = Int(UInt16.max) + package static let maximumTrackedSequences = 65_536 + package static let maximumPendingParts = 1_024 + package static let maximumPendingIndices = 4_096 +} diff --git a/Sources/BSVAirGap/AirGapDecoder.swift b/Sources/BSVAirGap/AirGapDecoder.swift new file mode 100644 index 0000000..fb87d8a --- /dev/null +++ b/Sources/BSVAirGap/AirGapDecoder.swift @@ -0,0 +1,268 @@ +import BSVCore + +/// Result of feeding one scanned string to ``AirGapDecoder``. +public struct AirGapProgress: Equatable, Sendable { + /// Whether this read was usable for the current message or a known duplicate. + public let ok: Bool + + /// Whether every source block has been recovered. + public let done: Bool + + /// Number of recovered source blocks. + public let have: Int + + /// Number of source blocks in the locked session, or zero before the first part. + public let total: Int + + public init(ok: Bool, done: Bool, have: Int, total: Int) { + self.ok = ok + self.done = done + self.have = have + self.total = total + } +} + +/// Stateful, bounded BRC-141 reassembly for a stream of camera scans. +/// +/// ``accept(_:)`` never throws. It soft-rejects malformed, foreign, and resource-exhausting input, +/// and ``message()`` never emits partial bytes or bytes that fail the complete-payload CRC. +public struct AirGapDecoder: Sendable { + private var identity: SessionIdentity? + private var blockBytes = 0 + private var seen: Set = [] + private var solved: [[UInt8]?] = [] + private var solvedCount = 0 + private var pending: [PendingPart] = [] + private var pendingIndexCount = 0 + private var candidateIdentity: SessionIdentity? + private var candidateCount = 0 + + public init() {} + + /// Forgets the current and candidate sessions. + public mutating func reset() { + startSession(nil) + } + + /// Feeds one scanned string and returns current progress without throwing. + public mutating func accept(_ text: String) -> AirGapProgress { + guard let part = parse(text) else { return progress(ok: false) } + guard enterSession(part) else { return progress(ok: false) } + + if isDone { return progress(ok: true) } + + if blockBytes == 0 { + blockBytes = part.payload.count + } else if part.payload.count != blockBytes { + return progress(ok: false) + } + + if seen.contains(part.sequence) { return progress(ok: true) } + return ingest(part) + } + + /// Returns the complete verified message, or `nil` while incomplete or after a CRC failure. + /// + /// A CRC failure resets the decoder so a still-looping sender can refill it. + public mutating func message() -> [UInt8]? { + guard let identity, isDone, blockBytes > 0 else { return nil } + var result: [UInt8] = [] + result.reserveCapacity(identity.total * blockBytes) + for block in solved { + guard let block else { return nil } + result.append(contentsOf: block) + } + result.removeLast(result.count - identity.messageLength) + guard crc32(result) == identity.checksum else { + reset() + return nil + } + return result + } + + private var isDone: Bool { + guard let identity else { return false } + return identity.total > 0 && solvedCount == identity.total + } + + private func progress(ok: Bool) -> AirGapProgress { + AirGapProgress( + ok: ok, + done: isDone, + have: solvedCount, + total: identity?.total ?? 0 + ) + } + + private mutating func startSession(_ newIdentity: SessionIdentity?) { + identity = newIdentity + blockBytes = 0 + seen = [] + solved = [Array?]( + repeating: nil, + count: newIdentity?.total ?? 0 + ) + solvedCount = 0 + pending = [] + pendingIndexCount = 0 + candidateIdentity = nil + candidateCount = 0 + } + + private func parse(_ text: String) -> ParsedPart? { + guard text.hasPrefix(AirGap.prefix) else { return nil } + guard text.utf8.count <= airGapPartCharLength(AirGap.maximumBlockBytes) else { + return nil + } + + let encoded = String(text.dropFirst(AirGap.prefix.count)) + let bytes: [UInt8] + do { + bytes = try Base64Encoding.decode( + encoded, + alphabet: .urlSafe, + padding: .omitted, + maximumDecodedByteCount: AirGap.headerByteCount + AirGap.maximumBlockBytes + ) + } catch { + return nil + } + guard bytes.count > AirGap.headerByteCount else { return nil } + + var cursor = ByteCursor(bytes) + guard let version = try? cursor.read(count: 1).first, + version == AirGap.wireVersion, + let sessionID = try? cursor.read(count: AirGap.sessionIDByteCount), + let sequence = try? cursor.readUInt32BE(), + let rawTotal = try? cursor.readUInt16BE(), + let rawMessageLength = try? cursor.readUInt32BE(), + let checksum = try? cursor.readUInt32BE(), + let payload = try? cursor.read(count: cursor.remaining) else { + return nil + } + + let total = Int(rawTotal) + let messageLength = Int(rawMessageLength) + guard total > 0, + messageLength > 0, + messageLength <= AirGap.maximumMessageBytes, + !payload.isEmpty, + payload.count <= AirGap.maximumBlockBytes, + (messageLength + payload.count - 1) / payload.count == total else { + return nil + } + + return ParsedPart( + identity: SessionIdentity( + sessionID: sessionID, + total: total, + messageLength: messageLength, + checksum: checksum + ), + sequence: sequence, + payload: payload + ) + } + + private mutating func enterSession(_ part: ParsedPart) -> Bool { + guard let identity else { + startSession(part.identity) + return true + } + if part.identity == identity { + candidateIdentity = nil + candidateCount = 0 + return true + } + + if part.identity == candidateIdentity { + candidateCount += 1 + } else { + candidateIdentity = part.identity + candidateCount = 1 + } + guard candidateCount >= AirGap.sessionSwitchPartCount else { return false } + startSession(part.identity) + return true + } + + private mutating func ingest(_ part: ParsedPart) -> AirGapProgress { + guard let identity else { return progress(ok: false) } + let rawIndices = part.sequence < UInt32(identity.total) + ? [Int(part.sequence)] + : airGapBlocksForPart(part.sequence, blockCount: identity.total) + var candidate = PendingPart(indices: Set(rawIndices), payload: part.payload) + reduce(&candidate) + + if candidate.indices.count > 1 { + guard pending.count < AirGap.maximumPendingParts, + pendingIndexCount + candidate.indices.count <= AirGap.maximumPendingIndices else { + return progress(ok: false) + } + pending.append(candidate) + pendingIndexCount += candidate.indices.count + } else if candidate.indices.count == 1 { + solve(candidate) + cascade() + } + + if seen.count < AirGap.maximumTrackedSequences { + seen.insert(part.sequence) + } + return progress(ok: true) + } + + private mutating func cascade() { + var progressed = true + while progressed { + progressed = false + var stillPending: [PendingPart] = [] + var stillPendingIndexCount = 0 + for var part in pending { + reduce(&part) + if part.indices.count == 1 { + solve(part) + progressed = true + } else if part.indices.count > 1 { + stillPending.append(part) + stillPendingIndexCount += part.indices.count + } + } + pending = stillPending + pendingIndexCount = stillPendingIndexCount + } + } + + private func reduce(_ part: inout PendingPart) { + for index in Array(part.indices) { + if let known = solved[index] { + airGapXOR(known, into: &part.payload) + part.indices.remove(index) + } + } + } + + private mutating func solve(_ part: PendingPart) { + guard let index = part.indices.first else { return } + solved[index] = part.payload + solvedCount += 1 + } +} + +private struct SessionIdentity: Equatable, Sendable { + let sessionID: [UInt8] + let total: Int + let messageLength: Int + let checksum: UInt32 +} + +private struct ParsedPart: Sendable { + let identity: SessionIdentity + let sequence: UInt32 + let payload: [UInt8] +} + +private struct PendingPart: Sendable { + var indices: Set + var payload: [UInt8] +} diff --git a/Sources/BSVAirGap/AirGapEncoder.swift b/Sources/BSVAirGap/AirGapEncoder.swift new file mode 100644 index 0000000..427cde9 --- /dev/null +++ b/Sources/BSVAirGap/AirGapEncoder.swift @@ -0,0 +1,106 @@ +import BSVCore +import BSVCrypto + +/// An immutable BRC-141 encoder for one arbitrary byte payload. +public struct AirGapEncoder: Sendable { + private let blocks: [[UInt8]] + private let checksum: UInt32 + + /// Number of source blocks, `ceil(messageLength / blockBytes)`. + public let blockCount: Int + + /// Bytes in every source block and encoded part body. + public let blockBytes: Int + + /// Original unpadded message length. + public let messageLength: Int + + /// Eight-byte identity carried by every part in this stream. + public let sessionID: [UInt8] + + /// Creates an encoder. The message and optional session identity are copied. + public init( + _ message: [UInt8], + blockBytes: Int = AirGap.defaultBlockBytes, + sessionID: [UInt8]? = nil, + randomSource: any SecureRandomSource = SystemSecureRandomSource() + ) throws { + guard !message.isEmpty else { throw AirGapError.emptyMessage } + guard message.count <= AirGap.maximumMessageBytes else { + throw AirGapError.messageTooLarge( + actual: message.count, + maximum: AirGap.maximumMessageBytes + ) + } + guard (1...AirGap.maximumBlockBytes).contains(blockBytes) else { + throw AirGapError.invalidBlockByteCount(blockBytes) + } + + let blockCount = (message.count + blockBytes - 1) / blockBytes + guard blockCount <= AirGap.maximumBlockCount else { + throw AirGapError.tooManyBlocks( + actual: blockCount, + maximum: AirGap.maximumBlockCount + ) + } + + let chosenSessionID: [UInt8] + if let sessionID { + guard sessionID.count == AirGap.sessionIDByteCount else { + throw AirGapError.invalidSessionIDByteCount(sessionID.count) + } + chosenSessionID = sessionID + } else { + do { + chosenSessionID = try randomSource.randomBytes(count: AirGap.sessionIDByteCount) + } catch { + throw AirGapError.randomGenerationFailed + } + guard chosenSessionID.count == AirGap.sessionIDByteCount else { + throw AirGapError.randomGenerationFailed + } + } + + var padded = [UInt8](repeating: 0, count: blockCount * blockBytes) + padded.replaceSubrange(0.. String { + var payload = [UInt8](repeating: 0, count: blockBytes) + if sequence < UInt32(blockCount) { + payload = blocks[Int(sequence)] + } else { + for index in airGapBlocksForPart(sequence, blockCount: blockCount) { + airGapXOR(blocks[index], into: &payload) + } + } + + var writer = ByteWriter(capacity: AirGap.headerByteCount + blockBytes) + writer.write([AirGap.wireVersion]) + writer.write(sessionID) + writer.writeUInt32BE(sequence) + writer.writeUInt16BE(UInt16(blockCount)) + writer.writeUInt32BE(UInt32(messageLength)) + writer.writeUInt32BE(checksum) + writer.write(payload) + let body = Base64Encoding.encode( + writer.bytes, + alphabet: .urlSafe, + padding: .omitted + ) + return AirGap.prefix + body + } +} diff --git a/Sources/BSVAirGap/AirGapError.swift b/Sources/BSVAirGap/AirGapError.swift new file mode 100644 index 0000000..de5866c --- /dev/null +++ b/Sources/BSVAirGap/AirGapError.swift @@ -0,0 +1,11 @@ +/// Construction failures for BRC-141 encoders and sizing helpers. +/// +/// Camera-fed decoding never throws; unusable scans are ordinary rejected progress values. +public enum AirGapError: Error, Equatable, Sendable { + case emptyMessage + case messageTooLarge(actual: Int, maximum: Int) + case invalidBlockByteCount(Int) + case tooManyBlocks(actual: Int, maximum: Int) + case invalidSessionIDByteCount(Int) + case randomGenerationFailed +} diff --git a/Sources/BSVAirGap/AirGapHelpers.swift b/Sources/BSVAirGap/AirGapHelpers.swift new file mode 100644 index 0000000..d5da372 --- /dev/null +++ b/Sources/BSVAirGap/AirGapHelpers.swift @@ -0,0 +1,24 @@ +/// Returns whether text has the BRC-141 routing prefix. +/// +/// This is deliberately only a cheap prefix test. Use ``AirGapDecoder/accept(_:)`` to validate a +/// complete part. +public func isAirGapPart(_ text: String) -> Bool { + text.hasPrefix(AirGap.prefix) +} + +/// Exact number of characters in every encoded part for `blockBytes`. +public func estimatePartCharLength( + _ blockBytes: Int = AirGap.defaultBlockBytes +) throws -> Int { + guard (1...AirGap.maximumBlockBytes).contains(blockBytes) else { + throw AirGapError.invalidBlockByteCount(blockBytes) + } + return airGapPartCharLength(blockBytes) +} + +package func airGapPartCharLength(_ blockBytes: Int) -> Int { + let byteCount = AirGap.headerByteCount + blockBytes + let remainder = byteCount % 3 + let encoded = (byteCount / 3) * 4 + (remainder == 0 ? 0 : remainder + 1) + return AirGap.prefix.utf8.count + encoded +} diff --git a/Sources/BSVAirGap/CRC32.swift b/Sources/BSVAirGap/CRC32.swift new file mode 100644 index 0000000..ab2cc2a --- /dev/null +++ b/Sources/BSVAirGap/CRC32.swift @@ -0,0 +1,19 @@ +/// IEEE CRC-32 of `bytes`, using the reflected `0xEDB88320` polynomial. +/// +/// BRC-141 uses this for accidental transport corruption only. It is not an authenticator. +public func crc32(_ bytes: [UInt8]) -> UInt32 { + var value = UInt32.max + for byte in bytes { + let index = Int((value ^ UInt32(byte)) & 0xff) + value = crc32Table[index] ^ (value >> 8) + } + return value ^ UInt32.max +} + +private let crc32Table: [UInt32] = (0..<256).map { entry in + var value = UInt32(entry) + for _ in 0..<8 { + value = value & 1 == 1 ? 0xedb8_8320 ^ (value >> 1) : value >> 1 + } + return value +} diff --git a/Sources/BSVAirGap/FountainCoding.swift b/Sources/BSVAirGap/FountainCoding.swift new file mode 100644 index 0000000..6377c11 --- /dev/null +++ b/Sources/BSVAirGap/FountainCoding.swift @@ -0,0 +1,41 @@ +package func airGapBlocksForPart(_ sequence: UInt32, blockCount: Int) -> [Int] { + var generator = AirGapXorShift32(seed: sequence &* 0x9e37_79b1) + let scale = UInt64(1 << 23) + let random = UInt64(generator.draw23()) + var degree = Int((scale + random) / (random + 1)) + if degree > blockCount { degree = 1 } + + var pool = Array(0.. UInt32 { + state ^= state << 13 + state ^= state >> 17 + state ^= state << 5 + return state + } + + mutating func draw23() -> UInt32 { + next() >> 9 + } +} diff --git a/Tests/BSVAirGapTests/AirGapConformanceTests.swift b/Tests/BSVAirGapTests/AirGapConformanceTests.swift new file mode 100644 index 0000000..85d46fa --- /dev/null +++ b/Tests/BSVAirGapTests/AirGapConformanceTests.swift @@ -0,0 +1,429 @@ +import BSVAirGap +import BSVCore +import BSVCrypto +import Foundation +import Testing + +@Suite("BRC-141 air-gap conformance", .serialized) +struct AirGapConformanceTests { + @Test("complete official append-only corpus") + func officialCorpus() throws { + let vectors = try loadVectors() + #expect(vectors.count == 31) + + var operations: Set = [] + for vector in vectors { + let id = try requiredString(vector, "id") + let input = try requiredObject(vector, "input") + let expected = try requiredObject(vector, "expected") + let operation = try requiredString(input, "operation") + operations.insert(operation) + + switch operation { + case "crc32": + let message = try hexBytes(requiredString(input, "message_hex")) + let expectedHex = try requiredString(expected, "crc32_hex") + #expect(String(format: "%08x", crc32(message)) == expectedHex, Comment(rawValue: id)) + + case "part-char-length": + let blockBytes = try requiredInt(input, "block_bytes") + let expectedChars = try requiredInt(expected, "chars") + #expect( + try estimatePartCharLength(blockBytes) == expectedChars, + Comment(rawValue: id) + ) + + case "encode-part": + let encoder = try AirGapEncoder( + hexBytes(requiredString(input, "message_hex")), + blockBytes: requiredInt(input, "block_bytes"), + sessionID: hexBytes(requiredString(input, "session_id_hex")) + ) + let sequence = try requiredUInt32(input, "seq") + let expectedPart = try requiredString(expected, "part") + #expect( + encoder.part(at: sequence) == expectedPart, + Comment(rawValue: id) + ) + + case "decode": + var decoder = AirGapDecoder() + var done = false + for part in try requiredStrings(input, "parts") { + done = decoder.accept(part).done || done + } + #expect(done, Comment(rawValue: id)) + let message = decoder.message() + let expectedMessage = try requiredString(expected, "message_hex") + #expect(message != nil, Comment(rawValue: id)) + #expect( + message.map(hexString) == expectedMessage, + Comment(rawValue: id) + ) + + case "progress": + var decoder = AirGapDecoder() + var progress = decoder.accept("") + for part in try requiredStrings(input, "parts") { + progress = decoder.accept(part) + } + let expectedHave = try requiredInt(expected, "have") + let expectedTotal = try requiredInt(expected, "total") + let expectedDone = try requiredBool(expected, "done") + #expect(progress.have == expectedHave, Comment(rawValue: id)) + #expect(progress.total == expectedTotal, Comment(rawValue: id)) + #expect(progress.done == expectedDone, Comment(rawValue: id)) + + case "accept-one": + var decoder = AirGapDecoder() + let progress = decoder.accept(try requiredString(input, "text")) + let expectedOK = try requiredBool(expected, "ok") + #expect(progress.ok == expectedOK, Comment(rawValue: id)) + #expect(decoder.message() == nil, Comment(rawValue: id)) + + default: + Issue.record("Unsupported corpus operation \(operation) in \(id)") + } + } + + #expect(operations == [ + "accept-one", "crc32", "decode", "encode-part", "part-char-length", "progress", + ]) + } + + @Test("frozen sequence-to-block mapping") + func frozenMapping() { + #expect(airGapBlocksForPart(3, blockCount: 3) == [2, 1]) + #expect(airGapBlocksForPart(4, blockCount: 3) == [0]) + #expect(airGapBlocksForPart(5, blockCount: 5) == [1, 3]) + #expect(airGapBlocksForPart(3_393_264, blockCount: 5) == [2, 4]) + #expect(airGapBlocksForPart(3_393_265, blockCount: 5) == [2, 0]) + #expect(airGapBlocksForPart(0x7fff_ffff, blockCount: 5) == [3, 0]) + #expect(airGapBlocksForPart(0xffff_ffff, blockCount: 5) == [1, 0, 3, 2]) + #expect(airGapBlocksForPart(0, blockCount: 4) == [2, 0, 1, 3]) + } + + @Test("encoder bounds and random session validation") + func encoderBounds() throws { + #expect(throws: AirGapError.emptyMessage) { + try AirGapEncoder([]) + } + #expect(throws: AirGapError.messageTooLarge(actual: 65_537, maximum: 65_536)) { + try AirGapEncoder([UInt8](repeating: 0, count: 65_537)) + } + for invalid in [0, 2_049] { + #expect(throws: AirGapError.invalidBlockByteCount(invalid)) { + try AirGapEncoder([1], blockBytes: invalid) + } + } + #expect(throws: AirGapError.tooManyBlocks(actual: 65_536, maximum: 65_535)) { + try AirGapEncoder([UInt8](repeating: 0, count: 65_536), blockBytes: 1) + } + #expect(throws: AirGapError.invalidSessionIDByteCount(7)) { + try AirGapEncoder([1], sessionID: [UInt8](repeating: 0, count: 7)) + } + #expect(throws: AirGapError.randomGenerationFailed) { + try AirGapEncoder([1], randomSource: ShortRandomSource()) + } + + let encoder = try AirGapEncoder([1], randomSource: FixedRandomSource()) + #expect(encoder.sessionID == Array(0..<8)) + } + + @Test("final CRC mismatch resets without emitting bytes") + func crcMismatchResets() throws { + let encoder = try AirGapEncoder( + Array("Hello".utf8), + blockBytes: 8, + sessionID: Array(1...8) + ) + let part = encoder.part(at: 0) + let body = String(part.dropFirst(AirGap.prefix.count)) + var decoded = try Base64Encoding.decode( + body, + alphabet: .urlSafe, + padding: .omitted, + maximumDecodedByteCount: 31 + ) + decoded[23] ^= 1 + let corrupted = AirGap.prefix + Base64Encoding.encode( + decoded, + alphabet: .urlSafe, + padding: .omitted + ) + + var decoder = AirGapDecoder() + #expect(decoder.accept(corrupted).done) + #expect(decoder.message() == nil) + let afterReset = decoder.accept("") + #expect(afterReset.total == 0) + #expect(afterReset.have == 0) + } + + @Test("later body-length mismatch is rejected without corrupting the session") + func bodyLengthMismatchDoesNotCorruptSession() throws { + let message = Array("123456789".utf8) + let sessionID = Array(UInt8(1)...UInt8(8)) + let fourByteBlocks = try AirGapEncoder( + message, + blockBytes: 4, + sessionID: sessionID + ) + let threeByteBlocks = try AirGapEncoder( + message, + blockBytes: 3, + sessionID: sessionID + ) + #expect(fourByteBlocks.blockCount == threeByteBlocks.blockCount) + + var decoder = AirGapDecoder() + let initial = decoder.accept(fourByteBlocks.part(at: 0)) + #expect(initial.ok) + #expect(initial.have == 1) + + let mismatch = decoder.accept(threeByteBlocks.part(at: 1)) + #expect(!mismatch.ok) + #expect(mismatch.have == 1) + #expect(mismatch.total == fourByteBlocks.blockCount) + + for sequence in 1.. [UInt32] { + var sequence = startingAt ?? UInt32(blockCount) + var result: [UInt32] = [] + result.reserveCapacity(count) + while result.count < count { + if airGapBlocksForPart(sequence, blockCount: blockCount).count == degree { + result.append(sequence) + } + sequence += 1 + } + return result +} + +private func sequencesExcludingIndex( + _ excludedIndex: Int, + blockCount: Int, + count: Int +) -> [UInt32] { + var sequence = UInt32(blockCount) + var result: [UInt32] = [] + result.reserveCapacity(count) + while result.count < count { + let indices = airGapBlocksForPart(sequence, blockCount: blockCount) + if !indices.contains(excludedIndex) { + result.append(sequence) + } + sequence += 1 + } + return result +} + +private func mirroredCollectionCount(_ value: Value, label: String) -> Int? { + guard let child = Mirror(reflecting: value).children.first(where: { $0.label == label }) else { + return nil + } + return Mirror(reflecting: child.value).children.count +} + +private struct FixedRandomSource: SecureRandomSource { + func randomBytes(count: Int) throws -> [UInt8] { + Array(0.. [UInt8] { + [UInt8](repeating: 0, count: max(0, count - 1)) + } +} + +private enum FixtureError: Error { + case missingFixture + case wrongShape(String) + case invalidHex(String) +} + +private typealias JSONObject = [String: Any] + +private func loadVectors() throws -> [JSONObject] { + guard let fixtureRoot = Bundle.module.url(forResource: "Fixtures", withExtension: nil) else { + throw FixtureError.missingFixture + } + let data = try Data(contentsOf: fixtureRoot.appendingPathComponent("air-gap-optical.json")) + guard let root = try JSONSerialization.jsonObject(with: data) as? JSONObject, + let vectors = root["vectors"] as? [JSONObject] else { + throw FixtureError.wrongShape("vectors") + } + return vectors +} + +private func requiredObject(_ object: JSONObject, _ key: String) throws -> JSONObject { + guard let value = object[key] as? JSONObject else { throw FixtureError.wrongShape(key) } + return value +} + +private func requiredString(_ object: JSONObject, _ key: String) throws -> String { + guard let value = object[key] as? String else { throw FixtureError.wrongShape(key) } + return value +} + +private func requiredStrings(_ object: JSONObject, _ key: String) throws -> [String] { + guard let value = object[key] as? [String] else { throw FixtureError.wrongShape(key) } + return value +} + +private func requiredInt(_ object: JSONObject, _ key: String) throws -> Int { + guard let value = object[key] as? NSNumber else { throw FixtureError.wrongShape(key) } + return value.intValue +} + +private func requiredUInt32(_ object: JSONObject, _ key: String) throws -> UInt32 { + guard let value = object[key] as? NSNumber, + value.uint64Value <= UInt64(UInt32.max) else { + throw FixtureError.wrongShape(key) + } + return UInt32(value.uint64Value) +} + +private func requiredBool(_ object: JSONObject, _ key: String) throws -> Bool { + guard let value = object[key] as? Bool else { throw FixtureError.wrongShape(key) } + return value +} + +private func hexBytes(_ text: String) throws -> [UInt8] { + guard text.utf8.count.isMultiple(of: 2) else { throw FixtureError.invalidHex(text) } + var result: [UInt8] = [] + result.reserveCapacity(text.utf8.count / 2) + var index = text.startIndex + while index < text.endIndex { + let end = text.index(index, offsetBy: 2) + guard let byte = UInt8(text[index.. String { + bytes.map { String(format: "%02x", $0) }.joined() +} diff --git a/Tests/BSVAirGapTests/Fixtures/air-gap-optical.json b/Tests/BSVAirGapTests/Fixtures/air-gap-optical.json new file mode 100644 index 0000000..74bd3d0 --- /dev/null +++ b/Tests/BSVAirGapTests/Fixtures/air-gap-optical.json @@ -0,0 +1,454 @@ +{ + "$schema": "../../schema/vector.schema.json", + "id": "transport.air-gap-optical", + "name": "Air-Gap Optical Transport v1 (BRC-141)", + "brc": ["BRC-141"], + "version": "1.0.0", + "reference_impl": "@bsv/air-gap@0.1.1", + "parity_class": "required", + "vectors": [ + { + "id": "transport.air-gap-optical.crc32.1", + "description": "IEEE CRC-32 check value for ASCII \"123456789\"", + "input": { + "operation": "crc32", + "message_hex": "313233343536373839" + }, + "expected": { + "crc32_hex": "cbf43926" + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.length.2", + "description": "exact rendered part length for blockBytes 1", + "input": { + "operation": "part-char-length", + "block_bytes": 1 + }, + "expected": { + "chars": 40 + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.length.3", + "description": "exact rendered part length for blockBytes 1200", + "input": { + "operation": "part-char-length", + "block_bytes": 1200 + }, + "expected": { + "chars": 1639 + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.length.4", + "description": "exact rendered part length for blockBytes 2048", + "input": { + "operation": "part-char-length", + "block_bytes": 2048 + }, + "expected": { + "chars": 2770 + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.encode.5", + "description": "K=1 \"Hello\" blockBytes 8 seq 0 (systematic/fountain of a single block)", + "input": { + "operation": "encode-part", + "message_hex": "48656c6c6f", + "block_bytes": 8, + "session_id_hex": "0102030405060708", + "seq": 0 + }, + "expected": { + "part": "air-gap:AQECAwQFBgcIAAAAAAABAAAABffRiYJIZWxsbwAAAA" + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.encode.6", + "description": "K=1 \"Hello\" blockBytes 8 seq 1 (systematic/fountain of a single block)", + "input": { + "operation": "encode-part", + "message_hex": "48656c6c6f", + "block_bytes": 8, + "session_id_hex": "0102030405060708", + "seq": 1 + }, + "expected": { + "part": "air-gap:AQECAwQFBgcIAAAAAQABAAAABffRiYJIZWxsbwAAAA" + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.encode.7", + "description": "K=3 pseudo-random 10-byte message blockBytes 4 seq 0", + "input": { + "operation": "encode-part", + "message_hex": "0726456483a2c1e0ff1e", + "block_bytes": 4, + "session_id_hex": "0102030405060708", + "seq": 0 + }, + "expected": { + "part": "air-gap:AQECAwQFBgcIAAAAAAADAAAACnLCHwsHJkVk" + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.encode.8", + "description": "K=3 pseudo-random 10-byte message blockBytes 4 seq 1", + "input": { + "operation": "encode-part", + "message_hex": "0726456483a2c1e0ff1e", + "block_bytes": 4, + "session_id_hex": "0102030405060708", + "seq": 1 + }, + "expected": { + "part": "air-gap:AQECAwQFBgcIAAAAAQADAAAACnLCHwuDosHg" + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.encode.9", + "description": "K=3 pseudo-random 10-byte message blockBytes 4 seq 2", + "input": { + "operation": "encode-part", + "message_hex": "0726456483a2c1e0ff1e", + "block_bytes": 4, + "session_id_hex": "0102030405060708", + "seq": 2 + }, + "expected": { + "part": "air-gap:AQECAwQFBgcIAAAAAgADAAAACnLCHwv_HgAA" + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.encode.10", + "description": "K=3 pseudo-random 10-byte message blockBytes 4 seq 3", + "input": { + "operation": "encode-part", + "message_hex": "0726456483a2c1e0ff1e", + "block_bytes": 4, + "session_id_hex": "0102030405060708", + "seq": 3 + }, + "expected": { + "part": "air-gap:AQECAwQFBgcIAAAAAwADAAAACnLCHwt8vMHg" + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.encode.11", + "description": "K=3 pseudo-random 10-byte message blockBytes 4 seq 4", + "input": { + "operation": "encode-part", + "message_hex": "0726456483a2c1e0ff1e", + "block_bytes": 4, + "session_id_hex": "0102030405060708", + "seq": 4 + }, + "expected": { + "part": "air-gap:AQECAwQFBgcIAAAABAADAAAACnLCHwsHJkVk" + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.encode.12", + "description": "K=5 boundary seq 3393264 — u32 modular seed (Math.imul); a float-multiply port diverges here or above", + "input": { + "operation": "encode-part", + "message_hex": "0726456483a2c1e0ff1e3d5c7b9ab9d8f7163554", + "block_bytes": 4, + "session_id_hex": "0000000000000000", + "seq": 3393264 + }, + "expected": { + "part": "air-gap:AQAAAAAAAAAAADPG8AAFAAAAFNHjUJUICAgI" + }, + "tags": ["edge-case", "seed-precision"] + }, + { + "id": "transport.air-gap-optical.encode.13", + "description": "K=5 boundary seq 3393265 — u32 modular seed (Math.imul); a float-multiply port diverges here or above", + "input": { + "operation": "encode-part", + "message_hex": "0726456483a2c1e0ff1e3d5c7b9ab9d8f7163554", + "block_bytes": 4, + "session_id_hex": "0000000000000000", + "seq": 3393265 + }, + "expected": { + "part": "air-gap:AQAAAAAAAAAAADPG8QAFAAAAFNHjUJX4OHg4" + }, + "tags": ["edge-case", "seed-precision"] + }, + { + "id": "transport.air-gap-optical.encode.14", + "description": "K=5 boundary seq 2147483647 — u32 modular seed (Math.imul); a float-multiply port diverges here or above", + "input": { + "operation": "encode-part", + "message_hex": "0726456483a2c1e0ff1e3d5c7b9ab9d8f7163554", + "block_bytes": 4, + "session_id_hex": "0000000000000000", + "seq": 2147483647 + }, + "expected": { + "part": "air-gap:AQAAAAAAAAAAf____wAFAAAAFNHjUJV8vPy8" + }, + "tags": ["edge-case", "seed-precision"] + }, + { + "id": "transport.air-gap-optical.encode.15", + "description": "K=5 boundary seq 4294967295 — u32 modular seed (Math.imul); a float-multiply port diverges here or above", + "input": { + "operation": "encode-part", + "message_hex": "0726456483a2c1e0ff1e3d5c7b9ab9d8f7163554", + "block_bytes": 4, + "session_id_hex": "0000000000000000", + "seq": 4294967295 + }, + "expected": { + "part": "air-gap:AQAAAAAAAAAA_____wAFAAAAFNHjUJUAAAAA" + }, + "tags": ["edge-case", "seed-precision"] + }, + { + "id": "transport.air-gap-optical.decode.16", + "description": "K=3 systematic cycle decodes to the exact message", + "input": { + "operation": "decode", + "parts": [ + "air-gap:AQECAwQFBgcIAAAAAAADAAAACnLCHwsHJkVk", + "air-gap:AQECAwQFBgcIAAAAAQADAAAACnLCHwuDosHg", + "air-gap:AQECAwQFBgcIAAAAAgADAAAACnLCHwv_HgAA" + ] + }, + "expected": { + "message_hex": "0726456483a2c1e0ff1e" + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.decode.17", + "description": "K=3 fountain part substitutes for a missed systematic part", + "input": { + "operation": "decode", + "parts": [ + "air-gap:AQECAwQFBgcIAAAAAAADAAAACnLCHwsHJkVk", + "air-gap:AQECAwQFBgcIAAAAAgADAAAACnLCHwv_HgAA", + "air-gap:AQECAwQFBgcIAAAAAwADAAAACnLCHwt8vMHg" + ] + }, + "expected": { + "message_hex": "0726456483a2c1e0ff1e" + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.decode.18", + "description": "single part completes a single-block message", + "input": { + "operation": "decode", + "parts": ["air-gap:AQECAwQFBgcIAAAAAQABAAAABffRiYJIZWxsbwAAAA"] + }, + "expected": { + "message_hex": "48656c6c6f" + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.session.19", + "description": "one foreign frame is rejected; the locked session still completes", + "input": { + "operation": "decode", + "parts": [ + "air-gap:AQECAwQFBgcIAAAAAAADAAAACnLCHwsHJkVk", + "air-gap:ARESExQVFhcYAAAAAAACAAAACKdWBCgHJkVk", + "air-gap:AQECAwQFBgcIAAAAAQADAAAACnLCHwuDosHg", + "air-gap:AQECAwQFBgcIAAAAAgADAAAACnLCHwv_HgAA" + ] + }, + "expected": { + "message_hex": "0726456483a2c1e0ff1e" + }, + "tags": ["adversarial"] + }, + { + "id": "transport.air-gap-optical.session.20", + "description": "three consecutive foreign parts switch the decoder to the new session", + "input": { + "operation": "decode", + "parts": [ + "air-gap:AQECAwQFBgcIAAAAAAADAAAACnLCHwsHJkVk", + "air-gap:ARESExQVFhcYAAAAAAACAAAACKdWBCgHJkVk", + "air-gap:ARESExQVFhcYAAAAAQACAAAACKdWBCiDosHg", + "air-gap:ARESExQVFhcYAAAAAgACAAAACKdWBCiEhISE", + "air-gap:ARESExQVFhcYAAAAAAACAAAACKdWBCgHJkVk", + "air-gap:ARESExQVFhcYAAAAAQACAAAACKdWBCiDosHg" + ] + }, + "expected": { + "message_hex": "0726456483a2c1e0" + }, + "tags": ["adversarial"] + }, + { + "id": "transport.air-gap-optical.session.21", + "description": "K=3 linearly dependent fountain parts stall at 1/3 recovered — recovery is probabilistic, receivers keep scanning", + "input": { + "operation": "progress", + "parts": [ + "air-gap:AQAAAAAAAAAAAAAABAADAAAAHgatDaIHJkVkg6LB4P8e", + "air-gap:AQAAAAAAAAAAAAAAGwADAAAAHgatDaIHJkVkg6LB4P8e", + "air-gap:AQAAAAAAAAAAAAAAJgADAAAAHgatDaIHJkVkg6LB4P8e", + "air-gap:AQAAAAAAAAAAAAAAOAADAAAAHgatDaIHJkVkg6LB4P8e", + "air-gap:AQAAAAAAAAAAAAAAPwADAAAAHgatDaIHJkVkg6LB4P8e", + "air-gap:AQAAAAAAAAAAAAAASAADAAAAHgatDaIHJkVkg6LB4P8e" + ] + }, + "expected": { + "have": 1, + "total": 3, + "done": false + }, + "tags": ["edge-case", "stall"] + }, + { + "id": "transport.air-gap-optical.session.22", + "description": "the stalled session completes once the remaining systematic parts arrive", + "input": { + "operation": "decode", + "parts": [ + "air-gap:AQAAAAAAAAAAAAAABAADAAAAHgatDaIHJkVkg6LB4P8e", + "air-gap:AQAAAAAAAAAAAAAAGwADAAAAHgatDaIHJkVkg6LB4P8e", + "air-gap:AQAAAAAAAAAAAAAAJgADAAAAHgatDaIHJkVkg6LB4P8e", + "air-gap:AQAAAAAAAAAAAAAAOAADAAAAHgatDaIHJkVkg6LB4P8e", + "air-gap:AQAAAAAAAAAAAAAAPwADAAAAHgatDaIHJkVkg6LB4P8e", + "air-gap:AQAAAAAAAAAAAAAASAADAAAAHgatDaIHJkVkg6LB4P8e", + "air-gap:AQAAAAAAAAAAAAAAAQADAAAAHgatDaI9XHuaudj3FjVU", + "air-gap:AQAAAAAAAAAAAAAAAgADAAAAHgatDaJzkrHQ7w4tTGuK" + ] + }, + "expected": { + "message_hex": "0726456483a2c1e0ff1e3d5c7b9ab9d8f71635547392b1d0ef0e2d4c6b8a" + }, + "tags": ["edge-case", "stall"] + }, + { + "id": "transport.air-gap-optical.reject.23", + "description": "legacy bsvpayf2 frame is not accepted", + "input": { + "operation": "accept-one", + "text": "bsvpayf2:AAAAAAAAAAAAAAAA" + }, + "expected": { + "ok": false + }, + "tags": ["adversarial"] + }, + { + "id": "transport.air-gap-optical.reject.24", + "description": "BRC-225 TKQR1 frame is not accepted", + "input": { + "operation": "accept-one", + "text": "TKQR1|0011223344556677|0|1|aGk=" + }, + "expected": { + "ok": false + }, + "tags": ["adversarial"] + }, + { + "id": "transport.air-gap-optical.reject.25", + "description": "wire version 0 is rejected", + "input": { + "operation": "accept-one", + "text": "air-gap:AAECAwQFBgcIAAAAAAADAAAACnLCHwsHJkVk" + }, + "expected": { + "ok": false + }, + "tags": ["adversarial"] + }, + { + "id": "transport.air-gap-optical.reject.26", + "description": "wire version 2 is rejected", + "input": { + "operation": "accept-one", + "text": "air-gap:AgECAwQFBgcIAAAAAAADAAAACnLCHwsHJkVk" + }, + "expected": { + "ok": false + }, + "tags": ["adversarial"] + }, + { + "id": "transport.air-gap-optical.reject.27", + "description": "header-only body is rejected", + "input": { + "operation": "accept-one", + "text": "air-gap:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "expected": { + "ok": false + }, + "tags": ["adversarial"] + }, + { + "id": "transport.air-gap-optical.reject.28", + "description": "payload longer than MAX_BLOCK_BYTES is rejected", + "input": { + "operation": "accept-one", + "text": "air-gap:AQECAwQFBgcIAAAAAAADAAAACnLCHwsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "expected": { + "ok": false + }, + "tags": ["adversarial"] + }, + { + "id": "transport.air-gap-optical.reject.29", + "description": "scanned text longer than the maximum part length is rejected before decoding", + "input": { + "operation": "accept-one", + "text": "air-gap:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "expected": { + "ok": false + }, + "tags": ["adversarial"] + }, + { + "id": "transport.air-gap-optical.reject.30", + "description": "base64url with padding is rejected", + "input": { + "operation": "accept-one", + "text": "air-gap:AQECAwQFBgcIAAAAAAADAAAACnLCHwsHJkVk=" + }, + "expected": { + "ok": false + }, + "tags": ["adversarial"] + }, + { + "id": "transport.air-gap-optical.reject.31", + "description": "whitespace inside the body is rejected", + "input": { + "operation": "accept-one", + "text": "air-gap:AQECAwQFBgcI AAAAAAADAAAACnLCHwsHJkVk" + }, + "expected": { + "ok": false + }, + "tags": ["adversarial"] + } + ] +}