diff --git a/Sources/BSVWallet/JSON/WalletBRC100CertificateJSON.swift b/Sources/BSVWallet/JSON/WalletBRC100CertificateJSON.swift index 5d05530..a4273e7 100644 --- a/Sources/BSVWallet/JSON/WalletBRC100CertificateJSON.swift +++ b/Sources/BSVWallet/JSON/WalletBRC100CertificateJSON.swift @@ -152,14 +152,7 @@ private struct CertificateDTO: Codable { type = value.type; subject = Hex.encode(value.subject.compressedBytes); serialNumber = value.serialNumber certifier = Hex.encode(value.certifier.compressedBytes); revocationOutpoint = value.revocationOutpoint.description signature = value.signature.map { Hex.encode($0.derBytes) } - var textFields: [String: String] = [:] - for (name, field) in value.fields { - guard let text = String(bytes: field.bytes, encoding: .utf8) else { - throw WalletJSONCodecError.invalidJSON - } - textFields[name.value] = text - } - fields = textFields + fields = Dictionary(uniqueKeysWithValues: value.fields.map { ($0.key.value, $0.value.base64) }) } init(type: CertificateTypeID, subject: String, serialNumber: CertificateSerialNumber, certifier: String, revocationOutpoint: String, signature: String?, fields: [String: String]) { self.type = type; self.subject = subject; self.serialNumber = serialNumber @@ -169,7 +162,7 @@ private struct CertificateDTO: Codable { func model(_ codec: WalletBRC100JSONCodec) throws -> Certificate { let signatureValue: ECDSASignature? if let signature { let bytes = try decodeCanonicalHex(signature, maximum: 72); let parsed = try ECDSASignature(derBytes: bytes); guard parsed.derBytes == bytes else { throw WalletJSONCodecError.invalidJSON }; signatureValue = parsed } else { signatureValue = nil } - let fieldValues = try dictionaryTextCiphertexts(fields, limits: codec.certificateLimits) + let fieldValues = try dictionaryBase64Ciphertexts(fields, limits: codec.certificateLimits) return try .init(type: type, serialNumber: serialNumber, subject: decodeWalletJSONPublicKey(subject), certifier: decodeWalletJSONPublicKey(certifier), revocationOutpoint: .init(revocationOutpoint), fields: fieldValues, signature: signatureValue, limits: codec.certificateLimits) } } @@ -246,8 +239,13 @@ private func dictionaryFieldNames(_ values: [String: T], limits: CertificateL return result } private func dictionaryCiphertexts(_ values: [String: CertificateCiphertext], limits: CertificateLimits) throws -> [CertificateFieldName: CertificateCiphertext] { try dictionaryFieldNames(values, limits: limits) } -private func dictionaryTextCiphertexts(_ values: [String: String], limits: CertificateLimits) throws -> [CertificateFieldName: CertificateCiphertext] { +private func dictionaryBase64Ciphertexts(_ values: [String: String], limits: CertificateLimits) throws -> [CertificateFieldName: CertificateCiphertext] { var result: [CertificateFieldName: CertificateCiphertext] = [:] - for (key, value) in values { result[try CertificateFieldName(key, limits: limits)] = try CertificateCiphertext(Array(value.utf8), maximumByteCount: limits.maximumFieldCiphertextByteCount) } + for (key, value) in values { + result[try CertificateFieldName(key, limits: limits)] = try CertificateCiphertext( + base64: value, + maximumByteCount: limits.maximumFieldCiphertextByteCount + ) + } return result } diff --git a/Tests/BSVWalletTests/WalletBRC100JSONCodecTests.swift b/Tests/BSVWalletTests/WalletBRC100JSONCodecTests.swift index 0c1bf12..c820f46 100644 --- a/Tests/BSVWalletTests/WalletBRC100JSONCodecTests.swift +++ b/Tests/BSVWalletTests/WalletBRC100JSONCodecTests.swift @@ -1,5 +1,6 @@ import XCTest import BSVCore +import BSVKeys import BSVTransaction @testable import BSVWallet @@ -155,6 +156,121 @@ final class WalletBRC100JSONCodecTests: XCTestCase { XCTAssertThrowsError(try codec.decodeRequest(route: route, from: Array("{".utf8))) } + func testCertificateCiphertextUsesCanonicalBase64AcrossJSONRoutes() throws { + let raw: [UInt8] = [0x00, 0xff, 0x80, 0x01, 0x7f, 0xa5, 0x42, 0xc3] + let expectedBase64 = "AP+AAX+lQsM=" + let codec = try makeCodec() + let (certificate, field) = try makeCertificate(fieldBytes: raw) + + let acquireRoute = try XCTUnwrap(WalletJSONRoute(methodName: "acquireCertificate")) + let acquireResult = WalletResult.certificate(.acquireCertificate(certificate)) + let acquireJSON = try codec.encodeResult(acquireResult) + XCTAssertEqual( + String(decoding: acquireJSON, as: UTF8.self), + "{\"certifier\":\"\(Hex.encode(certificate.certifier.compressedBytes))\",\"fields\":{\"binary\":\"\(expectedBase64)\"},\"revocationOutpoint\":\"\(certificate.revocationOutpoint)\",\"serialNumber\":\"\(certificate.serialNumber.base64)\",\"subject\":\"\(Hex.encode(certificate.subject.compressedBytes))\",\"type\":\"\(certificate.type.base64)\"}" + ) + guard case .certificate(.acquireCertificate(let acquired)) = try codec.decodeResult( + route: acquireRoute, + from: acquireJSON + ) else { return XCTFail("unexpected acquireCertificate result") } + XCTAssertEqual(acquired.fields[field]?.bytes, raw) + + let listRoute = try XCTUnwrap(WalletJSONRoute(methodName: "listCertificates")) + let listResult = WalletResult.certificate(.listCertificates(try .init( + totalCertificates: 1, + certificates: [.init(certificate: certificate, keyring: nil, verifier: [])] + ))) + let listJSON = try codec.encodeResult(listResult) + let listObject = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(listJSON)) as? [String: Any] + ) + let listedObjects = try XCTUnwrap(listObject["certificates"] as? [[String: Any]]) + let listedFields = try XCTUnwrap(listedObjects.first?["fields"] as? [String: String]) + XCTAssertEqual(listedFields[field.value], expectedBase64) + guard case .certificate(.listCertificates(let listed)) = try codec.decodeResult( + route: listRoute, + from: listJSON + ) else { return XCTFail("unexpected listCertificates result") } + XCTAssertEqual(listed.certificates.first?.certificate.fields[field]?.bytes, raw) + + let proveRoute = try XCTUnwrap(WalletJSONRoute(methodName: "proveCertificate")) + let proveRequest = WalletRequest.certificate(.proveCertificate(try .init( + certificate: certificate, + fieldsToReveal: [field], + verifier: walletTestPrivateKey(4).publicKey + ))) + let proveJSON = try codec.encodeRequest(proveRequest) + let proveObject = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(proveJSON)) as? [String: Any] + ) + let proveCertificate = try XCTUnwrap(proveObject["certificate"] as? [String: Any]) + let proveFields = try XCTUnwrap(proveCertificate["fields"] as? [String: String]) + XCTAssertEqual(proveFields[field.value], expectedBase64) + guard case .certificate(.proveCertificate(let proven)) = try codec.decodeRequest( + route: proveRoute, + from: proveJSON + ) else { return XCTFail("unexpected proveCertificate request") } + XCTAssertEqual(proven.certificate.fields[field]?.bytes, raw) + } + + func testSignedCertificateStillVerifiesAfterBRC100JSONTransport() async throws { + let raw: [UInt8] = [0x00, 0xff, 0x80, 0x01, 0x7f, 0xa5, 0x42, 0xc3] + let codec = try makeCodec() + let (unsigned, _) = try makeCertificate(fieldBytes: raw) + let signed = try await unsigned.signed( + using: ProtoWallet(rootKey: walletTestPrivateKey(3)) + ) + let signedIsValid = try await signed.verifySignature() + XCTAssertTrue(signedIsValid) + + let encoded = try codec.encodeResult( + WalletResult.certificate(.acquireCertificate(signed)) + ) + guard case .certificate(.acquireCertificate(let transported)) = try codec.decodeResult( + route: XCTUnwrap(WalletJSONRoute(methodName: "acquireCertificate")), + from: encoded + ) else { return XCTFail("unexpected acquireCertificate result") } + + XCTAssertEqual( + try transported.binary(includingSignature: false), + try signed.binary(includingSignature: false) + ) + XCTAssertEqual(transported.signature, signed.signature) + let transportedIsValid = try await transported.verifySignature() + XCTAssertTrue(transportedIsValid) + } + + func testCertificateCiphertextLimitAppliesToDecodedBytesNotBase64Expansion() throws { + let raw: [UInt8] = [0x00, 0xff, 0x80, 0x01, 0x7f, 0xa5, 0x42, 0xc3] + let standardCodec = try makeCodec() + let (certificate, field) = try makeCertificate(fieldBytes: raw) + let encoded = try standardCodec.encodeResult( + WalletResult.certificate(.acquireCertificate(certificate)) + ) + XCTAssertTrue(String(decoding: encoded, as: UTF8.self).contains("\"binary\":\"AP+AAX+lQsM=\"")) + + let exactCodec = try makeCodec(certificateLimits: try CertificateLimits( + maximumFieldCiphertextByteCount: raw.count + )) + guard case .certificate(.acquireCertificate(let exact)) = try exactCodec.decodeResult( + route: XCTUnwrap(WalletJSONRoute(methodName: "acquireCertificate")), + from: encoded + ) else { return XCTFail("unexpected acquireCertificate result") } + XCTAssertEqual(exact.fields[field]?.bytes, raw) + + let exactBase64 = "AP+AAX+lQsM=" + let oversizedBase64 = "AAAAAAAAAAAA" + XCTAssertEqual(exactBase64.utf8.count, oversizedBase64.utf8.count) + let oversized = String(decoding: encoded, as: UTF8.self) + .replacingOccurrences(of: exactBase64, with: oversizedBase64) + XCTAssertThrowsError(try exactCodec.decodeResult( + route: XCTUnwrap(WalletJSONRoute(methodName: "acquireCertificate")), + from: Array(oversized.utf8) + )) { error in + XCTAssertEqual(error as? CertificateError, .invalidCanonicalBase64) + } + } + func testProcessorSeparatesRouteBodyAndWalletFailures() async throws { let codec = try makeCodec() let context = try WalletRequestContext(rawOriginator: "example.com") @@ -215,7 +331,9 @@ final class WalletBRC100JSONCodecTests: XCTestCase { } } - private func makeCodec() throws -> WalletBRC100JSONCodec { + private func makeCodec( + certificateLimits: CertificateLimits = .standard + ) throws -> WalletBRC100JSONCodec { try WalletBRC100JSONCodec(beefLimits: BEEFLimits( maximumByteCount: 1_000_000, maximumMerklePathCount: 100, @@ -231,7 +349,25 @@ final class WalletBRC100JSONCodecTests: XCTestCase { maximumLeavesPerLevel: 100, maximumTotalLeaves: 1_000 ) - )) + ), certificateLimits: certificateLimits) + } + + private func makeCertificate( + fieldBytes: [UInt8] + ) throws -> (certificate: Certificate, field: CertificateFieldName) { + let fixture = try WalletWireCertificateFixture() + let field = try CertificateFieldName("binary") + return ( + try Certificate( + type: fixture.type, + serialNumber: fixture.serial, + subject: fixture.subject, + certifier: fixture.certifier, + revocationOutpoint: fixture.outpoint, + fields: [field: try CertificateCiphertext(fieldBytes)] + ), + field + ) } }