From 28c81f3887729454204c899d36046778564082b2 Mon Sep 17 00:00:00 2001 From: hude Date: Wed, 16 Sep 2026 09:03:47 +0900 Subject: [PATCH 1/2] Implement native Internet Identity authentication flow --- CHANGELOG.md | 14 + README.md | 88 ++ Sources/ICNativeClient/Certificate.swift | 7 +- Sources/ICNativeClient/Configuration.swift | 60 ++ Sources/ICNativeClient/ICClient.swift | 877 ++++++++++++++++-- Sources/ICNativeClient/Identity.swift | 170 +++- .../ICNativeClientTests.swift | 655 ++++++++++++- 7 files changed, 1803 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90f6792..c3159a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes are documented here. +## [Unreleased] + +### Added + +- Add `ICAuthSession.childDelegation(for:options:)` to sign a constrained child delegation for a caller-owned DER public key without exposing the session private key. +- Add split `submitRaw`/`completeRaw` and `submitCandid`/`completeCandid` update APIs so callers can retain the ingress request ID before polling completes. +- Add certified single-shot `requestStatus` APIs that distinguish absent, received, processing, replied, rejected, and done ingress states. +- Add per-request absolute ingress expiry and nonce options across Raw, Candid, and typed query/update APIs. +- Add codable `ICSignedQuery` and `ICSignedUpdate` envelopes with local signing, persisted-request validation, and separate send APIs. + +### Compatibility + +- Existing call sites and query/call method references retain their original signatures; generated bindings, stored sessions, and default wire formats are unchanged. + ## [0.7.8] - 2026-09-12 ### Changed diff --git a/README.md b/README.md index 95590f4..803e214 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,73 @@ let updateReply = try await client.callRaw( ) ``` +To keep an update's ingress request ID before polling completes, submit and complete it separately: + +```swift +let submission = try await client.submitRaw( + method: "some_update", + arg: candidUpdateArgument, + identity: identity +) +let requestID = submission.requestID +let updateReply = try await client.completeRaw(submission, identity: identity) +``` + +`submitCandid` and `completeCandid` provide the same split flow for `CandidArguments` and `CandidReply`. A v4 response that already contains a certified result is retained in the submission and completed without another network request. Pending v4 and accepted v2 calls are polled by `completeRaw` or `completeCandid`; if polling times out, the caller still owns the submission and its request ID. Transport failures and non-replicated rejections throw before a submission is returned. + +Use `requestStatus` to make one certified status check without entering the polling loop: + +```swift +switch try await client.requestStatus(for: submission, identity: identity) { +case .absent, .received, .processing: + // Retain its request ID and effective canister ID, then check again later. +case .replied(let bytes): + consume(bytes) +case .rejected(let reject): + handle(reject) +case .done: + handleDoneWithoutReply() +} +``` + +`requestStatus(requestID:effectiveCanisterId:identity:)` performs the same single check after an app restart when only the request ID and effective canister ID were retained. + +Set an absolute ingress expiry or an optional nonce per request. Explicit expiries must be in the next five minutes and cannot outlive the session delegation; nonces must contain 1–32 bytes. + +The options variants are overloads. The original query and call signatures remain available, including when methods are stored as function values; they delegate to the options variants with `.default`. + +```swift +let options = ICRequestOptions( + ingressExpiry: Date().addingTimeInterval(120), + nonce: requestNonce +) +let submission = try await client.submitRaw( + method: "some_update", + arg: candidUpdateArgument, + identity: identity, + options: options +) +``` + +For queueing or persistence before transport, create a complete signed envelope and send the exact same request later: + +```swift +let signed = try client.signUpdate( + method: "some_update", + arg: candidUpdateArgument, + identity: identity, + options: options +) +let stored = try JSONEncoder().encode(signed) + +let restored = try JSONDecoder().decode(ICSignedUpdate.self, from: stored) +let submission = try await client.submitSigned(restored) +``` + +`signQuery`, `querySigned`, and `unsafeQuerySigned` provide the corresponding query flow. Before transport, persisted signed requests are checked against their envelope, request ID, delegation constraints, and signatures. A signed envelope contains no private key, but it authorizes the encoded ingress request until expiry and should be stored as sensitive application data. + +The request ID is the 32-byte hash identifying the exact IC ingress message. It is not a ledger transaction index or a ledger-specific transaction hash. + Management-canister queries keep `aaaaa-aa` in the signed request content while routing to the subnet that hosts the target canister: ```swift @@ -284,6 +351,27 @@ The lifetime comes from `options.maxTimeToLiveNanoseconds`, or otherwise from `c `internetIdentityURL` and `derivationOrigin` remain configuration bindings in the existing storage format; this operation does not contact those URLs and they do not affect the root-key principal. Existing sessions require no storage migration. +## Child delegations + +An existing session can sign one child delegation for a caller-owned DER public key without exposing its session private key: + +```swift +let child = try identity.childDelegation( + for: childDERPublicKey, + options: ICChildDelegationOptions( + maxTimeToLiveNanoseconds: 3_600_000_000_000, + targets: [configuration.canisterId], + permissions: .all + ) +) +let childChain = ICDelegationChain( + publicKey: identity.delegation.publicKey, + delegations: identity.delegation.delegations + [child] +) +``` + +Only the signed child entry is returned. The recipient must combine it with the session's public parent chain and hold the private key corresponding to `childDERPublicKey`. An omitted lifetime uses the earliest parent expiration. Omitted targets and permissions inherit the parent chain's effective restrictions. Explicit values may narrow those restrictions but cannot expand them; excessive lifetimes, invalid DER keys, cycles, expired parents, and chains at the maximum depth are rejected. + ## Session storage `ICAuthSession` is not `Codable` and exposes no private-key accessor. `ICIdentityStore` keeps the secret in an internal storage DTO and Keychain item protected with `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`. diff --git a/Sources/ICNativeClient/Certificate.swift b/Sources/ICNativeClient/Certificate.swift index 585bf44..2b8234f 100644 --- a/Sources/ICNativeClient/Certificate.swift +++ b/Sources/ICNativeClient/Certificate.swift @@ -155,9 +155,11 @@ indirect enum ICHashTree: Sendable { } } -enum ICCertificateStatus: Equatable { +enum ICCertificateStatus: Equatable, Sendable { case absent case pending + case received + case processing case replied(Data) case rejected(ICReject) case done @@ -222,7 +224,8 @@ enum ICCertificateVerifier { case .found(let bytes): guard let status = String(data: bytes, encoding: .utf8) else { throw ICClientError.invalidResponse("request status UTF-8") } switch status { - case "received", "processing": return .pending + case "received": return .received + case "processing": return .processing case "done": return .done case "replied": guard case .found(let reply) = certificate.tree.lookup(base + [Data("reply".utf8)]) else { diff --git a/Sources/ICNativeClient/Configuration.swift b/Sources/ICNativeClient/Configuration.swift index 835d6e2..408da0b 100644 --- a/Sources/ICNativeClient/Configuration.swift +++ b/Sources/ICNativeClient/Configuration.swift @@ -135,6 +135,66 @@ public struct ICAuthenticationOptions: Equatable, Sendable { } } +public struct ICChildDelegationOptions: Equatable, Sendable { + public static let `default` = ICChildDelegationOptions( + validatedMaxTimeToLiveNanoseconds: nil, + canonicalTargets: nil, + permissions: nil + ) + + public let maxTimeToLiveNanoseconds: UInt64? + public let targets: [String]? + public let permissions: ICDelegationPermission? + + public init( + maxTimeToLiveNanoseconds: UInt64? = nil, + targets: [String]? = nil, + permissions: ICDelegationPermission? = nil + ) throws { + if let maxTimeToLiveNanoseconds { + guard maxTimeToLiveNanoseconds > 0, + maxTimeToLiveNanoseconds <= ICClientConfiguration.maximumDelegationTTLNanoseconds else { + throw ICClientError.invalidConfiguration("Child delegation lifetime must be between 1 ns and 30 days.") + } + } + + let canonicalTargets: [String]? + if let targets { + guard !targets.isEmpty, targets.count <= ICAuthenticationOptions.maximumTargets else { + throw ICClientError.invalidConfiguration("Child delegation targets must contain between 1 and 1000 canister IDs.") + } + let parsed = try targets.map { target -> String in + guard let principal = ICPrincipal.parse(target) else { + throw ICClientError.invalidConfiguration("Child delegation target is not a valid principal: \(target)") + } + return ICPrincipal.text(from: principal) + } + guard Set(parsed).count == parsed.count else { + throw ICClientError.invalidConfiguration("Child delegation targets must not contain duplicates.") + } + canonicalTargets = parsed + } else { + canonicalTargets = nil + } + + self.init( + validatedMaxTimeToLiveNanoseconds: maxTimeToLiveNanoseconds, + canonicalTargets: canonicalTargets, + permissions: permissions + ) + } + + private init( + validatedMaxTimeToLiveNanoseconds: UInt64?, + canonicalTargets: [String]?, + permissions: ICDelegationPermission? + ) { + maxTimeToLiveNanoseconds = validatedMaxTimeToLiveNanoseconds + targets = canonicalTargets + self.permissions = permissions + } +} + public struct ICClientConfiguration: Equatable, Sendable { public static let defaultDelegationTTLNanoseconds: UInt64 = 28_800_000_000_000 public static let maximumDelegationTTLNanoseconds: UInt64 = 2_592_000_000_000_000 diff --git a/Sources/ICNativeClient/ICClient.swift b/Sources/ICNativeClient/ICClient.swift index f645de0..42ad162 100644 --- a/Sources/ICNativeClient/ICClient.swift +++ b/Sources/ICNativeClient/ICClient.swift @@ -1,6 +1,102 @@ import CryptoKit import Foundation +public enum ICRequestStatus: Equatable, Sendable { + case absent + case received + case processing + case replied(Data) + case rejected(ICReject) + case done +} + +public struct ICRequestOptions: Equatable, Sendable { + public static let maximumNonceBytes = 32 + public static let maximumIngressTTL: TimeInterval = 300 + public static let `default` = ICRequestOptions() + + public let ingressExpiry: Date? + public let nonce: Data? + + public init(ingressExpiry: Date? = nil, nonce: Data? = nil) { + self.ingressExpiry = ingressExpiry + self.nonce = nonce + } +} + +public struct ICSignedQuery: Codable, Equatable, Sendable { + public let requestID: Data + public let canisterId: String + public let effectiveCanisterId: String + public let delegationTargetCanisterId: String + public let method: String + public let ingressExpiry: Date + public let envelope: Data + + init( + requestID: Data, + canisterId: String, + effectiveCanisterId: String, + delegationTargetCanisterId: String, + method: String, + ingressExpiry: Date, + envelope: Data + ) { + self.requestID = requestID + self.canisterId = canisterId + self.effectiveCanisterId = effectiveCanisterId + self.delegationTargetCanisterId = delegationTargetCanisterId + self.method = method + self.ingressExpiry = ingressExpiry + self.envelope = envelope + } +} + +public struct ICSignedUpdate: Codable, Equatable, Sendable { + public let requestID: Data + public let canisterId: String + public let effectiveCanisterId: String + public let method: String + public let ingressExpiry: Date + public let envelope: Data + + init( + requestID: Data, + canisterId: String, + effectiveCanisterId: String, + method: String, + ingressExpiry: Date, + envelope: Data + ) { + self.requestID = requestID + self.canisterId = canisterId + self.effectiveCanisterId = effectiveCanisterId + self.method = method + self.ingressExpiry = ingressExpiry + self.envelope = envelope + } +} + +public struct ICUpdateSubmission: Equatable, Sendable { + public let requestID: Data + public let effectiveCanisterId: String + + let initialStatus: ICCertificateStatus + let sender: Data + + init( + requestID: Data, + effectiveCanisterId: String, + initialStatus: ICCertificateStatus, + sender: Data + ) { + self.requestID = requestID + self.effectiveCanisterId = effectiveCanisterId + self.initialStatus = initialStatus + self.sender = sender + } +} + public final class ICClient: @unchecked Sendable { private let session: URLSession private let sleep: @Sendable (Duration) async throws -> Void @@ -40,6 +136,38 @@ public final class ICClient: @unchecked Sendable { delegationTargetCanisterId: String? = nil, identity: ICAuthSession? = nil ) async throws -> Data { + try await queryRaw( + method: method, + arg: arg, + canisterId: canisterId, + effectiveCanisterId: effectiveCanisterId, + delegationTargetCanisterId: delegationTargetCanisterId, + identity: identity, + options: .default + ) + } + + /// Performs a query with per-request expiry and nonce options. + public func queryRaw( + method: String, + arg: Data = Data(), + canisterId: String? = nil, + effectiveCanisterId: String? = nil, + delegationTargetCanisterId: String? = nil, + identity: ICAuthSession? = nil, + options: ICRequestOptions + ) async throws -> Data { + if let identity { + return try await querySigned(signQuery( + method: method, + arg: arg, + canisterId: canisterId, + effectiveCanisterId: effectiveCanisterId, + delegationTargetCanisterId: delegationTargetCanisterId, + identity: identity, + options: options + )) + } let requestText = canisterId ?? configuration.canisterId let effectiveText = effectiveCanisterId ?? requestText // Delegation targets constrain the signed content canister, while certificate ranges constrain routing. @@ -49,7 +177,8 @@ public final class ICClient: @unchecked Sendable { requestCanisterId: requestText, effectiveCanisterId: effectiveText, delegationTargetCanisterId: delegationTargetCanisterId ?? requestText, - identity: identity + identity: nil, + options: options ) var subnet = try await verifiedSubnet(for: effectiveText, forceRefresh: false) do { @@ -70,6 +199,38 @@ public final class ICClient: @unchecked Sendable { delegationTargetCanisterId: String? = nil, identity: ICAuthSession? = nil ) async throws -> Data { + try await unsafeQueryRaw( + method: method, + arg: arg, + canisterId: canisterId, + effectiveCanisterId: effectiveCanisterId, + delegationTargetCanisterId: delegationTargetCanisterId, + identity: identity, + options: .default + ) + } + + /// Explicit opt-out with per-request expiry and nonce options. + public func unsafeQueryRaw( + method: String, + arg: Data = Data(), + canisterId: String? = nil, + effectiveCanisterId: String? = nil, + delegationTargetCanisterId: String? = nil, + identity: ICAuthSession? = nil, + options: ICRequestOptions + ) async throws -> Data { + if let identity { + return try await unsafeQuerySigned(signQuery( + method: method, + arg: arg, + canisterId: canisterId, + effectiveCanisterId: effectiveCanisterId, + delegationTargetCanisterId: delegationTargetCanisterId, + identity: identity, + options: options + )) + } let requestText = canisterId ?? configuration.canisterId let effectiveText = effectiveCanisterId ?? requestText let (response, _) = try await performQuery( @@ -78,7 +239,8 @@ public final class ICClient: @unchecked Sendable { requestCanisterId: requestText, effectiveCanisterId: effectiveText, delegationTargetCanisterId: delegationTargetCanisterId ?? requestText, - identity: identity + identity: nil, + options: options ) return try response.result() } @@ -91,6 +253,27 @@ public final class ICClient: @unchecked Sendable { effectiveCanisterId: String? = nil, delegationTargetCanisterId: String? = nil, identity: ICAuthSession? = nil + ) async throws -> CandidReply { + try await queryCandid( + method: method, + arguments: arguments, + canisterId: canisterId, + effectiveCanisterId: effectiveCanisterId, + delegationTargetCanisterId: delegationTargetCanisterId, + identity: identity, + options: .default + ) + } + + /// Performs a verified Candid query with per-request options. + public func queryCandid( + method: String, + arguments: CandidArguments = CandidArguments(), + canisterId: String? = nil, + effectiveCanisterId: String? = nil, + delegationTargetCanisterId: String? = nil, + identity: ICAuthSession? = nil, + options: ICRequestOptions ) async throws -> CandidReply { let bytes = try arguments.encode() let reply = try await queryRaw( @@ -99,7 +282,8 @@ public final class ICClient: @unchecked Sendable { canisterId: canisterId, effectiveCanisterId: effectiveCanisterId, delegationTargetCanisterId: delegationTargetCanisterId, - identity: identity + identity: identity, + options: options ) return try CandidDecoder().decode(reply) } @@ -112,6 +296,28 @@ public final class ICClient: @unchecked Sendable { delegationTargetCanisterId: String? = nil, identity: ICAuthSession? = nil, as outputType: Output.Type = Output.self + ) async throws -> Output { + try await query( + method: method, + arguments: arguments, + canisterId: canisterId, + effectiveCanisterId: effectiveCanisterId, + delegationTargetCanisterId: delegationTargetCanisterId, + identity: identity, + options: .default, + as: outputType + ) + } + + public func query( + method: String, + arguments: CandidArguments = CandidArguments(), + canisterId: String? = nil, + effectiveCanisterId: String? = nil, + delegationTargetCanisterId: String? = nil, + identity: ICAuthSession? = nil, + options: ICRequestOptions, + as outputType: Output.Type = Output.self ) async throws -> Output { let reply = try await queryCandid( method: method, @@ -119,7 +325,8 @@ public final class ICClient: @unchecked Sendable { canisterId: canisterId, effectiveCanisterId: effectiveCanisterId, delegationTargetCanisterId: delegationTargetCanisterId, - identity: identity + identity: identity, + options: options ) return try reply.decode(outputType) } @@ -132,6 +339,28 @@ public final class ICClient: @unchecked Sendable { delegationTargetCanisterId: String? = nil, identity: ICAuthSession? = nil, as outputType: Output.Type = Output.self + ) async throws -> Output { + try await query( + method: method, + argument: argument, + canisterId: canisterId, + effectiveCanisterId: effectiveCanisterId, + delegationTargetCanisterId: delegationTargetCanisterId, + identity: identity, + options: .default, + as: outputType + ) + } + + public func query( + method: String, + argument: Input, + canisterId: String? = nil, + effectiveCanisterId: String? = nil, + delegationTargetCanisterId: String? = nil, + identity: ICAuthSession? = nil, + options: ICRequestOptions, + as outputType: Output.Type = Output.self ) async throws -> Output { try await query( method: method, @@ -140,10 +369,74 @@ public final class ICClient: @unchecked Sendable { effectiveCanisterId: effectiveCanisterId, delegationTargetCanisterId: delegationTargetCanisterId, identity: identity, + options: options, as: outputType ) } + /// Creates a reusable, fully signed query envelope without sending it. + public func signQuery( + method: String, + arg: Data = Data(), + canisterId: String? = nil, + effectiveCanisterId: String? = nil, + delegationTargetCanisterId: String? = nil, + identity: ICAuthSession, + options: ICRequestOptions = .default + ) throws -> ICSignedQuery { + let requestText = canisterId ?? configuration.canisterId + let effectiveText = effectiveCanisterId ?? requestText + let delegationTargetText = delegationTargetCanisterId ?? requestText + guard let canister = ICPrincipal.parse(requestText), + ICPrincipal.parse(effectiveText) != nil, + !method.isEmpty else { + throw ICClientError.invalidCanisterId + } + try validateIdentityForRequest( + identity, + requestCanisterId: delegationTargetText, + permission: .query + ) + let (expiry, expiryNanoseconds) = try resolvedIngressExpiry(options, identity: identity) + let content = requestContent( + type: "query", + canister: canister, + method: method, + arg: arg, + identity: identity, + ingressExpiry: expiryNanoseconds, + nonce: options.nonce + ) + return ICSignedQuery( + requestID: ICRequestID.hash(of: content), + canisterId: requestText, + effectiveCanisterId: effectiveText, + delegationTargetCanisterId: delegationTargetText, + method: method, + ingressExpiry: expiry, + envelope: try Self.signedEnvelope(content: content, identity: identity) + ) + } + + /// Sends a stored signed query and verifies its node signatures. + public func querySigned(_ request: ICSignedQuery) async throws -> Data { + let response = try await performSignedQuery(request) + var subnet = try await verifiedSubnet(for: request.effectiveCanisterId, forceRefresh: false) + do { + try verify(response: response, requestID: request.requestID, subnet: subnet) + } catch { + subnet = try await verifiedSubnet(for: request.effectiveCanisterId, forceRefresh: true) + try verify(response: response, requestID: request.requestID, subnet: subnet) + } + return try response.result() + } + + /// Explicitly sends a stored signed query without authenticating its response. + public func unsafeQuerySigned(_ request: ICSignedQuery) async throws -> Data { + let response = try await performSignedQuery(request) + return try response.result() + } + public func callRaw( method: String, arg: Data = Data(), @@ -151,35 +444,136 @@ public final class ICClient: @unchecked Sendable { effectiveCanisterId: String? = nil, identity: ICAuthSession ) async throws -> Data { + try await callRaw( + method: method, + arg: arg, + canisterId: canisterId, + effectiveCanisterId: effectiveCanisterId, + identity: identity, + options: .default + ) + } + + public func callRaw( + method: String, + arg: Data = Data(), + canisterId: String? = nil, + effectiveCanisterId: String? = nil, + identity: ICAuthSession, + options: ICRequestOptions + ) async throws -> Data { + let submission = try await submitRaw( + method: method, + arg: arg, + canisterId: canisterId, + effectiveCanisterId: effectiveCanisterId, + identity: identity, + options: options + ) + return try await completeRaw(submission, identity: identity) + } + + /// Submits an update and returns its ingress request ID before polling for completion. + public func submitRaw( + method: String, + arg: Data = Data(), + canisterId: String? = nil, + effectiveCanisterId: String? = nil, + identity: ICAuthSession, + options: ICRequestOptions = .default + ) async throws -> ICUpdateSubmission { + try await submitSigned(signUpdate( + method: method, + arg: arg, + canisterId: canisterId, + effectiveCanisterId: effectiveCanisterId, + identity: identity, + options: options + )) + } + + /// Creates a reusable, fully signed update envelope without sending it. + public func signUpdate( + method: String, + arg: Data = Data(), + canisterId: String? = nil, + effectiveCanisterId: String? = nil, + identity: ICAuthSession, + options: ICRequestOptions = .default + ) throws -> ICSignedUpdate { let targetText = canisterId ?? configuration.canisterId let effectiveText = effectiveCanisterId ?? targetText - guard let target = ICPrincipal.parse(targetText), let effective = ICPrincipal.parse(effectiveText) else { + guard let target = ICPrincipal.parse(targetText), + ICPrincipal.parse(effectiveText) != nil, + !method.isEmpty else { throw ICClientError.invalidCanisterId } - // Delegation targets constrain the content canister, while certificate ranges constrain routing. try validateIdentityForRequest(identity, requestCanisterId: targetText, permission: .call) - let content = requestContent(type: "call", canister: target, method: method, arg: arg, identity: identity) + let (expiry, expiryNanoseconds) = try resolvedIngressExpiry(options, identity: identity) + let content = requestContent( + type: "call", + canister: target, + method: method, + arg: arg, + identity: identity, + ingressExpiry: expiryNanoseconds, + nonce: options.nonce + ) let requestID = ICRequestID.hash(of: content) let envelope = try Self.signedEnvelope(content: content, identity: identity) + return ICSignedUpdate( + requestID: requestID, + canisterId: targetText, + effectiveCanisterId: effectiveText, + method: method, + ingressExpiry: expiry, + envelope: envelope + ) + } + + /// Sends a stored signed update and returns before polling for completion. + public func submitSigned(_ request: ICSignedUpdate) async throws -> ICUpdateSubmission { + let content = try validateSignedRequest( + envelope: request.envelope, + requestID: request.requestID, + canisterId: request.canisterId, + effectiveCanisterId: request.effectiveCanisterId, + method: request.method, + ingressExpiry: request.ingressExpiry, + expectedType: "call", + authorizationCanisterId: request.canisterId + ) + guard case .bytes(let sender) = try ICCBOR.requiredValue( + try ICCBOR.requiredMap(content, context: "signed update content"), + key: "sender", + context: "signed update content" + ), let effective = ICPrincipal.parse(request.effectiveCanisterId) else { + throw ICClientError.invalidIdentity("Signed update metadata does not match its envelope.") + } let (data, response) = try await postCBOR( - envelope, - to: apiURL(for: "call", canisterId: effectiveText, version: .v4), - operation: "update \(method)" + request.envelope, + to: apiURL(for: "call", canisterId: request.effectiveCanisterId, version: .v4), + operation: "update \(request.method)" ) if response.statusCode == 404 { - return try await callRawV2( - envelope: envelope, - requestID: requestID, - method: method, - effectiveText: effectiveText, - identity: identity + return try await submitRawV2( + envelope: request.envelope, + requestID: request.requestID, + method: request.method, + effectiveText: request.effectiveCanisterId, + sender: sender ) } guard response.statusCode == 200 || response.statusCode == 202 else { - throw ICClientError.backendUnavailable(Self.httpFailureContext("update \(method)", data: data, response: response)) + throw ICClientError.backendUnavailable(Self.httpFailureContext("update \(request.method)", data: data, response: response)) } if response.statusCode == 202 || data.isEmpty { - return try await poll(requestId: requestID, canisterId: effectiveText, identity: identity) + return updateSubmission( + requestID: request.requestID, + effectiveCanisterId: request.effectiveCanisterId, + status: .pending, + sender: sender + ) } let fields = try ICCBOR.requiredMap(ICCBOR.decodeStrict(data), context: "v4 call response") guard case .text(let status) = try ICCBOR.requiredValue(fields, key: "status", context: "v4 call response") else { @@ -195,7 +589,12 @@ public final class ICClient: @unchecked Sendable { effectiveCanisterID: effective, trustRoot: configuration.trustRoot ) - return try await resolve(status: ICCertificateVerifier.status(in: certificate, requestID: requestID), requestID: requestID, effectiveText: effectiveText, identity: identity) + return updateSubmission( + requestID: request.requestID, + effectiveCanisterId: request.effectiveCanisterId, + status: try ICCertificateVerifier.status(in: certificate, requestID: request.requestID), + sender: sender + ) case "non_replicated_rejection": throw ICClientError.rejected(try parseReject(fields, context: "v4 rejection")) default: @@ -203,6 +602,20 @@ public final class ICClient: @unchecked Sendable { } } + /// Resolves a previously submitted update, polling only when its initial response was pending. + public func completeRaw( + _ submission: ICUpdateSubmission, + identity: ICAuthSession + ) async throws -> Data { + try validateSubmissionIdentity(submission, identity: identity) + return try await resolve( + status: submission.initialStatus, + requestID: submission.requestID, + effectiveText: submission.effectiveCanisterId, + identity: identity + ) + } + /// Performs an update with Candid arguments using the same verified path as `callRaw`. public func callCandid( method: String, @@ -210,6 +623,25 @@ public final class ICClient: @unchecked Sendable { canisterId: String? = nil, effectiveCanisterId: String? = nil, identity: ICAuthSession + ) async throws -> CandidReply { + try await callCandid( + method: method, + arguments: arguments, + canisterId: canisterId, + effectiveCanisterId: effectiveCanisterId, + identity: identity, + options: .default + ) + } + + /// Performs a Candid update with per-request options. + public func callCandid( + method: String, + arguments: CandidArguments = CandidArguments(), + canisterId: String? = nil, + effectiveCanisterId: String? = nil, + identity: ICAuthSession, + options: ICRequestOptions ) async throws -> CandidReply { let bytes = try arguments.encode() let reply = try await callRaw( @@ -217,17 +649,66 @@ public final class ICClient: @unchecked Sendable { arg: bytes, canisterId: canisterId, effectiveCanisterId: effectiveCanisterId, - identity: identity + identity: identity, + options: options ) return try CandidDecoder().decode(reply) } + /// Encodes and submits a Candid update without waiting for its final reply. + public func submitCandid( + method: String, + arguments: CandidArguments = CandidArguments(), + canisterId: String? = nil, + effectiveCanisterId: String? = nil, + identity: ICAuthSession, + options: ICRequestOptions = .default + ) async throws -> ICUpdateSubmission { + try await submitRaw( + method: method, + arg: arguments.encode(), + canisterId: canisterId, + effectiveCanisterId: effectiveCanisterId, + identity: identity, + options: options + ) + } + + /// Resolves and decodes a previously submitted Candid update. + public func completeCandid( + _ submission: ICUpdateSubmission, + identity: ICAuthSession + ) async throws -> CandidReply { + let reply = try await completeRaw(submission, identity: identity) + return try CandidDecoder().decode(reply) + } + + public func call( + method: String, + arguments: CandidArguments = CandidArguments(), + canisterId: String? = nil, + effectiveCanisterId: String? = nil, + identity: ICAuthSession, + as outputType: Output.Type = Output.self + ) async throws -> Output { + try await call( + method: method, + arguments: arguments, + canisterId: canisterId, + effectiveCanisterId: effectiveCanisterId, + identity: identity, + options: .default, + as: outputType + ) + } + public func call( method: String, arguments: CandidArguments = CandidArguments(), canisterId: String? = nil, effectiveCanisterId: String? = nil, identity: ICAuthSession, + options: ICRequestOptions, as outputType: Output.Type = Output.self ) async throws -> Output { let reply = try await callCandid( @@ -235,7 +716,8 @@ public final class ICClient: @unchecked Sendable { arguments: arguments, canisterId: canisterId, effectiveCanisterId: effectiveCanisterId, - identity: identity + identity: identity, + options: options ) return try reply.decode(outputType) } @@ -247,6 +729,26 @@ public final class ICClient: @unchecked Sendable { effectiveCanisterId: String? = nil, identity: ICAuthSession, as outputType: Output.Type = Output.self + ) async throws -> Output { + try await call( + method: method, + argument: argument, + canisterId: canisterId, + effectiveCanisterId: effectiveCanisterId, + identity: identity, + options: .default, + as: outputType + ) + } + + public func call( + method: String, + argument: Input, + canisterId: String? = nil, + effectiveCanisterId: String? = nil, + identity: ICAuthSession, + options: ICRequestOptions, + as outputType: Output.Type = Output.self ) async throws -> Output { try await call( method: method, @@ -254,10 +756,61 @@ public final class ICClient: @unchecked Sendable { canisterId: canisterId, effectiveCanisterId: effectiveCanisterId, identity: identity, + options: options, as: outputType ) } + /// Reads one certified status for an ingress request without waiting or retrying. + public func requestStatus( + requestID: Data, + effectiveCanisterId: String? = nil, + identity: ICAuthSession + ) async throws -> ICRequestStatus { + let effectiveText = effectiveCanisterId ?? configuration.canisterId + guard requestID.count == 32, let effective = ICPrincipal.parse(effectiveText) else { + throw ICClientError.invalidConfiguration("Request status requires a 32-byte request ID and valid effective canister ID.") + } + try validateIdentityForRequest(identity, requestCanisterId: effectiveText, permission: .readState) + let (_, expiryNanoseconds) = try resolvedIngressExpiry(.default, identity: identity) + let content = readStateContent( + paths: [[Data("request_status".utf8), requestID]], + identity: identity, + ingressExpiry: expiryNanoseconds + ) + let envelope = try Self.signedEnvelope(content: content, identity: identity) + let (data, response) = try await postCBOR( + envelope, + to: apiURL(for: "read_state", canisterId: effectiveText), + operation: "read_state" + ) + guard response.statusCode == 200 else { + throw ICClientError.backendUnavailable(Self.httpFailureContext("read_state", data: data, response: response)) + } + let certificate = try ICCertificateVerifier.verify( + certificateData: decodeReadStateCertificate(data), + effectiveCanisterID: effective, + trustRoot: configuration.trustRoot + ) + return Self.publicStatus(try ICCertificateVerifier.status(in: certificate, requestID: requestID)) + } + + /// Returns a submission's retained certified result, or performs one status request when it was accepted as pending. + public func requestStatus( + for submission: ICUpdateSubmission, + identity: ICAuthSession + ) async throws -> ICRequestStatus { + try validateSubmissionIdentity(submission, identity: identity) + if submission.initialStatus != .pending { + return Self.publicStatus(submission.initialStatus) + } + return try await requestStatus( + requestID: submission.requestID, + effectiveCanisterId: submission.effectiveCanisterId, + identity: identity + ) + } + public func poll( requestId: Data, canisterId: String? = nil, @@ -266,33 +819,16 @@ public final class ICClient: @unchecked Sendable { ) async throws -> Data { let effectiveText = canisterId ?? configuration.canisterId let maximumAttempts = attempts ?? configuration.network.maximumPollingAttempts - guard requestId.count == 32, let effective = ICPrincipal.parse(effectiveText), maximumAttempts > 0 else { + guard requestId.count == 32, ICPrincipal.parse(effectiveText) != nil, maximumAttempts > 0 else { throw ICClientError.invalidConfiguration("Poll requires a 32-byte request ID and at least one attempt.") } - try validateIdentityForRequest(identity, requestCanisterId: effectiveText, permission: .readState) - let url = try apiURL(for: "read_state", canisterId: effectiveText) for _ in 0.. ICRequestStatus { + switch status { + case .absent, .pending: return .absent + case .received: return .received + case .processing: return .processing + case .replied(let data): return .replied(data) + case .rejected(let reject): return .rejected(reject) + case .done: return .done + } + } + + private func resolvedIngressExpiry( + _ options: ICRequestOptions, + identity: ICAuthSession? + ) throws -> (Date, UInt64) { + if let nonce = options.nonce, + nonce.isEmpty || nonce.count > ICRequestOptions.maximumNonceBytes { + throw ICClientError.invalidConfiguration("Request nonce must contain between 1 and 32 bytes.") + } + let now = Date() + let expiry = options.ingressExpiry ?? now.addingTimeInterval(ICRequestOptions.maximumIngressTTL) + let interval = expiry.timeIntervalSince1970 + guard interval.isFinite, expiry > now, + expiry.timeIntervalSince(now) <= ICRequestOptions.maximumIngressTTL else { + throw ICClientError.invalidConfiguration("Ingress expiry must be in the future and no more than 5 minutes away.") + } + let scaled = interval * 1_000_000_000 + guard scaled >= 0, scaled < Double(UInt64.max) else { + throw ICClientError.invalidConfiguration("Ingress expiry is outside the supported range.") + } + let nanoseconds = UInt64(scaled) + if let parentExpiry = identity?.delegation.delegations.map(\.delegation.expiration).min(), + nanoseconds > parentExpiry { + throw ICClientError.invalidIdentity("Ingress expiry exceeds the session delegation expiration.") + } + return (Date(timeIntervalSince1970: Double(nanoseconds) / 1_000_000_000), nanoseconds) + } + public static func signedEnvelope(content: ICCBOR.Value, identity: ICAuthSession) throws -> Data { let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: identity.sessionPrivateKey) let challenge = Data([0x0a]) + Data("ic-request".utf8) + ICRequestID.hash(of: content) @@ -337,7 +918,8 @@ public final class ICClient: @unchecked Sendable { requestCanisterId: String, effectiveCanisterId: String, delegationTargetCanisterId: String, - identity: ICAuthSession? + identity: ICAuthSession?, + options: ICRequestOptions ) async throws -> (ICQueryResponse, Data) { guard let canister = ICPrincipal.parse(requestCanisterId), ICPrincipal.parse(effectiveCanisterId) != nil, @@ -345,11 +927,18 @@ public final class ICClient: @unchecked Sendable { throw ICClientError.invalidCanisterId } let content: ICCBOR.Value + let (_, expiryNanoseconds) = try resolvedIngressExpiry(options, identity: identity) if let identity { try validateIdentityForRequest(identity, requestCanisterId: delegationTargetCanisterId, permission: .query) - content = requestContent(type: "query", canister: canister, method: method, arg: arg, identity: identity) + content = requestContent( + type: "query", canister: canister, method: method, arg: arg, identity: identity, + ingressExpiry: expiryNanoseconds, nonce: options.nonce + ) } else { - content = anonymousRequestContent(type: "query", canister: canister, method: method, arg: arg) + content = anonymousRequestContent( + type: "query", canister: canister, method: method, arg: arg, + ingressExpiry: expiryNanoseconds, nonce: options.nonce + ) } let envelope = try envelope(content: content, identity: identity) let (data, response) = try await postCBOR( @@ -363,6 +952,138 @@ public final class ICClient: @unchecked Sendable { return (try ICQueryResponse(cbor: data), ICRequestID.hash(of: content)) } + private func performSignedQuery(_ request: ICSignedQuery) async throws -> ICQueryResponse { + _ = try validateSignedRequest( + envelope: request.envelope, + requestID: request.requestID, + canisterId: request.canisterId, + effectiveCanisterId: request.effectiveCanisterId, + method: request.method, + ingressExpiry: request.ingressExpiry, + expectedType: "query", + authorizationCanisterId: request.delegationTargetCanisterId + ) + let (data, response) = try await postCBOR( + request.envelope, + to: apiURL(for: "query", canisterId: request.effectiveCanisterId), + operation: "query \(request.method)" + ) + guard response.statusCode == 200 else { + throw ICClientError.backendUnavailable(Self.httpFailureContext("query \(request.method)", data: data, response: response)) + } + return try ICQueryResponse(cbor: data) + } + + private func validateSignedRequest( + envelope: Data, + requestID: Data, + canisterId: String, + effectiveCanisterId: String, + method: String, + ingressExpiry: Date, + expectedType: String, + authorizationCanisterId: String + ) throws -> ICCBOR.Value { + guard requestID.count == 32, + let canister = ICPrincipal.parse(canisterId), + ICPrincipal.parse(effectiveCanisterId) != nil, + ICPrincipal.parse(authorizationCanisterId) != nil, + !method.isEmpty, + ingressExpiry > Date(), + ingressExpiry.timeIntervalSinceNow <= ICRequestOptions.maximumIngressTTL else { + throw ICClientError.invalidConfiguration("Signed request metadata is invalid or expired.") + } + let envelopeFields = try ICCBOR.requiredMap(ICCBOR.decodeStrict(envelope), context: "signed request envelope") + let content = try ICCBOR.requiredValue(envelopeFields, key: "content", context: "signed request envelope") + let fields = try ICCBOR.requiredMap(content, context: "signed request content") + guard case .text(let requestType) = try ICCBOR.requiredValue(fields, key: "request_type", context: "signed request content"), + requestType == expectedType, + case .bytes(let contentCanister) = try ICCBOR.requiredValue(fields, key: "canister_id", context: "signed request content"), + contentCanister == canister, + case .text(let contentMethod) = try ICCBOR.requiredValue(fields, key: "method_name", context: "signed request content"), + contentMethod == method, + case .unsigned(let contentExpiry) = try ICCBOR.requiredValue(fields, key: "ingress_expiry", context: "signed request content"), + Date(timeIntervalSince1970: Double(contentExpiry) / 1_000_000_000) == ingressExpiry, + ICRequestID.hash(of: content) == requestID, + case .bytes(let sender) = try ICCBOR.requiredValue(fields, key: "sender", context: "signed request content"), + case .bytes(let senderPublicKey) = try ICCBOR.requiredValue(envelopeFields, key: "sender_pubkey", context: "signed request envelope"), + sender == ICPrincipal.selfAuthenticatingPublicKey(senderPublicKey), + case .bytes(let senderSignature) = try ICCBOR.requiredValue(envelopeFields, key: "sender_sig", context: "signed request envelope") else { + throw ICClientError.invalidIdentity("Signed request metadata does not match its envelope.") + } + if let nonce = ICCBOR.optionalValue(fields, key: "nonce") { + guard case .bytes(let bytes) = nonce, + !bytes.isEmpty, bytes.count <= ICRequestOptions.maximumNonceBytes else { + throw ICClientError.invalidConfiguration("Signed request nonce is invalid.") + } + } + let chain = try delegationChain(from: envelopeFields, publicKey: senderPublicKey) + let leafKey = try ICIdentityValidation.validateEnvelopeDelegationChain( + chain, + canisterId: authorizationCanisterId, + permission: expectedType == "query" ? .query : .call, + requestExpiration: contentExpiry, + trustRoot: configuration.trustRoot + ) + try Self.verifyEnvelopeSignature(senderSignature, requestID: requestID, derPublicKey: leafKey) + return content + } + + private func delegationChain( + from envelopeFields: [(ICCBOR.Value, ICCBOR.Value)], + publicKey: Data + ) throws -> ICDelegationChain { + guard case .array(let values) = try ICCBOR.requiredValue( + envelopeFields, key: "sender_delegation", context: "signed request envelope" + ) else { + throw ICClientError.invalidIdentity("Signed request delegation chain is invalid.") + } + let signed = try values.map { value -> ICDelegationChain.SignedDelegation in + let fields = try ICCBOR.requiredMap(value, context: "signed request delegation") + let delegationValue = try ICCBOR.requiredValue(fields, key: "delegation", context: "signed request delegation") + let delegationFields = try ICCBOR.requiredMap(delegationValue, context: "signed request delegation") + guard case .bytes(let key) = try ICCBOR.requiredValue(delegationFields, key: "pubkey", context: "signed request delegation"), + case .unsigned(let expiration) = try ICCBOR.requiredValue(delegationFields, key: "expiration", context: "signed request delegation"), + case .bytes(let signature) = try ICCBOR.requiredValue(fields, key: "signature", context: "signed request delegation") else { + throw ICClientError.invalidIdentity("Signed request delegation chain is invalid.") + } + let targets: [Data]? + if let value = ICCBOR.optionalValue(delegationFields, key: "targets") { + guard case .array(let items) = value else { throw ICClientError.invalidIdentity("Signed request targets are invalid.") } + targets = try items.map { + guard case .bytes(let target) = $0 else { throw ICClientError.invalidIdentity("Signed request target is invalid.") } + return target + } + } else { targets = nil } + let permissions: ICDelegationPermission? + if let value = ICCBOR.optionalValue(delegationFields, key: "permissions") { + guard case .text(let raw) = value, let parsed = ICDelegationPermission(rawValue: raw) else { + throw ICClientError.invalidIdentity("Signed request permissions are invalid.") + } + permissions = parsed + } else { permissions = nil } + return ICDelegationChain.SignedDelegation( + delegation: .init(publicKey: key, expiration: expiration, targets: targets, permissions: permissions), + signature: signature + ) + } + return ICDelegationChain(publicKey: publicKey, delegations: signed) + } + + private static func verifyEnvelopeSignature(_ signature: Data, requestID: Data, derPublicKey: Data) throws { + let challenge = Data([0x0a]) + Data("ic-request".utf8) + requestID + do { + try ICCertificateVerifier.validateEd25519DERKey(derPublicKey) + let raw = derPublicKey.dropFirst(ICRC167Codec.ed25519DERPrefix.count) + let key = try Curve25519.Signing.PublicKey(rawRepresentation: raw) + guard key.isValidSignature(signature, for: challenge) else { throw ICClientError.invalidIdentity("Signed request signature is invalid.") } + } catch let error as ICClientError { + throw error + } catch { + throw ICClientError.invalidIdentity("Signed request signature is invalid.") + } + } + private func verifiedSubnet( for canisterText: String, forceRefresh: Bool @@ -420,35 +1141,52 @@ public final class ICClient: @unchecked Sendable { canister: Data, method: String, arg: Data, - identity: ICAuthSession + identity: ICAuthSession, + ingressExpiry: UInt64, + nonce: Data? ) -> ICCBOR.Value { - .map([ + var fields: [(ICCBOR.Value, ICCBOR.Value)] = [ (.text("request_type"), .text(type)), (.text("canister_id"), .bytes(canister)), (.text("method_name"), .text(method)), (.text("arg"), .bytes(arg)), (.text("sender"), .bytes(ICPrincipal.selfAuthenticatingPublicKey(identity.delegation.publicKey))), - (.text("ingress_expiry"), .unsigned(Self.ingressExpiry())), - ]) + (.text("ingress_expiry"), .unsigned(ingressExpiry)), + ] + if let nonce { fields.append((.text("nonce"), .bytes(nonce))) } + return .map(fields) } - private func anonymousRequestContent(type: String, canister: Data, method: String, arg: Data) -> ICCBOR.Value { - .map([ + private func anonymousRequestContent( + type: String, + canister: Data, + method: String, + arg: Data, + ingressExpiry: UInt64, + nonce: Data? + ) -> ICCBOR.Value { + var fields: [(ICCBOR.Value, ICCBOR.Value)] = [ (.text("request_type"), .text(type)), (.text("canister_id"), .bytes(canister)), (.text("method_name"), .text(method)), (.text("arg"), .bytes(arg)), (.text("sender"), .bytes(Data([0x04]))), - (.text("ingress_expiry"), .unsigned(Self.ingressExpiry())), - ]) + (.text("ingress_expiry"), .unsigned(ingressExpiry)), + ] + if let nonce { fields.append((.text("nonce"), .bytes(nonce))) } + return .map(fields) } - private func readStateContent(paths: [[Data]], identity: ICAuthSession?) -> ICCBOR.Value { + private func readStateContent( + paths: [[Data]], + identity: ICAuthSession?, + ingressExpiry: UInt64? = nil + ) -> ICCBOR.Value { .map([ (.text("request_type"), .text("read_state")), (.text("paths"), .array(paths.map { .array($0.map(ICCBOR.Value.bytes)) })), (.text("sender"), .bytes(identity.map { ICPrincipal.selfAuthenticatingPublicKey($0.delegation.publicKey) } ?? Data([0x04]))), - (.text("ingress_expiry"), .unsigned(Self.ingressExpiry())), + (.text("ingress_expiry"), .unsigned(ingressExpiry ?? Self.ingressExpiry())), ]) } @@ -458,16 +1196,16 @@ public final class ICClient: @unchecked Sendable { } private static func ingressExpiry() -> UInt64 { - UInt64((Date().timeIntervalSince1970 + 300) * 1_000_000_000) + UInt64((Date().timeIntervalSince1970 + ICRequestOptions.maximumIngressTTL) * 1_000_000_000) } - private func callRawV2( + private func submitRawV2( envelope: Data, requestID: Data, method: String, effectiveText: String, - identity: ICAuthSession - ) async throws -> Data { + sender: Data + ) async throws -> ICUpdateSubmission { let (data, response) = try await postCBOR( envelope, to: apiURL(for: "call", canisterId: effectiveText, version: .v2), @@ -480,7 +1218,26 @@ public final class ICClient: @unchecked Sendable { let fields = try ICCBOR.requiredMap(ICCBOR.decodeStrict(data), context: "v2 call rejection") throw ICClientError.rejected(try parseReject(fields, context: "v2 rejection")) } - return try await poll(requestId: requestID, canisterId: effectiveText, identity: identity) + return updateSubmission( + requestID: requestID, + effectiveCanisterId: effectiveText, + status: .pending, + sender: sender + ) + } + + private func updateSubmission( + requestID: Data, + effectiveCanisterId: String, + status: ICCertificateStatus, + sender: Data + ) -> ICUpdateSubmission { + ICUpdateSubmission( + requestID: requestID, + effectiveCanisterId: effectiveCanisterId, + initialStatus: status, + sender: sender + ) } private func resolve( @@ -493,7 +1250,7 @@ public final class ICClient: @unchecked Sendable { case .replied(let data): return data case .rejected(let reject): throw ICClientError.rejected(reject) case .done: throw ICClientError.requestDoneWithoutReply - case .absent, .pending: + case .absent, .pending, .received, .processing: return try await poll(requestId: requestID, canisterId: effectiveText, identity: identity) } } diff --git a/Sources/ICNativeClient/Identity.swift b/Sources/ICNativeClient/Identity.swift index 820bc2a..a9ea4cb 100644 --- a/Sources/ICNativeClient/Identity.swift +++ b/Sources/ICNativeClient/Identity.swift @@ -56,6 +56,90 @@ public struct ICAuthSession: Equatable, Sendable { return session } + /// Signs one child delegation for a caller-owned DER public key without exposing the session private key. + /// The returned value must be appended to this session's public delegation chain before use. + public func childDelegation( + for derPublicKey: Data, + options: ICChildDelegationOptions = .default + ) throws -> ICDelegationChain.SignedDelegation { + try childDelegation(for: derPublicKey, options: options, now: Date()) + } + + func childDelegation( + for derPublicKey: Data, + options: ICChildDelegationOptions, + now: Date + ) throws -> ICDelegationChain.SignedDelegation { + guard delegation.delegations.count < ICIdentityValidation.maximumDelegationDepth else { + throw ICClientError.invalidIdentity("Delegation chain is already at the maximum depth.") + } + try ICIdentityValidation.validateChildPublicKey(derPublicKey) + + let observedKeys = Set([delegation.publicKey] + delegation.delegations.map(\.delegation.publicKey)) + guard !observedKeys.contains(derPublicKey) else { + throw ICClientError.invalidIdentity("Child delegation public key would create a cycle.") + } + + let privateKey: Curve25519.Signing.PrivateKey + do { privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: sessionPrivateKey) } + catch { throw ICClientError.invalidIdentity("Session private key is invalid.") } + guard ICRC167Codec.derPublicKey(from: privateKey.publicKey.rawRepresentation) == sessionPublicKey else { + throw ICClientError.invalidIdentity("Session private and public keys do not match.") + } + + let nowNS = try ICIdentityValidation.nanosecondsSinceEpoch(now) + guard let parentExpiration = delegation.delegations.map(\.delegation.expiration).min(), + parentExpiration > nowNS else { + throw ICClientError.expiredDelegation + } + let expiration: UInt64 + if let ttl = options.maxTimeToLiveNanoseconds { + let (requestedExpiration, overflow) = nowNS.addingReportingOverflow(ttl) + guard !overflow, requestedExpiration <= parentExpiration else { + throw ICClientError.invalidIdentity("Child delegation lifetime exceeds its parent delegation.") + } + expiration = requestedExpiration + } else { + expiration = parentExpiration + } + + let parentTargetSets = delegation.delegations.compactMap { signed -> Set? in + signed.delegation.targets.map(Set.init) + } + let effectiveParentTargets = parentTargetSets.first.map { first in + parentTargetSets.dropFirst().reduce(first) { $0.intersection($1) } + } + let targets = try options.targets.map { values -> [Data] in + let parsed = try values.map { value -> Data in + guard let principal = ICPrincipal.parse(value) else { + throw ICClientError.invalidCanisterId + } + return principal + } + if let effectiveParentTargets, + !Set(parsed).isSubset(of: effectiveParentTargets) { + throw ICClientError.invalidIdentity("Child delegation targets exceed the parent delegation scope.") + } + return parsed + } + + let parentIsQueryOnly = delegation.delegations.contains { $0.delegation.permissions == .queries } + if parentIsQueryOnly, options.permissions == .all { + throw ICClientError.invalidIdentity("Child delegation permissions exceed the parent delegation scope.") + } + + let child = ICDelegationChain.SignedDelegation.Delegation( + publicKey: derPublicKey, + expiration: expiration, + targets: targets, + permissions: options.permissions + ) + return .init( + delegation: child, + signature: try privateKey.signature(for: ICIdentityValidation.delegationSignable(child)) + ) + } + public var formatVersion: Int { storage.formatVersion } public var principal: String { storage.principal } public var canisterId: String { storage.canisterId } @@ -346,6 +430,57 @@ enum ICIdentityValidation { guard !ttlOverflow, !skewOverflow, earliestExpiration <= maximumExpiry else { throw ICClientError.invalidPayload } } + /// Validates the public delegation material embedded in a persisted ingress envelope. + /// Returns the leaf key that must have signed the ingress request itself. + static func validateEnvelopeDelegationChain( + _ chain: ICDelegationChain, + canisterId: String, + permission: ICRequestPermission, + requestExpiration: UInt64, + trustRoot: ICTrustRoot, + now: Date = Date() + ) throws -> Data { + guard !chain.publicKey.isEmpty, + !chain.delegations.isEmpty, + chain.delegations.count <= maximumDelegationDepth, + let canister = ICPrincipal.parse(canisterId) else { + throw ICClientError.invalidIdentity("Signed request delegation chain is invalid.") + } + let nowNS = try nanosecondsSinceEpoch(now) + var signerKey = chain.publicKey + var observedKeys = Set([signerKey]) + for signed in chain.delegations { + let delegation = signed.delegation + guard !delegation.publicKey.isEmpty, + !signed.signature.isEmpty, + observedKeys.insert(delegation.publicKey).inserted, + delegation.expiration > nowNS, + delegation.expiration >= requestExpiration else { + throw ICClientError.invalidIdentity("Signed request delegation is expired or cyclic.") + } + if let targets = delegation.targets { + guard !targets.isEmpty, + targets.count <= maximumTargetsPerDelegation, + Set(targets).count == targets.count, + targets.allSatisfy({ $0.count <= 29 }), + targets.contains(canister) else { + throw ICClientError.invalidIdentity("Signed request exceeds its delegation targets.") + } + } + if delegation.permissions == .queries, permission == .call { + throw ICClientError.invalidIdentity("Signed request exceeds its delegation permissions.") + } + try verify( + signature: signed.signature, + payload: delegationSignable(delegation), + signerDERKey: signerKey, + trustRoot: trustRoot + ) + signerKey = delegation.publicKey + } + return signerKey + } + static func delegationSignable(_ delegation: ICDelegationChain.SignedDelegation.Delegation) -> Data { var fields: [(ICCBOR.Value, ICCBOR.Value)] = [ (.text("pubkey"), .bytes(delegation.publicKey)), @@ -356,6 +491,39 @@ enum ICIdentityValidation { return Data([0x1a]) + Data("ic-request-auth-delegation".utf8) + ICRequestID.hash(of: .map(fields)) } + fileprivate static func validateChildPublicKey(_ derPublicKey: Data) throws { + let spki: ICDERSubjectPublicKeyInfo + do { + spki = try ICDERSubjectPublicKeyInfo(data: derPublicKey) + } catch { + throw ICClientError.invalidIdentity("Child delegation public key is not valid DER.") + } + switch spki.algorithmOID { + case ICDERSubjectPublicKeyInfo.ed25519OID: + guard spki.parametersOID == nil, + spki.key.count == 32, + (try? Curve25519.Signing.PublicKey(rawRepresentation: spki.key)) != nil else { + throw ICClientError.invalidIdentity("Child Ed25519 public key is invalid.") + } + case ICDERSubjectPublicKeyInfo.ecPublicKeyOID: + guard spki.parametersOID == ICDERSubjectPublicKeyInfo.prime256v1OID, + spki.key.count == 65, + spki.key.first == 0x04, + (try? P256.Signing.PublicKey(x963Representation: spki.key)) != nil else { + throw ICClientError.invalidIdentity("Child P-256 public key is invalid.") + } + case ICDERSubjectPublicKeyInfo.canisterSignatureOID: + guard spki.parametersOID == nil, + let canisterLength = spki.key.first.map(Int.init), + canisterLength <= 29, + spki.key.count >= 1 + canisterLength else { + throw ICClientError.invalidIdentity("Child canister-signature public key is invalid.") + } + default: + throw ICClientError.invalidIdentity("Child delegation public key uses an unsupported algorithm.") + } + } + private static func verify( signature: Data, payload: Data, @@ -398,7 +566,7 @@ enum ICIdentityValidation { } } - private static func nanosecondsSinceEpoch(_ date: Date) throws -> UInt64 { + fileprivate static func nanosecondsSinceEpoch(_ date: Date) throws -> UInt64 { let seconds = date.timeIntervalSince1970 guard seconds >= 0, seconds <= Double(UInt64.max) / 1_000_000_000 else { throw ICClientError.invalidPayload } return UInt64(seconds * 1_000_000_000) diff --git a/Tests/ICNativeClientTests/ICNativeClientTests.swift b/Tests/ICNativeClientTests/ICNativeClientTests.swift index 98c436f..f1e63f9 100644 --- a/Tests/ICNativeClientTests/ICNativeClientTests.swift +++ b/Tests/ICNativeClientTests/ICNativeClientTests.swift @@ -14,6 +14,29 @@ final class ICNativeClientTests: XCTestCase { super.tearDown() } + func testLegacyQueryAndCallMethodReferencesRemainSourceCompatible() throws { + let configuredClient = client(try configuration(root: BLSTKey(seed: 44).derPublicKey)) + + let queryRaw: (String, Data, String?, String?, String?, ICAuthSession?) async throws -> Data = configuredClient.queryRaw + let unsafeQueryRaw: (String, Data, String?, String?, String?, ICAuthSession?) async throws -> Data = configuredClient.unsafeQueryRaw + let queryCandid: (String, CandidArguments, String?, String?, String?, ICAuthSession?) async throws -> CandidReply = configuredClient.queryCandid + let typedQuery: (String, CandidArguments, String?, String?, String?, ICAuthSession?, String.Type) async throws -> String = configuredClient.query + let convertedQuery: (String, UInt64, String?, String?, String?, ICAuthSession?, String.Type) async throws -> String = configuredClient.query + let callRaw: (String, Data, String?, String?, ICAuthSession) async throws -> Data = configuredClient.callRaw + let callCandid: (String, CandidArguments, String?, String?, ICAuthSession) async throws -> CandidReply = configuredClient.callCandid + let typedCall: (String, CandidArguments, String?, String?, ICAuthSession, String.Type) async throws -> String = configuredClient.call + let convertedCall: (String, UInt64, String?, String?, ICAuthSession, String.Type) async throws -> String = configuredClient.call + + _ = ( + queryRaw, unsafeQueryRaw, queryCandid, typedQuery, convertedQuery, + callRaw, callCandid, typedCall, convertedCall + ) + + let queryWithOptions: (String, Data, String?, String?, String?, ICAuthSession?, ICRequestOptions) async throws -> Data = configuredClient.queryRaw + let callWithOptions: (String, Data, String?, String?, ICAuthSession, ICRequestOptions) async throws -> Data = configuredClient.callRaw + _ = (queryWithOptions, callWithOptions) + } + func testEd25519DelegatingCreatesFreshSessionsWithoutRetainingRootKey() throws { let config = try configuration(root: BLSTKey(seed: 1).derPublicKey) let rootKey = Curve25519.Signing.PrivateKey() @@ -59,6 +82,165 @@ final class ICNativeClientTests: XCTestCase { XCTAssertNoThrow(try ICIdentityValidation.validateSession(maximum, configuration: config)) } + func testChildDelegationSignsCallerPublicKeyWithoutMutatingSession() throws { + let config = try configuration(root: BLSTKey(seed: 33).derPublicKey) + let sessionKey = Curve25519.Signing.PrivateKey() + let session = try makeAuthSession( + config: config, + session: sessionKey, + targets: [try XCTUnwrap(ICPrincipal.parse(canisterText))] + ) + let originalStorage = session.storage + let childKey = Curve25519.Signing.PrivateKey() + let childDER = ICRC167Codec.derPublicKey(from: childKey.publicKey.rawRepresentation) + let now = session.requestedAt + let child = try session.childDelegation( + for: childDER, + options: ICChildDelegationOptions( + maxTimeToLiveNanoseconds: 60_000_000_000, + targets: [canisterText], + permissions: .queries + ), + now: now + ) + + XCTAssertEqual(child.delegation.publicKey, childDER) + XCTAssertEqual(child.delegation.expiration, nanoseconds(now) + 60_000_000_000) + XCTAssertEqual(child.delegation.targets, [try XCTUnwrap(ICPrincipal.parse(canisterText))]) + XCTAssertEqual(child.delegation.permissions, .queries) + XCTAssertTrue(sessionKey.publicKey.isValidSignature( + child.signature, + for: delegationSignable(child.delegation) + )) + XCTAssertEqual(session.storage, originalStorage) + + let childChain = ICDelegationChain( + publicKey: session.delegation.publicKey, + delegations: session.delegation.delegations + [child] + ) + XCTAssertNoThrow(try ICIdentityValidation.validateDelegationChain( + childChain, + expectedSessionPublicKey: childDER, + canisterId: config.canisterId, + requestedAt: session.requestedAt, + maxTimeToLiveNanoseconds: session.maxTimeToLiveNanoseconds, + permission: .query, + trustRoot: config.trustRoot, + now: now + )) + let childSession = ICAuthSession(storage: replacing( + session.storage, + chain: childChain, + sessionPublicKey: childDER, + privateKey: childKey.rawRepresentation + )) + XCTAssertNoThrow(try ICIdentityValidation.validateSession( + childSession, + configuration: config, + permission: .query, + now: now + )) + let envelope = try ICClient.signedEnvelope( + content: .map([(.text("request_type"), .text("query"))]), + identity: childSession + ) + guard let delegationValue = ICCBOR.mapValue( + try ICCBOR.decodeStrict(envelope), + key: "sender_delegation" + ), case .array(let envelopeDelegations) = delegationValue else { + return XCTFail("expected child delegation chain in signed envelope") + } + XCTAssertEqual(envelopeDelegations.count, 2) + } + + func testChildDelegationInheritsBoundsAndRejectsScopeExpansion() throws { + let config = try configuration(root: BLSTKey(seed: 34).derPublicKey) + let target = try XCTUnwrap(ICPrincipal.parse(canisterText)) + let session = try makeAuthSession(config: config, targets: [target], permission: .queries) + let childDER = ICRC167Codec.derPublicKey(from: Curve25519.Signing.PrivateKey().publicKey.rawRepresentation) + let defaultChild = try session.childDelegation(for: childDER, options: .default, now: session.requestedAt) + XCTAssertEqual(defaultChild.delegation.expiration, session.delegation.delegations[0].delegation.expiration) + XCTAssertNil(defaultChild.delegation.targets) + XCTAssertNil(defaultChild.delegation.permissions) + + XCTAssertThrowsError(try session.childDelegation( + for: childDER, + options: ICChildDelegationOptions(targets: ["aaaaa-aa"]), + now: session.requestedAt + )) + XCTAssertThrowsError(try session.childDelegation( + for: childDER, + options: ICChildDelegationOptions(permissions: .all), + now: session.requestedAt + )) + XCTAssertThrowsError(try session.childDelegation( + for: childDER, + options: ICChildDelegationOptions(maxTimeToLiveNanoseconds: 3_601_000_000_000), + now: session.requestedAt + )) + XCTAssertThrowsError(try session.childDelegation( + for: session.sessionPublicKey, + options: .default, + now: session.requestedAt + )) + XCTAssertThrowsError(try session.childDelegation( + for: Data([0x30, 0x00]), + options: .default, + now: session.requestedAt + )) + let shortEd25519 = subjectPublicKeyDER( + algorithmOID: Data([0x2b, 0x65, 0x70]), + key: Data([0x01]) + ) + XCTAssertThrowsError(try session.childDelegation( + for: shortEd25519, + options: .default, + now: session.requestedAt + )) + let invalidP256 = ecPublicKeyDER(key: Data(repeating: 0, count: 65)) + XCTAssertThrowsError(try session.childDelegation( + for: invalidP256, + options: .default, + now: session.requestedAt + )) + let unsupported = subjectPublicKeyDER( + algorithmOID: Data([0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01]), + key: Data(repeating: 0x01, count: 32) + ) + XCTAssertThrowsError(try session.childDelegation( + for: unsupported, + options: .default, + now: session.requestedAt + )) + let p256Child = P256.Signing.PrivateKey().publicKey.derRepresentation + XCTAssertNoThrow(try session.childDelegation( + for: p256Child, + options: .default, + now: session.requestedAt + )) + XCTAssertThrowsError(try session.childDelegation( + for: childDER, + options: .default, + now: session.requestedAt.addingTimeInterval(3_601) + )) + XCTAssertThrowsError(try ICChildDelegationOptions(maxTimeToLiveNanoseconds: 0)) + XCTAssertThrowsError(try ICChildDelegationOptions(targets: [canisterText, canisterText])) + + let fullChain = ICDelegationChain( + publicKey: session.delegation.publicKey, + delegations: Array( + repeating: session.delegation.delegations[0], + count: ICIdentityValidation.maximumDelegationDepth + ) + ) + let fullSession = ICAuthSession(storage: replacing(session.storage, chain: fullChain)) + XCTAssertThrowsError(try fullSession.childDelegation( + for: childDER, + options: .default, + now: session.requestedAt + )) + } + #if canImport(UIKit) @available(iOS 17.4, *) func testAuthenticatorRetainsExplicitCallbackAndTimeoutAPI() throws { @@ -1059,14 +1241,20 @@ final class ICNativeClientTests: XCTestCase { ]).encode() let lock = NSLock() var sentArgument: Data? + var sentRequestID: Data? + var callRequests = 0 URLProtocolStub.handler = { request in let content = try requestContent(request) guard case .bytes(let argument) = ICCBOR.mapValue(content, key: "arg") else { throw ICClientError.invalidResponse("call arg") } - lock.withLock { sentArgument = argument } let requestID = ICRequestID.hash(of: content) + lock.withLock { + sentArgument = argument + sentRequestID = requestID + callRequests += 1 + } let base = [Data("request_status".utf8), requestID] let certificate = try self.makeCertificate(leaves: [ ([Data("time".utf8)], ICRequestID.leb128(self.nanoseconds(Date()))), @@ -1087,6 +1275,19 @@ final class ICNativeClientTests: XCTestCase { XCTAssertEqual(result, "updated") XCTAssertEqual(lock.withLock { sentArgument }, expectedArgument) + let configuredClient = client(config) + let submission = try await configuredClient.submitCandid( + method: "submitted-update", + arguments: CandidArguments(UInt64(7)), + identity: identity + ) + XCTAssertEqual(submission.requestID, lock.withLock { sentRequestID }) + XCTAssertEqual(submission.requestID.count, 32) + XCTAssertEqual(submission.effectiveCanisterId, canisterText) + let submittedReply = try await configuredClient.completeCandid(submission, identity: identity) + XCTAssertEqual(try submittedReply.decode(String.self), "updated") + XCTAssertEqual(lock.withLock { callRequests }, 2) + URLProtocolStub.handler = { request in response(request, status: 200, body: ICCBOR.encode(.map([ (.text("status"), .text("non_replicated_rejection")), (.text("reject_code"), .unsigned(4)), @@ -1123,9 +1324,10 @@ final class ICNativeClientTests: XCTestCase { ], key: root) return response(request, status: 200, body: readStateResponse(certificate)) } - await XCTAssertThrowsErrorAsync( - try await client(config).callRaw(method: "accepted", identity: identity) - ) { error in + let configuredClient = client(config) + let submission = try await configuredClient.submitRaw(method: "accepted", identity: identity) + XCTAssertEqual(submission.requestID, lock.withLock { updateRequestID }) + await XCTAssertThrowsErrorAsync(try await configuredClient.completeRaw(submission, identity: identity)) { error in XCTAssertEqual(error as? ICClientError, .requestDoneWithoutReply) } } @@ -1154,7 +1356,10 @@ final class ICNativeClientTests: XCTestCase { ], key: root) return response(request, status: 200, body: readStateResponse(certificate)) } - let reply = try await client(config).callRaw(method: "accepted-v2", identity: identity) + let configuredClient = client(config) + let submission = try await configuredClient.submitRaw(method: "accepted-v2", identity: identity) + XCTAssertEqual(submission.requestID, lock.withLock { updateRequestID }) + let reply = try await configuredClient.completeRaw(submission, identity: identity) XCTAssertEqual(reply, Data("v2 reply".utf8)) URLProtocolStub.handler = { request in @@ -1171,6 +1376,363 @@ final class ICNativeClientTests: XCTestCase { } } + func testSubmittedUpdateKeepsRequestIDAcrossCertifiedRejectAndPollTimeout() async throws { + let root = BLSTKey(seed: 35) + let config = try ICClientConfiguration( + canisterId: canisterText, + internetIdentityURL: URL(string: "https://id.ai/authorize")!, + derivationOrigin: "https://example.com", + trustRoot: .custom(root.derPublicKey), + network: ICNetworkConfiguration(pollingInterval: .milliseconds(1), maximumPollingAttempts: 1) + ) + let identity = try makeAuthSession(config: config) + let lock = NSLock() + var requests = 0 + URLProtocolStub.handler = { request in + let content = try requestContent(request) + let requestID = ICRequestID.hash(of: content) + let base = [Data("request_status".utf8), requestID] + let certificate = try self.makeCertificate(leaves: [ + ([Data("time".utf8)], ICRequestID.leb128(self.nanoseconds(Date()))), + (base + [Data("status".utf8)], Data("rejected".utf8)), + (base + [Data("reject_code".utf8)], ICRequestID.leb128(4)), + (base + [Data("reject_message".utf8)], Data("certified reject".utf8)), + ], key: root) + lock.withLock { requests += 1 } + return response(request, status: 200, body: ICCBOR.encode(.map([ + (.text("status"), .text("replied")), + (.text("certificate"), .bytes(certificate)), + ]))) + } + + let configuredClient = client(config) + let rejected = try await configuredClient.submitRaw(method: "certified-reject", identity: identity) + await XCTAssertThrowsErrorAsync(try await configuredClient.completeRaw(rejected, identity: identity)) { error in + XCTAssertEqual(error as? ICClientError, .rejected(ICReject( + code: 4, + message: "certified reject", + errorCode: nil, + isCertified: true + ))) + } + XCTAssertEqual(lock.withLock { requests }, 1) + + var submittedRequestID: Data? + URLProtocolStub.handler = { request in + if request.url?.path.hasSuffix("/call") == true { + let requestID = ICRequestID.hash(of: try requestContent(request)) + lock.withLock { submittedRequestID = requestID } + return response(request, status: 202, body: Data()) + } + let certificate = try self.makeCertificate(leaves: [ + ([Data("time".utf8)], ICRequestID.leb128(self.nanoseconds(Date()))), + ], key: root) + return response(request, status: 200, body: readStateResponse(certificate)) + } + let pending = try await configuredClient.submitRaw(method: "timeout", identity: identity) + XCTAssertEqual(pending.requestID, lock.withLock { submittedRequestID }) + await XCTAssertThrowsErrorAsync(try await configuredClient.completeRaw(pending, identity: identity)) { error in + XCTAssertEqual(error as? ICClientError, .pollTimeout) + } + XCTAssertEqual(pending.requestID, lock.withLock { submittedRequestID }) + + let otherIdentity = try makeAuthSession(config: config) + await XCTAssertThrowsErrorAsync(try await configuredClient.completeRaw(pending, identity: otherIdentity)) { error in + guard case .invalidIdentity? = error as? ICClientError else { + return XCTFail("expected identity mismatch, received \(error)") + } + } + } + + func testRequestOptionsAndSignedRequestsRoundTripWithoutChangingEnvelope() async throws { + let config = try configuration(root: BLSTKey(seed: 41).derPublicKey) + let identity = try makeAuthSession(config: config) + let expiry = Date().addingTimeInterval(120) + let nonce = Data((0..<32).map(UInt8.init)) + let options = ICRequestOptions(ingressExpiry: expiry, nonce: nonce) + let configuredClient = client(config) + + let update = try configuredClient.signUpdate( + method: "persisted_update", + arg: Data("argument".utf8), + identity: identity, + options: options + ) + let decodedUpdate = try JSONDecoder().decode( + ICSignedUpdate.self, + from: JSONEncoder().encode(update) + ) + XCTAssertEqual(decodedUpdate, update) + let updateEnvelope = try ICCBOR.decodeStrict(update.envelope) + let updateContent = try XCTUnwrap(ICCBOR.mapValue(updateEnvelope, key: "content")) + guard case .unsigned(let encodedExpiry) = ICCBOR.mapValue(updateContent, key: "ingress_expiry"), + case .bytes(let encodedNonce) = ICCBOR.mapValue(updateContent, key: "nonce") else { + return XCTFail("expected expiry and nonce in signed update") + } + XCTAssertEqual(update.requestID, ICRequestID.hash(of: updateContent)) + XCTAssertEqual(encodedExpiry, nanoseconds(update.ingressExpiry)) + XCTAssertEqual(encodedNonce, nonce) + + let lock = NSLock() + var submittedContent: ICCBOR.Value? + URLProtocolStub.handler = { request in + let content = try requestContent(request) + lock.withLock { submittedContent = content } + return response(request, status: 202, body: Data()) + } + let submission = try await configuredClient.submitSigned(decodedUpdate) + XCTAssertEqual(submission.requestID, update.requestID) + XCTAssertEqual(lock.withLock { submittedContent }, updateContent) + + let query = try configuredClient.signQuery( + method: "persisted_query", + identity: identity, + options: options + ) + let decodedQuery = try JSONDecoder().decode( + ICSignedQuery.self, + from: JSONEncoder().encode(query) + ) + XCTAssertEqual(decodedQuery, query) + URLProtocolStub.handler = { request in + XCTAssertEqual(try requestContent(request), try XCTUnwrap(ICCBOR.mapValue( + try ICCBOR.decodeStrict(query.envelope), key: "content" + ))) + return response(request, status: 200, body: queryResponse(arg: Data("reply".utf8), signatures: [])) + } + let queryReply = try await configuredClient.unsafeQuerySigned(decodedQuery) + XCTAssertEqual(queryReply, Data("reply".utf8)) + } + + func testSignedRequestRejectsInvalidOptionsExpiryAndPersistedTampering() async throws { + let config = try configuration(root: BLSTKey(seed: 42).derPublicKey) + let identity = try makeAuthSession(config: config) + let configuredClient = client(config) + + XCTAssertThrowsError(try configuredClient.signUpdate( + method: "expired", + identity: identity, + options: ICRequestOptions(ingressExpiry: Date().addingTimeInterval(-1)) + )) + XCTAssertThrowsError(try configuredClient.signUpdate( + method: "too-far", + identity: identity, + options: ICRequestOptions(ingressExpiry: Date().addingTimeInterval(301)) + )) + XCTAssertThrowsError(try configuredClient.signQuery( + method: "empty-nonce", + identity: identity, + options: ICRequestOptions(nonce: Data()) + )) + XCTAssertThrowsError(try configuredClient.signQuery( + method: "large-nonce", + identity: identity, + options: ICRequestOptions(nonce: Data(count: 33)) + )) + let shortSession = try ICAuthSession.delegating( + ed25519PrivateKey: Curve25519.Signing.PrivateKey().rawRepresentation, + configuration: config, + options: ICAuthenticationOptions(maxTimeToLiveNanoseconds: 60_000_000_000) + ) + XCTAssertThrowsError(try configuredClient.signUpdate( + method: "past-delegation", + identity: shortSession, + options: ICRequestOptions(ingressExpiry: Date().addingTimeInterval(120)) + )) + + let valid = try configuredClient.signUpdate( + method: "valid", + identity: identity, + options: ICRequestOptions(ingressExpiry: Date().addingTimeInterval(60)) + ) + let tampered = ICSignedUpdate( + requestID: Data(count: 32), + canisterId: valid.canisterId, + effectiveCanisterId: valid.effectiveCanisterId, + method: valid.method, + ingressExpiry: valid.ingressExpiry, + envelope: valid.envelope + ) + let lock = NSLock() + var requests = 0 + URLProtocolStub.handler = { request in + lock.withLock { requests += 1 } + return response(request, status: 202, body: Data()) + } + await XCTAssertThrowsErrorAsync(try await configuredClient.submitSigned(tampered)) + XCTAssertEqual(lock.withLock { requests }, 0) + } + + func testPersistedSignedUpdateRejectsEnvelopeSignatureAndMetadataTamperingBeforeTransport() async throws { + let config = try configuration(root: BLSTKey(seed: 45).derPublicKey) + let identity = try makeAuthSession(config: config) + let configuredClient = client(config) + let valid = try configuredClient.signUpdate( + method: "tamper", + arg: Data("original".utf8), + identity: identity, + options: ICRequestOptions(ingressExpiry: Date().addingTimeInterval(120)) + ) + let lock = NSLock() + var requests = 0 + URLProtocolStub.handler = { request in + lock.withLock { requests += 1 } + return response(request, status: 202, body: Data()) + } + + let senderSignatureEnvelope = try replacingEnvelopeField(valid.envelope, key: "sender_sig") { value in + guard case .bytes(var signature) = value, !signature.isEmpty else { + throw ICClientError.invalidPayload + } + signature[0] ^= 1 + return .bytes(signature) + } + await XCTAssertThrowsErrorAsync(try await configuredClient.submitSigned(ICSignedUpdate( + requestID: valid.requestID, + canisterId: valid.canisterId, + effectiveCanisterId: valid.effectiveCanisterId, + method: valid.method, + ingressExpiry: valid.ingressExpiry, + envelope: senderSignatureEnvelope + ))) { error in + guard case .invalidIdentity? = error as? ICClientError else { + return XCTFail("expected invalid sender signature, received \(error)") + } + } + + let delegationSignatureEnvelope = try replacingEnvelopeField(valid.envelope, key: "sender_delegation") { value in + guard case .array(var delegations) = value, + case .map(var fields) = delegations.first, + let signatureIndex = fields.firstIndex(where: { $0.0 == .text("signature") }), + case .bytes(var signature) = fields[signatureIndex].1, + !signature.isEmpty else { + throw ICClientError.invalidPayload + } + signature[0] ^= 1 + fields[signatureIndex].1 = .bytes(signature) + delegations[0] = .map(fields) + return .array(delegations) + } + await XCTAssertThrowsErrorAsync(try await configuredClient.submitSigned(ICSignedUpdate( + requestID: valid.requestID, + canisterId: valid.canisterId, + effectiveCanisterId: valid.effectiveCanisterId, + method: valid.method, + ingressExpiry: valid.ingressExpiry, + envelope: delegationSignatureEnvelope + ))) { error in + guard case .invalidIdentity? = error as? ICClientError else { + return XCTFail("expected invalid delegation signature, received \(error)") + } + } + + let contentEnvelope = try replacingEnvelopeField(valid.envelope, key: "content") { content in + try replacingMapField(content, key: "arg", with: .bytes(Data("changed".utf8))) + } + let decodedContentEnvelope = try ICCBOR.decodeStrict(contentEnvelope) + let changedContent = try XCTUnwrap(ICCBOR.mapValue(decodedContentEnvelope, key: "content")) + await XCTAssertThrowsErrorAsync(try await configuredClient.submitSigned(ICSignedUpdate( + requestID: ICRequestID.hash(of: changedContent), + canisterId: valid.canisterId, + effectiveCanisterId: valid.effectiveCanisterId, + method: valid.method, + ingressExpiry: valid.ingressExpiry, + envelope: contentEnvelope + ))) { error in + guard case .invalidIdentity? = error as? ICClientError else { + return XCTFail("expected content signature mismatch, received \(error)") + } + } + + await XCTAssertThrowsErrorAsync(try await configuredClient.submitSigned(ICSignedUpdate( + requestID: valid.requestID, + canisterId: valid.canisterId, + effectiveCanisterId: valid.effectiveCanisterId, + method: valid.method, + ingressExpiry: valid.ingressExpiry.addingTimeInterval(1), + envelope: valid.envelope + ))) { error in + guard case .invalidIdentity? = error as? ICClientError else { + return XCTFail("expected expiry metadata mismatch, received \(error)") + } + } + XCTAssertEqual(lock.withLock { requests }, 0) + } + + func testPersistedSignedUpdateRejectsDelegationTargetAndPermissionExpansionBeforeTransport() async throws { + let config = try configuration(root: BLSTKey(seed: 46).derPublicKey) + let configuredClient = client(config) + let otherTarget = try XCTUnwrap(ICPrincipal.parse("aaaaa-aa")) + let targetMismatch = try signedUpdateForValidation( + config: config, + targets: [otherTarget], + permissions: .all + ) + let queryOnly = try signedUpdateForValidation( + config: config, + targets: [try XCTUnwrap(ICPrincipal.parse(canisterText))], + permissions: .queries + ) + let lock = NSLock() + var requests = 0 + URLProtocolStub.handler = { request in + lock.withLock { requests += 1 } + return response(request, status: 202, body: Data()) + } + + await XCTAssertThrowsErrorAsync(try await configuredClient.submitSigned(targetMismatch)) { error in + XCTAssertEqual( + error as? ICClientError, + .invalidIdentity("Signed request exceeds its delegation targets.") + ) + } + await XCTAssertThrowsErrorAsync(try await configuredClient.submitSigned(queryOnly)) { error in + XCTAssertEqual( + error as? ICClientError, + .invalidIdentity("Signed request exceeds its delegation permissions.") + ) + } + XCTAssertEqual(lock.withLock { requests }, 0) + } + + func testRequestStatusReturnsDistinctCertifiedStatesAndUsesRetainedResult() async throws { + let root = BLSTKey(seed: 43) + let config = try configuration(root: root.derPublicKey) + let identity = try makeAuthSession(config: config) + let requestID = Data(repeating: 0x43, count: 32) + let lock = NSLock() + var status = "received" + var requests = 0 + URLProtocolStub.handler = { request in + lock.withLock { requests += 1 } + let base = [Data("request_status".utf8), requestID] + let certificate = try self.makeCertificate(leaves: [ + ([Data("time".utf8)], ICRequestID.leb128(self.nanoseconds(Date()))), + (base + [Data("status".utf8)], Data(lock.withLock { status }.utf8)), + ], key: root) + return response(request, status: 200, body: readStateResponse(certificate)) + } + let configuredClient = client(config) + let received = try await configuredClient.requestStatus(requestID: requestID, identity: identity) + XCTAssertEqual(received, .received) + lock.withLock { status = "processing" } + let processing = try await configuredClient.requestStatus(requestID: requestID, identity: identity) + XCTAssertEqual(processing, .processing) + + let retained = ICUpdateSubmission( + requestID: requestID, + effectiveCanisterId: canisterText, + initialStatus: .replied(Data("cached".utf8)), + sender: ICPrincipal.selfAuthenticatingPublicKey(identity.delegation.publicKey) + ) + let cached = try await configuredClient.requestStatus(for: retained, identity: identity) + XCTAssertEqual(cached, .replied(Data("cached".utf8))) + XCTAssertEqual(lock.withLock { requests }, 2) + await XCTAssertThrowsErrorAsync( + try await configuredClient.requestStatus(requestID: Data(count: 31), identity: identity) + ) + } + func testResponseLimitStopsBeforeBodyAcceptance() async throws { let root = BLSTKey(seed: 14) let config = try ICClientConfiguration( @@ -1458,6 +2020,89 @@ final class ICNativeClientTests: XCTestCase { XCTAssertThrowsError(try parseICRC(try icrcCallback(pending: target, targets: ["2vxsx-fae"]), pending: target, config: config)) } + private func replacingEnvelopeField( + _ envelope: Data, + key: String, + transform: (ICCBOR.Value) throws -> ICCBOR.Value + ) throws -> Data { + let decoded = try ICCBOR.decodeStrict(envelope) + guard case .tagged(let tag, .map(var fields)) = decoded, + let index = fields.firstIndex(where: { $0.0 == .text(key) }) else { + throw ICClientError.invalidPayload + } + fields[index].1 = try transform(fields[index].1) + return ICCBOR.encode(.tagged(tag, .map(fields))) + } + + private func replacingMapField( + _ value: ICCBOR.Value, + key: String, + with replacement: ICCBOR.Value + ) throws -> ICCBOR.Value { + guard case .map(var fields) = value, + let index = fields.firstIndex(where: { $0.0 == .text(key) }) else { + throw ICClientError.invalidPayload + } + fields[index].1 = replacement + return .map(fields) + } + + private func signedUpdateForValidation( + config: ICClientConfiguration, + targets: [Data]?, + permissions: ICDelegationPermission? + ) throws -> ICSignedUpdate { + let root = Curve25519.Signing.PrivateKey() + let sessionKey = Curve25519.Signing.PrivateKey() + let rootDER = ICRC167Codec.derPublicKey(from: root.publicKey.rawRepresentation) + let sessionDER = ICRC167Codec.derPublicKey(from: sessionKey.publicKey.rawRepresentation) + let requestedAt = Date() + let delegationExpiration = nanoseconds(requestedAt.addingTimeInterval(180)) + let ingressExpiration = nanoseconds(requestedAt.addingTimeInterval(60)) + let delegation = ICDelegationChain.SignedDelegation.Delegation( + publicKey: sessionDER, + expiration: delegationExpiration, + targets: targets, + permissions: permissions + ) + let chain = ICDelegationChain( + publicKey: rootDER, + delegations: [.init( + delegation: delegation, + signature: try root.signature(for: delegationSignable(delegation)) + )] + ) + let session = ICAuthSession(storage: ICStoredAuthSession( + formatVersion: ICAuthSession.currentFormatVersion, + principal: ICPrincipal.text(from: ICPrincipal.selfAuthenticatingPublicKey(rootDER)), + canisterId: config.canisterId, + internetIdentityURL: config.internetIdentityURL.absoluteString, + derivationOrigin: config.derivationOrigin, + sessionPublicKey: sessionDER, + sessionPrivateKey: sessionKey.rawRepresentation, + delegation: chain, + requestedAt: requestedAt, + maxTimeToLiveNanoseconds: config.delegationTTLNanoseconds + )) + let canister = try XCTUnwrap(ICPrincipal.parse(config.canisterId)) + let content: ICCBOR.Value = .map([ + (.text("request_type"), .text("call")), + (.text("canister_id"), .bytes(canister)), + (.text("method_name"), .text("scope-check")), + (.text("arg"), .bytes(Data())), + (.text("sender"), .bytes(ICPrincipal.selfAuthenticatingPublicKey(rootDER))), + (.text("ingress_expiry"), .unsigned(ingressExpiration)), + ]) + return ICSignedUpdate( + requestID: ICRequestID.hash(of: content), + canisterId: config.canisterId, + effectiveCanisterId: config.canisterId, + method: "scope-check", + ingressExpiry: Date(timeIntervalSince1970: Double(ingressExpiration) / 1_000_000_000), + envelope: try ICClient.signedEnvelope(content: content, identity: session) + ) + } + // MARK: Helpers private func configuration(root: Data) throws -> ICClientConfiguration { From 9b9b8ab423f68d6cd52eb00edfc7934ad4079ccb Mon Sep 17 00:00:00 2001 From: hude Date: Wed, 16 Sep 2026 09:08:00 +0900 Subject: [PATCH 2/2] docs: prepare ICNativeClient 0.8.0 release --- CHANGELOG.md | 2 +- README.md | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3159a9..5986f65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes are documented here. -## [Unreleased] +## [0.8.0] - 2026-09-16 ### Added diff --git a/README.md b/README.md index 803e214..7593574 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ICNativeClient -ICNativeClient is a Swift package for calling Internet Computer canisters from native Apple applications. Version 0.7.8 improves Candid processing and binding generation while preserving public APIs and wire formats. +ICNativeClient is a Swift package for calling Internet Computer canisters from native Apple applications. Version 0.8.0 adds constrained child delegations, update request IDs, certified request-status checks, per-request ingress options, and persistable signed requests. It includes principal/account helpers, a Candid DIDL codec, explicit Swift model conversion, and raw Candid-byte transport. @@ -400,6 +400,10 @@ let sharedStore = ICIdentityStore( Every participating target must include that access group in its Keychain Sharing entitlement. ICNativeClient does not migrate items between access groups or from application-specific storage formats. If the shared item is initially absent, authenticate and save the session from the main application before an extension attempts to load it. An invalid access group or missing entitlement is reported as `ICClientError.keychainFailure`. +## New in 0.8.0 + +0.8.0 adds `ICAuthSession.childDelegation(for:options:)` for issuing constrained child delegations without exposing session private keys. Update calls can be split into submit and complete phases so applications retain the 32-byte ingress request ID before polling, and certified single-shot status APIs distinguish received, processing, replied, rejected, and done states. Raw, Candid, and typed requests accept per-request ingress expiry and nonce options, while signed query and update envelopes can be persisted and validated before transport. Existing query and call signatures, generated bindings, session storage, and default wire formats remain compatible. The bundled generator remains version 0.1.3. + ## New in 0.7.8 0.7.8 is a backward-compatible patch release. Candid encoding reuses validated values, record projection uses a linear field scan, and binding generation avoids repeated keyword-set allocation. Redundant tests and unused internals have been removed, while delegation limits, signature validation, and shared decoding budgets retain focused regression checks. Public APIs, accepted inputs, session storage, wire formats, and default limits are unchanged. The bundled generator remains version 0.1.3, with rebuilt arm64 and x86_64 binaries and unchanged generated Swift output.