diff --git a/Sources/BSVWallet/ABI/WalletActionABI.swift b/Sources/BSVWallet/ABI/WalletActionABI.swift index 38cf47d..0353601 100644 --- a/Sources/BSVWallet/ABI/WalletActionABI.swift +++ b/Sources/BSVWallet/ABI/WalletActionABI.swift @@ -23,7 +23,7 @@ public enum WalletActionResultStatus: String, CaseIterable, Codable, Sendable { } public enum WalletActionStatus: String, CaseIterable, Codable, Sendable { - case completed, unprocessed, sending, unproven, unsigned, nosend, nonfinal + case completed, unprocessed, sending, unproven, unsigned, nosend, nonfinal, failed public init(_ text: String) throws { guard let value = Self(rawValue: text) else { throw WalletABIError.invalidEnumText(type: "WalletActionStatus", value: text) diff --git a/Sources/BSVWallet/ABI/WalletRequestHandling.swift b/Sources/BSVWallet/ABI/WalletRequestHandling.swift new file mode 100644 index 0000000..c27051e --- /dev/null +++ b/Sources/BSVWallet/ABI/WalletRequestHandling.swift @@ -0,0 +1,278 @@ +/// Transport metadata accompanying one decoded wallet request. +/// +/// `rawOriginator` preserves the transport-provided value exactly. A transport +/// or policy handler remains responsible for establishing trust in that value; +/// this type only enforces the BRC-100 wallet-wire size bound. +public struct WalletRequestContext: + Hashable, + Sendable, + CustomStringConvertible, + CustomDebugStringConvertible, + CustomReflectable { + public static let maximumRawOriginatorUTF8ByteCount = 255 + + public let rawOriginator: String + + public init(rawOriginator: String) throws { + let count = rawOriginator.utf8.count + guard count <= Self.maximumRawOriginatorUTF8ByteCount else { + throw WalletRequestContextError.originatorTooLong( + actualUTF8ByteCount: count, + maximumUTF8ByteCount: Self.maximumRawOriginatorUTF8ByteCount + ) + } + self.rawOriginator = rawOriginator + } + + public var description: String { "" } + public var debugDescription: String { description } + public var customMirror: Mirror { + Mirror(self, children: EmptyCollection<(label: String?, value: Any)>()) + } +} + +public enum WalletRequestContextError: Error, Equatable, Sendable { + case originatorTooLong(actualUTF8ByteCount: Int, maximumUTF8ByteCount: Int) +} + +/// The permission-seeking semantics of a decoded BRC-100 request. +/// +/// `rawValue` preserves the caller's tri-state input. `effectiveValue` applies +/// the operation-specific BRC-100 default, or is `nil` when the operation does +/// not define `seekPermission`. +public struct WalletSeekPermissionMetadata: Equatable, Sendable { + public let rawValue: Bool? + public let effectiveValue: Bool? + + public init(rawValue: Bool?, effectiveValue: Bool?) { + self.rawValue = rawValue + self.effectiveValue = effectiveValue + } +} + +/// A decoded request from any of the 28 BRC-100 wallet operations. +public enum WalletRequest: + Sendable, CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable { + case action(WalletWireActionRequest) + case certificate(WalletWireCertificateRequest) + case keyQuery(WalletWireKeyQueryRequest) + + public var call: WalletCall { + switch self { + case .action(let request): request.call + case .certificate(let request): request.call + case .keyQuery(let request): request.call + } + } + + /// Permission-seeking metadata for transport or host policy. + public var seekPermissionMetadata: WalletSeekPermissionMetadata { + switch self { + case .action(.listActions(let value)): + return makeSeekPermissionMetadata(rawValue: value.seekPermission, defaultValue: true) + case .action(.internalizeAction(let value)): + return makeSeekPermissionMetadata(rawValue: value.seekPermission, defaultValue: true) + case .action(.listOutputs(let value)): + return makeSeekPermissionMetadata(rawValue: value.seekPermission, defaultValue: true) + case .certificate(.discoverByIdentityKey(let value)): + return makeSeekPermissionMetadata(rawValue: value.seekPermission, defaultValue: false) + case .certificate(.discoverByAttributes(let value)): + return makeSeekPermissionMetadata(rawValue: value.seekPermission, defaultValue: false) + case .keyQuery(.getPublicKey(let value)): + return makeSeekPermissionMetadata(rawValue: value.access.seekPermission, defaultValue: true) + case .keyQuery(.encrypt(let value)): + return makeSeekPermissionMetadata(rawValue: value.access.seekPermission, defaultValue: true) + case .keyQuery(.decrypt(let value)): + return makeSeekPermissionMetadata(rawValue: value.access.seekPermission, defaultValue: true) + case .keyQuery(.createHMAC(let value)): + return makeSeekPermissionMetadata(rawValue: value.access.seekPermission, defaultValue: true) + case .keyQuery(.verifyHMAC(let value)): + return makeSeekPermissionMetadata(rawValue: value.access.seekPermission, defaultValue: true) + case .keyQuery(.createSignature(let value)): + return makeSeekPermissionMetadata(rawValue: value.access.seekPermission, defaultValue: true) + case .keyQuery(.verifySignature(let value)): + return makeSeekPermissionMetadata(rawValue: value.access.seekPermission, defaultValue: true) + case .action(.createAction), + .action(.signAction), + .action(.abortAction), + .action(.relinquishOutput), + .certificate(.revealCounterpartyKeyLinkage), + .certificate(.revealSpecificKeyLinkage), + .certificate(.acquireCertificate), + .certificate(.listCertificates), + .certificate(.proveCertificate), + .certificate(.relinquishCertificate), + .keyQuery(.isAuthenticated), + .keyQuery(.waitForAuthentication), + .keyQuery(.getHeight), + .keyQuery(.getHeaderForHeight), + .keyQuery(.getNetwork), + .keyQuery(.getVersion): + return WalletSeekPermissionMetadata(rawValue: nil, effectiveValue: nil) + } + } + + /// The caller-provided value without applying an operation default. + public var rawSeekPermission: Bool? { seekPermissionMetadata.rawValue } + + /// The operation's effective value after applying its BRC-100 default. + public var effectiveSeekPermission: Bool? { seekPermissionMetadata.effectiveValue } + + public var description: String { "" } + public var debugDescription: String { description } + public var customMirror: Mirror { Mirror(self, children: ["call": call.rawValue]) } +} + +private func makeSeekPermissionMetadata( + rawValue: Bool?, + defaultValue: Bool +) -> WalletSeekPermissionMetadata { + WalletSeekPermissionMetadata( + rawValue: rawValue, + effectiveValue: rawValue ?? defaultValue + ) +} + +/// A typed result from any of the 28 BRC-100 wallet operations. +public enum WalletResult: + Sendable, CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable { + case action(WalletWireActionResult) + case certificate(WalletWireCertificateResult) + case keyQuery(WalletWireKeyQueryResult) + + public var call: WalletCall { + switch self { + case .action(let result): result.call + case .certificate(let result): result.call + case .keyQuery(let result): result.call + } + } + + public var description: String { "" } + public var debugDescription: String { description } + public var customMirror: Mirror { Mirror(self, children: ["call": call.rawValue]) } +} + +/// A transport-neutral policy and dispatch boundary for decoded wallet calls. +/// +/// Permission-aware handlers can inspect both the full typed request and its +/// originator before forwarding to a wallet. The returned result must have the +/// same `call` as the request. +public protocol WalletRequestHandling: Sendable { + func handle( + _ request: WalletRequest, + context: WalletRequestContext + ) async throws -> WalletResult +} + +/// Adapts a trusted, in-process `WalletInterface` to request handling. +/// +/// This adapter intentionally does not authorize the originator. Use a policy +/// handler in front of it for requests that cross a trust boundary. +public struct WalletInterfaceRequestHandler: + WalletRequestHandling, + Sendable, + CustomStringConvertible, + CustomDebugStringConvertible, + CustomReflectable { + private let wallet: any WalletInterface + + public init(wallet: any WalletInterface) { + self.wallet = wallet + } + + public func handle( + _ request: WalletRequest, + context: WalletRequestContext + ) async throws -> WalletResult { + switch request { + case .action(let request): + return .action(try await handle(request)) + case .certificate(let request): + return .certificate(try await handle(request)) + case .keyQuery(let request): + return .keyQuery(try await handle(request)) + } + } + + private func handle(_ request: WalletWireActionRequest) async throws -> WalletWireActionResult { + switch request { + case .createAction(let value): + .createAction(try await wallet.createAction(value)) + case .signAction(let value): + .signAction(try await wallet.signAction(value)) + case .abortAction(let value): + .abortAction(try await wallet.abortAction(value)) + case .listActions(let value): + .listActions(try await wallet.listActions(value)) + case .internalizeAction(let value): + .internalizeAction(try await wallet.internalizeAction(value)) + case .listOutputs(let value): + .listOutputs(try await wallet.listOutputs(value)) + case .relinquishOutput(let value): + .relinquishOutput(try await wallet.relinquishOutput(value)) + } + } + + private func handle( + _ request: WalletWireCertificateRequest + ) async throws -> WalletWireCertificateResult { + switch request { + case .revealCounterpartyKeyLinkage(let value): + .revealCounterpartyKeyLinkage(try await wallet.revealCounterpartyKeyLinkage(value)) + case .revealSpecificKeyLinkage(let value): + .revealSpecificKeyLinkage(try await wallet.revealSpecificKeyLinkage(value)) + case .acquireCertificate(let value): + .acquireCertificate(try await wallet.acquireCertificate(value)) + case .listCertificates(let value): + .listCertificates(try await wallet.listCertificates(value)) + case .proveCertificate(let value): + .proveCertificate(try await wallet.proveCertificate(value)) + case .relinquishCertificate(let value): + .relinquishCertificate(try await wallet.relinquishCertificate(value)) + case .discoverByIdentityKey(let value): + .discoverByIdentityKey(try await wallet.discoverByIdentityKey(value)) + case .discoverByAttributes(let value): + .discoverByAttributes(try await wallet.discoverByAttributes(value)) + } + } + + private func handle( + _ request: WalletWireKeyQueryRequest + ) async throws -> WalletWireKeyQueryResult { + switch request { + case .getPublicKey(let value): + .getPublicKey(try await wallet.getPublicKey(value)) + case .encrypt(let value): + .encrypt(try await wallet.encrypt(value)) + case .decrypt(let value): + .decrypt(try await wallet.decrypt(value)) + case .createHMAC(let value): + .createHMAC(try await wallet.createHMAC(value)) + case .verifyHMAC(let value): + .verifyHMAC(try await wallet.verifyHMAC(value)) + case .createSignature(let value): + .createSignature(try await wallet.createSignature(value)) + case .verifySignature(let value): + .verifySignature(try await wallet.verifySignature(value)) + case .isAuthenticated(let value): + .isAuthenticated(try await wallet.isAuthenticated(value)) + case .waitForAuthentication(let value): + .waitForAuthentication(try await wallet.waitForAuthentication(value)) + case .getHeight(let value): + .getHeight(try await wallet.getHeight(value)) + case .getHeaderForHeight(let value): + .getHeaderForHeight(try await wallet.getHeaderForHeight(value)) + case .getNetwork(let value): + .getNetwork(try await wallet.getNetwork(value)) + case .getVersion(let value): + .getVersion(try await wallet.getVersion(value)) + } + } + + public var description: String { "" } + public var debugDescription: String { description } + public var customMirror: Mirror { + Mirror(self, children: EmptyCollection<(label: String?, value: Any)>()) + } +} diff --git a/Sources/BSVWallet/Crypto/ProtoWallet.swift b/Sources/BSVWallet/Crypto/ProtoWallet.swift index f273c7d..5a21f91 100644 --- a/Sources/BSVWallet/Crypto/ProtoWallet.swift +++ b/Sources/BSVWallet/Crypto/ProtoWallet.swift @@ -1,11 +1,13 @@ import BSVCore import BSVCrypto import BSVKeys +import Foundation /// A policy-free, offline BRC-100 cryptographic kernel. It stores immutable /// values only. Swift cannot guarantee zeroization of copied key material. public struct ProtoWallet: WalletKeyOperations, + WalletLinkageOperations, Sendable, CustomStringConvertible, CustomDebugStringConvertible, @@ -74,6 +76,103 @@ public struct ProtoWallet: } } + public func revealCounterpartyKeyLinkage( + _ request: WalletRevealCounterpartyKeyLinkageRequest + ) async throws -> WalletRevealCounterpartyKeyLinkageResult { + try await revealCounterpartyKeyLinkage( + request, + revelationTime: walletCurrentISO8601Timestamp(), + proofNonce: nil + ) + } + + package func revealCounterpartyKeyLinkage( + _ request: WalletRevealCounterpartyKeyLinkageRequest, + revelationTime: String, + proofNonce: PrivateKey? + ) async throws -> WalletRevealCounterpartyKeyLinkageResult { + try requireStandardPrivilege(request.privilege) + let linkage = try keyDeriver.revealCounterpartySecret(request.counterparty) + let proof = try keyDeriver.counterpartySecretProof( + for: request.counterparty, + nonce: proofNonce + ) + // BRC-97 defines z as a variable-width big-endian integer. The live + // TypeScript SDK emits its minimal representation; Go's fixed 32-byte + // padding differs only when z has leading zero bytes. + let encodedResponse = Array(proof.response.drop(while: { $0 == 0 })) + let proofBytes = proof.noncePublicKey.compressedBytes + + proof.nonceSharedSecret.compressedBytes + + encodedResponse + let protocolID = try WalletProtocolID( + securityLevel: .everyAppAndCounterparty, + name: "counterparty linkage revelation" + ) + let keyID = try WalletKeyID(revelationTime) + let encryptedLinkage = try await encrypt(WalletEncryptRequest( + protocolID: protocolID, + keyID: keyID, + counterparty: .publicKey(request.verifier), + plaintext: linkage.compressedBytes + )) + let encryptedProof = try await encrypt(WalletEncryptRequest( + protocolID: protocolID, + keyID: keyID, + counterparty: .publicKey(request.verifier), + plaintext: proofBytes + )) + return try WalletRevealCounterpartyKeyLinkageResult( + prover: keyDeriver.identityKey, + counterparty: request.counterparty, + verifier: request.verifier, + revelationTime: revelationTime, + encryptedLinkage: WalletLinkageCiphertext(encryptedLinkage.ciphertext), + encryptedLinkageProof: WalletLinkageCiphertext(encryptedProof.ciphertext) + ) + } + + public func revealSpecificKeyLinkage( + _ request: WalletRevealSpecificKeyLinkageRequest + ) async throws -> WalletRevealSpecificKeyLinkageResult { + try requireStandardPrivilege(request.privilege) + guard case .publicKey(let counterparty) = request.counterparty else { + throw WalletCryptoError.keyDerivationFailed + } + let linkage = try keyDeriver.revealSpecificSecret( + counterparty: request.counterparty, + protocolID: request.protocolID, + keyID: request.keyID + ) + let revelationProtocol = try WalletProtocolID( + securityLevel: .everyAppAndCounterparty, + name: "specific linkage revelation " + + "\(request.protocolID.securityLevel.rawValue) \(request.protocolID.name)" + ) + let encryptedLinkage = try await encrypt(WalletEncryptRequest( + protocolID: revelationProtocol, + keyID: request.keyID, + counterparty: .publicKey(request.verifier), + plaintext: linkage + )) + let proofType: UInt8 = 0 + let encryptedProof = try await encrypt(WalletEncryptRequest( + protocolID: revelationProtocol, + keyID: request.keyID, + counterparty: .publicKey(request.verifier), + plaintext: [proofType] + )) + return try WalletRevealSpecificKeyLinkageResult( + encryptedLinkage: WalletLinkageCiphertext(encryptedLinkage.ciphertext), + encryptedLinkageProof: WalletLinkageCiphertext(encryptedProof.ciphertext), + prover: keyDeriver.identityKey, + verifier: request.verifier, + counterparty: counterparty, + protocolID: request.protocolID, + keyID: request.keyID, + proofType: proofType + ) + } + public func encrypt(_ request: WalletEncryptRequest) async throws -> WalletEncryptResult { try requireStandardAccess(request.access) try walletRequirePayloadLimit(request.plaintext.count, limits: limits) @@ -175,7 +274,13 @@ public struct ProtoWallet: } private func requireStandardAccess(_ access: WalletKeyAccess) throws { - guard access == .standard else { + guard access.privileged != true, access.privilegedReason == nil else { + throw WalletCryptoError.permissionPolicyUnavailable + } + } + + private func requireStandardPrivilege(_ privilege: WalletPrivilege) throws { + guard privilege.privileged != true, privilege.privilegedReason == nil else { throw WalletCryptoError.permissionPolicyUnavailable } } @@ -194,3 +299,9 @@ public struct ProtoWallet: public var debugDescription: String { description } public var customMirror: Mirror { walletEmptyMirror(self) } } + +private func walletCurrentISO8601Timestamp() -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: Date()) +} diff --git a/Sources/BSVWallet/Crypto/WalletKeyDeriver.swift b/Sources/BSVWallet/Crypto/WalletKeyDeriver.swift index 7022229..0677dae 100644 --- a/Sources/BSVWallet/Crypto/WalletKeyDeriver.swift +++ b/Sources/BSVWallet/Crypto/WalletKeyDeriver.swift @@ -96,6 +96,58 @@ public struct WalletKeyDeriver: } } + /// Reveals the compressed BRC-42 root ECDH point for a concrete counterparty. + /// Revealing the wallet's self-secret is forbidden by BRC-69. + public func revealCounterpartySecret(_ counterparty: PublicKey) throws -> PublicKey { + guard counterparty != identityKey else { + throw WalletCryptoError.counterpartySelfLinkageForbidden + } + do { + return try rootKey.sharedSecret(with: counterparty) + } catch let error as WalletCryptoError { + throw error + } catch { + throw WalletCryptoError.keyDerivationFailed + } + } + + /// Reveals the BRC-69 method-2 offset for one protocol, key ID, and counterparty. + public func revealSpecificSecret( + counterparty: WalletCounterparty, + protocolID: WalletProtocolID, + keyID: WalletKeyID + ) throws -> [UInt8] { + do { + let sharedSecret = try rootKey.sharedSecret( + with: normalizedCounterparty(counterparty) + ) + return BSVHashing.hmacSHA256( + Array(invoice(protocolID: protocolID, keyID: keyID).utf8), + key: sharedSecret.compressedBytes + ).bytes + } catch let error as WalletCryptoError { + throw error + } catch { + throw WalletCryptoError.keyDerivationFailed + } + } + + /// Generates the BRC-94 proof without exposing the root private key. + package func counterpartySecretProof( + for counterparty: PublicKey, + nonce: PrivateKey? = nil + ) throws -> SharedSecretProof { + _ = try revealCounterpartySecret(counterparty) + do { + if let nonce { + return try rootKey.sharedSecretProof(with: counterparty, nonce: nonce) + } + return try rootKey.sharedSecretProof(with: counterparty) + } catch { + throw WalletCryptoError.proofGenerationFailed + } + } + internal func invoice(protocolID: WalletProtocolID, keyID: WalletKeyID) -> String { "\(protocolID.securityLevel.rawValue)-\(protocolID.name)-\(keyID.value)" } diff --git a/Sources/BSVWallet/JSON/WalletBRC100ActionJSON.swift b/Sources/BSVWallet/JSON/WalletBRC100ActionJSON.swift new file mode 100644 index 0000000..0199430 --- /dev/null +++ b/Sources/BSVWallet/JSON/WalletBRC100ActionJSON.swift @@ -0,0 +1,428 @@ +import BSVCore +import BSVTransaction + +extension WalletBRC100JSONCodec { + func decodeActionRequest(call: WalletCall, bytes: [UInt8]) throws -> WalletWireActionRequest { + switch call { + case .createAction: .createAction(try decode(CreateActionDTO.self, bytes).model(self)) + case .signAction: .signAction(try decode(SignActionDTO.self, bytes).model(self)) + case .abortAction: .abortAction(try decode(AbortActionDTO.self, bytes).model(self)) + case .listActions: .listActions(try decode(ListActionsDTO.self, bytes).model(self)) + case .internalizeAction: .internalizeAction(try decode(InternalizeActionDTO.self, bytes).model(self)) + case .listOutputs: .listOutputs(try decode(ListOutputsDTO.self, bytes).model(self)) + case .relinquishOutput: .relinquishOutput(try decode(RelinquishOutputDTO.self, bytes).model(self)) + default: throw WalletJSONCodecError.requestCallMismatch(expected: .createAction, actual: call) + } + } + + func encodeActionRequest(_ request: WalletWireActionRequest) throws -> [UInt8] { + switch request { + case .createAction(let value): try encode(CreateActionDTO(value, codec: self)) + case .signAction(let value): try encode(SignActionDTO(value, codec: self)) + case .abortAction(let value): try encode(AbortActionDTO(value)) + case .listActions(let value): try encode(ListActionsDTO(value)) + case .internalizeAction(let value): try encode(InternalizeActionDTO(value, codec: self)) + case .listOutputs(let value): try encode(ListOutputsDTO(value)) + case .relinquishOutput(let value): try encode(RelinquishOutputDTO(value)) + } + } + + func decodeActionResult(call: WalletCall, bytes: [UInt8]) throws -> WalletWireActionResult { + switch call { + case .createAction: .createAction(try decode(CreateActionResultDTO.self, bytes).model(self)) + case .signAction: .signAction(try decode(SignActionResultDTO.self, bytes).model(self)) + case .abortAction: + .abortAction(.init(aborted: try decode(AbortResultDTO.self, bytes).aborted)) + case .listActions: .listActions(try decode(ListActionsResultDTO.self, bytes).model(self)) + case .internalizeAction: + .internalizeAction(.init(accepted: try decode(AcceptedResultDTO.self, bytes).accepted)) + case .listOutputs: .listOutputs(try decode(ListOutputsResultDTO.self, bytes).model(self)) + case .relinquishOutput: + .relinquishOutput(.init(relinquished: try decode(RelinquishedResultDTO.self, bytes).relinquished)) + default: throw WalletJSONCodecError.resultCallMismatch(expected: .createAction, actual: call) + } + } + + func encodeActionResult(_ result: WalletWireActionResult) throws -> [UInt8] { + switch result { + case .createAction(let value): try encode(CreateActionResultDTO(value, codec: self)) + case .signAction(let value): try encode(SignActionResultDTO(value, codec: self)) + case .abortAction(let value): try encode(AbortResultDTO(aborted: value.aborted)) + case .listActions(let value): try encode(ListActionsResultDTO(value, codec: self)) + case .internalizeAction(let value): try encode(AcceptedResultDTO(accepted: value.accepted)) + case .listOutputs(let value): try encode(ListOutputsResultDTO(value, codec: self)) + case .relinquishOutput(let value): try encode(RelinquishedResultDTO(relinquished: value.relinquished)) + } + } +} + +private struct CreateActionInputDTO: Codable { + let outpoint: String + let inputDescription: String + let unlockingScript: String? + let unlockingScriptLength: UInt32? + let sequenceNumber: UInt32? + + init(_ value: WalletCreateActionInput) { + outpoint = value.outpoint.description + inputDescription = value.inputDescription + switch value.unlocking { + case .script(let bytes): unlockingScript = Hex.encode(bytes); unlockingScriptLength = nil + case .scriptLength(let count): unlockingScript = nil; unlockingScriptLength = count + } + sequenceNumber = value.sequenceNumber + } + + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletCreateActionInput { + try WalletCreateActionInput( + outpoint: Outpoint(outpoint), + inputDescription: inputDescription, + unlockingScript: try unlockingScript.map { try decodeCanonicalHex($0, maximum: codec.abiLimits.maximumBytePayloadCount) }, + unlockingScriptLength: unlockingScriptLength, + sequenceNumber: sequenceNumber, + limits: codec.abiLimits + ) + } +} + +private struct CreateActionOutputDTO: Codable { + let lockingScript: String + let satoshis: UInt64 + let outputDescription: String + let basket: String? + let customInstructions: String? + let tags: [String]? + + init(_ value: WalletCreateActionOutput) { + lockingScript = Hex.encode(value.lockingScript) + satoshis = value.satoshis + outputDescription = value.outputDescription + basket = value.basket + customInstructions = value.customInstructions + tags = value.tags.isEmpty ? nil : value.tags + } + + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletCreateActionOutput { + try WalletCreateActionOutput( + lockingScript: decodeCanonicalHex(lockingScript, maximum: codec.abiLimits.maximumBytePayloadCount), + satoshis: satoshis, + outputDescription: outputDescription, + basket: basket, + customInstructions: customInstructions, + tags: tags ?? [], + limits: codec.abiLimits + ) + } +} + +private struct CreateActionOptionsDTO: Codable { + let signAndProcess: Bool? + let acceptDelayedBroadcast: Bool? + let trustSelf: WalletTrustSelf? + let knownTxids: [String]? + let returnTXIDOnly: Bool? + let noSend: Bool? + let noSendChange: [String]? + let sendWith: [String]? + let randomizeOutputs: Bool? + + init(_ value: WalletCreateActionOptions) { + signAndProcess = value.signAndProcess + acceptDelayedBroadcast = value.acceptDelayedBroadcast + trustSelf = value.trustSelf + knownTxids = value.knownTransactionIDs?.map(\.displayHex) + returnTXIDOnly = value.returnTransactionIDOnly + noSend = value.noSend + noSendChange = value.noSendChange?.map(\.description) + sendWith = value.sendWith?.map(\.displayHex) + randomizeOutputs = value.randomizeOutputs + } + + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletCreateActionOptions { + try WalletCreateActionOptions( + signAndProcess: signAndProcess, + acceptDelayedBroadcast: acceptDelayedBroadcast, + trustSelf: trustSelf, + knownTransactionIDs: try knownTxids?.map(TransactionID.init(displayHex:)), + returnTransactionIDOnly: returnTXIDOnly, + noSend: noSend, + noSendChange: try noSendChange?.map { try Outpoint($0) }, + sendWith: try sendWith?.map(TransactionID.init(displayHex:)), + randomizeOutputs: randomizeOutputs, + limits: codec.abiLimits + ) + } +} + +private struct CreateActionDTO: Codable { + let description: String + let inputBEEF: [UInt8]? + let inputs: [CreateActionInputDTO]? + let outputs: [CreateActionOutputDTO]? + let lockTime: UInt32? + let version: UInt32? + let labels: [String]? + let options: CreateActionOptionsDTO? + + init(_ value: WalletCreateActionRequest, codec: WalletBRC100JSONCodec) throws { + description = value.description + inputBEEF = try value.inputBEEF?.serialized(limits: codec.beefLimits) + inputs = value.inputs?.map(CreateActionInputDTO.init) + outputs = value.outputs?.map(CreateActionOutputDTO.init) + lockTime = value.lockTime + version = value.version + labels = value.labels + options = value.options.map(CreateActionOptionsDTO.init) + } + + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletCreateActionRequest { + try WalletCreateActionRequest( + description: description, + inputBEEF: try inputBEEF.map { try BEEF(bytes: $0, limits: codec.beefLimits) }, + inputs: try inputs?.map { try $0.model(codec) }, + outputs: try outputs?.map { try $0.model(codec) }, + lockTime: lockTime, + version: version, + labels: labels, + options: try options?.model(codec), + limits: codec.abiLimits + ) + } +} + +private struct SendWithResultDTO: Codable { + let txid: String + let status: WalletActionResultStatus + init(_ value: WalletSendWithResult) { txid = value.transactionID.displayHex; status = value.status } + func model() throws -> WalletSendWithResult { .init(transactionID: try .init(displayHex: txid), status: status) } +} + +private struct SignableTransactionDTO: Codable { + let tx: [UInt8] + let reference: String + init(_ value: WalletSignableTransaction, codec: WalletBRC100JSONCodec) throws { + tx = try value.transaction.serialized(limits: codec.beefLimits) + reference = value.reference.base64 + } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletSignableTransaction { + .init(transaction: try AtomicBEEF(bytes: tx, limits: codec.beefLimits), reference: try .init(base64: reference, limits: codec.abiLimits)) + } +} + +private struct CreateActionResultDTO: Codable { + let txid: String? + let tx: [UInt8]? + let noSendChange: [String]? + let sendWithResults: [SendWithResultDTO]? + let signableTransaction: SignableTransactionDTO? + + init(_ value: WalletCreateActionResult, codec: WalletBRC100JSONCodec) throws { + txid = value.transactionID?.displayHex + tx = try value.transaction?.serialized(limits: codec.beefLimits) + noSendChange = value.noSendChange?.map(\.description) + sendWithResults = value.sendWithResults?.map(SendWithResultDTO.init) + signableTransaction = try value.signableTransaction.map { try SignableTransactionDTO($0, codec: codec) } + } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletCreateActionResult { + try WalletCreateActionResult( + transactionID: try txid.map(TransactionID.init(displayHex:)), + transaction: try tx.map { try AtomicBEEF(bytes: $0, limits: codec.beefLimits) }, + noSendChange: try noSendChange?.map { try Outpoint($0) }, + sendWithResults: try sendWithResults?.map { try $0.model() }, + signableTransaction: try signableTransaction?.model(codec), + limits: codec.abiLimits + ) + } +} + +private struct SignActionSpendDTO: Codable { + let unlockingScript: String + let sequenceNumber: UInt32? + init(_ value: WalletSignActionSpend) { unlockingScript = Hex.encode(value.unlockingScript); sequenceNumber = value.sequenceNumber } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletSignActionSpend { + try .init(unlockingScript: decodeCanonicalHex(unlockingScript, maximum: codec.abiLimits.maximumBytePayloadCount), sequenceNumber: sequenceNumber, limits: codec.abiLimits) + } +} + +private struct SignActionOptionsDTO: Codable { + let acceptDelayedBroadcast: Bool? + let returnTXIDOnly: Bool? + let noSend: Bool? + let sendWith: [String]? + init(_ value: WalletSignActionOptions) { + acceptDelayedBroadcast = value.acceptDelayedBroadcast + returnTXIDOnly = value.returnTransactionIDOnly + noSend = value.noSend + sendWith = value.sendWith?.map(\.displayHex) + } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletSignActionOptions { + try .init(acceptDelayedBroadcast: acceptDelayedBroadcast, returnTransactionIDOnly: returnTXIDOnly, noSend: noSend, sendWith: try sendWith?.map(TransactionID.init(displayHex:)), limits: codec.abiLimits) + } +} + +private struct SignActionDTO: Codable { + let reference: String + let spends: [String: SignActionSpendDTO] + let options: SignActionOptionsDTO? + init(_ value: WalletSignActionRequest, codec: WalletBRC100JSONCodec) throws { + reference = value.reference.base64 + spends = Dictionary(uniqueKeysWithValues: value.spends.map { (String($0.key), SignActionSpendDTO($0.value)) }) + options = value.options.map(SignActionOptionsDTO.init) + } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletSignActionRequest { + var decoded: [UInt32: WalletSignActionSpend] = [:] + for (key, value) in spends { + guard let index = UInt32(key), String(index) == key else { throw WalletJSONCodecError.invalidJSON } + decoded[index] = try value.model(codec) + } + return try .init(reference: .init(base64: reference, limits: codec.abiLimits), spends: decoded, options: try options?.model(codec), limits: codec.abiLimits) + } +} + +private struct SignActionResultDTO: Codable { + let txid: String? + let tx: [UInt8]? + let sendWithResults: [SendWithResultDTO]? + init(_ value: WalletSignActionResult, codec: WalletBRC100JSONCodec) throws { + txid = value.transactionID?.displayHex + tx = try value.transaction?.serialized(limits: codec.beefLimits) + sendWithResults = value.sendWithResults?.map(SendWithResultDTO.init) + } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletSignActionResult { + try .init(transactionID: try txid.map(TransactionID.init(displayHex:)), transaction: try tx.map { try AtomicBEEF(bytes: $0, limits: codec.beefLimits) }, sendWithResults: try sendWithResults?.map { try $0.model() }, limits: codec.abiLimits) + } +} + +private struct AbortActionDTO: Codable { + let reference: String + init(_ value: WalletAbortActionRequest) { reference = value.reference.base64 } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletAbortActionRequest { .init(reference: try .init(base64: reference, limits: codec.abiLimits)) } +} +private struct AbortResultDTO: Codable { let aborted: Bool } +private struct AcceptedResultDTO: Codable { let accepted: Bool } +private struct RelinquishedResultDTO: Codable { let relinquished: Bool } + +private struct ListActionsDTO: Codable { + let labels: [String] + let labelQueryMode: WalletQueryMode? + let includeLabels: Bool? + let includeInputs: Bool? + let includeInputSourceLockingScripts: Bool? + let includeInputUnlockingScripts: Bool? + let includeOutputs: Bool? + let includeOutputLockingScripts: Bool? + let limit: UInt32? + let offset: UInt32? + let seekPermission: Bool? + init(_ value: WalletListActionsRequest) { + labels = value.labels; labelQueryMode = value.labelQueryMode; includeLabels = value.includeLabels + includeInputs = value.includeInputs; includeInputSourceLockingScripts = value.includeInputSourceLockingScripts + includeInputUnlockingScripts = value.includeInputUnlockingScripts; includeOutputs = value.includeOutputs + includeOutputLockingScripts = value.includeOutputLockingScripts; limit = value.pagination.limit + offset = value.pagination.offset; seekPermission = value.seekPermission + } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletListActionsRequest { + try .init(labels: labels, labelQueryMode: labelQueryMode, includeLabels: includeLabels, includeInputs: includeInputs, includeInputSourceLockingScripts: includeInputSourceLockingScripts, includeInputUnlockingScripts: includeInputUnlockingScripts, includeOutputs: includeOutputs, includeOutputLockingScripts: includeOutputLockingScripts, pagination: .init(limit: limit, offset: offset), seekPermission: seekPermission, limits: codec.abiLimits) + } +} + +private struct ActionInputDTO: Codable { + let sourceOutpoint: String; let sourceSatoshis: UInt64; let sourceLockingScript: String? + let unlockingScript: String?; let inputDescription: String; let sequenceNumber: UInt32 + init(_ value: WalletActionInput) { + sourceOutpoint = value.sourceOutpoint.description; sourceSatoshis = value.sourceSatoshis + sourceLockingScript = value.sourceLockingScript.map(Hex.encode); unlockingScript = value.unlockingScript.map(Hex.encode) + inputDescription = value.inputDescription; sequenceNumber = value.sequenceNumber + } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletActionInput { + try .init(sourceOutpoint: .init(sourceOutpoint), sourceSatoshis: sourceSatoshis, sourceLockingScript: try sourceLockingScript.map { try decodeCanonicalHex($0, maximum: codec.abiLimits.maximumBytePayloadCount) }, unlockingScript: try unlockingScript.map { try decodeCanonicalHex($0, maximum: codec.abiLimits.maximumBytePayloadCount) }, inputDescription: inputDescription, sequenceNumber: sequenceNumber, limits: codec.abiLimits) + } +} + +private struct ActionOutputDTO: Codable { + let satoshis: UInt64; let lockingScript: String?; let spendable: Bool; let customInstructions: String? + let tags: [String]; let outputIndex: UInt32; let outputDescription: String; let basket: String + init(_ value: WalletActionOutput) { + satoshis = value.satoshis; lockingScript = value.lockingScript.map(Hex.encode); spendable = value.spendable + customInstructions = value.customInstructions; tags = value.tags; outputIndex = value.outputIndex + outputDescription = value.outputDescription; basket = value.basket + } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletActionOutput { + try .init(satoshis: satoshis, lockingScript: try lockingScript.map { try decodeCanonicalHex($0, maximum: codec.abiLimits.maximumBytePayloadCount) }, spendable: spendable, customInstructions: customInstructions, tags: tags, outputIndex: outputIndex, outputDescription: outputDescription, basket: basket, limits: codec.abiLimits) + } +} + +private struct ActionDTO: Codable { + let txid: String; let satoshis: Int64; let status: WalletActionStatus; let isOutgoing: Bool + let description: String; let labels: [String]?; let version: UInt32; let lockTime: UInt32 + let inputs: [ActionInputDTO]?; let outputs: [ActionOutputDTO]? + init(_ value: WalletAction) { + txid = value.transactionID.displayHex; satoshis = value.satoshis; status = value.status + isOutgoing = value.isOutgoing; description = value.description; labels = value.labels + version = value.version; lockTime = value.lockTime; inputs = value.inputs?.map(ActionInputDTO.init) + outputs = value.outputs?.map(ActionOutputDTO.init) + } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletAction { + try .init(transactionID: .init(displayHex: txid), satoshis: satoshis, status: status, isOutgoing: isOutgoing, description: description, labels: labels, version: version, lockTime: lockTime, inputs: try inputs?.map { try $0.model(codec) }, outputs: try outputs?.map { try $0.model(codec) }, limits: codec.abiLimits) + } +} + +private struct ListActionsResultDTO: Codable { + let totalActions: UInt32; let actions: [ActionDTO] + init(_ value: WalletListActionsResult, codec: WalletBRC100JSONCodec) { totalActions = value.totalActions; actions = value.actions.map(ActionDTO.init) } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletListActionsResult { try .init(totalActions: totalActions, actions: try actions.map { try $0.model(codec) }, limits: codec.abiLimits) } +} + +private struct PaymentRemittanceDTO: Codable { + let derivationPrefix: String; let derivationSuffix: String; let senderIdentityKey: String + init(_ value: WalletPaymentRemittance) { derivationPrefix = value.derivationPrefix.base64; derivationSuffix = value.derivationSuffix.base64; senderIdentityKey = Hex.encode(value.senderIdentityKey.compressedBytes) } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletPaymentRemittance { try .init(derivationPrefix: .init(base64: derivationPrefix, limits: codec.abiLimits), derivationSuffix: .init(base64: derivationSuffix, limits: codec.abiLimits), senderIdentityKey: decodeWalletJSONPublicKey(senderIdentityKey), limits: codec.abiLimits) } +} +private struct BasketInsertionDTO: Codable { + let basket: String; let customInstructions: String?; let tags: [String]? + init(_ value: WalletBasketInsertion) { basket = value.basket; customInstructions = value.customInstructions; tags = value.tags.isEmpty ? nil : value.tags } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletBasketInsertion { try .init(basket: basket, customInstructions: customInstructions, tags: tags ?? [], limits: codec.abiLimits) } +} +private struct InternalizeOutputDTO: Codable { + let outputIndex: UInt32; let `protocol`: WalletInternalizeProtocol + let paymentRemittance: PaymentRemittanceDTO?; let insertionRemittance: BasketInsertionDTO? + init(_ value: WalletInternalizeOutput) { + outputIndex = value.outputIndex; `protocol` = value.remittance.protocol + switch value.remittance { + case .walletPayment(let payment): paymentRemittance = PaymentRemittanceDTO(payment); insertionRemittance = nil + case .basketInsertion(let insertion): paymentRemittance = nil; insertionRemittance = BasketInsertionDTO(insertion) + } + } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletInternalizeOutput { + try .init(outputIndex: outputIndex, protocol: `protocol`, paymentRemittance: try paymentRemittance?.model(codec), insertionRemittance: try insertionRemittance?.model(codec)) + } +} +private struct InternalizeActionDTO: Codable { + let tx: [UInt8]; let outputs: [InternalizeOutputDTO]; let description: String; let labels: [String]?; let seekPermission: Bool? + init(_ value: WalletInternalizeActionRequest, codec: WalletBRC100JSONCodec) throws { tx = try value.transaction.serialized(limits: codec.beefLimits); outputs = value.outputs.map(InternalizeOutputDTO.init); description = value.description; labels = value.labels.isEmpty ? nil : value.labels; seekPermission = value.seekPermission } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletInternalizeActionRequest { try .init(transaction: .init(bytes: tx, limits: codec.beefLimits), description: description, labels: labels ?? [], seekPermission: seekPermission, outputs: try outputs.map { try $0.model(codec) }, limits: codec.abiLimits) } +} + +private struct OutputDTO: Codable { + let satoshis: UInt64; let lockingScript: String?; let spendable: Bool; let customInstructions: String? + let tags: [String]?; let outpoint: String; let labels: [String]? + init(_ value: WalletOutput) { satoshis = value.satoshis; lockingScript = value.lockingScript.map(Hex.encode); spendable = value.spendable; customInstructions = value.customInstructions; tags = value.tags; outpoint = value.outpoint.description; labels = value.labels } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletOutput { try .init(satoshis: satoshis, lockingScript: try lockingScript.map { try decodeCanonicalHex($0, maximum: codec.abiLimits.maximumBytePayloadCount) }, spendable: spendable, customInstructions: customInstructions, tags: tags, outpoint: .init(outpoint), labels: labels, limits: codec.abiLimits) } +} +private struct ListOutputsDTO: Codable { + let basket: String; let tags: [String]?; let tagQueryMode: WalletQueryMode?; let include: WalletOutputInclude? + let includeCustomInstructions: Bool?; let includeTags: Bool?; let includeLabels: Bool? + let limit: UInt32?; let offset: UInt32?; let seekPermission: Bool? + init(_ value: WalletListOutputsRequest) { basket = value.basket; tags = value.tags.isEmpty ? nil : value.tags; tagQueryMode = value.tagQueryMode; include = value.include; includeCustomInstructions = value.includeCustomInstructions; includeTags = value.includeTags; includeLabels = value.includeLabels; limit = value.pagination.limit; offset = value.pagination.offset; seekPermission = value.seekPermission } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletListOutputsRequest { try .init(basket: basket, tags: tags ?? [], tagQueryMode: tagQueryMode, include: include, includeCustomInstructions: includeCustomInstructions, includeTags: includeTags, includeLabels: includeLabels, pagination: .init(limit: limit, offset: offset), seekPermission: seekPermission, limits: codec.abiLimits) } +} +private struct ListOutputsResultDTO: Codable { + let totalOutputs: UInt32; let BEEF: [UInt8]?; let outputs: [OutputDTO] + init(_ value: WalletListOutputsResult, codec: WalletBRC100JSONCodec) throws { totalOutputs = value.totalOutputs; BEEF = try value.beef?.serialized(limits: codec.beefLimits); outputs = value.outputs.map(OutputDTO.init) } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletListOutputsResult { try .init(totalOutputs: totalOutputs, beef: try BEEF.map { try BSVTransaction.BEEF(bytes: $0, limits: codec.beefLimits) }, outputs: try outputs.map { try $0.model(codec) }, limits: codec.abiLimits) } +} +private struct RelinquishOutputDTO: Codable { + let basket: String; let output: String + init(_ value: WalletRelinquishOutputRequest) { basket = value.basket; output = value.output.description } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletRelinquishOutputRequest { try .init(basket: basket, output: .init(output), limits: codec.abiLimits) } +} diff --git a/Sources/BSVWallet/JSON/WalletBRC100CertificateJSON.swift b/Sources/BSVWallet/JSON/WalletBRC100CertificateJSON.swift new file mode 100644 index 0000000..5d05530 --- /dev/null +++ b/Sources/BSVWallet/JSON/WalletBRC100CertificateJSON.swift @@ -0,0 +1,253 @@ +import BSVCore +import BSVKeys +import BSVTransaction + +extension WalletBRC100JSONCodec { + func decodeCertificateRequest(call: WalletCall, bytes: [UInt8]) throws -> WalletWireCertificateRequest { + switch call { + case .revealCounterpartyKeyLinkage: + .revealCounterpartyKeyLinkage(try decode(RevealCounterpartyRequestDTO.self, bytes).model(self)) + case .revealSpecificKeyLinkage: + .revealSpecificKeyLinkage(try decode(RevealSpecificRequestDTO.self, bytes).model(self)) + case .acquireCertificate: + .acquireCertificate(try decode(AcquireCertificateDTO.self, bytes).model(self)) + case .listCertificates: + .listCertificates(try decode(ListCertificatesDTO.self, bytes).model(self)) + case .proveCertificate: + .proveCertificate(try decode(ProveCertificateDTO.self, bytes).model(self)) + case .relinquishCertificate: + .relinquishCertificate(try decode(RelinquishCertificateDTO.self, bytes).model(self)) + case .discoverByIdentityKey: + .discoverByIdentityKey(try decode(DiscoverIdentityDTO.self, bytes).model(self)) + case .discoverByAttributes: + .discoverByAttributes(try decode(DiscoverAttributesDTO.self, bytes).model(self)) + default: throw WalletJSONCodecError.requestCallMismatch(expected: .acquireCertificate, actual: call) + } + } + + func encodeCertificateRequest(_ request: WalletWireCertificateRequest) throws -> [UInt8] { + switch request { + case .revealCounterpartyKeyLinkage(let value): try encode(RevealCounterpartyRequestDTO(value)) + case .revealSpecificKeyLinkage(let value): try encode(RevealSpecificRequestDTO(value)) + case .acquireCertificate(let value): try encode(AcquireCertificateDTO(value)) + case .listCertificates(let value): try encode(ListCertificatesDTO(value)) + case .proveCertificate(let value): try encode(try ProveCertificateDTO(value)) + case .relinquishCertificate(let value): try encode(RelinquishCertificateDTO(value)) + case .discoverByIdentityKey(let value): try encode(DiscoverIdentityDTO(value)) + case .discoverByAttributes(let value): try encode(DiscoverAttributesDTO(value)) + } + } + + func decodeCertificateResult(call: WalletCall, bytes: [UInt8]) throws -> WalletWireCertificateResult { + switch call { + case .revealCounterpartyKeyLinkage: + .revealCounterpartyKeyLinkage(try decode(RevealCounterpartyResultDTO.self, bytes).model(self)) + case .revealSpecificKeyLinkage: + .revealSpecificKeyLinkage(try decode(RevealSpecificResultDTO.self, bytes).model(self)) + case .acquireCertificate: + .acquireCertificate(try decode(CertificateDTO.self, bytes).model(self)) + case .listCertificates: + .listCertificates(try decode(ListCertificatesResultDTO.self, bytes).model(self)) + case .proveCertificate: + .proveCertificate(try decode(ProveCertificateResultDTO.self, bytes).model(self)) + case .relinquishCertificate: + .relinquishCertificate(.init(relinquished: try decode(CertificateRelinquishedDTO.self, bytes).relinquished)) + case .discoverByIdentityKey: + .discoverByIdentityKey(try decode(DiscoveryResultDTO.self, bytes).model(self)) + case .discoverByAttributes: + .discoverByAttributes(try decode(DiscoveryResultDTO.self, bytes).model(self)) + default: throw WalletJSONCodecError.resultCallMismatch(expected: .acquireCertificate, actual: call) + } + } + + func encodeCertificateResult(_ result: WalletWireCertificateResult) throws -> [UInt8] { + switch result { + case .revealCounterpartyKeyLinkage(let value): try encode(RevealCounterpartyResultDTO(value)) + case .revealSpecificKeyLinkage(let value): try encode(RevealSpecificResultDTO(value)) + case .acquireCertificate(let value): try encode(try CertificateDTO(value)) + case .listCertificates(let value): try encode(try ListCertificatesResultDTO(value)) + case .proveCertificate(let value): try encode(ProveCertificateResultDTO(value)) + case .relinquishCertificate(let value): try encode(CertificateRelinquishedDTO(relinquished: value.relinquished)) + case .discoverByIdentityKey(let value), .discoverByAttributes(let value): try encode(try DiscoveryResultDTO(value)) + } + } +} + +private struct PrivilegeDTO: Codable { + let privileged: Bool? + let privilegedReason: String? + init(_ value: WalletPrivilege) { privileged = value.privileged; privilegedReason = value.privilegedReason } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletPrivilege { try .init(privileged: privileged, privilegedReason: privilegedReason, limits: codec.abiLimits) } +} + +private struct RevealCounterpartyRequestDTO: Codable { + let counterparty: String; let verifier: String; let privileged: Bool?; let privilegedReason: String? + init(_ value: WalletRevealCounterpartyKeyLinkageRequest) { counterparty = Hex.encode(value.counterparty.compressedBytes); verifier = Hex.encode(value.verifier.compressedBytes); privileged = value.privilege.privileged; privilegedReason = value.privilege.privilegedReason } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletRevealCounterpartyKeyLinkageRequest { .init(counterparty: try decodeWalletJSONPublicKey(counterparty), verifier: try decodeWalletJSONPublicKey(verifier), privilege: try .init(privileged: privileged, privilegedReason: privilegedReason, limits: codec.abiLimits)) } +} +private struct RevealSpecificRequestDTO: Codable { + let counterparty: WalletCounterparty; let verifier: String; let protocolID: WalletProtocolID; let keyID: WalletKeyID + let privileged: Bool?; let privilegedReason: String? + init(_ value: WalletRevealSpecificKeyLinkageRequest) { counterparty = value.counterparty; verifier = Hex.encode(value.verifier.compressedBytes); protocolID = value.protocolID; keyID = value.keyID; privileged = value.privilege.privileged; privilegedReason = value.privilege.privilegedReason } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletRevealSpecificKeyLinkageRequest { try .init(counterparty: counterparty, verifier: decodeWalletJSONPublicKey(verifier), protocolID: protocolID, keyID: keyID, privilege: .init(privileged: privileged, privilegedReason: privilegedReason, limits: codec.abiLimits)) } +} +private struct RevealCounterpartyResultDTO: Codable { + let prover: String; let verifier: String; let counterparty: String; let revelationTime: String + let encryptedLinkage: [UInt8]; let encryptedLinkageProof: [UInt8] + init(_ value: WalletRevealCounterpartyKeyLinkageResult) { prover = Hex.encode(value.prover.compressedBytes); verifier = Hex.encode(value.verifier.compressedBytes); counterparty = Hex.encode(value.counterparty.compressedBytes); revelationTime = value.revelationTime; encryptedLinkage = value.encryptedLinkage.bytes; encryptedLinkageProof = value.encryptedLinkageProof.bytes } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletRevealCounterpartyKeyLinkageResult { try .init(prover: decodeWalletJSONPublicKey(prover), counterparty: decodeWalletJSONPublicKey(counterparty), verifier: decodeWalletJSONPublicKey(verifier), revelationTime: revelationTime, encryptedLinkage: .init(encryptedLinkage, limits: codec.abiLimits), encryptedLinkageProof: .init(encryptedLinkageProof, limits: codec.abiLimits), limits: codec.abiLimits) } +} +private struct RevealSpecificResultDTO: Codable { + let prover: String; let verifier: String; let counterparty: String; let protocolID: WalletProtocolID; let keyID: WalletKeyID + let encryptedLinkage: [UInt8]; let encryptedLinkageProof: [UInt8]; let proofType: UInt8 + init(_ value: WalletRevealSpecificKeyLinkageResult) { prover = Hex.encode(value.prover.compressedBytes); verifier = Hex.encode(value.verifier.compressedBytes); counterparty = Hex.encode(value.counterparty.compressedBytes); protocolID = value.protocolID; keyID = value.keyID; encryptedLinkage = value.encryptedLinkage.bytes; encryptedLinkageProof = value.encryptedLinkageProof.bytes; proofType = value.proofType } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletRevealSpecificKeyLinkageResult { try .init(encryptedLinkage: .init(encryptedLinkage, limits: codec.abiLimits), encryptedLinkageProof: .init(encryptedLinkageProof, limits: codec.abiLimits), prover: decodeWalletJSONPublicKey(prover), verifier: decodeWalletJSONPublicKey(verifier), counterparty: decodeWalletJSONPublicKey(counterparty), protocolID: protocolID, keyID: keyID, proofType: proofType, limits: codec.abiLimits) } +} + +private struct AcquireCertificateDTO: Codable { + let type: CertificateTypeID; let certifier: String; let acquisitionProtocol: WalletCertificateAcquisitionProtocol + let fields: [String: String]; let serialNumber: CertificateSerialNumber?; let revocationOutpoint: String? + let signature: String?; let certifierUrl: String?; let keyringRevealer: String? + let keyringForSubject: [String: CertificateCiphertext]?; let privileged: Bool?; let privilegedReason: String? + init(_ value: WalletAcquireCertificateRequest) { + type = value.type; certifier = Hex.encode(value.certifier.compressedBytes) + acquisitionProtocol = value.acquisition.protocol + fields = Dictionary(uniqueKeysWithValues: value.fields.map { ($0.key.value, $0.value) }) + switch value.acquisition { + case .direct(let direct): + serialNumber = direct.serialNumber; revocationOutpoint = direct.revocationOutpoint.description + signature = Hex.encode(direct.signature.derBytes); certifierUrl = nil + switch direct.keyringRevealer { case .certifier: keyringRevealer = nil; case .publicKey(let key): keyringRevealer = Hex.encode(key.compressedBytes) } + keyringForSubject = Dictionary(uniqueKeysWithValues: direct.keyringForSubject.map { ($0.key.value, $0.value) }) + case .issuance(let issuance): + serialNumber = nil; revocationOutpoint = nil; signature = nil; certifierUrl = issuance.certifierURL + keyringRevealer = nil; keyringForSubject = nil + } + privileged = value.privilege.privileged; privilegedReason = value.privilege.privilegedReason + } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletAcquireCertificateRequest { + let namedFields = try dictionaryFieldNames(fields, limits: codec.certificateLimits) + let acquisition: WalletCertificateAcquisition + switch acquisitionProtocol { + case .direct: + guard let serialNumber, let revocationOutpoint, let signature, let keyringForSubject else { throw WalletJSONCodecError.invalidJSON } + let signatureBytes = try decodeCanonicalHex(signature, maximum: 72) + let parsedSignature = try ECDSASignature(derBytes: signatureBytes) + guard parsedSignature.derBytes == signatureBytes else { throw WalletJSONCodecError.invalidJSON } + let revealer: WalletKeyringRevealer = try keyringRevealer.map { .publicKey(try decodeWalletJSONPublicKey($0)) } ?? .certifier + let keyring = try dictionaryCiphertexts(keyringForSubject, limits: codec.certificateLimits) + acquisition = .direct(try .init(serialNumber: serialNumber, revocationOutpoint: .init(revocationOutpoint), signature: parsedSignature, keyringRevealer: revealer, keyringForSubject: keyring, limits: codec.abiLimits)) + case .issuance: + guard let certifierUrl else { throw WalletJSONCodecError.invalidJSON } + acquisition = .issuance(try .init(certifierURL: certifierUrl, limits: codec.abiLimits)) + } + return try .init(type: type, certifier: decodeWalletJSONPublicKey(certifier), fields: namedFields, acquisition: acquisition, privilege: .init(privileged: privileged, privilegedReason: privilegedReason, limits: codec.abiLimits), limits: codec.abiLimits) + } +} + +private struct CertificateDTO: Codable { + let type: CertificateTypeID; let subject: String; let serialNumber: CertificateSerialNumber; let certifier: String + let revocationOutpoint: String; let signature: String?; let fields: [String: String] + init(_ value: Certificate) throws { + 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 + } + 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 + self.certifier = certifier; self.revocationOutpoint = revocationOutpoint + self.signature = signature; self.fields = fields + } + 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) + return try .init(type: type, serialNumber: serialNumber, subject: decodeWalletJSONPublicKey(subject), certifier: decodeWalletJSONPublicKey(certifier), revocationOutpoint: .init(revocationOutpoint), fields: fieldValues, signature: signatureValue, limits: codec.certificateLimits) + } +} + +private struct ListCertificatesDTO: Codable { + let certifiers: [String]; let types: [CertificateTypeID]; let limit: UInt32?; let offset: UInt32? + let privileged: Bool?; let privilegedReason: String? + init(_ value: WalletListCertificatesRequest) { certifiers = value.certifiers.map { Hex.encode($0.compressedBytes) }; types = value.types; limit = value.pagination.limit; offset = value.pagination.offset; privileged = value.privilege.privileged; privilegedReason = value.privilege.privilegedReason } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletListCertificatesRequest { try .init(certifiers: certifiers.map(decodeWalletJSONPublicKey), types: types, pagination: .init(limit: limit, offset: offset), privilege: .init(privileged: privileged, privilegedReason: privilegedReason, limits: codec.abiLimits), limits: codec.abiLimits) } +} +private struct CertificateResultDTO: Codable { + let type: CertificateTypeID; let subject: String; let serialNumber: CertificateSerialNumber; let certifier: String + let revocationOutpoint: String; let signature: String?; let fields: [String: String] + let keyring: [String: CertificateCiphertext]?; let verifier: String? + init(_ value: WalletCertificateResult) throws { let certificate = try CertificateDTO(value.certificate); type = certificate.type; subject = certificate.subject; serialNumber = certificate.serialNumber; certifier = certificate.certifier; revocationOutpoint = certificate.revocationOutpoint; signature = certificate.signature; fields = certificate.fields; keyring = value.keyring.map { Dictionary(uniqueKeysWithValues: $0.map { ($0.key.value, $0.value) }) }; if value.verifier.isEmpty { verifier = nil } else { guard let text = String(bytes: value.verifier, encoding: .utf8) else { throw WalletJSONCodecError.invalidJSON }; verifier = text } } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletCertificateResult { try .init(certificate: CertificateDTO(type: type, subject: subject, serialNumber: serialNumber, certifier: certifier, revocationOutpoint: revocationOutpoint, signature: signature, fields: fields).model(codec), keyring: try keyring.map { try dictionaryCiphertexts($0, limits: codec.certificateLimits) }, verifier: verifier.map { Array($0.utf8) } ?? [], limits: codec.abiLimits) } +} +private struct ListCertificatesResultDTO: Codable { + let totalCertificates: UInt32; let certificates: [CertificateResultDTO] + init(_ value: WalletListCertificatesResult) throws { totalCertificates = value.totalCertificates; certificates = try value.certificates.map(CertificateResultDTO.init) } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletListCertificatesResult { try .init(totalCertificates: totalCertificates, certificates: try certificates.map { try $0.model(codec) }, limits: codec.abiLimits) } +} + +private struct ProveCertificateDTO: Codable { + let certificate: CertificateDTO; let fieldsToReveal: [CertificateFieldName]; let verifier: String + let privileged: Bool?; let privilegedReason: String? + init(_ value: WalletProveCertificateRequest) throws { certificate = try CertificateDTO(value.certificate); fieldsToReveal = value.fieldsToReveal; verifier = Hex.encode(value.verifier.compressedBytes); privileged = value.privilege.privileged; privilegedReason = value.privilege.privilegedReason } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletProveCertificateRequest { try .init(certificate: certificate.model(codec), fieldsToReveal: fieldsToReveal, verifier: decodeWalletJSONPublicKey(verifier), privilege: .init(privileged: privileged, privilegedReason: privilegedReason, limits: codec.abiLimits), limits: codec.abiLimits) } +} +private struct ProveCertificateResultDTO: Codable { + let keyringForVerifier: [String: CertificateCiphertext] + init(_ value: WalletProveCertificateResult) { keyringForVerifier = Dictionary(uniqueKeysWithValues: value.keyringForVerifier.map { ($0.key.value, $0.value) }) } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletProveCertificateResult { try .init(keyringForVerifier: dictionaryCiphertexts(keyringForVerifier, limits: codec.certificateLimits), limits: codec.abiLimits) } +} +private struct RelinquishCertificateDTO: Codable { + let type: CertificateTypeID; let serialNumber: CertificateSerialNumber; let certifier: String + init(_ value: WalletRelinquishCertificateRequest) { type = value.type; serialNumber = value.serialNumber; certifier = Hex.encode(value.certifier.compressedBytes) } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletRelinquishCertificateRequest { .init(type: type, serialNumber: serialNumber, certifier: try decodeWalletJSONPublicKey(certifier)) } +} +private struct CertificateRelinquishedDTO: Codable { let relinquished: Bool } + +private struct IdentityCertifierDTO: Codable { + let name: String; let iconUrl: String; let description: String; let trust: UInt8 + init(_ value: WalletIdentityCertifier) { name = value.name; iconUrl = value.iconURL; description = value.description; trust = value.trust } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletIdentityCertifier { try .init(name: name, iconURL: iconUrl, description: description, trust: trust, limits: codec.abiLimits) } +} +private struct IdentityCertificateDTO: Codable { + let type: CertificateTypeID; let subject: String; let serialNumber: CertificateSerialNumber; let certifier: String + let revocationOutpoint: String; let signature: String?; let fields: [String: String] + let certifierInfo: IdentityCertifierDTO; let publiclyRevealedKeyring: [String: CertificateCiphertext] + let decryptedFields: [String: String] + init(_ value: WalletIdentityCertificate) throws { let certificate = try CertificateDTO(value.certificate); type = certificate.type; subject = certificate.subject; serialNumber = certificate.serialNumber; certifier = certificate.certifier; revocationOutpoint = certificate.revocationOutpoint; signature = certificate.signature; fields = certificate.fields; certifierInfo = .init(value.certifierInfo); publiclyRevealedKeyring = Dictionary(uniqueKeysWithValues: value.publiclyRevealedKeyring.map { ($0.key.value, $0.value) }); decryptedFields = Dictionary(uniqueKeysWithValues: value.decryptedFields.map { ($0.key.value, $0.value) }) } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletIdentityCertificate { try .init(certificate: CertificateDTO(type: type, subject: subject, serialNumber: serialNumber, certifier: certifier, revocationOutpoint: revocationOutpoint, signature: signature, fields: fields).model(codec), certifierInfo: certifierInfo.model(codec), publiclyRevealedKeyring: dictionaryCiphertexts(publiclyRevealedKeyring, limits: codec.certificateLimits), decryptedFields: dictionaryFieldNames(decryptedFields, limits: codec.certificateLimits), limits: codec.abiLimits) } +} +private struct DiscoverIdentityDTO: Codable { + let identityKey: String; let limit: UInt32?; let offset: UInt32?; let seekPermission: Bool? + init(_ value: WalletDiscoverByIdentityKeyRequest) { identityKey = Hex.encode(value.identityKey.compressedBytes); limit = value.pagination.limit; offset = value.pagination.offset; seekPermission = value.seekPermission } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletDiscoverByIdentityKeyRequest { .init(identityKey: try decodeWalletJSONPublicKey(identityKey), pagination: try .init(limit: limit, offset: offset), seekPermission: seekPermission) } +} +private struct DiscoverAttributesDTO: Codable { + let attributes: [String: String]; let limit: UInt32?; let offset: UInt32?; let seekPermission: Bool? + init(_ value: WalletDiscoverByAttributesRequest) { attributes = Dictionary(uniqueKeysWithValues: value.attributes.map { ($0.key.value, $0.value) }); limit = value.pagination.limit; offset = value.pagination.offset; seekPermission = value.seekPermission } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletDiscoverByAttributesRequest { try .init(attributes: dictionaryFieldNames(attributes, limits: codec.certificateLimits), pagination: .init(limit: limit, offset: offset), seekPermission: seekPermission, limits: codec.abiLimits) } +} +private struct DiscoveryResultDTO: Codable { + let totalCertificates: UInt32; let certificates: [IdentityCertificateDTO] + init(_ value: WalletDiscoverCertificatesResult) throws { totalCertificates = value.totalCertificates; certificates = try value.certificates.map(IdentityCertificateDTO.init) } + func model(_ codec: WalletBRC100JSONCodec) throws -> WalletDiscoverCertificatesResult { try .init(totalCertificates: totalCertificates, certificates: try certificates.map { try $0.model(codec) }, limits: codec.abiLimits) } +} + +private func dictionaryFieldNames(_ values: [String: T], limits: CertificateLimits) throws -> [CertificateFieldName: T] { + var result: [CertificateFieldName: T] = [:] + for (key, value) in values { result[try CertificateFieldName(key, limits: limits)] = value } + 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] { + var result: [CertificateFieldName: CertificateCiphertext] = [:] + for (key, value) in values { result[try CertificateFieldName(key, limits: limits)] = try CertificateCiphertext(Array(value.utf8), maximumByteCount: limits.maximumFieldCiphertextByteCount) } + return result +} diff --git a/Sources/BSVWallet/JSON/WalletBRC100JSONCodec.swift b/Sources/BSVWallet/JSON/WalletBRC100JSONCodec.swift new file mode 100644 index 0000000..cfe9f88 --- /dev/null +++ b/Sources/BSVWallet/JSON/WalletBRC100JSONCodec.swift @@ -0,0 +1,471 @@ +import Foundation +import BSVCore +import BSVKeys +import BSVTransaction + +/// Canonical method metadata for the BRC-5 JSON wallet substrate. +public struct WalletJSONRoute: Hashable, Sendable { + public let call: WalletCall + public let methodName: String + public var path: String { "/\(methodName)" } + + public init?(methodName: String) { + guard let call = WalletCall.allCases.first(where: { $0.jsonMethodName == methodName }) else { + return nil + } + self.call = call + self.methodName = methodName + } + + public init(call: WalletCall) { + self.call = call + self.methodName = call.jsonMethodName + } + + /// Accepts exactly one unversioned `/` route. + public init?(path: String) { + guard path.first == "/", path.dropFirst().contains("/") == false else { return nil } + self.init(methodName: String(path.dropFirst())) + } + + public static let all = WalletCall.allCases.map(Self.init(call:)) +} + +public extension WalletCall { + var jsonMethodName: String { + switch self { + case .createAction: "createAction" + case .signAction: "signAction" + case .abortAction: "abortAction" + case .listActions: "listActions" + case .internalizeAction: "internalizeAction" + case .listOutputs: "listOutputs" + case .relinquishOutput: "relinquishOutput" + case .getPublicKey: "getPublicKey" + case .revealCounterpartyKeyLinkage: "revealCounterpartyKeyLinkage" + case .revealSpecificKeyLinkage: "revealSpecificKeyLinkage" + case .encrypt: "encrypt" + case .decrypt: "decrypt" + case .createHMAC: "createHmac" + case .verifyHMAC: "verifyHmac" + case .createSignature: "createSignature" + case .verifySignature: "verifySignature" + case .acquireCertificate: "acquireCertificate" + case .listCertificates: "listCertificates" + case .proveCertificate: "proveCertificate" + case .relinquishCertificate: "relinquishCertificate" + case .discoverByIdentityKey: "discoverByIdentityKey" + case .discoverByAttributes: "discoverByAttributes" + case .isAuthenticated: "isAuthenticated" + case .waitForAuthentication: "waitForAuthentication" + case .getHeight: "getHeight" + case .getHeaderForHeight: "getHeaderForHeight" + case .getNetwork: "getNetwork" + case .getVersion: "getVersion" + } + } +} + +public enum WalletJSONCodecError: Error, Equatable, Sendable { + case unknownRoute(String) + case requestCallMismatch(expected: WalletCall, actual: WalletCall) + case resultCallMismatch(expected: WalletCall, actual: WalletCall) + case invalidJSON + case jsonTooLarge(actual: Int, maximum: Int) + case encodedJSONTooLarge(actual: Int, maximum: Int) +} + +/// The JSON error object consumed by the live TypeScript `HTTPWalletJSON` +/// substrate. Extra fields carry typed WERR details without losing their JSON +/// shape (for example `parameter` or `moreSatoshisNeeded`). +public struct WalletJSONErrorPayload: Equatable, Codable, Sendable { + public let name: String + public let message: String + public let isError: Bool + public let code: UInt8? + public let details: [String: WalletJSONValue] + + public init( + name: String, + message: String, + code: UInt8? = nil, + details: [String: WalletJSONValue] = [:] + ) { + self.name = name + self.message = message + self.isError = true + self.code = code + self.details = details + } + + private enum FixedKeys: String, CodingKey { case name, message, isError, code } + + public init(from decoder: Decoder) throws { + let fixed = try decoder.container(keyedBy: FixedKeys.self) + name = try fixed.decode(String.self, forKey: .name) + message = try fixed.decode(String.self, forKey: .message) + isError = try fixed.decode(Bool.self, forKey: .isError) + guard isError else { throw WalletJSONCodecError.invalidJSON } + code = try fixed.decodeIfPresent(UInt8.self, forKey: .code) + let dynamic = try decoder.container(keyedBy: WalletJSONDynamicKey.self) + var values: [String: WalletJSONValue] = [:] + for key in dynamic.allKeys where FixedKeys(stringValue: key.stringValue) == nil { + values[key.stringValue] = try dynamic.decode(WalletJSONValue.self, forKey: key) + } + details = values + } + + public func encode(to encoder: Encoder) throws { + var fixed = encoder.container(keyedBy: FixedKeys.self) + try fixed.encode(name, forKey: .name) + try fixed.encode(message, forKey: .message) + try fixed.encode(true, forKey: .isError) + try fixed.encodeIfPresent(code, forKey: .code) + var dynamic = encoder.container(keyedBy: WalletJSONDynamicKey.self) + for (name, value) in details { + guard FixedKeys(stringValue: name) == nil else { continue } + try dynamic.encode(value, forKey: WalletJSONDynamicKey(name)) + } + } +} + +public indirect enum WalletJSONValue: Equatable, Codable, Sendable { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([WalletJSONValue]) + case object([String: WalletJSONValue]) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { self = .null } + else if let value = try? container.decode(Bool.self) { self = .bool(value) } + else if let value = try? container.decode(Double.self) { self = .number(value) } + else if let value = try? container.decode(String.self) { self = .string(value) } + else if let value = try? container.decode([WalletJSONValue].self) { self = .array(value) } + else if let value = try? container.decode([String: WalletJSONValue].self) { self = .object(value) } + else { throw WalletJSONCodecError.invalidJSON } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: try container.encodeNil() + case .bool(let value): try container.encode(value) + case .number(let value): try container.encode(value) + case .string(let value): try container.encode(value) + case .array(let value): try container.encode(value) + case .object(let value): try container.encode(value) + } + } +} + +/// A transport-neutral outcome suitable for an HTTP, XDM, or native host. +/// The host decides how these cases map to status codes or transport frames. +public enum WalletJSONProcessingOutcome: Equatable, Sendable { + case success(call: WalletCall, body: [UInt8]) + case unknownRoute(path: String) + case invalidRequest(call: WalletCall) + case walletFailure(call: WalletCall, body: [UInt8]) +} + +public protocol WalletJSONFailureMapping: Sendable { + func payload(for error: any Error, call: WalletCall) -> WalletJSONErrorPayload +} + +/// A conservative mapper that does not expose arbitrary error descriptions. +public struct WalletJSONRedactingFailureMapper: WalletJSONFailureMapping, Sendable { + public init() {} + public func payload(for error: any Error, call: WalletCall) -> WalletJSONErrorPayload { + WalletJSONErrorPayload(name: "WERR_UNKNOWN", message: "Wallet request failed") + } +} + +/// Decodes, dispatches, and encodes one JSON wallet request without owning a +/// socket, HTTP status policy, CORS policy, or origin trust policy. +public struct WalletBRC100JSONProcessor: Sendable { + public let codec: WalletBRC100JSONCodec + private let handler: any WalletRequestHandling + private let failureMapper: any WalletJSONFailureMapping + + public init( + codec: WalletBRC100JSONCodec, + handler: any WalletRequestHandling, + failureMapper: any WalletJSONFailureMapping = WalletJSONRedactingFailureMapper() + ) { + self.codec = codec + self.handler = handler + self.failureMapper = failureMapper + } + + public func process( + path: String, + body: [UInt8], + context: WalletRequestContext + ) async -> WalletJSONProcessingOutcome { + guard let route = WalletJSONRoute(path: path) else { return .unknownRoute(path: path) } + let request: WalletRequest + do { request = try codec.decodeRequest(route: route, from: body) } + catch { return .invalidRequest(call: route.call) } + do { + let result = try await handler.handle(request, context: context) + guard result.call == route.call else { + return .walletFailure( + call: route.call, + body: (try? codec.encodeError(.init( + name: "WERR_UNKNOWN", + message: "Wallet returned a mismatched result" + ))) ?? [] + ) + } + return .success(call: route.call, body: try codec.encodeResult(result)) + } catch { + let payload = failureMapper.payload(for: error, call: route.call) + return .walletFailure( + call: route.call, + body: (try? codec.encodeError(payload)) ?? [] + ) + } + } +} + +/// Transport-neutral codec for the BRC-5 JSON substrate. It performs no HTTP, +/// origin, authorization, or wallet work. +public struct WalletBRC100JSONCodec: Sendable { + public let maximumJSONByteCount: Int + public let abiLimits: WalletABILimits + public let cryptoLimits: WalletCryptoLimits + public let certificateLimits: CertificateLimits + public let beefLimits: BEEFLimits + + public init( + beefLimits: BEEFLimits, + maximumJSONByteCount: Int = 8_388_608, + abiLimits: WalletABILimits = .standard, + cryptoLimits: WalletCryptoLimits = .standard, + certificateLimits: CertificateLimits = .standard + ) throws { + guard maximumJSONByteCount >= 0 else { + throw WalletJSONCodecError.jsonTooLarge(actual: 0, maximum: maximumJSONByteCount) + } + self.maximumJSONByteCount = maximumJSONByteCount + self.abiLimits = abiLimits + self.cryptoLimits = cryptoLimits + self.certificateLimits = certificateLimits + self.beefLimits = beefLimits + } + + public func decodeRequest(route: WalletJSONRoute, from bytes: [UInt8]) throws -> WalletRequest { + switch route.call { + case .createAction, .signAction, .abortAction, .listActions, + .internalizeAction, .listOutputs, .relinquishOutput: + return .action(try decodeActionRequest(call: route.call, bytes: bytes)) + case .revealCounterpartyKeyLinkage, .revealSpecificKeyLinkage, + .acquireCertificate, .listCertificates, .proveCertificate, + .relinquishCertificate, .discoverByIdentityKey, .discoverByAttributes: + return .certificate(try decodeCertificateRequest(call: route.call, bytes: bytes)) + case .getPublicKey: + return .keyQuery(.getPublicKey(try decode(WalletGetPublicKeyRequest.self, bytes))) + case .encrypt: + return .keyQuery(.encrypt(try decode(WalletEncryptRequest.self, bytes))) + case .decrypt: + return .keyQuery(.decrypt(try decode(WalletDecryptRequest.self, bytes))) + case .createHMAC: + return .keyQuery(.createHMAC(try decode(WalletCreateHMACRequest.self, bytes))) + case .verifyHMAC: + return .keyQuery(.verifyHMAC(try decode(WalletVerifyHMACRequest.self, bytes))) + case .createSignature: + return .keyQuery(.createSignature(try decode(WalletCreateSignatureRequest.self, bytes))) + case .verifySignature: + return .keyQuery(.verifySignature(try decode(WalletVerifySignatureRequest.self, bytes))) + case .isAuthenticated: + try decodeEmpty(bytes); return .keyQuery(.isAuthenticated(.init())) + case .waitForAuthentication: + try decodeEmpty(bytes); return .keyQuery(.waitForAuthentication(.init())) + case .getHeight: + try decodeEmpty(bytes); return .keyQuery(.getHeight(.init())) + case .getHeaderForHeight: + let dto = try decode(HeaderRequestDTO.self, bytes) + return .keyQuery(.getHeaderForHeight(.init(height: dto.height))) + case .getNetwork: + try decodeEmpty(bytes); return .keyQuery(.getNetwork(.init())) + case .getVersion: + try decodeEmpty(bytes); return .keyQuery(.getVersion(.init())) + } + } + + public func encodeRequest(_ request: WalletRequest) throws -> [UInt8] { + switch request { + case .action(let value): return try encodeActionRequest(value) + case .certificate(let value): return try encodeCertificateRequest(value) + case .keyQuery(let value): + switch value { + case .getPublicKey(let dto): return try encode(dto) + case .encrypt(let dto): return try encode(dto) + case .decrypt(let dto): return try encode(dto) + case .createHMAC(let dto): return try encode(dto) + case .verifyHMAC(let dto): return try encode(dto) + case .createSignature(let dto): return try encode(dto) + case .verifySignature(let dto): return try encode(dto) + case .isAuthenticated, .waitForAuthentication, .getHeight, .getNetwork, .getVersion: + return try encode(EmptyDTO()) + case .getHeaderForHeight(let dto): return try encode(HeaderRequestDTO(height: dto.height)) + } + } + } + + public func decodeResult(route: WalletJSONRoute, from bytes: [UInt8]) throws -> WalletResult { + switch route.call { + case .createAction, .signAction, .abortAction, .listActions, + .internalizeAction, .listOutputs, .relinquishOutput: + return .action(try decodeActionResult(call: route.call, bytes: bytes)) + case .revealCounterpartyKeyLinkage, .revealSpecificKeyLinkage, + .acquireCertificate, .listCertificates, .proveCertificate, + .relinquishCertificate, .discoverByIdentityKey, .discoverByAttributes: + return .certificate(try decodeCertificateResult(call: route.call, bytes: bytes)) + case .getPublicKey: return .keyQuery(.getPublicKey(try decode(WalletGetPublicKeyResult.self, bytes))) + case .encrypt: return .keyQuery(.encrypt(try decode(WalletEncryptResult.self, bytes))) + case .decrypt: return .keyQuery(.decrypt(try decode(WalletDecryptResult.self, bytes))) + case .createHMAC: return .keyQuery(.createHMAC(try decode(WalletCreateHMACResult.self, bytes))) + case .verifyHMAC: return .keyQuery(.verifyHMAC(try decode(WalletVerifyHMACResult.self, bytes))) + case .createSignature: return .keyQuery(.createSignature(try decode(WalletCreateSignatureResult.self, bytes))) + case .verifySignature: return .keyQuery(.verifySignature(try decode(WalletVerifySignatureResult.self, bytes))) + case .isAuthenticated: return .keyQuery(.isAuthenticated(try authenticatedResult(bytes))) + case .waitForAuthentication: return .keyQuery(.waitForAuthentication(try authenticatedResult(bytes))) + case .getHeight: + return .keyQuery(.getHeight(.init(height: try decode(HeightResultDTO.self, bytes).height))) + case .getHeaderForHeight: + let value = try decode(HeaderResultDTO.self, bytes) + let header = try decodeCanonicalHex(value.header, maximum: WalletGetHeaderResult.byteCount) + return .keyQuery(.getHeaderForHeight(try .init(header: header))) + case .getNetwork: + return .keyQuery(.getNetwork(.init(network: try decode(NetworkResultDTO.self, bytes).network))) + case .getVersion: + return .keyQuery(.getVersion(try .init(version: decode(VersionResultDTO.self, bytes).version, limits: abiLimits))) + } + } + + public func encodeResult(_ result: WalletResult) throws -> [UInt8] { + switch result { + case .action(let value): return try encodeActionResult(value) + case .certificate(let value): return try encodeCertificateResult(value) + case .keyQuery(let value): + switch value { + case .getPublicKey(let dto): return try encode(dto) + case .encrypt(let dto): return try encode(dto) + case .decrypt(let dto): return try encode(dto) + case .createHMAC(let dto): return try encode(dto) + case .verifyHMAC(let dto): return try encode(dto) + case .createSignature(let dto): return try encode(dto) + case .verifySignature(let dto): return try encode(dto) + case .isAuthenticated(let dto), .waitForAuthentication(let dto): + return try encode(AuthenticatedResultDTO(authenticated: dto.authenticated)) + case .getHeight(let dto): return try encode(HeightResultDTO(height: dto.height)) + case .getHeaderForHeight(let dto): return try encode(HeaderResultDTO(header: Hex.encode(dto.header))) + case .getNetwork(let dto): return try encode(NetworkResultDTO(network: dto.network)) + case .getVersion(let dto): return try encode(VersionResultDTO(version: dto.version)) + } + } + } + + public func decodeError(from bytes: [UInt8]) throws -> WalletJSONErrorPayload { + try decode(WalletJSONErrorPayload.self, bytes) + } + + public func encodeError(_ error: WalletJSONErrorPayload) throws -> [UInt8] { + try encode(error) + } + + private func authenticatedResult(_ bytes: [UInt8]) throws -> WalletAuthenticatedResult { + .init(authenticated: try decode(AuthenticatedResultDTO.self, bytes).authenticated) + } + + func decode(_ type: T.Type, _ bytes: [UInt8]) throws -> T { + guard bytes.count <= maximumJSONByteCount else { + throw WalletJSONCodecError.jsonTooLarge(actual: bytes.count, maximum: maximumJSONByteCount) + } + let decoder = JSONDecoder() + decoder.userInfo[.walletJSONABILimits] = abiLimits + decoder.userInfo[.walletJSONCryptoLimits] = cryptoLimits + decoder.userInfo[.walletJSONCertificateLimits] = certificateLimits + decoder.userInfo[.walletJSONBEEFLimits] = beefLimits + if let key = WalletCodingContext.limitsKey { decoder.userInfo[key] = cryptoLimits } + do { return try decoder.decode(type, from: Data(bytes)) } + catch let error as WalletJSONCodecError { throw error } + catch let error as WalletABIError { throw error } + catch let error as WalletCryptoError { throw error } + catch let error as CertificateError { throw error } + catch let error as BEEFError { throw error } + catch { throw WalletJSONCodecError.invalidJSON } + } + + func encode(_ value: T) throws -> [UInt8] { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + encoder.userInfo[.walletJSONABILimits] = abiLimits + encoder.userInfo[.walletJSONCryptoLimits] = cryptoLimits + encoder.userInfo[.walletJSONCertificateLimits] = certificateLimits + encoder.userInfo[.walletJSONBEEFLimits] = beefLimits + if let key = WalletCodingContext.limitsKey { encoder.userInfo[key] = cryptoLimits } + let data: Data + do { data = try encoder.encode(value) } + catch let error as WalletJSONCodecError { throw error } + catch let error as WalletABIError { throw error } + catch let error as WalletCryptoError { throw error } + catch let error as CertificateError { throw error } + catch let error as BEEFError { throw error } + catch { throw WalletJSONCodecError.invalidJSON } + guard data.count <= maximumJSONByteCount else { + throw WalletJSONCodecError.encodedJSONTooLarge(actual: data.count, maximum: maximumJSONByteCount) + } + return [UInt8](data) + } + + private func decodeEmpty(_ bytes: [UInt8]) throws { _ = try decode(EmptyDTO.self, bytes) } +} + +extension CodingUserInfoKey { + static let walletJSONABILimits = CodingUserInfoKey(rawValue: "org.bsv.swift-sdk.wallet.json.abi")! + static let walletJSONCryptoLimits = CodingUserInfoKey(rawValue: "org.bsv.swift-sdk.wallet.json.crypto")! + static let walletJSONCertificateLimits = CodingUserInfoKey(rawValue: "org.bsv.swift-sdk.wallet.json.certificate")! + static let walletJSONBEEFLimits = CodingUserInfoKey(rawValue: "org.bsv.swift-sdk.wallet.json.beef")! +} + +struct WalletJSONDynamicKey: CodingKey { + let stringValue: String + let intValue: Int? + init(_ value: String) { stringValue = value; intValue = nil } + init?(stringValue: String) { self.init(stringValue) } + init?(intValue: Int) { stringValue = String(intValue); self.intValue = intValue } +} + +private struct EmptyDTO: Codable { + init() {} + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: WalletJSONDynamicKey.self) + guard values.allKeys.isEmpty else { throw WalletJSONCodecError.invalidJSON } + } +} +private struct HeaderRequestDTO: Codable { let height: UInt32 } +private struct AuthenticatedResultDTO: Codable { let authenticated: Bool } +private struct HeightResultDTO: Codable { let height: UInt32 } +private struct HeaderResultDTO: Codable { let header: String } +private struct NetworkResultDTO: Codable { let network: WalletNetwork } +private struct VersionResultDTO: Codable { let version: String } + +func decodeCanonicalHex(_ value: String, maximum: Int) throws -> [UInt8] { + do { + let bytes = try Hex.decode(value, maximumDecodedByteCount: maximum) + guard Hex.encode(bytes) == value else { throw WalletJSONCodecError.invalidJSON } + return bytes + } catch { throw WalletJSONCodecError.invalidJSON } +} + +func decodeWalletJSONPublicKey(_ value: String) throws -> PublicKey { + let bytes = try decodeCanonicalHex(value, maximum: 33) + guard bytes.count == 33, + let key = try? PublicKey(bytes), + key.compressedBytes == bytes else { throw WalletJSONCodecError.invalidJSON } + return key +} diff --git a/Sources/BSVWallet/Substrates/WalletWireProcessor.swift b/Sources/BSVWallet/Substrates/WalletWireProcessor.swift index 17633e7..b559c47 100644 --- a/Sources/BSVWallet/Substrates/WalletWireProcessor.swift +++ b/Sources/BSVWallet/Substrates/WalletWireProcessor.swift @@ -7,8 +7,7 @@ public struct WalletWireProcessor: CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable { - private let wallet: any WalletInterface - private let authorizer: any WalletWireOriginatorAuthorizing + private let handler: any WalletRequestHandling private let failureMapper: any WalletWireFailureMapping private let beefLimits: BEEFLimits private let certificateLimits: CertificateLimits @@ -22,8 +21,26 @@ public struct WalletWireProcessor: certificateLimits: CertificateLimits, wireLimits: WalletWireLimits ) { - self.wallet = wallet - self.authorizer = authorizer + self.handler = WalletCoarselyAuthorizedRequestHandler( + wallet: wallet, + authorizer: authorizer + ) + self.failureMapper = failureMapper + self.beefLimits = beefLimits + self.certificateLimits = certificateLimits + self.wireLimits = wireLimits + } + + /// Creates a processor whose decoded calls pass through a transport-neutral + /// policy and dispatch handler. + public init( + handler: any WalletRequestHandling, + failureMapper: any WalletWireFailureMapping, + beefLimits: BEEFLimits, + certificateLimits: CertificateLimits, + wireLimits: WalletWireLimits + ) { + self.handler = handler self.failureMapper = failureMapper self.beefLimits = beefLimits self.certificateLimits = certificateLimits @@ -69,21 +86,6 @@ public struct WalletWireProcessor: return response } - private func authorize( - originator: String, - call: WalletCall, - responseLimits: WalletWireLimits - ) async throws -> [UInt8]? { - do { - try await authorizer.authorize(originator: originator, call: call) - return nil - } catch is CancellationError { - throw CancellationError() - } catch { - return try encodeFailure(error, call: call, limits: responseLimits) - } - } - private func processAction( _ bytes: [UInt8], responseLimits: WalletWireLimits @@ -93,32 +95,27 @@ public struct WalletWireProcessor: beefLimits: beefLimits, limits: wireLimits ) - if let failure = try await authorize( - originator: decoded.originator, - call: decoded.request.call, - responseLimits: responseLimits - ) { - return failure - } try Task.checkCancellation() let result: WalletWireActionResult do { - switch decoded.request { - case .createAction(let request): - result = .createAction(try await wallet.createAction(request)) - case .signAction(let request): - result = .signAction(try await wallet.signAction(request)) - case .abortAction(let request): - result = .abortAction(try await wallet.abortAction(request)) - case .listActions(let request): - result = .listActions(try await wallet.listActions(request)) - case .internalizeAction(let request): - result = .internalizeAction(try await wallet.internalizeAction(request)) - case .listOutputs(let request): - result = .listOutputs(try await wallet.listOutputs(request)) - case .relinquishOutput(let request): - result = .relinquishOutput(try await wallet.relinquishOutput(request)) + let request = WalletRequest.action(decoded.request) + let handled = try await handler.handle( + request, + context: try WalletRequestContext(rawOriginator: decoded.originator) + ) + guard handled.call == request.call else { + throw WalletWireSubstrateError.unexpectedResult( + expected: request.call, + actual: handled.call + ) } + guard case .action(let actionResult) = handled else { + throw WalletWireSubstrateError.unexpectedResult( + expected: request.call, + actual: handled.call + ) + } + result = actionResult } catch is CancellationError { throw CancellationError() } catch { @@ -142,40 +139,27 @@ public struct WalletWireProcessor: certificateLimits: certificateLimits, limits: wireLimits ) - if let failure = try await authorize( - originator: decoded.originator, - call: decoded.request.call, - responseLimits: responseLimits - ) { - return failure - } try Task.checkCancellation() let result: WalletWireCertificateResult do { - switch decoded.request { - case .revealCounterpartyKeyLinkage(let request): - result = .revealCounterpartyKeyLinkage( - try await wallet.revealCounterpartyKeyLinkage(request) - ) - case .revealSpecificKeyLinkage(let request): - result = .revealSpecificKeyLinkage( - try await wallet.revealSpecificKeyLinkage(request) + let request = WalletRequest.certificate(decoded.request) + let handled = try await handler.handle( + request, + context: try WalletRequestContext(rawOriginator: decoded.originator) + ) + guard handled.call == request.call else { + throw WalletWireSubstrateError.unexpectedResult( + expected: request.call, + actual: handled.call ) - case .acquireCertificate(let request): - result = .acquireCertificate(try await wallet.acquireCertificate(request)) - case .listCertificates(let request): - result = .listCertificates(try await wallet.listCertificates(request)) - case .proveCertificate(let request): - result = .proveCertificate(try await wallet.proveCertificate(request)) - case .relinquishCertificate(let request): - result = .relinquishCertificate( - try await wallet.relinquishCertificate(request) + } + guard case .certificate(let certificateResult) = handled else { + throw WalletWireSubstrateError.unexpectedResult( + expected: request.call, + actual: handled.call ) - case .discoverByIdentityKey(let request): - result = .discoverByIdentityKey(try await wallet.discoverByIdentityKey(request)) - case .discoverByAttributes(let request): - result = .discoverByAttributes(try await wallet.discoverByAttributes(request)) } + result = certificateResult } catch is CancellationError { throw CancellationError() } catch { @@ -194,44 +178,27 @@ public struct WalletWireProcessor: responseLimits: WalletWireLimits ) async throws -> [UInt8] { let decoded = try WalletWireCodec.decodeKeyQueryRequest(bytes, limits: wireLimits) - if let failure = try await authorize( - originator: decoded.originator, - call: decoded.request.call, - responseLimits: responseLimits - ) { - return failure - } try Task.checkCancellation() let result: WalletWireKeyQueryResult do { - switch decoded.request { - case .getPublicKey(let request): - result = .getPublicKey(try await wallet.getPublicKey(request)) - case .encrypt(let request): - result = .encrypt(try await wallet.encrypt(request)) - case .decrypt(let request): - result = .decrypt(try await wallet.decrypt(request)) - case .createHMAC(let request): - result = .createHMAC(try await wallet.createHMAC(request)) - case .verifyHMAC(let request): - result = .verifyHMAC(try await wallet.verifyHMAC(request)) - case .createSignature(let request): - result = .createSignature(try await wallet.createSignature(request)) - case .verifySignature(let request): - result = .verifySignature(try await wallet.verifySignature(request)) - case .isAuthenticated(let request): - result = .isAuthenticated(try await wallet.isAuthenticated(request)) - case .waitForAuthentication(let request): - result = .waitForAuthentication(try await wallet.waitForAuthentication(request)) - case .getHeight(let request): - result = .getHeight(try await wallet.getHeight(request)) - case .getHeaderForHeight(let request): - result = .getHeaderForHeight(try await wallet.getHeaderForHeight(request)) - case .getNetwork(let request): - result = .getNetwork(try await wallet.getNetwork(request)) - case .getVersion(let request): - result = .getVersion(try await wallet.getVersion(request)) + let request = WalletRequest.keyQuery(decoded.request) + let handled = try await handler.handle( + request, + context: try WalletRequestContext(rawOriginator: decoded.originator) + ) + guard handled.call == request.call else { + throw WalletWireSubstrateError.unexpectedResult( + expected: request.call, + actual: handled.call + ) } + guard case .keyQuery(let keyQueryResult) = handled else { + throw WalletWireSubstrateError.unexpectedResult( + expected: request.call, + actual: handled.call + ) + } + result = keyQueryResult } catch is CancellationError { throw CancellationError() } catch { @@ -289,3 +256,25 @@ public struct WalletWireProcessor: Mirror(self, children: EmptyCollection<(label: String?, value: Any)>()) } } + +private struct WalletCoarselyAuthorizedRequestHandler: WalletRequestHandling { + let adapter: WalletInterfaceRequestHandler + let authorizer: any WalletWireOriginatorAuthorizing + + init(wallet: any WalletInterface, authorizer: any WalletWireOriginatorAuthorizing) { + adapter = WalletInterfaceRequestHandler(wallet: wallet) + self.authorizer = authorizer + } + + func handle( + _ request: WalletRequest, + context: WalletRequestContext + ) async throws -> WalletResult { + try await authorizer.authorize( + originator: context.rawOriginator, + call: request.call + ) + try Task.checkCancellation() + return try await adapter.handle(request, context: context) + } +} diff --git a/Sources/BSVWallet/Values/WalletCounterparty.swift b/Sources/BSVWallet/Values/WalletCounterparty.swift index b0d35c4..1b22c9a 100644 --- a/Sources/BSVWallet/Values/WalletCounterparty.swift +++ b/Sources/BSVWallet/Values/WalletCounterparty.swift @@ -43,8 +43,11 @@ public enum WalletCounterparty: Equatable, Codable, Sendable { } } -/// Permission metadata carried by BRC-100 requests. This offline kernel has no -/// permission policy and rejects every non-standard value before cryptography. +/// Permission metadata carried by BRC-100 key requests. +/// +/// `seekPermission` preserves whether the caller omitted the flag or supplied +/// an explicit `false` or `true`. Its effective default is operation-specific +/// and is exposed by `WalletRequest`, rather than being applied while decoding. public struct WalletKeyAccess: Equatable, Codable, @@ -53,16 +56,16 @@ public struct WalletKeyAccess: CustomDebugStringConvertible, CustomReflectable { public static let maximumPrivilegedReasonUTF8ByteCount = 1_024 - public static let standard = WalletKeyAccess(validatedPrivileged: false, reason: nil, seek: false) + public static let standard = WalletKeyAccess(validatedPrivileged: false, reason: nil, seek: nil) public let privileged: Bool public let privilegedReason: String? - public let seekPermission: Bool + public let seekPermission: Bool? public init( privileged: Bool = false, privilegedReason: String? = nil, - seekPermission: Bool = false + seekPermission: Bool? = nil ) throws { if let privilegedReason { let count = privilegedReason.utf8.count @@ -76,7 +79,7 @@ public struct WalletKeyAccess: self.init(validatedPrivileged: privileged, reason: privilegedReason, seek: seekPermission) } - private init(validatedPrivileged: Bool, reason: String?, seek: Bool) { + private init(validatedPrivileged: Bool, reason: String?, seek: Bool?) { self.privileged = validatedPrivileged self.privilegedReason = reason self.seekPermission = seek @@ -98,7 +101,7 @@ public struct WalletKeyAccess: : nil let seek = container.contains(.seekPermission) ? try container.decode(Bool.self, forKey: .seekPermission) - : false + : nil try self.init( privileged: privileged, privilegedReason: reason, @@ -110,7 +113,7 @@ public struct WalletKeyAccess: var container = encoder.container(keyedBy: CodingKeys.self) if privileged { try container.encode(true, forKey: .privileged) } try container.encodeIfPresent(privilegedReason, forKey: .privilegedReason) - if seekPermission { try container.encode(true, forKey: .seekPermission) } + try container.encodeIfPresent(seekPermission, forKey: .seekPermission) } public var description: String { "" } diff --git a/Sources/BSVWallet/Values/WalletCryptoLimits.swift b/Sources/BSVWallet/Values/WalletCryptoLimits.swift index a02a52f..6ca4d83 100644 --- a/Sources/BSVWallet/Values/WalletCryptoLimits.swift +++ b/Sources/BSVWallet/Values/WalletCryptoLimits.swift @@ -48,6 +48,7 @@ public struct WalletCryptoLimits: Hashable, Sendable { public enum WalletCryptoError: Error, Equatable, Sendable { case permissionPolicyUnavailable + case counterpartySelfLinkageForbidden case payloadTooLarge(actual: Int, maximum: Int) case ciphertextTooShort(actual: Int, minimum: Int) case ciphertextTooLarge(actual: Int, maximum: Int) @@ -56,6 +57,7 @@ public enum WalletCryptoError: Error, Equatable, Sendable { case encryptionFailed case authenticationFailed case signingFailed + case proofGenerationFailed case invalidJSON case jsonTooLarge(actual: Int, maximum: Int) case encodedJSONTooLarge(actual: Int, maximum: Int) diff --git a/Sources/BSVWallet/Values/WalletIdentifiers.swift b/Sources/BSVWallet/Values/WalletIdentifiers.swift index ab5ce00..6f71ba9 100644 --- a/Sources/BSVWallet/Values/WalletIdentifiers.swift +++ b/Sources/BSVWallet/Values/WalletIdentifiers.swift @@ -28,6 +28,7 @@ public enum WalletValidationError: Error, Equatable, Sendable { case consecutiveProtocolSpaces case redundantProtocolSuffix case reservedAdminProtocol + case walletInternalProtocolRequiresAdminPrefix case keyIDTooShort case keyIDTooLong(actualUTF8ByteCount: Int, maximum: Int) case privilegedReasonTooLong(actualUTF8ByteCount: Int, maximum: Int) @@ -43,11 +44,44 @@ public enum WalletValidationError: Error, Equatable, Sendable { public struct WalletProtocolID: Hashable, Codable, Sendable { public static let minimumNameUTF8ByteCount = 5 public static let maximumNameUTF8ByteCount = 400 + public static let maximumSpecificLinkageRevelationNameUTF8ByteCount = 430 public let securityLevel: WalletSecurityLevel public let name: String public init(securityLevel: WalletSecurityLevel, name: String) throws { + let normalized = try Self.canonicalName(name) + guard !normalized.hasPrefix("admin") else { + throw WalletValidationError.reservedAdminProtocol + } + + self.securityLevel = securityLevel + self.name = normalized + } + + /// Constructs a BRC-44 reserved protocol for trusted wallet software. + /// + /// This is intentionally separate from the normal initializer. Values + /// created here can be used for in-process wallet cryptography, but their + /// Codable and wallet-wire encoders reject them so they cannot cross an + /// external BRC-100 boundary accidentally. + public static func walletInternalAdmin( + securityLevel: WalletSecurityLevel, + name: String + ) throws -> Self { + let normalized = try canonicalName(name) + guard normalized.hasPrefix("admin") else { + throw WalletValidationError.walletInternalProtocolRequiresAdminPrefix + } + return Self(securityLevel: securityLevel, canonicalName: normalized) + } + + private init(securityLevel: WalletSecurityLevel, canonicalName: String) { + self.securityLevel = securityLevel + self.name = canonicalName + } + + private static func canonicalName(_ name: String) throws -> String { let source = Array(name.utf8) let start = source.firstIndex(where: { !Self.isASCIIWhitespace($0) }) ?? source.endIndex let end: Int @@ -67,10 +101,13 @@ public struct WalletProtocolID: Hashable, Codable, Sendable { minimum: Self.minimumNameUTF8ByteCount ) } - guard bytes.count <= Self.maximumNameUTF8ByteCount else { + let maximumNameByteCount = Self.hasSpecificLinkageRevelationEnvelope(bytes) + ? Self.maximumSpecificLinkageRevelationNameUTF8ByteCount + : Self.maximumNameUTF8ByteCount + guard bytes.count <= maximumNameByteCount else { throw WalletValidationError.protocolNameTooLong( actualUTF8ByteCount: bytes.count, - maximum: Self.maximumNameUTF8ByteCount + maximum: maximumNameByteCount ) } @@ -90,12 +127,7 @@ public struct WalletProtocolID: Hashable, Codable, Sendable { guard !normalized.hasSuffix(" protocol") else { throw WalletValidationError.redundantProtocolSuffix } - guard !normalized.hasPrefix("admin") else { - throw WalletValidationError.reservedAdminProtocol - } - - self.securityLevel = securityLevel - self.name = normalized + return normalized } public init(from decoder: Decoder) throws { @@ -118,6 +150,15 @@ public struct WalletProtocolID: Hashable, Codable, Sendable { } public func encode(to encoder: Encoder) throws { + guard !name.hasPrefix("admin") else { + throw EncodingError.invalidValue( + self, + EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "BRC-44 admin protocols are wallet-internal and cannot be encoded" + ) + ) + } var container = encoder.unkeyedContainer() try container.encode(securityLevel) try container.encode(name) @@ -126,6 +167,18 @@ public struct WalletProtocolID: Hashable, Codable, Sendable { private static func isASCIIWhitespace(_ byte: UInt8) -> Bool { byte == 32 || (9...13).contains(byte) } + + /// BRC-100's only protocol-name length exception. The 430-byte ceiling is + /// 28 bytes of prefix, one security-level digit, one separator, and a + /// normal 400-byte target protocol name. + private static func hasSpecificLinkageRevelationEnvelope(_ bytes: [UInt8]) -> Bool { + let prefix = Array("specific linkage revelation ".utf8) + guard bytes.starts(with: prefix), bytes.count > prefix.count + 1 else { + return false + } + let level = bytes[prefix.count] + return (48...50).contains(level) && bytes[prefix.count + 1] == 32 + } } /// A BRC-43 key identifier. Swift strings and value copies cannot guarantee diff --git a/Sources/BSVWallet/Values/WalletRequests.swift b/Sources/BSVWallet/Values/WalletRequests.swift index bec1a87..6272641 100644 --- a/Sources/BSVWallet/Values/WalletRequests.swift +++ b/Sources/BSVWallet/Values/WalletRequests.swift @@ -63,7 +63,7 @@ private func decodeAccess( : nil let seek = container.contains(.seekPermission) ? try container.decode(Bool.self, forKey: .seekPermission) - : false + : nil return try WalletKeyAccess( privileged: privileged, privilegedReason: reason, @@ -84,7 +84,7 @@ private func encodeAccess( ) throws { if access.privileged { try container.encode(true, forKey: .privileged) } try container.encodeIfPresent(access.privilegedReason, forKey: .privilegedReason) - if access.seekPermission { try container.encode(true, forKey: .seekPermission) } + try container.encodeIfPresent(access.seekPermission, forKey: .seekPermission) } private func decodeCounterparty( diff --git a/Sources/BSVWallet/Wire/WalletWireActionResultCodec.swift b/Sources/BSVWallet/Wire/WalletWireActionResultCodec.swift index 84ced94..363fce0 100644 --- a/Sources/BSVWallet/Wire/WalletWireActionResultCodec.swift +++ b/Sources/BSVWallet/Wire/WalletWireActionResultCodec.swift @@ -280,6 +280,7 @@ private extension WalletWireCodec { case .unsigned: 5 case .nosend: 6 case .nonfinal: 7 + case .failed: 8 } } @@ -292,6 +293,7 @@ private extension WalletWireCodec { case 5: .unsigned case 6: .nosend case 7: .nonfinal + case 8: .failed default: throw WalletWireError.invalidDiscriminator(kind: "action status", value: value) } } diff --git a/Sources/BSVWallet/Wire/WalletWireCodec.swift b/Sources/BSVWallet/Wire/WalletWireCodec.swift index f86439f..860f745 100644 --- a/Sources/BSVWallet/Wire/WalletWireCodec.swift +++ b/Sources/BSVWallet/Wire/WalletWireCodec.swift @@ -376,7 +376,7 @@ public enum WalletWireCodec { switch try reader.readByte() { case 1: let access = try walletWireDecodeAccess(from: &reader, limits: limits) - let seek = try reader.readOptionalBoolean(kind: "seek permission") ?? false + let seek = try reader.readOptionalBoolean(kind: "seek permission") decoded = .getPublicKey(WalletGetPublicKeyRequest( selection: .identity, access: try walletWireAccessWithSeek(access, seek: seek) @@ -384,7 +384,7 @@ public enum WalletWireCodec { case 0: let key = try walletWireDecodeKeyParameters(from: &reader, limits: limits) let forSelf = try reader.readOptionalBoolean(kind: "for self") ?? false - let seek = try reader.readOptionalBoolean(kind: "seek permission") ?? false + let seek = try reader.readOptionalBoolean(kind: "seek permission") decoded = .getPublicKey(WalletGetPublicKeyRequest( selection: .derived( protocolID: key.protocolID, @@ -403,7 +403,7 @@ public enum WalletWireCodec { maximum: limits.cryptoLimits.maximumPayloadByteCount, kind: "plaintext" ) - let seek = try reader.readOptionalBoolean(kind: "seek permission") ?? false + let seek = try reader.readOptionalBoolean(kind: "seek permission") decoded = .encrypt(WalletEncryptRequest( protocolID: key.protocolID, keyID: key.keyID, counterparty: key.counterparty, plaintext: plaintext, @@ -415,7 +415,7 @@ public enum WalletWireCodec { maximum: limits.cryptoLimits.maximumCiphertextByteCount, kind: "ciphertext" ) - let seek = try reader.readOptionalBoolean(kind: "seek permission") ?? false + let seek = try reader.readOptionalBoolean(kind: "seek permission") decoded = .decrypt(WalletDecryptRequest( protocolID: key.protocolID, keyID: key.keyID, counterparty: key.counterparty, ciphertext: ciphertext, @@ -427,7 +427,7 @@ public enum WalletWireCodec { maximum: limits.cryptoLimits.maximumPayloadByteCount, kind: "HMAC data" ) - let seek = try reader.readOptionalBoolean(kind: "seek permission") ?? false + let seek = try reader.readOptionalBoolean(kind: "seek permission") decoded = .createHMAC(WalletCreateHMACRequest( protocolID: key.protocolID, keyID: key.keyID, counterparty: key.counterparty, data: data, @@ -440,7 +440,7 @@ public enum WalletWireCodec { maximum: limits.cryptoLimits.maximumPayloadByteCount, kind: "HMAC data" ) - let seek = try reader.readOptionalBoolean(kind: "seek permission") ?? false + let seek = try reader.readOptionalBoolean(kind: "seek permission") decoded = .verifyHMAC(WalletVerifyHMACRequest( protocolID: key.protocolID, keyID: key.keyID, counterparty: key.counterparty, data: data, @@ -454,7 +454,7 @@ public enum WalletWireCodec { rejectEmptyData: false, limits: limits ) - let seek = try reader.readOptionalBoolean(kind: "seek permission") ?? false + let seek = try reader.readOptionalBoolean(kind: "seek permission") decoded = .createSignature(WalletCreateSignatureRequest( protocolID: key.protocolID, keyID: key.keyID, counterparty: key.counterparty, payload: payload, @@ -473,7 +473,7 @@ public enum WalletWireCodec { rejectEmptyData: true, limits: limits ) - let seek = try reader.readOptionalBoolean(kind: "seek permission") ?? false + let seek = try reader.readOptionalBoolean(kind: "seek permission") decoded = .verifySignature(WalletVerifySignatureRequest( protocolID: key.protocolID, keyID: key.keyID, counterparty: key.counterparty, payload: payload, diff --git a/Sources/BSVWallet/Wire/WalletWirePrimitives.swift b/Sources/BSVWallet/Wire/WalletWirePrimitives.swift index a7b2728..b8ce9bc 100644 --- a/Sources/BSVWallet/Wire/WalletWirePrimitives.swift +++ b/Sources/BSVWallet/Wire/WalletWirePrimitives.swift @@ -268,6 +268,9 @@ func walletWireEncodeProtocol( to writer: inout WalletWireWriter, limits: WalletWireLimits ) throws { + guard !protocolID.name.hasPrefix("admin") else { + throw WalletWireError.nonRoundTrippableValue(kind: "BRC-44 wallet-internal protocol identifier") + } try walletWireRequireText( protocolID.name, kind: "protocol name", @@ -286,7 +289,10 @@ func walletWireDecodeProtocol( throw WalletWireError.invalidDiscriminator(kind: "protocol security level", value: levelByte) } let name = try reader.readString( - maximum: min(walletWireMaximumText(limits), WalletProtocolID.maximumNameUTF8ByteCount), + maximum: min( + walletWireMaximumText(limits), + WalletProtocolID.maximumSpecificLinkageRevelationNameUTF8ByteCount + ), kind: "protocol name" ) do { @@ -409,7 +415,7 @@ func walletWireDecodeKeyParameters( ) } -func walletWireAccessWithSeek(_ access: WalletKeyAccess, seek: Bool) throws -> WalletKeyAccess { +func walletWireAccessWithSeek(_ access: WalletKeyAccess, seek: Bool?) throws -> WalletKeyAccess { do { return try WalletKeyAccess( privileged: access.privileged, diff --git a/Tests/BSVConformanceTests/WalletWireGoOracleTests.swift b/Tests/BSVConformanceTests/WalletWireGoOracleTests.swift index 6fd94d0..e9f6e76 100644 --- a/Tests/BSVConformanceTests/WalletWireGoOracleTests.swift +++ b/Tests/BSVConformanceTests/WalletWireGoOracleTests.swift @@ -37,7 +37,7 @@ final class WalletWireGoOracleTests: XCTestCase { keyID: keyID, counterparty: .self, forSelf: false - ))), + ), access: access)), .encrypt(WalletEncryptRequest( protocolID: protocolID, keyID: keyID, plaintext: [1, 2], access: access )), @@ -227,6 +227,44 @@ final class WalletWireGoOracleTests: XCTestCase { ) } + // GO-067: pinned Go v1.3.3 stores key-request seekPermission as Bool, + // so it cannot preserve the BRC-100 absent sentinel. Swift and the + // TypeScript reference preserve absence and apply the default later. + let omittedSeekRequest = WalletWireKeyQueryRequest.getPublicKey( + WalletGetPublicKeyRequest(selection: .derived( + protocolID: try WalletProtocolID(securityLevel: .silent, name: "wire test"), + keyID: try WalletKeyID("oracle-key"), + counterparty: .self, + forSelf: false + )) + ) + let omittedSeekBytes = try WalletWireCodec.encodeKeyQueryRequest( + omittedSeekRequest, + originator: "oracle" + ) + XCTAssertEqual(omittedSeekBytes.last, 0xFF) + let decodedOmittedSeek = try WalletWireCodec.decodeKeyQueryRequest(omittedSeekBytes) + XCTAssertNil(WalletRequest.keyQuery(decodedOmittedSeek.request).rawSeekPermission) + XCTAssertEqual( + WalletRequest.keyQuery(decodedOmittedSeek.request).effectiveSeekPermission, + true + ) + XCTAssertEqual( + try WalletWireCodec.encodeKeyQueryRequest( + decodedOmittedSeek.request, + originator: decodedOmittedSeek.originator + ), + omittedSeekBytes + ) + let goOmittedSeekBytes = try oracleBytes( + client, + operation: "wallet.wire.request.reencode", + call: .getPublicKey, + bytes: omittedSeekBytes, + sequence: &sequence + ) + XCTAssertEqual(goOmittedSeekBytes, Array(omittedSeekBytes.dropLast()) + [0]) + // Pinned Go preserves its optional-Boolean absence sentinel. Swift // accepts that inbound form as false and emits the canonical 00 form. let absentGetPublicKey = [UInt8](arrayLiteral: WalletCall.getPublicKey.rawValue, 0, 0) diff --git a/Tests/BSVNetworkTests/URLSessionHTTPTransportTests.swift b/Tests/BSVNetworkTests/URLSessionHTTPTransportTests.swift index 0b4d28a..90e1421 100644 --- a/Tests/BSVNetworkTests/URLSessionHTTPTransportTests.swift +++ b/Tests/BSVNetworkTests/URLSessionHTTPTransportTests.swift @@ -99,7 +99,10 @@ struct URLSessionHTTPTransportTests { func cancellation() async throws { MockURLProtocol.configure(.stall) let operation = Task { - try await transport().send(request, maximumResponseBodyByteCount: 8) + try await transport( + requestTimeout: .seconds(10), + resourceTimeout: .seconds(10) + ).send(request, maximumResponseBodyByteCount: 8) } while MockURLProtocol.requestCount == 0 { await Task.yield() @@ -110,7 +113,7 @@ struct URLSessionHTTPTransportTests { await #expect(throws: NetworkServiceError.cancelled) { try await operation.value } - #expect(start.duration(to: clock.now) < .seconds(1)) + #expect(start.duration(to: clock.now) < .seconds(3)) await expectProtocolStopWhenObservable() } @@ -118,10 +121,13 @@ struct URLSessionHTTPTransportTests { HTTPRequest(method: .get, url: URL(string: "https://mock.invalid/resource")!) } - private func transport() -> URLSessionHTTPTransport { + private func transport( + requestTimeout: Duration = .seconds(1), + resourceTimeout: Duration = .seconds(2) + ) -> URLSessionHTTPTransport { URLSessionHTTPTransport( - requestTimeout: .seconds(1), - resourceTimeout: .seconds(2) + requestTimeout: requestTimeout, + resourceTimeout: resourceTimeout ) { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [MockURLProtocol.self] diff --git a/Tests/BSVWalletTests/ABI/WalletABITests.swift b/Tests/BSVWalletTests/ABI/WalletABITests.swift index 673a63f..06982d1 100644 --- a/Tests/BSVWalletTests/ABI/WalletABITests.swift +++ b/Tests/BSVWalletTests/ABI/WalletABITests.swift @@ -37,6 +37,7 @@ struct WalletABITests { #expect(try WalletTrustSelf("known") == .known) #expect(try WalletActionResultStatus("failed") == .failed) #expect(try WalletActionStatus("nonfinal") == .nonfinal) + #expect(try WalletActionStatus("failed") == .failed) #expect(try WalletQueryMode("any") == .any) #expect(try WalletOutputInclude("locking scripts") == .lockingScripts) #expect(try WalletNetwork("mainnet") == .mainnet) @@ -183,6 +184,14 @@ struct WalletABITests { } } + @Test("key access preserves tri-state permission metadata") + func keyAccessPermissionMetadata() throws { + #expect(WalletKeyAccess.standard.seekPermission == nil) + #expect(try WalletKeyAccess(seekPermission: nil).seekPermission == nil) + #expect(try WalletKeyAccess(seekPermission: false).seekPermission == false) + #expect(try WalletKeyAccess(seekPermission: true).seekPermission == true) + } + @Test("tagged unions reject every contradictory representation") func unionContradictions() throws { let outpoint = testOutpoint(index: 0) diff --git a/Tests/BSVWalletTests/ProtoWalletTests.swift b/Tests/BSVWalletTests/ProtoWalletTests.swift index 8e0b484..b8cd233 100644 --- a/Tests/BSVWalletTests/ProtoWalletTests.swift +++ b/Tests/BSVWalletTests/ProtoWalletTests.swift @@ -15,6 +15,10 @@ private struct FixedRandomSource: SecureRandomSource, Sendable { } final class ProtoWalletTests: XCTestCase { + private func scalar(_ value: UInt8) -> [UInt8] { + [UInt8](repeating: 0, count: 31) + [value] + } + private func brcValues() throws -> (PrivateKey, PublicKey, WalletProtocolID, WalletKeyID) { ( try PrivateKey(walletTestHex("6a2991c9de20e38b31d7ea147bf55f5039e4bbc073160f5e0d541d1f17e321b8")), @@ -58,6 +62,52 @@ final class ProtoWalletTests: XCTestCase { XCTAssertEqual(hmac, [81,240,18,153,163,45,174,85,9,246,142,125,209,133,82,76,254,103,46,182,86,59,219,61,126,30,176,232,233,100,234,14]) } + func testWalletInternalAdminMetadataEncryptionVector() async throws { + let root = try walletTestPrivateKey(42) + let protocolID = try WalletProtocolID.walletInternalAdmin( + securityLevel: .everyAppAndCounterparty, + name: "admin metadata encryption" + ) + let keyID = try WalletKeyID("1") + let nonce = (0..<32).map { UInt8($0) } + let plaintext = Array("Yours Wallet metadata".utf8) + // Pinned against live @bsv/sdk 2.1.6 KeyDeriver/ProtoWallet using + // invoice `2-admin metadata encryption-1` and counterparty `self`. + XCTAssertEqual( + Hex.encode(try WalletKeyDeriver(rootKey: root).deriveSymmetricKey( + protocolID: protocolID, + keyID: keyID, + counterparty: .self + ).bytes), + "0070b653097c3ff272c94e9e9aa6ccdbd2bd93ead043b7f2c8e79025f2649480" + ) + let wallet = ProtoWallet( + rootKey: root, + randomSource: FixedRandomSource(bytes: nonce, throwsError: false) + ) + + let ciphertext = try await wallet.encrypt(WalletEncryptRequest( + protocolID: protocolID, + keyID: keyID, + counterparty: .self, + plaintext: plaintext + )).ciphertext + XCTAssertEqual( + Hex.encode(ciphertext), + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + + "807fec26e74ee6626d38e9fd233e99da007e19d43997557b81ce0d4165636f0f" + + "20fb1ca5be" + ) + + let decrypted = try await ProtoWallet(rootKey: root).decrypt(WalletDecryptRequest( + protocolID: protocolID, + keyID: keyID, + counterparty: .self, + ciphertext: ciphertext + )).plaintext + XCTAssertEqual(decrypted, plaintext) + } + func testPublishedBRC3SignatureAndRoundTrips() async throws { let (_, counterparty, _, keyID) = try brcValues() let protocolID = try WalletProtocolID(securityLevel: .everyAppAndCounterparty, name: "BRC3 Test") @@ -101,6 +151,267 @@ final class ProtoWalletTests: XCTestCase { } } + func testCounterpartyKeyLinkageMatchesBRC69BRC72AndBRC94() async throws { + let proverKey = try PrivateKey(scalar(7)) + let counterpartyKey = try PrivateKey(scalar(13)) + let verifierKey = try PrivateKey(scalar(17)) + let revelationTime = "2026-08-30T23:45:12.345Z" + let prover = ProtoWallet( + rootKey: proverKey, + randomSource: FixedRandomSource( + bytes: [UInt8](repeating: 0x2a, count: 32), + throwsError: false + ) + ) + let verifier = ProtoWallet(rootKey: verifierKey) + + let result = try await prover.revealCounterpartyKeyLinkage( + WalletRevealCounterpartyKeyLinkageRequest( + counterparty: counterpartyKey.publicKey, + verifier: verifierKey.publicKey + ), + revelationTime: revelationTime, + proofNonce: try PrivateKey(scalar(23)) + ) + + XCTAssertEqual(result.prover, proverKey.publicKey) + XCTAssertEqual(result.counterparty, counterpartyKey.publicKey) + XCTAssertEqual(result.verifier, verifierKey.publicKey) + XCTAssertEqual(result.revelationTime, revelationTime) + + let revelationProtocol = try WalletProtocolID( + securityLevel: .everyAppAndCounterparty, + name: "counterparty linkage revelation" + ) + let keyID = try WalletKeyID(revelationTime) + let linkage = try await verifier.decrypt(WalletDecryptRequest( + protocolID: revelationProtocol, + keyID: keyID, + counterparty: .publicKey(proverKey.publicKey), + ciphertext: result.encryptedLinkage.bytes + )).plaintext + XCTAssertEqual( + linkage, + try proverKey.sharedSecret(with: counterpartyKey.publicKey).compressedBytes + ) + + let proofBytes = try await verifier.decrypt(WalletDecryptRequest( + protocolID: revelationProtocol, + keyID: keyID, + counterparty: .publicKey(proverKey.publicKey), + ciphertext: result.encryptedLinkageProof.bytes + )).plaintext + XCTAssertEqual(proofBytes.count, 98) + let encodedResponse = Array(proofBytes.dropFirst(66)) + let response = [UInt8](repeating: 0, count: 32 - encodedResponse.count) + + encodedResponse + let proof = try SharedSecretProof( + noncePublicKey: PublicKey(Array(proofBytes[0..<33])), + nonceSharedSecret: PublicKey(Array(proofBytes[33..<66])), + response: response + ) + XCTAssertTrue(proof.verify( + proverPublicKey: proverKey.publicKey, + counterpartyPublicKey: counterpartyKey.publicKey, + sharedSecret: try PublicKey(linkage) + )) + } + + func testCounterpartyProofSerializesResponseAsBRC97MinimalInteger() async throws { + let proverKey = try PrivateKey(scalar(7)) + let counterpartyKey = try PrivateKey(scalar(13)) + let verifierKey = try PrivateKey(scalar(17)) + let deriver = WalletKeyDeriver(rootKey: proverKey) + var selectedNonce: PrivateKey? + var selectedResponse: [UInt8] = [] + for value in UInt16(1)...UInt16(1_024) { + var bytes = [UInt8](repeating: 0, count: 32) + bytes[30] = UInt8(value >> 8) + bytes[31] = UInt8(truncatingIfNeeded: value) + let nonce = try PrivateKey(bytes) + let response = try deriver.counterpartySecretProof( + for: counterpartyKey.publicKey, + nonce: nonce + ).response + if response.first == 0 { + selectedNonce = nonce + selectedResponse = response + break + } + } + let nonce = try XCTUnwrap(selectedNonce) + let wallet = ProtoWallet( + rootKey: proverKey, + randomSource: FixedRandomSource( + bytes: [UInt8](repeating: 0x55, count: 32), + throwsError: false + ) + ) + let verifier = ProtoWallet(rootKey: verifierKey) + let revelationTime = "2026-08-30T23:45:12.345Z" + let result = try await wallet.revealCounterpartyKeyLinkage( + WalletRevealCounterpartyKeyLinkageRequest( + counterparty: counterpartyKey.publicKey, + verifier: verifierKey.publicKey + ), + revelationTime: revelationTime, + proofNonce: nonce + ) + let proofBytes = try await verifier.decrypt(WalletDecryptRequest( + protocolID: WalletProtocolID( + securityLevel: .everyAppAndCounterparty, + name: "counterparty linkage revelation" + ), + keyID: WalletKeyID(revelationTime), + counterparty: .publicKey(proverKey.publicKey), + ciphertext: result.encryptedLinkageProof.bytes + )).plaintext + let expectedResponse = Array(selectedResponse.drop(while: { $0 == 0 })) + XCTAssertEqual(Array(proofBytes.dropFirst(66)), expectedResponse) + XCTAssertLessThan(proofBytes.count, 98) + } + + func testSpecificKeyLinkageMatchesBRC69BRC72AndProofTypeZero() async throws { + let proverKey = try PrivateKey(scalar(7)) + let counterpartyKey = try PrivateKey(scalar(13)) + let verifierKey = try PrivateKey(scalar(17)) + let protocolID = try WalletProtocolID(securityLevel: .silent, name: "tests") + let keyID = try WalletKeyID("test key id") + let prover = ProtoWallet( + rootKey: proverKey, + randomSource: FixedRandomSource( + bytes: [UInt8](repeating: 0x4d, count: 32), + throwsError: false + ) + ) + let verifier = ProtoWallet(rootKey: verifierKey) + + let result = try await prover.revealSpecificKeyLinkage( + WalletRevealSpecificKeyLinkageRequest( + counterparty: .publicKey(counterpartyKey.publicKey), + verifier: verifierKey.publicKey, + protocolID: protocolID, + keyID: keyID + ) + ) + + XCTAssertEqual(result.prover, proverKey.publicKey) + XCTAssertEqual(result.counterparty, counterpartyKey.publicKey) + XCTAssertEqual(result.verifier, verifierKey.publicKey) + XCTAssertEqual(result.protocolID, protocolID) + XCTAssertEqual(result.keyID, keyID) + XCTAssertEqual(result.proofType, 0) + + let revelationProtocol = try WalletProtocolID( + securityLevel: .everyAppAndCounterparty, + name: "specific linkage revelation 0 tests" + ) + let linkage = try await verifier.decrypt(WalletDecryptRequest( + protocolID: revelationProtocol, + keyID: keyID, + counterparty: .publicKey(proverKey.publicKey), + ciphertext: result.encryptedLinkage.bytes + )).plaintext + let sharedSecret = try proverKey.sharedSecret(with: counterpartyKey.publicKey) + XCTAssertEqual( + linkage, + BSVHashing.hmacSHA256( + Array("0-tests-test key id".utf8), + key: sharedSecret.compressedBytes + ).bytes + ) + let proof = try await verifier.decrypt(WalletDecryptRequest( + protocolID: revelationProtocol, + keyID: keyID, + counterparty: .publicKey(proverKey.publicKey), + ciphertext: result.encryptedLinkageProof.bytes + )).plaintext + XCTAssertEqual(proof, [0]) + } + + func testSpecificKeyLinkageWrapsMaximumLengthTargetProtocol() async throws { + let proverKey = try PrivateKey(scalar(7)) + let counterpartyKey = try PrivateKey(scalar(13)) + let verifierKey = try PrivateKey(scalar(17)) + let targetProtocol = try WalletProtocolID( + securityLevel: .everyAppAndCounterparty, + name: String(repeating: "a", count: 400) + ) + let keyID = try WalletKeyID("boundary") + let wallet = ProtoWallet( + rootKey: proverKey, + randomSource: FixedRandomSource( + bytes: [UInt8](repeating: 0x61, count: 32), + throwsError: false + ) + ) + let result = try await wallet.revealSpecificKeyLinkage( + WalletRevealSpecificKeyLinkageRequest( + counterparty: .publicKey(counterpartyKey.publicKey), + verifier: verifierKey.publicKey, + protocolID: targetProtocol, + keyID: keyID + ) + ) + let wrappedName = "specific linkage revelation 2 " + targetProtocol.name + XCTAssertEqual(wrappedName.utf8.count, 430) + let verifier = ProtoWallet(rootKey: verifierKey) + let plaintext = try await verifier.decrypt(WalletDecryptRequest( + protocolID: WalletProtocolID( + securityLevel: .everyAppAndCounterparty, + name: wrappedName + ), + keyID: keyID, + counterparty: .publicKey(proverKey.publicKey), + ciphertext: result.encryptedLinkage.bytes + )).plaintext + XCTAssertEqual(plaintext.count, 32) + } + + func testKeyLinkageRejectsSelfAndUnavailablePrivilegePolicy() async throws { + let root = try PrivateKey(scalar(7)) + let counterparty = try PrivateKey(scalar(13)).publicKey + let verifier = try PrivateKey(scalar(17)).publicKey + let protocolID = try WalletProtocolID(securityLevel: .silent, name: "tests") + let keyID = try WalletKeyID("test") + let wallet = ProtoWallet(rootKey: root) + + await XCTAssertThrowsErrorAsync(try await wallet.revealCounterpartyKeyLinkage( + WalletRevealCounterpartyKeyLinkageRequest( + counterparty: root.publicKey, + verifier: verifier + ) + )) { error in + XCTAssertEqual(error as? WalletCryptoError, .counterpartySelfLinkageForbidden) + } + + for privilege in [ + try WalletPrivilege(privileged: true), + try WalletPrivilege(privilegedReason: "audit reason"), + ] { + await XCTAssertThrowsErrorAsync(try await wallet.revealCounterpartyKeyLinkage( + WalletRevealCounterpartyKeyLinkageRequest( + counterparty: counterparty, + verifier: verifier, + privilege: privilege + ) + )) { error in + XCTAssertEqual(error as? WalletCryptoError, .permissionPolicyUnavailable) + } + await XCTAssertThrowsErrorAsync(try await wallet.revealSpecificKeyLinkage( + try WalletRevealSpecificKeyLinkageRequest( + counterparty: .publicKey(counterparty), + verifier: verifier, + protocolID: protocolID, + keyID: keyID, + privilege: privilege + ) + )) { error in + XCTAssertEqual(error as? WalletCryptoError, .permissionPolicyUnavailable) + } + } + } + func testBoundsAuthenticationMutationsAndRandomFailures() async throws { let limits = try WalletCryptoLimits(maximumPayloadByteCount: 4, maximumJSONByteCount: 1_024) let root = try walletTestPrivateKey(42) @@ -166,12 +477,11 @@ final class ProtoWalletTests: XCTestCase { let identity = try await wallet.getPublicKey(WalletGetPublicKeyRequest(selection: .identity)).publicKey XCTAssertEqual(identity, try walletTestPrivateKey(42).publicKey) - let accesses = [ + let rejectedAccesses = [ try WalletKeyAccess(privileged: true), try WalletKeyAccess(privilegedReason: "reason"), - try WalletKeyAccess(seekPermission: true), ] - for access in accesses { + for access in rejectedAccesses { await XCTAssertThrowsErrorAsync(try await wallet.getPublicKey(WalletGetPublicKeyRequest(selection: .identity, access: access))) { error in XCTAssertEqual(error as? WalletCryptoError, .permissionPolicyUnavailable) } @@ -195,6 +505,56 @@ final class ProtoWalletTests: XCTestCase { XCTAssertEqual(error as? WalletCryptoError, .permissionPolicyUnavailable) } } + + for seekPermission in [nil, false, true] as [Bool?] { + let access = try WalletKeyAccess(seekPermission: seekPermission) + _ = try await wallet.getPublicKey(WalletGetPublicKeyRequest( + selection: .identity, + access: access + )) + let encrypted = try await wallet.encrypt(WalletEncryptRequest( + protocolID: protocolID, + keyID: keyID, + plaintext: [1], + access: access + )) + let decrypted = try await wallet.decrypt(WalletDecryptRequest( + protocolID: protocolID, + keyID: keyID, + ciphertext: encrypted.ciphertext, + access: access + )) + XCTAssertEqual(decrypted.plaintext, [1]) + let soughtHMAC = try await wallet.createHMAC(WalletCreateHMACRequest( + protocolID: protocolID, + keyID: keyID, + data: [1], + access: access + )) + let verifiedHMAC = try await wallet.verifyHMAC(WalletVerifyHMACRequest( + protocolID: protocolID, + keyID: keyID, + data: [1], + hmac: soughtHMAC.hmac, + access: access + )) + XCTAssertTrue(verifiedHMAC.valid) + let soughtSignature = try await wallet.createSignature(WalletCreateSignatureRequest( + protocolID: protocolID, + keyID: keyID, + counterparty: .self, + payload: .data([1]), + access: access + )) + let verifiedSignature = try await wallet.verifySignature(WalletVerifySignatureRequest( + protocolID: protocolID, + keyID: keyID, + payload: .data([1]), + signature: soughtSignature.signature, + access: access + )) + XCTAssertTrue(verifiedSignature.valid) + } } func testConcurrentImmutableOperations() async throws { diff --git a/Tests/BSVWalletTests/Substrates/WalletWireSubstrateTests.swift b/Tests/BSVWalletTests/Substrates/WalletWireSubstrateTests.swift index 2170a53..ba54add 100644 --- a/Tests/BSVWalletTests/Substrates/WalletWireSubstrateTests.swift +++ b/Tests/BSVWalletTests/Substrates/WalletWireSubstrateTests.swift @@ -1,3 +1,5 @@ +import BSVCore +import BSVCrypto import BSVTransaction import BSVWallet import Testing @@ -240,6 +242,174 @@ struct WalletWireSubstrateTests { ) } } + + @Test("handler receives originator and full protocol and basket scopes") + func handlerReceivesScopedRequests() async throws { + let limits = WalletWireLimits.standard + let handler = ScopedRequestRecorder() + let processor = WalletWireProcessor( + handler: handler, + failureMapper: try WalletWireRedactingFailureMapper(limits: limits), + beefLimits: try substrateBEEFLimits(), + certificateLimits: .standard, + wireLimits: limits + ) + let firstClient = try WalletWireTransceiver( + transport: processor, + originator: "first.example", + beefLimits: try substrateBEEFLimits(), + certificateLimits: .standard, + wireLimits: limits + ) + let secondClient = try WalletWireTransceiver( + transport: processor, + originator: "second.example", + beefLimits: try substrateBEEFLimits(), + certificateLimits: .standard, + wireLimits: limits + ) + + _ = try await firstClient.listOutputs(try WalletListOutputsRequest(basket: "alpha basket")) + _ = try await secondClient.listOutputs(try WalletListOutputsRequest(basket: "beta basket")) + _ = try await firstClient.encrypt(WalletEncryptRequest( + protocolID: try walletTestProtocol("alpha scope"), + keyID: try walletTestKeyID("key"), + plaintext: [] + )) + _ = try await secondClient.encrypt(WalletEncryptRequest( + protocolID: try walletTestProtocol("beta scope"), + keyID: try walletTestKeyID("key"), + plaintext: [] + )) + + #expect(await handler.snapshot() == [ + ScopedCall( + originator: "first.example", call: .listOutputs, scope: "alpha basket", + rawSeekPermission: nil, effectiveSeekPermission: true + ), + ScopedCall( + originator: "second.example", call: .listOutputs, scope: "beta basket", + rawSeekPermission: nil, effectiveSeekPermission: true + ), + ScopedCall( + originator: "first.example", call: .encrypt, scope: "alpha scope", + rawSeekPermission: nil, effectiveSeekPermission: true + ), + ScopedCall( + originator: "second.example", call: .encrypt, scope: "beta scope", + rawSeekPermission: nil, effectiveSeekPermission: true + ), + ]) + } + + @Test("request context preserves bounded raw originator") + func requestContextValidation() throws { + let raw = " HTTPS://Example.COM:443 " + let context = try WalletRequestContext(rawOriginator: raw) + #expect(context.rawOriginator == raw) + #expect(!context.description.contains(raw)) + #expect(Array(Mirror(reflecting: context).children).isEmpty) + + let tooLong = String( + repeating: "a", + count: WalletRequestContext.maximumRawOriginatorUTF8ByteCount + 1 + ) + #expect(throws: WalletRequestContextError.originatorTooLong( + actualUTF8ByteCount: 256, + maximumUTF8ByteCount: 255 + )) { + try WalletRequestContext(rawOriginator: tooLong) + } + } + + @Test("request metadata applies BRC-100 permission defaults without losing raw input") + func seekPermissionMetadata() throws { + let protocolID = try walletTestProtocol("permission metadata") + let keyID = try walletTestKeyID("key") + let hmac = try WalletHMAC(bytes: [UInt8](repeating: 0, count: 32)) + let signature = try walletTestPrivateKey(3).sign(digest: BSVHashing.sha256([1])) + let defaultAccess = WalletKeyAccess.standard + + let defaultTrueRequests: [WalletRequest] = [ + .action(.listActions(try WalletListActionsRequest(labels: []))), + .action(.internalizeAction(try WalletInternalizeActionRequest( + transaction: actionAtomicBEEF(), + description: "internalize", + outputs: [] + ))), + .action(.listOutputs(try WalletListOutputsRequest(basket: "default"))), + .keyQuery(.getPublicKey(WalletGetPublicKeyRequest( + selection: .identity, + access: defaultAccess + ))), + .keyQuery(.encrypt(WalletEncryptRequest( + protocolID: protocolID, keyID: keyID, plaintext: [], access: defaultAccess + ))), + .keyQuery(.decrypt(WalletDecryptRequest( + protocolID: protocolID, keyID: keyID, ciphertext: [], access: defaultAccess + ))), + .keyQuery(.createHMAC(WalletCreateHMACRequest( + protocolID: protocolID, keyID: keyID, data: [], access: defaultAccess + ))), + .keyQuery(.verifyHMAC(WalletVerifyHMACRequest( + protocolID: protocolID, keyID: keyID, data: [], hmac: hmac, access: defaultAccess + ))), + .keyQuery(.createSignature(WalletCreateSignatureRequest( + protocolID: protocolID, keyID: keyID, payload: .data([]), access: defaultAccess + ))), + .keyQuery(.verifySignature(WalletVerifySignatureRequest( + protocolID: protocolID, keyID: keyID, payload: .data([1]), + signature: signature, access: defaultAccess + ))), + ] + + for request in defaultTrueRequests { + #expect(request.seekPermissionMetadata == WalletSeekPermissionMetadata( + rawValue: nil, + effectiveValue: true + ), "call \(request.call.rawValue)") + } + + let explicitFalse = WalletRequest.keyQuery(.encrypt(WalletEncryptRequest( + protocolID: protocolID, + keyID: keyID, + plaintext: [], + access: try WalletKeyAccess(seekPermission: false) + ))) + #expect(explicitFalse.rawSeekPermission == false) + #expect(explicitFalse.effectiveSeekPermission == false) + + let identityKey = try walletTestPrivateKey(4).publicKey + let defaultDiscovery = WalletRequest.certificate(.discoverByIdentityKey(.init( + identityKey: identityKey + ))) + #expect(defaultDiscovery.rawSeekPermission == nil) + #expect(defaultDiscovery.effectiveSeekPermission == false) + + let explicitDiscovery = WalletRequest.certificate(.discoverByAttributes(try .init( + attributes: [:], + seekPermission: true + ))) + #expect(explicitDiscovery.rawSeekPermission == true) + #expect(explicitDiscovery.effectiveSeekPermission == true) + + let notApplicable: [WalletRequest] = [ + .action(.abortAction(WalletAbortActionRequest( + reference: try WalletBase64Data([1]) + ))), + .certificate(.revealCounterpartyKeyLinkage(.init( + counterparty: identityKey, + verifier: identityKey + ))), + .keyQuery(.getHeight(WalletGetHeightRequest())), + ] + for request in notApplicable { + #expect(request.seekPermissionMetadata == WalletSeekPermissionMetadata( + rawValue: nil, + effectiveValue: nil + ), "call \(request.call.rawValue)") + } + } } private struct OriginatorCall: Equatable, Sendable { @@ -288,6 +458,51 @@ private actor OriginatorRecorder: WalletWireOriginatorAuthorizing { func snapshot() -> [OriginatorCall] { calls } } +private struct ScopedCall: Equatable, Sendable { + let originator: String + let call: WalletCall + let scope: String + let rawSeekPermission: Bool? + let effectiveSeekPermission: Bool? +} + +private actor ScopedRequestRecorder: WalletRequestHandling { + private var calls: [ScopedCall] = [] + + func handle( + _ request: WalletRequest, + context: WalletRequestContext + ) async throws -> WalletResult { + switch request { + case .action(.listOutputs(let value)): + calls.append(ScopedCall( + originator: context.rawOriginator, + call: request.call, + scope: value.basket, + rawSeekPermission: request.rawSeekPermission, + effectiveSeekPermission: request.effectiveSeekPermission + )) + return .action(.listOutputs(try WalletListOutputsResult( + totalOutputs: 0, + outputs: [] + ))) + case .keyQuery(.encrypt(let value)): + calls.append(ScopedCall( + originator: context.rawOriginator, + call: request.call, + scope: value.protocolID.name, + rawSeekPermission: request.rawSeekPermission, + effectiveSeekPermission: request.effectiveSeekPermission + )) + return .keyQuery(.encrypt(WalletEncryptResult(ciphertext: [1, 2, 3]))) + default: + throw TestFailure.unexpectedCall + } + } + + func snapshot() -> [ScopedCall] { calls } +} + private struct FixedTransport: WalletWireTransport { let response: [UInt8] func transmit( diff --git a/Tests/BSVWalletTests/WalletBRC100JSONCodecTests.swift b/Tests/BSVWalletTests/WalletBRC100JSONCodecTests.swift new file mode 100644 index 0000000..0c1bf12 --- /dev/null +++ b/Tests/BSVWalletTests/WalletBRC100JSONCodecTests.swift @@ -0,0 +1,258 @@ +import XCTest +import BSVCore +import BSVTransaction +@testable import BSVWallet + +final class WalletBRC100JSONCodecTests: XCTestCase { + func testAllCanonicalRoutesAreExactAndUnversioned() { + let names = [ + "createAction", "signAction", "abortAction", "listActions", "internalizeAction", + "listOutputs", "relinquishOutput", "getPublicKey", "revealCounterpartyKeyLinkage", + "revealSpecificKeyLinkage", "encrypt", "decrypt", "createHmac", "verifyHmac", + "createSignature", "verifySignature", "acquireCertificate", "listCertificates", + "proveCertificate", "relinquishCertificate", "discoverByIdentityKey", + "discoverByAttributes", "isAuthenticated", "waitForAuthentication", "getHeight", + "getHeaderForHeight", "getNetwork", "getVersion", + ] + XCTAssertEqual(WalletJSONRoute.all.map(\.methodName), names) + for (index, name) in names.enumerated() { + let route = WalletJSONRoute(path: "/\(name)") + XCTAssertEqual(route?.call.rawValue, UInt8(index + 1)) + XCTAssertEqual(route?.path, "/\(name)") + } + XCTAssertNil(WalletJSONRoute(path: "getVersion")) + XCTAssertNil(WalletJSONRoute(path: "/v1/getVersion")) + XCTAssertNil(WalletJSONRoute(path: "/getversion")) + XCTAssertNil(WalletJSONRoute(path: "/getVersion/")) + XCTAssertNil(WalletJSONRoute(path: "/unknown")) + } + + func testEmptyObjectMethodsRejectNonemptyBodies() throws { + let codec = try makeCodec() + for name in ["isAuthenticated", "waitForAuthentication", "getHeight", "getNetwork", "getVersion"] { + let route = try XCTUnwrap(WalletJSONRoute(methodName: name)) + let request = try codec.decodeRequest(route: route, from: Array("{}".utf8)) + XCTAssertEqual(request.call, route.call) + XCTAssertEqual(try codec.encodeRequest(request), Array("{}".utf8)) + XCTAssertThrowsError(try codec.decodeRequest(route: route, from: Array("{\"extra\":1}".utf8))) + } + } + + func testAbsentAndExplicitFalseRemainDistinct() throws { + let codec = try makeCodec() + let route = try XCTUnwrap(WalletJSONRoute(methodName: "listActions")) + let absent = try codec.decodeRequest(route: route, from: Array("{\"labels\":[]}".utf8)) + let explicit = try codec.decodeRequest( + route: route, + from: Array("{\"labels\":[],\"includeLabels\":false,\"seekPermission\":false}".utf8) + ) + guard case .action(.listActions(let absentValue)) = absent, + case .action(.listActions(let explicitValue)) = explicit else { + return XCTFail("unexpected request case") + } + XCTAssertNil(absentValue.includeLabels) + XCTAssertNil(absentValue.seekPermission) + XCTAssertEqual(explicitValue.includeLabels, false) + XCTAssertEqual(explicitValue.seekPermission, false) + XCTAssertEqual(explicit.rawSeekPermission, false) + XCTAssertEqual(explicit.effectiveSeekPermission, false) + XCTAssertEqual( + String(decoding: try codec.encodeRequest(explicit), as: UTF8.self), + "{\"includeLabels\":false,\"labels\":[],\"seekPermission\":false}" + ) + } + + func testHexBase64AndByteArrayShapes() throws { + let codec = try makeCodec() + let create = try codec.decodeRequest( + route: XCTUnwrap(WalletJSONRoute(methodName: "createAction")), + from: Array("{\"description\":\"hello\",\"outputs\":[{\"lockingScript\":\"51\",\"satoshis\":1,\"outputDescription\":\"test output\"}]}".utf8) + ) + guard case .action(.createAction(let request)) = create else { return XCTFail() } + XCTAssertEqual(request.outputs?.first?.lockingScript, [0x51]) + XCTAssertTrue(String(decoding: try codec.encodeRequest(create), as: UTF8.self).contains("\"lockingScript\":\"51\"")) + + let abort = try codec.decodeRequest( + route: XCTUnwrap(WalletJSONRoute(methodName: "abortAction")), + from: Array("{\"reference\":\"AQID\"}".utf8) + ) + guard case .action(.abortAction(let abortRequest)) = abort else { return XCTFail() } + XCTAssertEqual(abortRequest.reference.bytes, [1, 2, 3]) + XCTAssertEqual(String(decoding: try codec.encodeRequest(abort), as: UTF8.self), "{\"reference\":\"AQID\"}") + + let headerHex = String(repeating: "00", count: 80) + let header = try codec.decodeResult( + route: XCTUnwrap(WalletJSONRoute(methodName: "getHeaderForHeight")), + from: Array("{\"header\":\"\(headerHex)\"}".utf8) + ) + guard case .keyQuery(.getHeaderForHeight(let headerResult)) = header else { return XCTFail() } + XCTAssertEqual(headerResult.header, Array(repeating: 0, count: 80)) + + let encrypted = try codec.decodeResult( + route: XCTUnwrap(WalletJSONRoute(methodName: "encrypt")), + from: Array("{\"ciphertext\":[\(Array(repeating: "0", count: 48).joined(separator: ","))]}".utf8) + ) + guard case .keyQuery(.encrypt(let encryptedResult)) = encrypted else { return XCTFail() } + XCTAssertEqual(encryptedResult.ciphertext.count, 48) + } + + func testFailedActionStatusUsesStringNotErrorCode() throws { + let codec = try makeCodec() + let txid = String(repeating: "00", count: 32) + let json = "{\"totalActions\":1,\"actions\":[{\"txid\":\"\(txid)\",\"satoshis\":0,\"status\":\"failed\",\"isOutgoing\":true,\"description\":\"failed action\",\"version\":1,\"lockTime\":0}]}" + let result = try codec.decodeResult( + route: XCTUnwrap(WalletJSONRoute(methodName: "listActions")), + from: Array(json.utf8) + ) + guard case .action(.listActions(let list)) = result else { return XCTFail() } + XCTAssertEqual(list.actions.first?.status, .failed) + XCTAssertTrue(String(decoding: try codec.encodeResult(result), as: UTF8.self).contains("\"status\":\"failed\"")) + } + + func testCanonicalWERRFiveSixSevenPayloads() throws { + let codec = try makeCodec() + let values = [ + WalletJSONErrorPayload( + name: "WERR_REVIEW_ACTIONS", + message: "Review actions", + code: 5, + details: ["txid": .string("abc"), "reviewActionResults": .array([])] + ), + WalletJSONErrorPayload( + name: "WERR_INVALID_PARAMETER", + message: "Invalid parameter", + code: 6, + details: ["parameter": .string("description")] + ), + WalletJSONErrorPayload( + name: "WERR_INSUFFICIENT_FUNDS", + message: "Insufficient funds", + code: 7, + details: ["totalSatoshisNeeded": .number(5_000), "moreSatoshisNeeded": .number(2_000)] + ), + ] + for value in values { + let bytes = try codec.encodeError(value) + let decoded = try codec.decodeError(from: bytes) + XCTAssertEqual(decoded, value) + XCTAssertTrue(String(decoding: bytes, as: UTF8.self).contains("\"isError\":true")) + } + } + + func testIssuanceCertificateAndMalformedJSON() throws { + let codec = try makeCodec() + let type = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + let key = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + let json = "{\"type\":\"\(type)\",\"certifier\":\"\(key)\",\"acquisitionProtocol\":\"issuance\",\"fields\":{\"name\":\"Alice\"},\"certifierUrl\":\"https://certifier.example.com\"}" + let route = try XCTUnwrap(WalletJSONRoute(methodName: "acquireCertificate")) + let request = try codec.decodeRequest(route: route, from: Array(json.utf8)) + guard case .certificate(.acquireCertificate(let acquisition)) = request, + case .issuance(let issuance) = acquisition.acquisition else { return XCTFail() } + XCTAssertEqual(issuance.certifierURL, "https://certifier.example.com") + XCTAssertEqual(acquisition.fields.first?.value, "Alice") + let encoded = String(decoding: try codec.encodeRequest(request), as: UTF8.self) + XCTAssertTrue(encoded.contains("\"acquisitionProtocol\":\"issuance\"")) + XCTAssertThrowsError(try codec.decodeRequest(route: route, from: Array("{".utf8))) + } + + func testProcessorSeparatesRouteBodyAndWalletFailures() async throws { + let codec = try makeCodec() + let context = try WalletRequestContext(rawOriginator: "example.com") + let successful = WalletBRC100JSONProcessor( + codec: codec, + handler: NetworkHandler(shouldFail: false) + ) + let unknown = await successful.process( + path: "/unknown", body: Array("{}".utf8), context: context + ) + XCTAssertEqual(unknown, .unknownRoute(path: "/unknown")) + let invalid = await successful.process( + path: "/getNetwork", body: Array("{".utf8), context: context + ) + XCTAssertEqual(invalid, .invalidRequest(call: .getNetwork)) + let success = await successful.process( + path: "/getNetwork", body: Array("{}".utf8), context: context + ) + XCTAssertEqual( + success, + .success(call: .getNetwork, body: Array("{\"network\":\"mainnet\"}".utf8)) + ) + + let failing = WalletBRC100JSONProcessor( + codec: codec, + handler: NetworkHandler(shouldFail: true), + failureMapper: InvalidParameterMapper() + ) + let outcome = await failing.process( + path: "/getNetwork", + body: Array("{}".utf8), + context: context + ) + guard case .walletFailure(.getNetwork, let body) = outcome else { return XCTFail() } + let payload = try codec.decodeError(from: body) + XCTAssertEqual(payload.code, 6) + XCTAssertEqual(payload.details["parameter"], .string("network")) + } + + func testOuterJSONLimitAppliesBeforeDecodeAndAfterEncode() throws { + let base = try makeCodec() + let codec = try WalletBRC100JSONCodec( + beefLimits: base.beefLimits, + maximumJSONByteCount: 1 + ) + let route = try XCTUnwrap(WalletJSONRoute(methodName: "getVersion")) + XCTAssertThrowsError(try codec.decodeRequest(route: route, from: Array("{}".utf8))) { + XCTAssertEqual( + $0 as? WalletJSONCodecError, + .jsonTooLarge(actual: 2, maximum: 1) + ) + } + let result = WalletResult.keyQuery(.getVersion(try .init(version: "wallet-1.0"))) + XCTAssertThrowsError(try codec.encodeResult(result)) { + guard case .encodedJSONTooLarge = $0 as? WalletJSONCodecError else { + return XCTFail("unexpected error: \($0)") + } + } + } + + private func makeCodec() throws -> WalletBRC100JSONCodec { + try WalletBRC100JSONCodec(beefLimits: BEEFLimits( + maximumByteCount: 1_000_000, + maximumMerklePathCount: 100, + maximumTransactionCount: 1_000, + transactionLimits: TransactionLimits( + maximumTransactionByteCount: 100_000, + maximumInputCount: 100, + maximumOutputCount: 100, + maximumScriptByteCount: 10_000 + ), + merklePathLimits: MerklePathLimits( + maximumByteCount: 100_000, + maximumLeavesPerLevel: 100, + maximumTotalLeaves: 1_000 + ) + )) + } +} + +private enum ProcessorTestError: Error { case failed } + +private struct NetworkHandler: WalletRequestHandling { + let shouldFail: Bool + func handle(_ request: WalletRequest, context: WalletRequestContext) async throws -> WalletResult { + guard request.call == .getNetwork else { throw ProcessorTestError.failed } + if shouldFail { throw ProcessorTestError.failed } + return .keyQuery(.getNetwork(.init(network: .mainnet))) + } +} + +private struct InvalidParameterMapper: WalletJSONFailureMapping { + func payload(for error: any Error, call: WalletCall) -> WalletJSONErrorPayload { + .init( + name: "WERR_INVALID_PARAMETER", + message: "Invalid network", + code: 6, + details: ["parameter": .string("network")] + ) + } +} diff --git a/Tests/BSVWalletTests/WalletIdentifierTests.swift b/Tests/BSVWalletTests/WalletIdentifierTests.swift index 1ef4da4..2b94985 100644 --- a/Tests/BSVWalletTests/WalletIdentifierTests.swift +++ b/Tests/BSVWalletTests/WalletIdentifierTests.swift @@ -59,6 +59,88 @@ final class WalletIdentifierTests: XCTestCase { XCTAssertNoThrow(try walletTestProtocol("admi1")) } + func testWalletInternalAdminProtocolsStayOffExternalBoundaries() throws { + let value = try WalletProtocolID.walletInternalAdmin( + securityLevel: .everyAppAndCounterparty, + name: " \tAdMiN Metadata Encryption\n" + ) + XCTAssertEqual(value.securityLevel, .everyAppAndCounterparty) + XCTAssertEqual(value.name, "admin metadata encryption") + + XCTAssertThrowsError(try WalletProtocolID( + securityLevel: .everyAppAndCounterparty, + name: value.name + )) { error in + XCTAssertEqual(error as? WalletValidationError, .reservedAdminProtocol) + } + XCTAssertThrowsError(try WalletJSON.decode( + WalletProtocolID.self, + from: Array("[2,\"admin metadata encryption\"]".utf8) + )) + XCTAssertThrowsError(try WalletJSON.encode(value)) + + for invalid in [ + "metadata encryption", + "admi1 metadata encryption", + "admin_metadata encryption", + "admin metadata encryption", + "admin metadata encryption protocol", + ] { + XCTAssertThrowsError(try WalletProtocolID.walletInternalAdmin( + securityLevel: .everyAppAndCounterparty, + name: invalid + ), "expected internal factory to reject \(invalid)") + } + + let wireRequest = WalletWireKeyQueryRequest.encrypt(WalletEncryptRequest( + protocolID: value, + keyID: try WalletKeyID("1"), + plaintext: [1] + )) + XCTAssertThrowsError(try WalletWireCodec.encodeKeyQueryRequest( + wireRequest, + originator: "wallet.example" + )) { error in + XCTAssertEqual( + error as? WalletWireError, + .nonRoundTrippableValue(kind: "BRC-44 wallet-internal protocol identifier") + ) + } + } + + func testSpecificLinkageRevelationHasTheOnlyExtendedProtocolLimit() throws { + let prefix = "specific linkage revelation " + let exact = prefix + "2 " + String(repeating: "a", count: 400) + XCTAssertEqual(exact.utf8.count, 430) + XCTAssertEqual( + try WalletProtocolID(securityLevel: .everyAppAndCounterparty, name: exact).name, + exact + ) + + let tooLong = prefix + "2 " + String(repeating: "a", count: 401) + XCTAssertEqual(tooLong.utf8.count, 431) + XCTAssertThrowsError(try walletTestProtocol(tooLong)) { error in + XCTAssertEqual( + error as? WalletValidationError, + .protocolNameTooLong(actualUTF8ByteCount: 431, maximum: 430) + ) + } + + for malformed in [ + prefix + "3 " + String(repeating: "a", count: 371), + prefix + "x " + String(repeating: "a", count: 371), + "specific linkage revelation2 " + String(repeating: "a", count: 372), + ] { + XCTAssertEqual(malformed.utf8.count, 401) + XCTAssertThrowsError(try walletTestProtocol(malformed)) { error in + XCTAssertEqual( + error as? WalletValidationError, + .protocolNameTooLong(actualUTF8ByteCount: 401, maximum: 400) + ) + } + } + } + func testEveryASCIIProtocolCharacterAndSuffixCaseVariant() throws { let allowed = Set(Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 ".utf8)) for byte in UInt8.min...127 { diff --git a/Tests/BSVWalletTests/WalletJSONTests.swift b/Tests/BSVWalletTests/WalletJSONTests.swift index d8046a8..d595c0e 100644 --- a/Tests/BSVWalletTests/WalletJSONTests.swift +++ b/Tests/BSVWalletTests/WalletJSONTests.swift @@ -24,6 +24,33 @@ final class WalletJSONTests: XCTestCase { XCTAssertTrue(object["plaintext"] is [Any]) XCTAssertEqual(try WalletJSON.decode(WalletEncryptRequest.self, from: bytes).counterparty, .self) + let omittedAccess = try WalletJSON.decode( + WalletEncryptRequest.self, + from: Array("{\"protocolID\":[0,\"testprotocol\"],\"keyID\":\"1\",\"plaintext\":[]}".utf8) + ).access + XCTAssertNil(omittedAccess.seekPermission) + let omittedAccessObject = try XCTUnwrap( + JSONSerialization.jsonObject( + with: Data(WalletJSON.encode(omittedAccess)) + ) as? [String: Any] + ) + XCTAssertNil(omittedAccessObject["seekPermission"]) + + let explicitFalse = try WalletKeyAccess(seekPermission: false) + let explicitFalseObject = try XCTUnwrap( + JSONSerialization.jsonObject( + with: Data(WalletJSON.encode(explicitFalse)) + ) as? [String: Any] + ) + XCTAssertEqual(explicitFalseObject["seekPermission"] as? Bool, false) + XCTAssertEqual( + try WalletJSON.decode( + WalletKeyAccess.self, + from: WalletJSON.encode(explicitFalse) + ).seekPermission, + false + ) + let base = "{\"protocolID\":[0,\"testprotocol\"],\"keyID\":\"1\",\"plaintext\":" for invalid in ["[-1]", "[256]", "[1.5]", "[1e999]", "[\"1\"]", "[true]", "[null]"] { XCTAssertThrowsError(try WalletJSON.decode(WalletEncryptRequest.self, from: Array((base + invalid + "}").utf8))) diff --git a/Tests/BSVWalletTests/Wire/WalletWireActionTests.swift b/Tests/BSVWalletTests/Wire/WalletWireActionTests.swift index a9fabb8..e7f752c 100644 --- a/Tests/BSVWalletTests/Wire/WalletWireActionTests.swift +++ b/Tests/BSVWalletTests/Wire/WalletWireActionTests.swift @@ -181,7 +181,7 @@ struct WalletWireActionTests { let action = try WalletAction( transactionID: id, satoshis: -12, - status: .completed, + status: .failed, isOutgoing: true, description: "sent", labels: ["label", ""], diff --git a/Tests/BSVWalletTests/Wire/WalletWireKeyQueryTests.swift b/Tests/BSVWalletTests/Wire/WalletWireKeyQueryTests.swift index fff1f9a..efc7c80 100644 --- a/Tests/BSVWalletTests/Wire/WalletWireKeyQueryTests.swift +++ b/Tests/BSVWalletTests/Wire/WalletWireKeyQueryTests.swift @@ -111,6 +111,30 @@ final class WalletWireKeyQueryTests: XCTestCase { } } + func testMaximumSpecificLinkageProtocolRoundTripsWalletWire() throws { + let protocolName = "specific linkage revelation 2 " + + String(repeating: "a", count: 400) + XCTAssertEqual(protocolName.utf8.count, 430) + let request = WalletWireKeyQueryRequest.encrypt(WalletEncryptRequest( + protocolID: try WalletProtocolID( + securityLevel: .everyAppAndCounterparty, + name: protocolName + ), + keyID: try WalletKeyID("boundary"), + plaintext: [] + )) + let encoded = try WalletWireCodec.encodeKeyQueryRequest(request, originator: "test") + let decoded = try WalletWireCodec.decodeKeyQueryRequest(encoded) + XCTAssertEqual(decoded.request.call, .encrypt) + XCTAssertEqual( + try WalletWireCodec.encodeKeyQueryRequest( + decoded.request, + originator: decoded.originator + ), + encoded + ) + } + func testTypedDecoderThrowsBoundedRemoteError() throws { let remote = try WalletWireRemoteError( code: 42, @@ -126,6 +150,41 @@ final class WalletWireKeyQueryTests: XCTestCase { } } + func testSeekPermissionPreservesAbsentFalseAndTrueOnWire() throws { + let protocolID = try walletTestProtocol("seek permission") + let keyID = try walletTestKeyID("wire-key") + + for (rawValue, encodedValue): (Bool?, UInt8) in [ + (nil, 0xFF), + (false, 0), + (true, 1), + ] { + let request = WalletWireKeyQueryRequest.encrypt(WalletEncryptRequest( + protocolID: protocolID, + keyID: keyID, + plaintext: [], + access: try WalletKeyAccess(seekPermission: rawValue) + )) + let bytes = try WalletWireCodec.encodeKeyQueryRequest(request, originator: "") + let frame = try WalletWireCodec.decodeRequestFrame(bytes) + XCTAssertEqual(frame.parameters.last, encodedValue) + + let decoded = try WalletWireCodec.decodeKeyQueryRequest(bytes) + guard case .encrypt(let value) = decoded.request else { + XCTFail("wrong request type") + continue + } + XCTAssertEqual(value.access.seekPermission, rawValue) + XCTAssertEqual( + try WalletWireCodec.encodeKeyQueryRequest( + decoded.request, + originator: decoded.originator + ), + bytes + ) + } + } + func testAbsentForSelfNormalizesToExplicitFalse() throws { let protocolID = try walletTestProtocol("wire normalization") let keyID = try walletTestKeyID("wire-key")