diff --git a/.changeset/chat-product-authority.md b/.changeset/chat-product-authority.md new file mode 100644 index 000000000..3071334ac --- /dev/null +++ b/.changeset/chat-product-authority.md @@ -0,0 +1,9 @@ +--- +"@parity/truapi-host": minor +--- + +Add product-scoped Chat v2 authority with dedicated Chat authorization, separate from username disclosure. Apply the +boundary to local and SSO sessions, expose it in the iOS permission flow, and avoid cloning secret-bearing pairing +results. + +Support keyless Statement Store allowances for a selected product account in the native signing host. diff --git a/CHANGELOG.md b/CHANGELOG.md index 02df9c378..ee5f513e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ generated from [Conventional Commits](https://www.conventionalcommits.org/). ### Added +- Add `account.productDeviceChat` for host-private Chat v2 identity binding and + identity-route sealing/opening through local or paired account authorities, + guarded by a dedicated, product-scoped Chat-authority permission. - expose local signing-wallet username registration and chain-verified identity refresh through the browser worker, with native UID proofs and RFC-0004 X25519 identifier keys @@ -29,6 +32,15 @@ generated from [Conventional Commits](https://www.conventionalcommits.org/). ### Fixed +- persist typed Statement Store allowance approvals and denials per product and + account selector for implicit, idempotent provisioning; explicit requests for + additional quota retain per-operation confirmation and increase semantics. + Stop unscoped product background renewal, including previously recorded + targets, so artifact-scoped revocation cannot be bypassed. +- require separate Chat-authority consent on the local product API as well as + SSO; existing username-disclosure grants do not authorize Chat operations, + and denial or revocation blocks subsequent binding, sealing, and opening (#709) +- move the secret-bearing SSO pairing result instead of cloning it (#709) - keep host-backed allowance helpers available on Wasm with browser-compatible polling clocks, while excluding the native-only renewal driver (#540) - report the immutable PolkaVM runtime revision actually pinned by the optional diff --git a/README.md b/README.md index f91f91941..4aa4c7a81 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,12 @@ requests after a bounded deadline; pass `requestTimeoutMs` to `createTransport` See [`js/packages/truapi/README.md`](js/packages/truapi/README.md) for the full client reference. +`account.productDeviceChat` binds a product-derived account to the connected +wallet's Chat v2 identity and seals or opens identity-route payloads without +exposing the wallet's X25519 private key. Browser pairing hosts forward the +operation over encrypted SSO; signing hosts require the calling product's +dedicated Chat-authority permission before using local wallet material. + ## Repository layout ``` diff --git a/docs/rfcs/0010-allowance.md b/docs/rfcs/0010-allowance.md index fe31710da..0392e4534 100644 --- a/docs/rfcs/0010-allowance.md +++ b/docs/rfcs/0010-allowance.md @@ -11,6 +11,24 @@ Products running on a Polkadot Host need to submit data to three allowance-gated systems — the Bulletin chain, the Statement Store, and Asset Hub smart contracts — each of which grants free-to-use resources to users but requires the signing origin to hold the appropriate allowance. This RFC defines how products obtain and use those allowances via TrUAPI without managing the underlying slot-table state themselves, by introducing a single pre-allocation call (`host_request_resource_allocation`) and a companion Accounts Protocol request used by the Host to negotiate private-key material with the Account Holder. +**SDK 0.16 implementation constraint (Statement Store).** Explicit +`ResourceAllocation.request` calls retain the additional-slot (`Increase`) +semantics below and require confirmation for every operation. Implicit +provisioning always uses `Ignore`: an existing current-period allowance is +reused, not scaled up when a product reopens. Durable +`StatementStoreAllowance { derivation_index }` authorization distinguishes the +legacy allowance account (`None`) from each product account (`Some(index)`). +An explicit approval can establish a missing durable grant in its single review; +cancelling a subsequent increase does not revoke that grant. Durable grants +authorize implicit provisioning, not unlimited additional funding. + +Product grant-derived background renewal is disabled, and old product ledger +entries are pruned: the signing host cannot resolve artifact-scoped permission +storage from a product id alone. Next-period provisioning happens on demand. +Wallet and paired-device background renewal remain independent. Revocation +stops subsequent provisioning, not already issued on-chain quota. Paired signing +hosts retain their separate confirmation boundary. + ## Motivation Three systems in the Polkadot ecosystem grant sponsored access via per-user quotas: diff --git a/hosts/ios/Packages/Products/Sources/Products/Model/AllocatableResource+Codable.swift b/hosts/ios/Packages/Products/Sources/Products/Model/AllocatableResource+Codable.swift index 24db62775..904f99d05 100644 --- a/hosts/ios/Packages/Products/Sources/Products/Model/AllocatableResource+Codable.swift +++ b/hosts/ios/Packages/Products/Sources/Products/Model/AllocatableResource+Codable.swift @@ -21,6 +21,9 @@ extension AllocatableResource: Decodable { self = .smartContractAllowance(dest: dest) case "AutoSigning": self = .autoSigning + case "ProductStatementStoreAllowance": + let dest = try container.decode(ProductAccountSelector.self, forKey: .dest) + self = .productStatementStoreAllowance(dest: dest) default: throw DecodingError.dataCorruptedError( forKey: .kind, @@ -56,7 +59,8 @@ extension AllocationOutcome: Encodable { case let .bulletInAllowance(privateKey): let account = SlotAccount(slotAccountKey: privateKey) try container.encode(account, forKey: .bulletInAllowance) - case .smartContractAllowance: + case .smartContractAllowance, + .productStatementStoreAllowance: break } case .rejected: diff --git a/hosts/ios/Packages/Products/Sources/Products/Model/AllocatableResource+ScaleCodable.swift b/hosts/ios/Packages/Products/Sources/Products/Model/AllocatableResource+ScaleCodable.swift index 5fa02d05f..26b77bfab 100644 --- a/hosts/ios/Packages/Products/Sources/Products/Model/AllocatableResource+ScaleCodable.swift +++ b/hosts/ios/Packages/Products/Sources/Products/Model/AllocatableResource+ScaleCodable.swift @@ -17,6 +17,9 @@ extension AllocatableResource: ScaleCodable { self = .smartContractAllowance(dest: dest) case 3: self = .autoSigning + case 4: + let dest = try ProductAccountSelector(scaleDecoder: scaleDecoder) + self = .productStatementStoreAllowance(dest: dest) default: throw ScaleCodingError.unexpectedDecodedValue } @@ -30,7 +33,8 @@ extension AllocatableResource: ScaleCodable { .bulletInAllowance, .autoSigning: break - case let .smartContractAllowance(dest): + case let .smartContractAllowance(dest), + let .productStatementStoreAllowance(dest): try dest.encode(scaleEncoder: scaleEncoder) } } @@ -41,6 +45,7 @@ extension AllocatableResource: ScaleCodable { case .bulletInAllowance: 1 case .smartContractAllowance: 2 case .autoSigning: 3 + case .productStatementStoreAllowance: 4 } } } @@ -103,6 +108,8 @@ extension AllocatedResource: ScaleCodable { case 3: let secrets = try AutoSigningSecrets(scaleDecoder: scaleDecoder) self = .autoSigning(secrets) + case 4: + self = .productStatementStoreAllowance default: throw ScaleCodingError.unexpectedDecodedValue } @@ -116,7 +123,8 @@ extension AllocatedResource: ScaleCodable { try key.encode(scaleEncoder: scaleEncoder) case let .bulletInAllowance(key): try key.encode(scaleEncoder: scaleEncoder) - case .smartContractAllowance: + case .smartContractAllowance, + .productStatementStoreAllowance: break case let .autoSigning(secrets): try secrets.encode(scaleEncoder: scaleEncoder) @@ -129,6 +137,7 @@ extension AllocatedResource: ScaleCodable { case .bulletInAllowance: 1 case .smartContractAllowance: 2 case .autoSigning: 3 + case .productStatementStoreAllowance: 4 } } } diff --git a/hosts/ios/Packages/Products/Sources/Products/Model/AllocatableResource.swift b/hosts/ios/Packages/Products/Sources/Products/Model/AllocatableResource.swift index 2a5908c6c..4c34772c8 100644 --- a/hosts/ios/Packages/Products/Sources/Products/Model/AllocatableResource.swift +++ b/hosts/ios/Packages/Products/Sources/Products/Model/AllocatableResource.swift @@ -6,6 +6,7 @@ public enum AllocatableResource: Equatable { case bulletInAllowance case smartContractAllowance(dest: ProductAccountSelector) case autoSigning + case productStatementStoreAllowance(dest: ProductAccountSelector) } public enum AllocationOutcome: Equatable { @@ -19,6 +20,7 @@ public enum AllocatedResource: Equatable { case statementStoreAllowance(privateKey: Data) case bulletInAllowance(privateKey: Data) case smartContractAllowance + case productStatementStoreAllowance } public enum AutoSigningSecretsError: Error, Equatable { diff --git a/hosts/ios/Packages/Products/Sources/Products/Permissions/Guard/ProductPermissionGuard.swift b/hosts/ios/Packages/Products/Sources/Products/Permissions/Guard/ProductPermissionGuard.swift index 7d91d1d80..2aafdad58 100644 --- a/hosts/ios/Packages/Products/Sources/Products/Permissions/Guard/ProductPermissionGuard.swift +++ b/hosts/ios/Packages/Products/Sources/Products/Permissions/Guard/ProductPermissionGuard.swift @@ -67,7 +67,8 @@ public final class ProductPermissionGuard: ProductPermissionGuarding, @unchecked .chainSubmitAccess, .preimageSubmitAccess, .statementSubmitAccess, - .userIdentityAccess: + .userIdentityAccess, + .chatAuthority: try await remoteHandler.request(productId: productId, permission: permission) } } @@ -148,7 +149,8 @@ public final class ProductPermissionGuard: ProductPermissionGuarding, @unchecked .chainSubmitAccess, .preimageSubmitAccess, .statementSubmitAccess, - .userIdentityAccess: + .userIdentityAccess, + .chatAuthority: try await remoteHandler.isGranted(productId: productId, permission: permission) } } diff --git a/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/ProductPermission.swift b/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/ProductPermission.swift index 59d47e04a..0c38bf7a5 100644 --- a/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/ProductPermission.swift +++ b/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/ProductPermission.swift @@ -13,6 +13,7 @@ public enum ProductPermission: Equatable, Sendable { public static let balanceAccessTypeName = "balance_access" public static let statementSubmitAccessTypeName = "statement_submit" public static let userIdentityAccessTypeName = "user_identity_access" + public static let chatAuthorityTypeName = "chat_authority" case deviceCapability(DeviceCapabilityType) case networkAccess(domain: String) @@ -23,6 +24,7 @@ public enum ProductPermission: Equatable, Sendable { case preimageSubmitAccess case statementSubmitAccess case userIdentityAccess + case chatAuthority public var typeName: String { switch self { @@ -44,6 +46,8 @@ public enum ProductPermission: Equatable, Sendable { Self.statementSubmitAccessTypeName case .userIdentityAccess: Self.userIdentityAccessTypeName + case .chatAuthority: + Self.chatAuthorityTypeName } } @@ -60,7 +64,8 @@ public enum ProductPermission: Equatable, Sendable { .chainSubmitAccess, .preimageSubmitAccess, .statementSubmitAccess, - .userIdentityAccess: + .userIdentityAccess, + .chatAuthority: "" } } @@ -88,6 +93,8 @@ public enum ProductPermission: Equatable, Sendable { return .statementSubmitAccess case userIdentityAccessTypeName: return .userIdentityAccess + case chatAuthorityTypeName: + return .chatAuthority default: return nil } diff --git a/hosts/ios/Packages/Products/Sources/Products/Services/ProductsAccountManager.swift b/hosts/ios/Packages/Products/Sources/Products/Services/ProductsAccountManager.swift index 5758a064e..c2dd01edb 100644 --- a/hosts/ios/Packages/Products/Sources/Products/Services/ProductsAccountManager.swift +++ b/hosts/ios/Packages/Products/Sources/Products/Services/ProductsAccountManager.swift @@ -129,6 +129,16 @@ private extension ProductsAccountManager { ) let privateKey = try wallet.fetchRawSecretKey() return .allocated(.statementStoreAllowance(privateKey: privateKey)) + case let .productStatementStoreAllowance(dest): + let accountId = try accountHolder.deriveAccount( + ProductAccountId(productId: productId, derivationIndex: dest) + ) + try await allowanceSupport.sssManager.allocate( + accountId: accountId, + policy: policy, + priority: .normal + ) + return .allocated(.productStatementStoreAllowance) case .bulletInAllowance: let wallet = try accountHolder.deriveBulletInAccount(for: productId) let accountId = try wallet.getRawPublicKey() diff --git a/hosts/ios/Packages/Products/Tests/ProductsTests/ProductStatementStoreAllowanceTests.swift b/hosts/ios/Packages/Products/Tests/ProductsTests/ProductStatementStoreAllowanceTests.swift new file mode 100644 index 000000000..1b32ed46b --- /dev/null +++ b/hosts/ios/Packages/Products/Tests/ProductsTests/ProductStatementStoreAllowanceTests.swift @@ -0,0 +1,78 @@ +import Foundation +import Individuality +import KeyDerivation +import SubstrateSdk +import Testing +import UIKitExt +@testable import Products + +@MainActor +struct ProductStatementStoreAllowanceTests { + @Test + func selectedProductAccountIsFundedWithoutExportingItsKey() async throws { + let entropy = AllocationTestEntropy() + let prompt = AllocationTestPrompt() + let ledger = AllocationTestLedger() + let manager = ProductsAccountManager( + entropyManager: entropy, + allowanceSupport: AllowanceSupport( + allowancePromptRouter: prompt, + sssManager: ledger, + bulletInManager: ledger, + smartContractManager: ledger + ) + ) + // Rust AllocatableResource::ProductStatementStoreAllowance(Index(7)). + let request = try AllocatableResource.fromScaleEncoded(Data([4, 0, 7, 0, 0, 0])) + let expectedAccount = try ProductAccountHolder(entropyManager: entropy).deriveAccount( + ProductAccountId(productId: "chat.dot", derivationIndex: .index(7)) + ) + + let outcomes = try await manager.requestResourceAllocation( + for: "chat.dot", resources: [request], policy: .increase + ) + #expect(await ledger.accounts == [expectedAccount]) + let outcome = try #require(outcomes.first) + // SSO Allocated(ProductStatementStoreAllowance), with no secret payload. + #expect(try outcome.scaleEncoded() == Data([0, 4])) + #expect(try JSONSerialization.jsonObject(with: JSONEncoder().encode(outcome)) as? [String: String] + == ["kind": "Allocated"]) + + prompt.decision = .rejected + let denied = try await manager.requestResourceAllocation( + for: "chat.dot", resources: [request], policy: .increase + ) + #expect(denied == [.rejected]) + #expect(await ledger.accounts == [expectedAccount]) + } +} + +private struct AllocationTestEntropy: RootEntropyManaging { + func fetchRootEntropy() throws -> Data { Data(repeating: 0xAB, count: 16) } + func createRootEntropy(_: Data) throws {} + func hasRootEntropy() throws -> Bool { true } +} + +private actor AllocationTestLedger: AllowanceManaging { + private(set) var accounts: [AccountId] = [] + + func allocate( + accountId: AccountId, + policy _: OnExistingAllowancePolicy, + priority _: AllowanceRecord.Priority + ) async throws { + accounts.append(accountId) + } +} + +@MainActor +private final class AllocationTestPrompt: AllowancePromptRouting { + var decision: AllowancePromptDecision = .approved + var isReady: Bool { true } + + func setPresentationView(_: ControllerBackedProtocol) {} + func present(view _: ControllerBackedProtocol) -> Bool { false } + func showAllowancePrompt(context: AllowancePromptContext) { + context.deliver(decision) + } +} diff --git a/hosts/ios/polkadot-app/Localization/Products.xcstrings b/hosts/ios/polkadot-app/Localization/Products.xcstrings index 5239e4677..0c7baf1f0 100644 --- a/hosts/ios/polkadot-app/Localization/Products.xcstrings +++ b/hosts/ios/polkadot-app/Localization/Products.xcstrings @@ -341,6 +341,23 @@ } } }, + "app.permission.chatAuthority.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat identity authority" + } + }, + "es-ES": { + "stringUnit": { + "state": "translated", + "value": "Autoridad de identidad de Chat" + } + } + } + }, "app.permission.userIdentity.title": { "extractionState": "manual", "localizations": { @@ -647,6 +664,23 @@ } } }, + "permission.body.chatAuthority": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allows this product to bind its device account to your wallet Chat identity and encrypt or decrypt Chat routing data." + } + }, + "es-ES": { + "stringUnit": { + "state": "translated", + "value": "Permite que este producto vincule su cuenta de dispositivo a la identidad de Chat de tu cartera y cifre o descifre los datos de enrutamiento de Chat." + } + } + } + }, "permission.body.userIdentityAccess": { "extractionState": "manual", "localizations": { @@ -1004,6 +1038,23 @@ } } }, + "permission.title.chatAuthority": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$(productId)@ would like to use your Chat identity authority" + } + }, + "es-ES": { + "stringUnit": { + "state": "translated", + "value": "%1$(productId)@ quiere usar tu autoridad de identidad de Chat" + } + } + } + }, "permission.title.deviceCapability": { "extractionState": "manual", "localizations": { diff --git a/hosts/ios/polkadot-app/Modules/Products/AllowancePrompt/AllowancePromptViewFactory.swift b/hosts/ios/polkadot-app/Modules/Products/AllowancePrompt/AllowancePromptViewFactory.swift index c8d454d6b..0e24ff7a3 100644 --- a/hosts/ios/polkadot-app/Modules/Products/AllowancePrompt/AllowancePromptViewFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/AllowancePrompt/AllowancePromptViewFactory.swift @@ -52,7 +52,8 @@ private extension AllowancePromptViewFactory { static func resourceDescription(for resource: AllocatableResource) -> String { switch resource { - case .statementStoreAllowance: + case .statementStoreAllowance, + .productStatementStoreAllowance: String(localized: .Products.allowanceResourceStatementStore) case .bulletInAllowance: String(localized: .Products.allowanceResourceBulletIn) diff --git a/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift b/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift index 78451b066..c066e4a50 100644 --- a/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift @@ -146,6 +146,12 @@ private extension ProductPermissionPromptViewFactory { body: String(localized: .Products.permissionBodyUserIdentityAccess), icon: makeIcon(systemName: "person.text.rectangle") ) + case .chatAuthority: + PromptContent( + title: String(localized: .Products.permissionTitleChatAuthority(productId: productId)), + body: String(localized: .Products.permissionBodyChatAuthority), + icon: makeIcon(systemName: "message.badge.shield") + ) } } @@ -188,6 +194,8 @@ private extension ProductPermissionPromptViewFactory { ) case .userIdentityAccess: "- " + String(localized: .Products.permissionBodyUserIdentityAccess) + case .chatAuthority: + "- " + String(localized: .Products.permissionBodyChatAuthority) } } diff --git a/hosts/ios/polkadot-app/Modules/Products/ProductsNativeApi+Resource.swift b/hosts/ios/polkadot-app/Modules/Products/ProductsNativeApi+Resource.swift index 91a96ae7e..958fa1087 100644 --- a/hosts/ios/polkadot-app/Modules/Products/ProductsNativeApi+Resource.swift +++ b/hosts/ios/polkadot-app/Modules/Products/ProductsNativeApi+Resource.swift @@ -54,7 +54,8 @@ private extension ProductsNativeApi { kind: .bulletIn ) case .autoSigning, - .smartContractAllowance: + .smartContractAllowance, + .productStatementStoreAllowance: break } } diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/TrUAPIHostAccount+Conversions.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/TrUAPIHostAccount+Conversions.swift index bbee8ce72..2bb9c20cc 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/TrUAPIHostAccount+Conversions.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/TrUAPIHostAccount+Conversions.swift @@ -6,6 +6,7 @@ import TrUAPIHost enum TrUAPIReviewMappingError: Error, Equatable { case invalidDerivationIndexLength(Int) case notASigningReview + case notAPermissionReview } // MARK: - TrUAPIHost account conversions diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/TrUAPIReviewPromptMapper.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/TrUAPIReviewPromptMapper.swift index 7f021a447..140f7033a 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/TrUAPIReviewPromptMapper.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/TrUAPIReviewPromptMapper.swift @@ -20,6 +20,7 @@ struct TrUAPIAllowanceRequest: Equatable { /// and the statement-sign prompt. protocol TrUAPIReviewPromptMapping: Sendable { func makePermissionRequest(from review: IdentityDisclosureReview) -> TrUAPIPermissionRequest + func makePermissionRequest(from review: ChatAuthorityReview) -> TrUAPIPermissionRequest func makePermissionRequest(from review: PreimageSubmitReview) -> TrUAPIPermissionRequest func makePermissionRequest(from review: AccountAccessReview) -> TrUAPIPermissionRequest func makePermissionRequest(from review: ProductSubtreeReview) -> TrUAPIPermissionRequest @@ -40,6 +41,13 @@ struct TrUAPIReviewPromptMapper: TrUAPIReviewPromptMapping { ) } + func makePermissionRequest(from review: ChatAuthorityReview) -> TrUAPIPermissionRequest { + TrUAPIPermissionRequest( + productId: review.productId, + permissions: [.chatAuthority] + ) + } + /// `PreimageSubmitReview` carries no product identity: the submit is /// host-mediated, so the prompt is raised without a product scope. func makePermissionRequest(from _: PreimageSubmitReview) -> TrUAPIPermissionRequest { @@ -126,6 +134,8 @@ private extension TrUAPIReviewPromptMapper { try .smartContractAllowance(dest: index.toSelector()) case .autoSigning: .autoSigning + case let .productStatementStoreAllowance(index): + try .productStatementStoreAllowance(dest: index.toSelector()) } } diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIConfirmationPresenter.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIConfirmationPresenter.swift index 40d3fc8a9..f40eee949 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIConfirmationPresenter.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIConfirmationPresenter.swift @@ -52,26 +52,13 @@ private extension TrUAPIConfirmationPresenter { await confirmStatementSign( promptMapper.makeStatementSignRequest(from: statementReview) ) - case let .identityDisclosure(identityReview): - await confirmPermission( - promptMapper.makePermissionRequest(from: identityReview) - ) - case let .preimageSubmit(preimageReview): - await confirmPermission( - promptMapper.makePermissionRequest(from: preimageReview) - ) - case let .accountAccess(accessReview): - await confirmPermission( - promptMapper.makePermissionRequest(from: accessReview) - ) - case let .productSubtree(subtreeReview): - await confirmPermission( - promptMapper.makePermissionRequest(from: subtreeReview) - ) - case let .accountAlias(aliasReview): - await confirmPermission( - promptMapper.makePermissionRequest(from: aliasReview) - ) + case .identityDisclosure, + .chatAuthority, + .preimageSubmit, + .accountAccess, + .productSubtree, + .accountAlias: + try await confirmPermissionReview(review) case let .createProof(proofReview): try await confirmCreateProof( promptMapper.makeCreateProofRequest(from: proofReview) @@ -122,6 +109,27 @@ private extension TrUAPIConfirmationPresenter { } } + func confirmPermissionReview(_ review: UserConfirmationReview) async throws -> Bool { + let request: TrUAPIPermissionRequest + switch review { + case let .identityDisclosure(value): + request = promptMapper.makePermissionRequest(from: value) + case let .chatAuthority(value): + request = promptMapper.makePermissionRequest(from: value) + case let .preimageSubmit(value): + request = promptMapper.makePermissionRequest(from: value) + case let .accountAccess(value): + request = promptMapper.makePermissionRequest(from: value) + case let .productSubtree(value): + request = promptMapper.makePermissionRequest(from: value) + case let .accountAlias(value): + request = promptMapper.makePermissionRequest(from: value) + default: + throw TrUAPIReviewMappingError.notAPermissionReview + } + return await confirmPermission(request) + } + func confirmPermission(_ request: TrUAPIPermissionRequest) async -> Bool { await awaitDecision { [routerFacade] in let decision: PermissionDecision = await withCheckedContinuation { continuation in diff --git a/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift b/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift index f0f736b4c..8eee2167c 100644 --- a/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift @@ -82,6 +82,11 @@ private extension AppPermissionsViewModelFactory { String(localized: .Products.appPermissionUserIdentityTitle), String(localized: .Products.permissionBodyUserIdentityAccess) ) + case .chatAuthority: + ( + String(localized: .Products.appPermissionChatAuthorityTitle), + String(localized: .Products.permissionBodyChatAuthority) + ) } } diff --git a/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIReviewPromptMapperTests.swift b/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIReviewPromptMapperTests.swift index 554ec617a..d6010305f 100644 --- a/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIReviewPromptMapperTests.swift +++ b/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIReviewPromptMapperTests.swift @@ -21,6 +21,18 @@ struct TrUAPIReviewPromptMapperTests { )) } + @Test + func mapsChatAuthorityToDedicatedPermission() { + let request = mapper.makePermissionRequest( + from: ChatAuthorityReview(productId: "chat.dot") + ) + + #expect(request == TrUAPIPermissionRequest( + productId: "chat.dot", + permissions: [.chatAuthority] + )) + } + @Test func mapsPreimageSubmitToHostProductPermission() { let request = mapper.makePermissionRequest(from: PreimageSubmitReview(size: 1_024)) @@ -109,7 +121,8 @@ struct TrUAPIReviewPromptMapperTests { .statementStoreAllowance, .bulletinAllowance, .smartContractAllowance(.index(4)), - .autoSigning + .autoSigning, + .productStatementStoreAllowance(.index(7)) ] )) @@ -119,7 +132,8 @@ struct TrUAPIReviewPromptMapperTests { .statementStoreAllowance, .bulletInAllowance, .smartContractAllowance(dest: .index(4)), - .autoSigning + .autoSigning, + .productStatementStoreAllowance(dest: .index(7)) ] )) } diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index 33d99f942..f1ac18505 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "e883e2c0b9857933"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "2a2713140f9fb3e1"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; @@ -223,6 +223,33 @@ impl RequestMethod for AccountRingVrfSign { const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; } +/// `account_product_device_chat` method marker. +pub struct AccountProductDeviceChat; +impl AccountProductDeviceChat { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Account", + method: "product_device_chat", + wire_name: "account_product_device_chat", + request_type: "truapi::versioned::account::HostProductDeviceChatRequest", + response_type: "truapi::versioned::account::HostProductDeviceChatResponse", + error_type: Some("truapi::versioned::account::HostProductDeviceChatError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 2, + method_id: 11, + }), + }; +} +impl RequestMethod for AccountProductDeviceChat { + type Request = truapi::versioned::account::HostProductDeviceChatRequest; + type Response = truapi::versioned::account::HostProductDeviceChatResponse; + type Error = truapi::versioned::account::HostProductDeviceChatError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + /// `account_get_legacy_accounts` method marker. pub struct AccountGetLegacyAccounts; impl AccountGetLegacyAccounts { @@ -2050,6 +2077,7 @@ pub const APP_METHODS: &[MethodDescriptor] = &[ AccountRegisterRingVrfKey::DESCRIPTOR, AccountListRingVrfKeys::DESCRIPTOR, AccountRingVrfSign::DESCRIPTOR, + AccountProductDeviceChat::DESCRIPTOR, AccountGetLegacyAccounts::DESCRIPTOR, AccountGetUserId::DESCRIPTOR, AccountRequestLogin::DESCRIPTOR, @@ -2122,6 +2150,7 @@ pub const WIDGET_METHODS: &[MethodDescriptor] = &[ AccountRegisterRingVrfKey::DESCRIPTOR, AccountListRingVrfKeys::DESCRIPTOR, AccountRingVrfSign::DESCRIPTOR, + AccountProductDeviceChat::DESCRIPTOR, AccountGetLegacyAccounts::DESCRIPTOR, AccountGetUserId::DESCRIPTOR, AccountRequestLogin::DESCRIPTOR, @@ -2194,6 +2223,7 @@ pub const WORKER_METHODS: &[MethodDescriptor] = &[ AccountRegisterRingVrfKey::DESCRIPTOR, AccountListRingVrfKeys::DESCRIPTOR, AccountRingVrfSign::DESCRIPTOR, + AccountProductDeviceChat::DESCRIPTOR, AccountGetLegacyAccounts::DESCRIPTOR, AccountGetUserId::DESCRIPTOR, AccountRequestLogin::DESCRIPTOR, diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 2b020998f..9137d8c50 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -10,6 +10,7 @@ import { AllocatableResource, Bytes32, ChainIdentifier, + DerivationIndex, HostAccountSignVrfRequest, HostDevicePermissionRequest, HostSignPayloadRequest, @@ -113,6 +114,16 @@ export type AuthState = */ | { tag: "Authenticating"; value?: undefined }; +/** + * Review shown before a product binds or uses wallet-held Chat identity authority. + */ +export interface ChatAuthorityReview { + /** + * Product requesting the Chat identity operation. + */ + productId: string; +} + /** * Core-owned host-private storage slots. Products never address these slots; * the host chooses the backing store for each slot. @@ -324,7 +335,18 @@ export type PermissionAuthorizationRequest = /** * Product-scoped permission to access another product's account context. */ - | { tag: "AccountAccess"; value: { targetProductId: string } }; + | { tag: "AccountAccess"; value: { targetProductId: string } } + /** + * Product-scoped permission to bind and use wallet-held Chat identity authority. + */ + | { tag: "ChatAuthority"; value?: undefined } + /** + * Product-scoped permission to ensure Statement Store quota, not increase it. + */ + | { + tag: "StatementStoreAllowance"; + value: { derivationIndex?: DerivationIndex }; + }; /** * Authorization status for a permission request. @@ -575,7 +597,11 @@ export type UserConfirmationReview = /** * Resolve a product's own account subtree over SSO. */ - | { tag: "ProductSubtree"; value: ProductSubtreeReview }; + | { tag: "ProductSubtree"; value: ProductSubtreeReview } + /** + * Allow a product to bind and use wallet-held Chat identity authority. + */ + | { tag: "ChatAuthority"; value: ChatAuthorityReview }; /** * Review shown before a product asks to access another product account. @@ -619,6 +645,14 @@ export const AuthState: S.Codec = S.lazy( }), ); +/** + * Review shown before a product binds or uses wallet-held Chat identity authority. + */ +export const ChatAuthorityReview: S.Codec = S.lazy( + (): S.Codec => + S.Struct({ productId: S.str }) as S.Codec, +); + /** * Core-owned host-private storage slots. Products never address these slots; * the host chooses the backing store for each slot. @@ -761,6 +795,10 @@ export const PermissionAuthorizationRequest: S.Codec, + ChatAuthority: S._void, + StatementStoreAllowance: S.Struct({ + derivationIndex: S.Option(DerivationIndex), + }) as S.Codec<{ derivationIndex?: DerivationIndex }>, }), ); @@ -928,6 +966,7 @@ export const UserConfirmationReview: S.Codec = S.lazy( AccountAccess: AccountAccessReview, SignVrf: SignVrfReview, ProductSubtree: ProductSubtreeReview, + ChatAuthority: ChatAuthorityReview, }), ); diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs index 4d8295ff7..143152ade 100644 --- a/rust/crates/truapi-host-cli/src/platform.rs +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -875,6 +875,13 @@ fn approval_summary(review: &UserConfirmationReview) -> (&'static str, String) { review.product_id ), ), + UserConfirmationReview::ChatAuthority(review) => ( + "use Chat identity authority", + format!( + "Product {} requested permission to bind its device account to your wallet Chat identity and encrypt or decrypt Chat routing data.", + review.product_id + ), + ), } } diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 0d45c5d41..162d556bb 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -31,7 +31,7 @@ uniffi::use_remote_type!(truapi::Bytes32); use truapi::Bytes32; use truapi::latest::{ AllocatableResource, ChainIdentifier, ChatAction, ChatActions, ChatCustomMessage, ChatFile, - ChatMedia, ChatMessageContent, ChatReaction, ChatRichText, GenericError, + ChatMedia, ChatMessageContent, ChatReaction, ChatRichText, DerivationIndex, GenericError, HostChatCreateRoomError, HostChatCreateRoomRequest, HostChatCreateRoomResponse, HostChatListSubscribeItem, HostChatPostMessageError, HostChatPostMessageRequest, HostChatPostMessageResponse, HostChatRegisterBotError, HostChatRegisterBotRequest, @@ -1051,6 +1051,15 @@ pub enum PermissionAuthorizationRequest { /// Product whose account context may be accessed. target_product_id: String, }, + /// Product-scoped permission to bind and use wallet-held Chat identity authority. + #[codec(index = 4)] + ChatAuthority, + /// Product-scoped permission to ensure Statement Store quota, not increase it. + #[codec(index = 5)] + StatementStoreAllowance { + /// `None` selects the legacy allowance account; `Some` selects a product account. + derivation_index: Option, + }, } /// Authorization status for a permission request. @@ -1441,6 +1450,25 @@ impl CoreStorageKey { }, } } + + /// Persisted authorization key for wallet-held Chat identity authority. + pub fn chat_authority_authorization(product_id: &str) -> Self { + Self::PermissionAuthorization { + product_id: product_id.to_string(), + request: PermissionAuthorizationRequest::ChatAuthority, + } + } + + /// Persisted authorization for one statement allowance account selector. + pub fn statement_store_allowance_authorization( + product_id: &str, + derivation_index: Option, + ) -> Self { + Self::PermissionAuthorization { + product_id: product_id.to_string(), + request: PermissionAuthorizationRequest::StatementStoreAllowance { derivation_index }, + } + } } /// Canonical storage form for one remote-access domain pattern. @@ -2357,6 +2385,8 @@ mod tests { let account_access = CoreStorageKey::account_access_authorization("product.dot", "target.dot"); let other_target = CoreStorageKey::account_access_authorization("product.dot", "other.dot"); + let chat_authority = CoreStorageKey::chat_authority_authorization("product.dot"); + let other_product_chat = CoreStorageKey::chat_authority_authorization("other.dot"); assert_ne!(camera, other_product); assert_ne!(camera, remote); @@ -2365,6 +2395,9 @@ mod tests { assert_ne!(identity, other_product_identity); assert_ne!(account_access, other_target); assert_ne!(account_access, camera); + assert_ne!(chat_authority, identity); + assert_ne!(chat_authority, account_access); + assert_ne!(chat_authority, other_product_chat); } #[test] @@ -2774,6 +2807,14 @@ pub struct IdentityDisclosureReview { pub product_id: String, } +/// Review shown before a product binds or uses wallet-held Chat identity authority. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct ChatAuthorityReview { + /// Product requesting the Chat identity operation. + pub product_id: String, +} + /// Review shown before a product resolves its own account subtree over SSO, /// when the value is not cached and the core must ask the Account Holder. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] @@ -2820,6 +2861,8 @@ pub enum UserConfirmationReview { SignVrf(SignVrfReview), /// Resolve a product's own account subtree over SSO. ProductSubtree(ProductSubtreeReview), + /// Allow a product to bind and use wallet-held Chat identity authority. + ChatAuthority(ChatAuthorityReview), } /// Local user confirmation UI for sensitive core-owned operations. diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index fda2963f3..3bf12209d 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -56,7 +56,7 @@ getrandom = { version = "0.2", features = ["js"] } p256 = { version = "0.13", default-features = false, features = ["ecdh"] } hkdf = "0.12" chacha20poly1305 = { version = "0.10", default-features = false, features = ["alloc"] } -x25519-dalek = { version = "2", default-features = false, features = ["static_secrets"] } +x25519-dalek = { version = "2", default-features = false, features = ["static_secrets", "zeroize"] } sha2 = "0.10" merlin = "3" parking_lot = "0.12" diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index a334612bd..5209eee1b 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -104,6 +104,31 @@ Remote permissions carry one exception. A product whose label is listed in surface revokes it. Device permissions, identity disclosure and account access always prompt. +Statement Store allocation uses the durable +`StatementStoreAllowance { derivation_index }` decision: `None` is the legacy +allowance account; `Some(index)` is that exact product-account selector. Both +approval and denial survive runtime restart through the same `CoreAdmin` +permission APIs. The product connection's storage is authoritative, including +any host-provided artifact namespace. A storage failure prevents provisioning. +Chat, identity disclosure, other resources, and ordinary signing retain their +separate authorization contracts. + +Implicit Statement Store provisioning ensures the current-period allowance +without adding slots and reuses an authorized durable decision without another +grant prompt. An explicit `ResourceAllocation.request` instead requests additional +quota (`Increase`) and always requires per-operation confirmation. Its initial +approval can establish a missing durable grant in that same review; cancelling +an increase never revokes a previously authorized grant. Denied grants, storage +failures, session changes, and changed administrative decisions block allocation. + +Revocation blocks subsequent provisioning but does not withdraw already issued +on-chain quota. Product grants no longer create background renewal promises: +the signing host's global storage cannot resolve an artifact-scoped decision. +Old product-derived renewal entries are pruned; wallet and paired-device renewal +remain enabled. Next-period provisioning happens on demand through the product's +scoped runtime. A remote signing host retains its independent per-operation +confirmation; the product grant does not silently authorize another host. + ```text Product app (product_id = "my-product") diff --git a/rust/crates/truapi-server/src/host_logic/permissions.rs b/rust/crates/truapi-server/src/host_logic/permissions.rs index f8cd35c62..b366166d5 100644 --- a/rust/crates/truapi-server/src/host_logic/permissions.rs +++ b/rust/crates/truapi-server/src/host_logic/permissions.rs @@ -7,9 +7,9 @@ //! The cache layer is shared but keys are typed so a device grant cannot //! authorize a remote operation by accident. Keys are also scoped by product id //! so one product's authorization never grants another product's request. -//! Identity disclosure is also represented as a product-scoped authorization, -//! but the prompt itself is handled by the account runtime because it uses the -//! richer user-confirmation surface rather than the device/remote callbacks. +//! Identity disclosure is also represented as a product-scoped authorization; +//! its richer user-confirmation prompt is coordinated here so local and remote +//! account-authority paths share one decision state machine. //! //! Domain grants (`RemotePermission::Remote`) are the one request that does not //! occupy a single slot. A product may ask for several domains at once, while @@ -33,7 +33,7 @@ //! authorized for every remote permission while nothing is stored, and never //! reaches the prompt callback. A stored decision still wins, so a denial //! written through the admin surface revokes the grant. Device permissions, -//! identity disclosure and account access are never covered. +//! identity disclosure, account access, and Chat authority are never covered. use parity_scale_codec::{Decode, Encode}; @@ -42,8 +42,9 @@ use truapi::latest::{ RemotePermissionRequest, RemotePermissionResponse, }; use truapi_platform::{ - CoreStorage, CoreStorageKey, DevicePermissionStatus, PermissionAuthorizationRequest, - PermissionAuthorizationStatus, PermissionStatusHost, Permissions, + ChatAuthorityReview, CoreStorage, CoreStorageKey, DevicePermissionStatus, + IdentityDisclosureReview, PermissionAuthorizationRequest, PermissionAuthorizationStatus, + PermissionStatusHost, Permissions, UserConfirmation, UserConfirmationReview, has_trusted_remote_permissions, remote_domain_candidates, }; @@ -297,6 +298,23 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a ) .await } + PermissionAuthorizationRequest::ChatAuthority => { + authorization_status( + self.storage, + CoreStorageKey::chat_authority_authorization(self.product_id), + ) + .await + } + PermissionAuthorizationRequest::StatementStoreAllowance { derivation_index } => { + authorization_status( + self.storage, + CoreStorageKey::statement_store_allowance_authorization( + self.product_id, + derivation_index.clone(), + ), + ) + .await + } } } @@ -354,10 +372,85 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a PermissionAuthorizationRequest::AccountAccess { target_product_id } => { CoreStorageKey::account_access_authorization(self.product_id, target_product_id) } + PermissionAuthorizationRequest::ChatAuthority => { + CoreStorageKey::chat_authority_authorization(self.product_id) + } + PermissionAuthorizationRequest::StatementStoreAllowance { derivation_index } => { + CoreStorageKey::statement_store_allowance_authorization( + self.product_id, + derivation_index.clone(), + ) + } }; set_authorization_status(self.storage, key, status).await } + /// Resolve the product's identity-disclosure grant, prompting once when no + /// durable user decision exists. + pub async fn check_or_prompt_identity_disclosure( + &self, + ) -> Result + where + P: UserConfirmation, + { + let request = PermissionAuthorizationRequest::IdentityDisclosure; + let cached = self.authorization_status(&request).await?; + if cached != PermissionAuthorizationStatus::NotDetermined { + return Ok(cached); + } + let confirmed = match self + .prompt + .confirm_user_action(UserConfirmationReview::IdentityDisclosure( + IdentityDisclosureReview { + product_id: self.product_id.to_string(), + }, + )) + .await + { + Ok(confirmed) => confirmed, + Err(_) => return Ok(PermissionAuthorizationStatus::NotDetermined), + }; + let status = if confirmed { + PermissionAuthorizationStatus::Authorized + } else { + PermissionAuthorizationStatus::Denied + }; + self.set_authorization_status(&request, status).await?; + Ok(status) + } + + /// Resolve the product's Chat authority grant, prompting once when no + /// durable user decision exists. + pub async fn check_or_prompt_chat_authority( + &self, + ) -> Result + where + P: UserConfirmation, + { + let request = PermissionAuthorizationRequest::ChatAuthority; + let cached = self.authorization_status(&request).await?; + if cached != PermissionAuthorizationStatus::NotDetermined { + return Ok(cached); + } + let confirmed = match self + .prompt + .confirm_user_action(UserConfirmationReview::ChatAuthority(ChatAuthorityReview { + product_id: self.product_id.to_string(), + })) + .await + { + Ok(confirmed) => confirmed, + Err(_) => return Ok(PermissionAuthorizationStatus::NotDetermined), + }; + let status = if confirmed { + PermissionAuthorizationStatus::Authorized + } else { + PermissionAuthorizationStatus::Denied + }; + self.set_authorization_status(&request, status).await?; + Ok(status) + } + /// Resolves a device capability against both the OS state and the stored /// product decision, prompting the platform's `device_permission` callback /// and persisting the answer when the question is still open. @@ -518,6 +611,7 @@ fn status_into_stored(status: PermissionAuthorizationStatus) -> Option "bulletin-allowance", Self::SmartContractAllowance => "smart-contract-allowance", Self::AutoSigning { .. } => "auto-signing", + Self::ProductStatementStoreAllowance => "product-statement-store-allowance", } } } @@ -297,6 +301,22 @@ pub struct ProductSubtreeRequest { /// Account Holder response carrying a product subtree public key. pub type ProductSubtreeResponse = Result<[u8; 32], String>; +/// Exact unsigned Statement Store payload to sign with a product-derived account. +/// +/// The signing host validates the payload as canonical unsigned statement +/// fields before signing, so this cannot become a generic signing oracle. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct StatementStoreProductSignRequest { + /// Product making the request. + pub calling_product_id: String, + /// Product account that signs the statement payload. + pub account: v01::ProductAccountId, + /// Exact unsigned statement fields, without their SCALE vector prefix. + pub payload: Vec, +} + +/// Account Holder response carrying the product-account sr25519 signature. +pub type StatementStoreProductSignResponse = Result<[u8; 64], String>; /// Request sent when a product asks the signing host to create a transaction /// for a product-derived account. @@ -321,6 +341,63 @@ pub struct CreateTransactionWithLegacyAccountRequest { pub payload: CreateTransactionLegacyPayload, } +/// Product-device Chat v2 operation carried over encrypted SSO. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum SsoProductDeviceChatOperation { + /// Bind the product device to the wallet identity and derive peer routes. + Bind { + /// Product account index; the Account Holder re-derives the device + /// account instead of trusting a pairing-host supplied public key. + derivation_index: v01::DerivationIndex, + /// Peer wallet identity account used for directional routing. + peer_identity_account_id: [u8; 32], + /// Peer's X25519 Chat identity public key. + peer_chat_public_key: [u8; 32], + }, + /// Seal an identity-route payload for the peer. + Seal { + /// Peer's X25519 Chat identity public key. + peer_chat_public_key: [u8; 32], + /// Explicit legacy or context-bound cipher suite. + cipher_suite: v01::HostProductDeviceChatCipherSuite, + /// Identity-route plaintext. + plaintext: Vec, + }, + /// Open an authenticated identity-route payload from the peer. + Open { + /// Peer's X25519 Chat identity public key. + peer_chat_public_key: [u8; 32], + /// Explicit legacy or context-bound cipher suite. + cipher_suite: v01::HostProductDeviceChatCipherSuite, + /// Nonce-prefixed ChaCha20-Poly1305 ciphertext and tag. + combined_ciphertext: Vec, + }, + /// Sign a canonical Chat first-contact proof payload as the product device. + SignRequestProof { + /// Product account index to derive on the signing host. + derivation_index: v01::DerivationIndex, + /// Canonical SCALE-encoded Chat request proof payload. + payload: Vec, + }, + /// Read the authorized wallet's public Chat identity. + Identity, + /// Verify an incoming peer's identity-to-device binding. + VerifyPeerDevice { + /// Peer wallet identity account. + peer_identity_account_id: [u8; 32], + /// Peer's independently resolved X25519 Chat public key. + peer_chat_public_key: [u8; 32], + /// Device account authenticated by the signed request. + peer_device_account_id: [u8; 32], + /// Keyed identity binding from the request. + proof: [u8; 32], + }, +} + +/// Product-device Chat v2 response returned by the Account Holder. +pub type ProductDeviceChatResponse = + Result; + /// Versioned legacy transaction-creation payload. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum CreateTransactionLegacyPayload { @@ -586,6 +663,7 @@ mod tests { use crate::host_logic::sso::wire::SsoRequest; use crate::host_logic::statement_store::{ StatementField, build_signed_statement, decode_statement_data, + unsigned_statement_signing_payload, }; use crate::test_support::sso_host_and_responder_sessions; use schnorrkel::{ExpansionMode, MiniSecretKey}; @@ -955,6 +1033,88 @@ mod tests { ); } + #[test] + fn product_device_chat_messages_pin_mobile_wire_indices() { + let chat_request = ProductRequest { + calling_product_id: "egui-chat.paseo".to_string(), + payload: SsoProductDeviceChatOperation::Bind { + derivation_index: DerivationIndex::Index(0), + peer_identity_account_id: [0x55; 32], + peer_chat_public_key: [0x66; 32], + }, + }; + let request = RemoteMessage::request("request".to_string(), chat_request); + let encoded_request = request.encode(); + assert_eq!(encoded_request[9], 24); + assert_eq!( + RemoteMessage::decode(&mut encoded_request.as_slice()).unwrap(), + request + ); + + let product_response: ProductDeviceChatResponse = + Ok(v01::HostProductDeviceChatResponse::Sealed { + combined_ciphertext: vec![0x77; 28], + }); + let response_envelope = Response { + responding_to: "request".to_string(), + payload: product_response, + }; + let response = RemoteMessage { + message_id: "response".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::ProductDeviceChatResponse( + response_envelope.clone(), + )), + }; + let encoded_response = response.encode(); + assert_eq!(encoded_response[10], 25); + let RemoteMessageData::V1(data) = response.data; + assert_eq!( + ProductRequest::::response_from_message(data), + Some(response_envelope) + ); + } + #[test] + fn statement_store_product_sign_messages_pin_extension_wire_indices() { + let payload = unsigned_statement_signing_payload(vec![ + StatementField::Data(vec![1, 2, 3]), + StatementField::Channel([0x44; 32]), + ]) + .unwrap(); + let request_payload = StatementStoreProductSignRequest { + calling_product_id: "egui-chat.paseo".to_string(), + account: ProductAccountId { + dot_ns_identifier: "egui-chat.paseo".to_string(), + derivation_index: DerivationIndex::Index(0), + }, + payload, + }; + let request = RemoteMessage::request("request".to_string(), request_payload); + let encoded_request = request.encode(); + assert_eq!(encoded_request[9], 26); + assert_eq!( + RemoteMessage::decode(&mut encoded_request.as_slice()).unwrap(), + request + ); + + let response_envelope = Response { + responding_to: "request".to_string(), + payload: Ok([0xAB; 64]), + }; + let response = RemoteMessage { + message_id: "response".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::StatementStoreProductSignResponse( + response_envelope.clone(), + )), + }; + let encoded_response = response.encode(); + assert_eq!(encoded_response[10], 27); + let RemoteMessageData::V1(data) = response.data; + assert_eq!( + StatementStoreProductSignRequest::response_from_message(data), + Some(response_envelope) + ); + } + #[test] fn sign_vrf_messages_match_mobile_wire_contract() { let payload = HostAccountSignVrfRequest { diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs index 1532e8506..e316cbc90 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs @@ -14,10 +14,11 @@ use truapi::latest::{ use super::{ CreateAccountProofResponse, CreateTransactionRequest, CreateTransactionResponse, CreateTransactionWithLegacyAccountRequest, GetAccountAliasResponse, ListRingVrfKeysResponse, - ProductRequest, ProductSubtreeRequest, ProductSubtreeResponse, RegisterRingVrfKeyResponse, - ResourceAllocationRequest, ResourceAllocationResponse, Response, RingVrfSignResponse, - SignRawWithLegacyAccountRequest, SignRawWithLegacyAccountResponse, SignRequest, SignResponse, - SignVrfResponse, + ProductDeviceChatResponse, ProductRequest, ProductSubtreeRequest, ProductSubtreeResponse, + RegisterRingVrfKeyResponse, ResourceAllocationRequest, ResourceAllocationResponse, Response, + RingVrfSignResponse, SignRawWithLegacyAccountRequest, SignRawWithLegacyAccountResponse, + SignRequest, SignResponse, SignVrfResponse, SsoProductDeviceChatOperation, + StatementStoreProductSignRequest, StatementStoreProductSignResponse, }; /// v1 messages exchanged with the paired signing host over the encrypted SSO channel. @@ -84,4 +85,16 @@ pub enum RemoteMessage { /// Account Holder's answer to [`RemoteMessage::RingVrfSignRequest`]. #[codec(index = 23)] RingVrfSignResponse(Response), + /// Forward a product-device Chat v2 operation to the Account Holder. + #[codec(index = 24)] + ProductDeviceChatRequest(ProductRequest), + /// Account Holder's product-device Chat v2 response. + #[codec(index = 25)] + ProductDeviceChatResponse(Response), + /// Ask the Account Holder to sign an exact Statement Store product payload. + #[codec(index = 26)] + StatementStoreProductSignRequest(StatementStoreProductSignRequest), + /// Account Holder's product-account Statement Store signature. + #[codec(index = 27)] + StatementStoreProductSignResponse(Response), } diff --git a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs index 037095471..d8a49f86f 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -254,13 +254,15 @@ pub fn establish_sso_session_info( /// The statement keypair signs every session statement (its public key is the /// `identityAccountId` the pairing host binds the session to), and the X25519 /// secret is the persistent `sso` key both sides feed into the session ECDH. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq, zeroize::Zeroize, zeroize::ZeroizeOnDrop, derive_more::Debug)] pub struct ResponderIdentity { /// Expanded Ed25519 secret used to sign session statements. + #[debug("\"\"")] pub statement_secret: [u8; 64], /// Ed25519 public key advertised as the session identity account. pub statement_public_key: [u8; 32], /// X25519 secret key used to derive the shared session channels. + #[debug("\"\"")] pub encryption_secret_key: [u8; 32], /// Raw X25519 public key advertised during pairing. pub encryption_public_key: [u8; 32], diff --git a/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs b/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs index e4ffebfec..aca105596 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs @@ -5,6 +5,7 @@ //! use parity_scale_codec::{Decode, Encode}; +use zeroize::Zeroize; /// Handshake proposal sent by the host. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] @@ -79,3 +80,10 @@ pub struct Success { /// Wallet-derived source for deterministic product entropy, never the raw root secret. pub root_entropy_source: [u8; 32], } + +impl Drop for Success { + fn drop(&mut self) { + self.identity_chat_private_key.zeroize(); + self.root_entropy_source.zeroize(); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/sso/wire.rs b/rust/crates/truapi-server/src/host_logic/sso/wire.rs index 1f9fb98a4..56c25c279 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/wire.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/wire.rs @@ -6,7 +6,7 @@ use core::fmt::Display; -use truapi::latest::HostAccountSignVrfError; +use truapi::{latest::HostAccountSignVrfError, v01::HostProductDeviceChatError}; use super::messages::{RemoteMessage, RemoteMessageData, Response, RingVrfError, v1}; @@ -50,6 +50,12 @@ impl SsoError for HostAccountSignVrfError { } } +impl SsoError for HostProductDeviceChatError { + fn not_connected() -> Self { + Self::NotConnected + } +} + /// Outcome code and reason recorded in the SSO transcript for one response. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResponseOutcome { diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 193a5f6ce..990debd10 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -2758,49 +2758,6 @@ mod tests { )); } - #[test] - fn permission_authorization_request_mirror_round_trips() { - let device_cases = [ - v01::HostDevicePermissionRequest::Notifications, - v01::HostDevicePermissionRequest::Camera, - v01::HostDevicePermissionRequest::Microphone, - v01::HostDevicePermissionRequest::Bluetooth, - v01::HostDevicePermissionRequest::NFC, - v01::HostDevicePermissionRequest::Location, - v01::HostDevicePermissionRequest::Clipboard, - v01::HostDevicePermissionRequest::OpenUrl, - v01::HostDevicePermissionRequest::Biometrics, - ]; - let remote_cases = [ - v01::RemotePermission::Remote { - domains: vec!["a.dot".to_string(), "b.dot".to_string()], - }, - v01::RemotePermission::WebRtc, - v01::RemotePermission::ChainSubmit, - v01::RemotePermission::PreimageSubmit, - v01::RemotePermission::StatementSubmit, - ]; - - let mut cases: Vec = Vec::new(); - cases.extend( - device_cases - .into_iter() - .map(PermissionAuthorizationRequest::Device), - ); - cases.extend(remote_cases.into_iter().map(|permission| { - PermissionAuthorizationRequest::Remote(v01::RemotePermissionRequest { permission }) - })); - cases.push(PermissionAuthorizationRequest::IdentityDisclosure); - cases.push(PermissionAuthorizationRequest::AccountAccess { - target_product_id: "other.dot".to_string(), - }); - - for case in cases { - let native = case.clone(); - assert_eq!(native, case); - } - } - #[test] fn native_auth_presenter_forwards_states_across_the_ffi_mirror() { let (callbacks, _events, platform) = event_platform(); diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 803942773..0f7c8c117 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -46,7 +46,7 @@ use std::sync::Arc; use std::time::Instant; pub(crate) use actions::ActionChannel; -use authority::{AuthorityCancelError, AuthoritySession}; +use authority::{AuthorityCancelError, AuthoritySession, ProductDeviceChatAuthorityError}; pub(crate) use authority::{AuthorityError, BulletinAllowanceKey, ProductAuthority}; pub(crate) use chat::chat_platform_for; use futures::{FutureExt, StreamExt, pin_mut}; @@ -66,7 +66,9 @@ pub use signing_host::{PairedSsoPeer, ResponderExit}; use tracing::{instrument, warn}; use truapi::api::{Chat, Pocket, Renderer}; use truapi::latest::GenericError; -use truapi::versioned::account::{HostAccountGetError, HostAccountSignVrfError}; +use truapi::versioned::account::{ + HostAccountGetError, HostAccountSignVrfError, HostProductDeviceChatError, +}; use truapi::versioned::chat::{ HostChatActionSubscribeItem, HostChatCreateRoomError, HostChatCreateRoomRequest, HostChatCreateRoomResponse, HostChatListSubscribeItem, HostChatPostMessageError, @@ -81,7 +83,7 @@ use truapi::versioned::preimage::RemotePreimageSubmitError; use truapi::versioned::renderer::HostRendererActionSubscribeItem; use truapi::{CallContext, CallError, CancellationReason, Subscription, v01}; use truapi_platform::{ - AccountAccessReview, ChatFieldError, IdentityDisclosureReview, PermissionAuthorizationRequest, + AccountAccessReview, ChatFieldError, PermissionAuthorizationRequest, PermissionAuthorizationStatus, Platform, ProductContext, ProductStorageKey, SessionUiInfo, UserConfirmationReview, normalize_chat_identifier, normalize_product_identifier, validate_chat_icon, validate_chat_message_content, validate_chat_name, @@ -636,41 +638,19 @@ impl ProductRuntimeHost { &self, ) -> Result { let product_id = self.product_id(); - let request = PermissionAuthorizationRequest::IdentityDisclosure; - let service = self.permissions_service(&product_id); - let cached = service - .authorization_status(&request) + self.permissions_service(&product_id) + .check_or_prompt_identity_disclosure() .await - .map_err(|err| format!("permission storage failed: {err:?}"))?; - if cached != PermissionAuthorizationStatus::NotDetermined { - return Ok(cached); - } + .map_err(|err| format!("permission storage failed: {err:?}")) + } - // A dismissed/unavailable confirmation has no durable user decision. - // Fail the current disclosure request closed but keep authorization in - // the ask/default state so the next request can prompt again. - let confirmed = match self - .platform - .confirm_user_action(UserConfirmationReview::IdentityDisclosure( - IdentityDisclosureReview { - product_id: product_id.clone(), - }, - )) - .await - { - Ok(confirmed) => confirmed, - Err(_) => return Ok(PermissionAuthorizationStatus::NotDetermined), - }; - let status = if confirmed { - PermissionAuthorizationStatus::Authorized - } else { - PermissionAuthorizationStatus::Denied - }; - service - .set_authorization_status(&request, status) + #[instrument(skip_all, fields(runtime.method = "permissions.chat_authority_authorization"))] + async fn chat_authority_authorization(&self) -> Result { + let product_id = self.product_id(); + self.permissions_service(&product_id) + .check_or_prompt_chat_authority() .await - .map_err(|err| format!("permission storage failed: {err:?}"))?; - Ok(status) + .map_err(|err| format!("permission storage failed: {err:?}")) } async fn classify_legacy_address_signer( @@ -828,6 +808,43 @@ fn account_get_authority_error(err: AuthorityError) -> CallError CallError { + let error = match error { + AuthorityError::Disconnected => v01::HostProductDeviceChatError::NotConnected, + AuthorityError::Rejected => v01::HostProductDeviceChatError::Rejected, + AuthorityError::Cancelled(error) => v01::HostProductDeviceChatError::Unknown { + reason: error.to_string(), + }, + AuthorityError::Unavailable { reason } + | AuthorityError::NotSupported { reason } + | AuthorityError::Unknown { reason } => v01::HostProductDeviceChatError::Unknown { reason }, + }; + CallError::Domain(HostProductDeviceChatError::V1(error)) +} + +fn product_device_chat_authority_error( + error: ProductDeviceChatAuthorityError, +) -> CallError { + let error = match error { + ProductDeviceChatAuthorityError::Disconnected => { + v01::HostProductDeviceChatError::NotConnected + } + ProductDeviceChatAuthorityError::Rejected => v01::HostProductDeviceChatError::Rejected, + ProductDeviceChatAuthorityError::InvalidPeerKey => { + v01::HostProductDeviceChatError::InvalidPeerKey + } + ProductDeviceChatAuthorityError::InvalidCiphertext => { + v01::HostProductDeviceChatError::InvalidCiphertext + } + ProductDeviceChatAuthorityError::Unavailable(reason) => { + v01::HostProductDeviceChatError::Unknown { reason } + } + }; + CallError::Domain(HostProductDeviceChatError::V1(error)) +} + fn ring_vrf_alias_error(err: RingVrfError) -> v01::HostAccountGetAliasError { match err { RingVrfError::RingNotFound => v01::HostAccountGetAliasError::RingNotFound, diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs index 28f892345..78fb24801 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -20,6 +20,9 @@ use truapi::latest::{ HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, ProductAccountId, ProductAccountTxPayload, VrfSignature, }; +use truapi::v01::{ + DerivationIndex, HostProductDeviceChatCipherSuite, HostProductDeviceChatResponse, +}; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; use truapi::{CallContext, CallError, CancellationReason}; use truapi_platform::ProductContext; @@ -243,10 +246,68 @@ pub(crate) enum CreateTransactionAuthorityRequest { IdentityAccount(LegacyAccountTxPayload), } +/// Host-private Chat identity operation after product authorization. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ProductDeviceChatAuthorityRequest { + Bind { + calling_product_id: String, + device_account_id: [u8; 32], + derivation_index: DerivationIndex, + peer_identity_account_id: [u8; 32], + peer_chat_public_key: [u8; 32], + }, + Seal { + calling_product_id: String, + peer_chat_public_key: [u8; 32], + cipher_suite: HostProductDeviceChatCipherSuite, + plaintext: Vec, + }, + Open { + calling_product_id: String, + peer_chat_public_key: [u8; 32], + cipher_suite: HostProductDeviceChatCipherSuite, + combined_ciphertext: Vec, + }, + SignRequestProof { + calling_product_id: String, + product_account_id: ProductAccountId, + payload: Vec, + }, + Identity { + calling_product_id: String, + }, + VerifyPeerDevice { + calling_product_id: String, + peer_identity_account_id: [u8; 32], + peer_chat_public_key: [u8; 32], + peer_device_account_id: [u8; 32], + proof: [u8; 32], + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ProductDeviceChatAuthorityError { + Disconnected, + Rejected, + InvalidPeerKey, + InvalidCiphertext, + Unavailable(String), +} + +impl From for ProductDeviceChatAuthorityError { + fn from(error: AuthorityError) -> Self { + match error { + AuthorityError::Disconnected => Self::Disconnected, + AuthorityError::Rejected => Self::Rejected, + other => Self::Unavailable(other.to_string()), + } + } +} /// Statement-store allowance signing material held by the authority layer. -#[derive(Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq, zeroize::Zeroize, zeroize::ZeroizeOnDrop, derive_more::Debug)] pub(crate) struct StatementStoreAllowanceKey { /// sr25519 secret used to sign allowance statements. + #[debug("\"\"")] pub(crate) secret: [u8; 64], /// Public key derived from `secret`. pub(crate) public_key: [u8; 32], @@ -415,6 +476,14 @@ pub(crate) trait ProductAuthority: Send + Sync { request: ProductRequest, ) -> Result; + /// Bind/seal/open using the active wallet's host-private Chat identity key. + async fn product_device_chat( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: ProductDeviceChatAuthorityRequest, + ) -> Result; + /// Ask the account authority to allocate product-scoped resources. async fn allocate_resources( &self, @@ -470,6 +539,326 @@ pub(crate) trait ProductAuthority: Send + Sync { ) -> Result<[u8; 32], AuthorityError>; } +pub(super) fn execute_product_device_chat( + identity_chat_private_key: &[u8; 32], + identity_account_id: [u8; 32], + request: ProductDeviceChatAuthorityRequest, +) -> Result { + use chacha20poly1305::aead::{Aead, KeyInit, Payload}; + use chacha20poly1305::{ChaCha20Poly1305, Nonce}; + use hkdf::Hkdf; + use sha2::Sha256; + use x25519_dalek::{PublicKey, StaticSecret}; + use zeroize::Zeroizing; + + let peer_public_key = match &request { + ProductDeviceChatAuthorityRequest::Bind { + peer_chat_public_key, + .. + } + | ProductDeviceChatAuthorityRequest::Seal { + peer_chat_public_key, + .. + } + | ProductDeviceChatAuthorityRequest::Open { + peer_chat_public_key, + .. + } + | ProductDeviceChatAuthorityRequest::VerifyPeerDevice { + peer_chat_public_key, + .. + } => *peer_chat_public_key, + ProductDeviceChatAuthorityRequest::Identity { .. } => { + return Ok(HostProductDeviceChatResponse::Identity { + identity_account_id, + chat_public_key: PublicKey::from(&StaticSecret::from(*identity_chat_private_key)) + .to_bytes(), + }); + } + ProductDeviceChatAuthorityRequest::SignRequestProof { .. } => { + return Err(ProductDeviceChatAuthorityError::Unavailable( + "Chat request proof signing must be handled by the product signing authority" + .to_string(), + )); + } + }; + if !is_canonical_x25519_public_key(&peer_public_key) { + return Err(ProductDeviceChatAuthorityError::InvalidPeerKey); + } + let shared_secret = Zeroizing::new( + StaticSecret::from(*identity_chat_private_key) + .diffie_hellman(&PublicKey::from(peer_public_key)) + .to_bytes(), + ); + if *shared_secret == [0; 32] { + return Err(ProductDeviceChatAuthorityError::InvalidPeerKey); + } + + return match request { + ProductDeviceChatAuthorityRequest::Bind { + device_account_id, + peer_identity_account_id, + .. + } => { + let proof = chat_device_identity_proof( + &shared_secret, + &identity_account_id, + &device_account_id, + ); + let mut proof_bytes = [0; 32]; + proof_bytes.copy_from_slice(proof.as_bytes()); + let wallet_own_session_id = chat_identity_session_id( + &shared_secret, + &identity_account_id, + &peer_identity_account_id, + ); + let peer_own_session_id = chat_identity_session_id( + &shared_secret, + &peer_identity_account_id, + &identity_account_id, + ); + let wallet_outgoing_channel_id = chat_request_channel_id( + &shared_secret, + &identity_account_id, + &peer_identity_account_id, + ); + let wallet_incoming_channel_id = chat_request_channel_id( + &shared_secret, + &peer_identity_account_id, + &identity_account_id, + ); + Ok(HostProductDeviceChatResponse::IdentityBinding { + identity_account_id, + proof: proof_bytes, + wallet_own_session_id, + peer_own_session_id, + wallet_outgoing_channel_id, + wallet_incoming_channel_id, + }) + } + ProductDeviceChatAuthorityRequest::VerifyPeerDevice { + peer_identity_account_id, + peer_device_account_id, + proof, + .. + } => { + let expected = chat_device_identity_proof( + &shared_secret, + &peer_identity_account_id, + &peer_device_account_id, + ); + // Hash's slice comparison is constant-time for this fixed length. + Ok(HostProductDeviceChatResponse::PeerDeviceVerified { + valid: expected.eq(proof.as_slice()), + }) + } + ProductDeviceChatAuthorityRequest::Seal { + calling_product_id, + cipher_suite, + plaintext, + .. + } => { + let (key, aad) = product_device_chat_aead_material( + &shared_secret, + &calling_product_id, + &identity_account_id, + &cipher_suite, + true, + )?; + let key = Zeroizing::new(key); + let mut nonce = [0; 12]; + getrandom::getrandom(&mut nonce).map_err(|error| { + ProductDeviceChatAuthorityError::Unavailable(format!( + "failed to generate Chat identity-route nonce: {error}" + )) + })?; + let encrypted = ChaCha20Poly1305::new((&*key).into()) + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: &plaintext, + aad: &aad, + }, + ) + .map_err(|_| { + ProductDeviceChatAuthorityError::Unavailable( + "Chat identity-route encryption failed".to_string(), + ) + })?; + let mut combined_ciphertext = Vec::with_capacity(12 + encrypted.len()); + combined_ciphertext.extend_from_slice(&nonce); + combined_ciphertext.extend_from_slice(&encrypted); + Ok(HostProductDeviceChatResponse::Sealed { + combined_ciphertext, + }) + } + ProductDeviceChatAuthorityRequest::Open { + calling_product_id, + cipher_suite, + combined_ciphertext, + .. + } => { + if combined_ciphertext.len() < 28 { + return Err(ProductDeviceChatAuthorityError::InvalidCiphertext); + } + let (key, aad) = product_device_chat_aead_material( + &shared_secret, + &calling_product_id, + &identity_account_id, + &cipher_suite, + false, + )?; + let key = Zeroizing::new(key); + let plaintext = ChaCha20Poly1305::new((&*key).into()) + .decrypt( + Nonce::from_slice(&combined_ciphertext[..12]), + Payload { + msg: &combined_ciphertext[12..], + aad: &aad, + }, + ) + .map_err(|_| ProductDeviceChatAuthorityError::InvalidCiphertext)?; + Ok(HostProductDeviceChatResponse::Opened { plaintext }) + } + ProductDeviceChatAuthorityRequest::Identity { .. } => { + unreachable!("public identity returns before shared-key derivation") + } + ProductDeviceChatAuthorityRequest::SignRequestProof { .. } => { + Err(ProductDeviceChatAuthorityError::Unavailable( + "Chat request proof signing must be handled by the product signing authority" + .to_string(), + )) + } + }; + + fn chat_device_identity_proof( + shared_secret: &[u8; 32], + identity: &[u8; 32], + device: &[u8; 32], + ) -> blake2b_simd::Hash { + const CONTEXT: &[u8] = b"mds-chat-request"; + let mut payload = [0; 65 + CONTEXT.len()]; + payload[..32].copy_from_slice(identity); + payload[32..64].copy_from_slice(device); + payload[64] = (CONTEXT.len() as u8) << 2; + payload[65..].copy_from_slice(CONTEXT); + blake2b_simd::Params::new() + .hash_length(32) + .key(shared_secret) + .hash(&payload) + } + + fn product_device_chat_aead_material( + shared_secret: &[u8; 32], + calling_product_id: &str, + identity_account_id: &[u8; 32], + cipher_suite: &HostProductDeviceChatCipherSuite, + sealing: bool, + ) -> Result<([u8; 32], Vec), ProductDeviceChatAuthorityError> { + let mut key = [0; 32]; + let HostProductDeviceChatCipherSuite::ContextBoundV1 { + peer_account_id, + channel_id, + } = cipher_suite + else { + Hkdf::::new(Some(&[]), shared_secret) + .expand(&[], &mut key) + .map_err(|_| { + ProductDeviceChatAuthorityError::Unavailable( + "Chat identity-route HKDF failed".to_string(), + ) + })?; + return Ok((key, Vec::new())); + }; + let product_id_len = u32::try_from(calling_product_id.len()).map_err(|_| { + ProductDeviceChatAuthorityError::Unavailable( + "Chat product identifier is too long".to_string(), + ) + })?; + let (sender_account_id, recipient_account_id) = if sealing { + (identity_account_id, peer_account_id) + } else { + (peer_account_id, identity_account_id) + }; + let domain = b"dotli-chat/context-bound/v1"; + let mut aad = Vec::with_capacity( + domain.len() + 4 + calling_product_id.len() + 32 + 32 + channel_id.len(), + ); + aad.extend_from_slice(domain); + aad.extend_from_slice(&product_id_len.to_le_bytes()); + aad.extend_from_slice(calling_product_id.as_bytes()); + aad.extend_from_slice(sender_account_id); + aad.extend_from_slice(recipient_account_id); + aad.extend_from_slice(channel_id); + Hkdf::::new(Some(domain), shared_secret) + .expand(&aad, &mut key) + .map_err(|_| { + ProductDeviceChatAuthorityError::Unavailable( + "context-bound Chat identity-route HKDF failed".to_string(), + ) + })?; + Ok((key, aad)) + } + + fn is_canonical_x25519_public_key(key: &[u8; 32]) -> bool { + const FIELD_MODULUS: [u8; 32] = [ + 0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0x7f, + ]; + if key[31] & 0x80 != 0 { + return false; + } + for index in (0..32).rev() { + if key[index] < FIELD_MODULUS[index] { + return true; + } + if key[index] > FIELD_MODULUS[index] { + return false; + } + } + false + } + + fn chat_identity_session_id( + shared_secret: &[u8; 32], + first_account_id: &[u8; 32], + second_account_id: &[u8; 32], + ) -> [u8; 32] { + let mut input = Vec::with_capacity(7 + 32 + 32 + 2); + input.extend_from_slice(b"session"); + input.extend_from_slice(first_account_id); + input.extend_from_slice(second_account_id); + input.extend_from_slice(b"//"); + let hash = blake2b_simd::Params::new() + .hash_length(32) + .key(shared_secret) + .hash(&input); + let mut output = [0; 32]; + output.copy_from_slice(hash.as_bytes()); + output + } + + fn chat_request_channel_id( + shared_secret: &[u8; 32], + requester_account_id: &[u8; 32], + acceptor_account_id: &[u8; 32], + ) -> [u8; 32] { + let mut input = Vec::with_capacity(12 + 32 + 32 + 2); + input.extend_from_slice(b"chat-request"); + input.extend_from_slice(requester_account_id); + input.extend_from_slice(acceptor_account_id); + input.extend_from_slice(b"//"); + let hash = blake2b_simd::Params::new() + .hash_length(32) + .key(shared_secret) + .hash(&input); + let mut output = [0; 32]; + output.copy_from_slice(hash.as_bytes()); + output + } +} + /// Build the neutral authority-session snapshot for `session`. pub(super) fn authority_session(session: &SessionInfo) -> AuthoritySession { AuthoritySession::from_session_info(session, authority_session_validation_id(session)) @@ -508,3 +897,281 @@ pub(super) fn authority_session_validation_id(session: &SessionInfo) -> Vec } id } + +#[cfg(test)] +mod tests { + use super::*; + + fn hex32(value: &str) -> [u8; 32] { + hex::decode(value).unwrap().try_into().unwrap() + } + + #[test] + fn product_device_bind_matches_ios_chat_v2_derivations() { + let peer_public_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from([0x22; 32])).to_bytes(); + assert_eq!( + peer_public_key, + hex32("0faa684ed28867b97f4a6a2dee5df8ce974e76b7018e3f22a1c4cf2678570f20") + ); + + let response = execute_product_device_chat( + &[0x11; 32], + [0x33; 32], + ProductDeviceChatAuthorityRequest::Bind { + calling_product_id: "egui-chat.paseo".to_string(), + device_account_id: [0x44; 32], + derivation_index: DerivationIndex::Index(0), + peer_identity_account_id: [0x55; 32], + peer_chat_public_key: peer_public_key, + }, + ) + .unwrap(); + let HostProductDeviceChatResponse::IdentityBinding { + identity_account_id, + proof, + wallet_own_session_id, + peer_own_session_id, + wallet_outgoing_channel_id, + wallet_incoming_channel_id, + } = response + else { + panic!("Bind must return an identity binding"); + }; + assert_eq!(identity_account_id, [0x33; 32]); + assert_eq!( + proof, + hex32("0263d1995da865e34e06de38b4f4c0c88524e2e591b1ae6714578219bffad333") + ); + assert_eq!( + wallet_own_session_id, + hex32("460db8611d842e65414f9eea4aa74d3fe1ac2e31468d4fbebededd914be28422") + ); + assert_eq!( + peer_own_session_id, + hex32("bfb5eb8c0b959f95b3ab09bd0f8001ab80f100cf5bb617640534372ab777c5c3") + ); + assert_eq!( + wallet_outgoing_channel_id, + hex32("576f71aa7f51aa340f411c20779c35f476361d8008247db367a8ce4d7e087d70") + ); + assert_eq!( + wallet_incoming_channel_id, + hex32("19de8cf16554a8463d0f8af7ad23717f4106463af331ee33f297b7367c8fe9fa") + ); + } + + #[test] + fn peer_device_binding_verifies_reciprocally_and_rejects_substitution() { + let sender_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from([0x11; 32])).to_bytes(); + // Independent iOS-compatible binding vector from the sender test above. + let proof = hex32("0263d1995da865e34e06de38b4f4c0c88524e2e591b1ae6714578219bffad333"); + let verify = |identity, device, binding| { + execute_product_device_chat( + &[0x22; 32], + [0x55; 32], + ProductDeviceChatAuthorityRequest::VerifyPeerDevice { + calling_product_id: "egui-chat.paseo".to_string(), + peer_identity_account_id: identity, + peer_chat_public_key: sender_key, + peer_device_account_id: device, + proof: binding, + }, + ) + .unwrap() + }; + assert_eq!( + verify([0x33; 32], [0x44; 32], proof), + HostProductDeviceChatResponse::PeerDeviceVerified { valid: true } + ); + let mut corrupted = proof; + corrupted[31] ^= 1; + for (identity, device, binding) in [ + ([0x34; 32], [0x44; 32], proof), + ([0x33; 32], [0x45; 32], proof), + ([0x33; 32], [0x44; 32], corrupted), + ] { + assert_eq!( + verify(identity, device, binding), + HostProductDeviceChatResponse::PeerDeviceVerified { valid: false } + ); + } + } + + #[test] + fn product_device_seal_open_round_trip_and_authenticate() { + let identity_chat_private_key = [0x11; 32]; + let peer_chat_public_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from([0x22; 32])).to_bytes(); + let plaintext = b"private first-contact payload".to_vec(); + let sealed = execute_product_device_chat( + &identity_chat_private_key, + [0x33; 32], + ProductDeviceChatAuthorityRequest::Seal { + calling_product_id: "egui-chat.paseo".to_string(), + peer_chat_public_key, + cipher_suite: HostProductDeviceChatCipherSuite::LegacyV2, + plaintext: plaintext.clone(), + }, + ) + .unwrap(); + let HostProductDeviceChatResponse::Sealed { + mut combined_ciphertext, + } = sealed + else { + panic!("Seal must return ciphertext"); + }; + + let opened = execute_product_device_chat( + &identity_chat_private_key, + [0x33; 32], + ProductDeviceChatAuthorityRequest::Open { + calling_product_id: "egui-chat.paseo".to_string(), + peer_chat_public_key, + cipher_suite: HostProductDeviceChatCipherSuite::LegacyV2, + combined_ciphertext: combined_ciphertext.clone(), + }, + ) + .unwrap(); + assert_eq!(opened, HostProductDeviceChatResponse::Opened { plaintext }); + + let last = combined_ciphertext.len() - 1; + combined_ciphertext[last] ^= 1; + assert_eq!( + execute_product_device_chat( + &identity_chat_private_key, + [0x33; 32], + ProductDeviceChatAuthorityRequest::Open { + calling_product_id: "egui-chat.paseo".to_string(), + peer_chat_public_key, + cipher_suite: HostProductDeviceChatCipherSuite::LegacyV2, + combined_ciphertext, + }, + ), + Err(ProductDeviceChatAuthorityError::InvalidCiphertext) + ); + } + + #[test] + fn context_bound_product_device_chat_rejects_downgrade_and_wrong_context() { + let sender_private_key = [0x11; 32]; + let recipient_private_key = [0x22; 32]; + let sender_public_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(sender_private_key)) + .to_bytes(); + let recipient_public_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(recipient_private_key)) + .to_bytes(); + let sender_account_id = [0x33; 32]; + let recipient_account_id = [0x44; 32]; + let channel_id = [0x55; 32]; + let plaintext = b"context-bound identity payload".to_vec(); + let sealed = execute_product_device_chat( + &sender_private_key, + sender_account_id, + ProductDeviceChatAuthorityRequest::Seal { + calling_product_id: "egui-chat.paseo".to_string(), + peer_chat_public_key: recipient_public_key, + cipher_suite: HostProductDeviceChatCipherSuite::ContextBoundV1 { + peer_account_id: recipient_account_id, + channel_id, + }, + plaintext: plaintext.clone(), + }, + ) + .unwrap(); + let HostProductDeviceChatResponse::Sealed { + combined_ciphertext, + } = sealed + else { + panic!("Seal must return ciphertext"); + }; + + let open = |calling_product_id: &str, cipher_suite: HostProductDeviceChatCipherSuite| { + execute_product_device_chat( + &recipient_private_key, + recipient_account_id, + ProductDeviceChatAuthorityRequest::Open { + calling_product_id: calling_product_id.to_string(), + peer_chat_public_key: sender_public_key, + cipher_suite, + combined_ciphertext: combined_ciphertext.clone(), + }, + ) + }; + assert_eq!( + open( + "egui-chat.paseo", + HostProductDeviceChatCipherSuite::ContextBoundV1 { + peer_account_id: sender_account_id, + channel_id, + }, + ), + Ok(HostProductDeviceChatResponse::Opened { + plaintext: plaintext.clone(), + }) + ); + assert_eq!( + open( + "egui-chat.paseo", + HostProductDeviceChatCipherSuite::LegacyV2 + ), + Err(ProductDeviceChatAuthorityError::InvalidCiphertext) + ); + assert_eq!( + open( + "egui-chat.paseo", + HostProductDeviceChatCipherSuite::ContextBoundV1 { + peer_account_id: sender_account_id, + channel_id: [0x56; 32], + }, + ), + Err(ProductDeviceChatAuthorityError::InvalidCiphertext) + ); + assert_eq!( + open( + "egui-chat.westend", + HostProductDeviceChatCipherSuite::ContextBoundV1 { + peer_account_id: sender_account_id, + channel_id, + }, + ), + Err(ProductDeviceChatAuthorityError::InvalidCiphertext) + ); + } + + #[test] + fn product_device_rejects_invalid_peer_keys() { + assert_eq!( + execute_product_device_chat( + &[0x11; 32], + [0x33; 32], + ProductDeviceChatAuthorityRequest::Seal { + calling_product_id: "egui-chat.paseo".to_string(), + peer_chat_public_key: [0; 32], + cipher_suite: HostProductDeviceChatCipherSuite::LegacyV2, + plaintext: Vec::new(), + }, + ), + Err(ProductDeviceChatAuthorityError::InvalidPeerKey) + ); + + let mut noncanonical_peer_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from([0x22; 32])).to_bytes(); + noncanonical_peer_key[31] |= 0x80; + assert_eq!( + execute_product_device_chat( + &[0x11; 32], + [0x33; 32], + ProductDeviceChatAuthorityRequest::Seal { + calling_product_id: "egui-chat.paseo".to_string(), + peer_chat_public_key: noncanonical_peer_key, + cipher_suite: HostProductDeviceChatCipherSuite::LegacyV2, + plaintext: Vec::new(), + }, + ), + Err(ProductDeviceChatAuthorityError::InvalidPeerKey) + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/capabilities/account.rs b/rust/crates/truapi-server/src/runtime/capabilities/account.rs index 9877d56b5..1ab8595a1 100644 --- a/rust/crates/truapi-server/src/runtime/capabilities/account.rs +++ b/rust/crates/truapi-server/src/runtime/capabilities/account.rs @@ -18,8 +18,9 @@ use truapi::versioned::account::{ HostAccountRingVrfSignRequest, HostAccountRingVrfSignResponse, HostAccountSignVrfError, HostAccountSignVrfRequest, HostAccountSignVrfResponse, HostGetLegacyAccountsError, HostGetLegacyAccountsRequest, HostGetLegacyAccountsResponse, HostGetUserIdError, - HostGetUserIdRequest, HostGetUserIdResponse, HostRequestLoginError, HostRequestLoginRequest, - HostRequestLoginResponse, + HostGetUserIdRequest, HostGetUserIdResponse, HostProductDeviceChatError, + HostProductDeviceChatRequest, HostProductDeviceChatResponse, HostRequestLoginError, + HostRequestLoginRequest, HostRequestLoginResponse, }; use truapi::{CallContext, CallError, Subscription, latest, v01}; use truapi_platform::{ @@ -28,8 +29,10 @@ use truapi_platform::{ }; use crate::host_logic::sso::messages::ProductRequest; +use crate::runtime::authority::ProductDeviceChatAuthorityRequest; use crate::runtime::{ ProductRuntimeHost, account_access_authorization, account_get_authority_error, + product_device_chat_account_authority_error, product_device_chat_authority_error, remote_authority_call, remote_authority_context, ring_vrf_alias_error, ring_vrf_list_error, ring_vrf_proof_error, ring_vrf_register_error, ring_vrf_sign_error, validate_vrf_transcript, vrf_call_error, @@ -325,6 +328,140 @@ impl Account for ProductRuntimeHost { .map_err(|err| CallError::Domain(HostAccountRingVrfSignError::V1(ring_vrf_sign_error(err)))) } + #[instrument(skip_all, fields(runtime.method = "account.product_device_chat"))] + async fn product_device_chat( + &self, + cx: &CallContext, + request: HostProductDeviceChatRequest, + ) -> Result> { + let HostProductDeviceChatRequest::V1(request) = request; + let product_account_id = match &request { + v01::HostProductDeviceChatRequest::Bind { + product_account_id, .. + } + | v01::HostProductDeviceChatRequest::Seal { + product_account_id, .. + } + | v01::HostProductDeviceChatRequest::Open { + product_account_id, .. + } + | v01::HostProductDeviceChatRequest::SignRequestProof { + product_account_id, .. + } + | v01::HostProductDeviceChatRequest::Identity { + product_account_id, .. + } + | v01::HostProductDeviceChatRequest::VerifyPeerDevice { + product_account_id, .. + } => product_account_id.clone(), + }; + let product_account_id = + Self::normalize_product_account_id(product_account_id).map_err(|()| { + CallError::Domain(HostProductDeviceChatError::V1( + v01::HostProductDeviceChatError::Unknown { + reason: "Invalid product account".to_string(), + }, + )) + })?; + if product_account_id.dot_ns_identifier != self.product_id() { + return Err(CallError::Domain(HostProductDeviceChatError::V1( + v01::HostProductDeviceChatError::Unknown { + reason: "product account does not belong to the calling product".to_string(), + }, + ))); + } + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostProductDeviceChatError::V1( + v01::HostProductDeviceChatError::NotConnected, + ))); + }; + if self + .chat_authority_authorization() + .await + .map_err(|reason| CallError::HostFailure { reason })? + != PermissionAuthorizationStatus::Authorized + { + return Err(CallError::Domain(HostProductDeviceChatError::V1( + v01::HostProductDeviceChatError::Rejected, + ))); + } + let cx = remote_authority_context(cx); + let authority_request = match request { + v01::HostProductDeviceChatRequest::Bind { + peer_identity_account_id, + peer_chat_public_key, + .. + } => { + let device_account_id = self + .product_account_public_key(&cx, &session, &product_account_id) + .await + .map_err(product_device_chat_account_authority_error)?; + ProductDeviceChatAuthorityRequest::Bind { + calling_product_id: self.product_id(), + device_account_id, + derivation_index: product_account_id.derivation_index.clone(), + peer_identity_account_id, + peer_chat_public_key, + } + } + v01::HostProductDeviceChatRequest::Seal { + peer_chat_public_key, + cipher_suite, + plaintext, + .. + } => ProductDeviceChatAuthorityRequest::Seal { + calling_product_id: self.product_id(), + peer_chat_public_key, + cipher_suite, + plaintext, + }, + v01::HostProductDeviceChatRequest::Open { + peer_chat_public_key, + cipher_suite, + combined_ciphertext, + .. + } => ProductDeviceChatAuthorityRequest::Open { + calling_product_id: self.product_id(), + peer_chat_public_key, + cipher_suite, + combined_ciphertext, + }, + v01::HostProductDeviceChatRequest::SignRequestProof { payload, .. } => { + ProductDeviceChatAuthorityRequest::SignRequestProof { + calling_product_id: self.product_id(), + product_account_id, + payload, + } + } + v01::HostProductDeviceChatRequest::Identity { .. } => { + ProductDeviceChatAuthorityRequest::Identity { + calling_product_id: self.product_id(), + } + } + v01::HostProductDeviceChatRequest::VerifyPeerDevice { + peer_identity_account_id, + peer_chat_public_key, + peer_device_account_id, + proof, + .. + } => ProductDeviceChatAuthorityRequest::VerifyPeerDevice { + calling_product_id: self.product_id(), + peer_identity_account_id, + peer_chat_public_key, + peer_device_account_id, + proof, + }, + }; + remote_authority_call( + &cx, + self.authority + .product_device_chat(&cx, &session, authority_request), + ) + .await + .map(HostProductDeviceChatResponse::V1) + .map_err(product_device_chat_authority_error) + } + #[instrument(skip_all, fields(runtime.method = "account.sign_vrf"))] async fn sign_vrf( &self, diff --git a/rust/crates/truapi-server/src/runtime/capabilities/resources.rs b/rust/crates/truapi-server/src/runtime/capabilities/resources.rs index 2a39d4142..f8cf9009d 100644 --- a/rust/crates/truapi-server/src/runtime/capabilities/resources.rs +++ b/rust/crates/truapi-server/src/runtime/capabilities/resources.rs @@ -10,13 +10,129 @@ use truapi::versioned::resource_allocation::{ HostRequestResourceAllocationResponse, }; use truapi::{CallContext, CallError, v01}; -use truapi_platform::{ResourceAllocationReview, UserConfirmationReview}; +use truapi_platform::{ + PermissionAuthorizationRequest, PermissionAuthorizationStatus, ResourceAllocationReview, + UserConfirmationReview, +}; use crate::runtime::{ ProductRuntimeHost, RESOURCE_ALLOCATION_REMOTE_AUTHORITY_RESPONSE_TIMEOUT, remote_authority_call, remote_authority_context_with_default, }; +impl ProductRuntimeHost { + /// Resolve a durable allowance grant in the product connection's storage, + /// never the signing host's (potentially differently scoped) storage. + pub(in crate::runtime) async fn require_statement_store_allowance( + &self, + session: &crate::runtime::authority::AuthoritySession, + derivation_index: Option, + ) -> Result<(), String> { + let require_session = || { + if self.authority.current_session().as_ref() == Some(session) { + Ok(()) + } else { + Err("Statement allowance session changed".to_string()) + } + }; + require_session()?; + let product_id = self.product_id(); + let service = self.permissions_service(&product_id); + let request = PermissionAuthorizationRequest::StatementStoreAllowance { + derivation_index: derivation_index.clone(), + }; + let mut status = service + .authorization_status(&request) + .await + .map_err(|err| { + format!( + "statement allowance authorization read failed: {}", + err.reason + ) + })?; + require_session()?; + if status == PermissionAuthorizationStatus::NotDetermined { + let resource = match derivation_index { + Some(index) => v01::AllocatableResource::ProductStatementStoreAllowance(index), + None => v01::AllocatableResource::StatementStoreAllowance, + }; + let confirmed = self + .platform + .confirm_user_action(UserConfirmationReview::ResourceAllocation( + ResourceAllocationReview { + calling_product_id: product_id.clone(), + resources: vec![resource], + }, + )) + .await + .map_err(|err| { + format!("statement allowance confirmation failed: {}", err.reason) + })?; + require_session()?; + // An administrative decision made while the prompt was open wins. + status = service + .authorization_status(&request) + .await + .map_err(|err| { + format!( + "statement allowance authorization read failed: {}", + err.reason + ) + })?; + require_session()?; + if status != PermissionAuthorizationStatus::NotDetermined { + return Err( + "Statement allowance authorization changed during confirmation".to_string(), + ); + } + status = if confirmed { + PermissionAuthorizationStatus::Authorized + } else { + PermissionAuthorizationStatus::Denied + }; + service + .set_authorization_status(&request, status) + .await + .map_err(|err| { + format!( + "statement allowance authorization write failed: {}", + err.reason + ) + })?; + require_session()?; + } + if status != PermissionAuthorizationStatus::Authorized { + return Err("Statement allowance authorization denied".to_string()); + } + Ok(()) + } + + pub(in crate::runtime) async fn check_statement_store_allowance( + &self, + session: &crate::runtime::authority::AuthoritySession, + derivation_index: Option, + ) -> Result<(), String> { + let status = self + .permission_authorization_status( + PermissionAuthorizationRequest::StatementStoreAllowance { derivation_index }, + ) + .await + .map_err(|err| { + format!( + "statement allowance authorization read failed: {}", + err.reason + ) + })?; + if self.authority.current_session().as_ref() != Some(session) { + return Err("Statement allowance session changed".to_string()); + } + if status != PermissionAuthorizationStatus::Authorized { + return Err("Statement allowance authorization denied".to_string()); + } + Ok(()) + } +} + #[truapi::async_trait] impl ResourceAllocation for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "resource_allocation.request"))] @@ -35,11 +151,50 @@ impl ResourceAllocation for ProductRuntimeHost { ))); }; + let require_session = || { + if self.authority.current_session().as_ref() == Some(&session) { + Ok(()) + } else { + Err(CallError::HostFailure { + reason: "Resource allocation session changed".to_string(), + }) + } + }; + let product_id = self.product_id(); + let service = self.permissions_service(&product_id); + let mut grants = Vec::new(); + for resource in &inner.resources { + let derivation_index = match resource { + v01::AllocatableResource::StatementStoreAllowance => None, + v01::AllocatableResource::ProductStatementStoreAllowance(index) => { + Some(index.clone()) + } + _ => continue, + }; + let request = + PermissionAuthorizationRequest::StatementStoreAllowance { derivation_index }; + if grants.iter().any(|(existing, _)| existing == &request) { + continue; + } + let status = service + .authorization_status(&request) + .await + .map_err(|err| CallError::HostFailure { reason: err.reason })?; + require_session()?; + if status == PermissionAuthorizationStatus::Denied { + return Err(CallError::Denied); + } + grants.push((request, status)); + } + + // An explicit request means additional quota, not merely ensure. Always + // confirm it, even when implicit provisioning has a durable grant. The + // same review establishes any missing grants without a second prompt. let confirmed = self .platform .confirm_user_action(UserConfirmationReview::ResourceAllocation( ResourceAllocationReview { - calling_product_id: self.product_id(), + calling_product_id: product_id.clone(), resources: inner.resources.clone(), }, )) @@ -47,13 +202,51 @@ impl ResourceAllocation for ProductRuntimeHost { .map_err(|err| CallError::HostFailure { reason: format!("resource allocation confirmation failed: {err:?}"), })?; + require_session()?; + for (request, before) in &grants { + let current = service + .authorization_status(request) + .await + .map_err(|err| CallError::HostFailure { reason: err.reason })?; + require_session()?; + // Never overwrite a decision changed by administration during the + // review, including Authorized -> NotDetermined resets. + if current != *before { + return Err(CallError::Denied); + } + } + for (request, before) in &grants { + if *before == PermissionAuthorizationStatus::NotDetermined { + let decision = if confirmed { + PermissionAuthorizationStatus::Authorized + } else { + PermissionAuthorizationStatus::Denied + }; + service + .set_authorization_status(request, decision) + .await + .map_err(|err| CallError::HostFailure { reason: err.reason })?; + require_session()?; + } + } if !confirmed { + // Declining an increase does not revoke an existing durable grant. return Err(CallError::Domain(HostRequestResourceAllocationError::V1( v01::ResourceAllocationError::Unknown { reason: "User rejected resource allocation".to_string(), }, ))); } + for (request, _) in &grants { + let status = service + .authorization_status(request) + .await + .map_err(|err| CallError::HostFailure { reason: err.reason })?; + require_session()?; + if status != PermissionAuthorizationStatus::Authorized { + return Err(CallError::Denied); + } + } let cx = remote_authority_context_with_default( cx, RESOURCE_ALLOCATION_REMOTE_AUTHORITY_RESPONSE_TIMEOUT, diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index a4babd51e..e4e247fe4 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -25,9 +25,9 @@ use super::allowances::{self, AllowanceCacheKey, AllowanceResource}; use super::auth_state::AuthStateMachine; use super::authority::{ AuthorityError, AuthoritySession, AutoSigningKey, BulletinAllowanceKey, - CreateTransactionAuthorityRequest, ProductAuthority, SignPayloadAuthorityRequest, - SignRawAuthorityRequest, StatementStoreAllowanceKey, authority_session, - require_current_session, + CreateTransactionAuthorityRequest, ProductAuthority, ProductDeviceChatAuthorityError, + ProductDeviceChatAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, + StatementStoreAllowanceKey, authority_session, require_current_session, }; use super::connected_session_ui_info; use super::identity::resolve_session_identity_with_chain; @@ -2273,6 +2273,19 @@ impl PairingHost { .await } + async fn product_device_chat( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: ProductDeviceChatAuthorityRequest, + ) -> Result { + let private_session = self + .current_private_session(session) + .map_err(|_| ProductDeviceChatAuthorityError::Disconnected)?; + self.remote_product_device_chat(cx, &private_session, request) + .await + } + async fn allocate_resources( &self, cx: &CallContext, @@ -2320,17 +2333,14 @@ impl PairingHost { async fn sign_statement_store_product_payload( &self, - _cx: &CallContext, + cx: &CallContext, session: &AuthoritySession, - _account: v01::ProductAccountId, - _payload: Vec, + account: v01::ProductAccountId, + payload: Vec, ) -> Result<[u8; 64], AuthorityError> { - self.current_private_session(session)?; - Err(AuthorityError::Unavailable { - reason: "pairing host: exact statement proof signing is not supported over the \ - current SSO raw-signing protocol" - .to_string(), - }) + let session = self.current_private_session(session)?; + self.remote_sign_statement_store_product_payload(cx, &session, account, payload) + .await } fn derive_entropy( @@ -2522,6 +2532,15 @@ impl ProductAuthority for PairingHost { PairingHost::ring_vrf_sign(self, cx, session, request).await } + async fn product_device_chat( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: ProductDeviceChatAuthorityRequest, + ) -> Result { + PairingHost::product_device_chat(self, cx, session, request).await + } + async fn allocate_resources( &self, cx: &CallContext, diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs index 7a6fa1574..8ba84dfb3 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -2,6 +2,7 @@ use super::super::authority::{ AuthorityCancelError, AuthorityError, BulletinAllowanceKey, CreateTransactionAuthorityRequest, + ProductDeviceChatAuthorityError, ProductDeviceChatAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, StatementStoreAllowanceKey, }; use super::super::sso_remote::{ @@ -17,7 +18,8 @@ use crate::host_logic::sso::messages::{ CreateTransactionWithLegacyAccountRequest, OnExistingAllowancePolicy, ProductRequest, ProductSubtreeRequest, RemoteMessage, RemoteMessageData, ResourceAllocationRequest, RingVrfError, SignRawWithLegacyAccountRequest, SignRequest, SsoAllocatedResource, - SsoAllocationOutcome, SsoSessionStatement, build_outgoing_request_statement, + SsoAllocationOutcome, SsoProductDeviceChatOperation, SsoSessionStatement, + StatementStoreProductSignRequest, build_outgoing_request_statement, decode_sso_session_statement, v1, }; use crate::host_logic::sso::wire::SsoRequest; @@ -26,7 +28,7 @@ use crate::host_logic::statement_store::parse_new_statements_result; use futures::FutureExt; use futures::future::{AbortHandle, Abortable}; use tracing::{debug, instrument, warn}; -use truapi::{CallContext, latest}; +use truapi::{CallContext, latest, v01}; /// Active peer-disconnect watcher for one SSO session; aborts on drop. pub(super) struct SsoDisconnectMonitor { @@ -487,6 +489,135 @@ impl PairingHost { .await .map_err(ring_vrf_transport_error)? } + /// Forward exact Statement Store product-account signing to the Account Holder. + pub(super) async fn remote_sign_statement_store_product_payload( + &self, + cx: &CallContext, + session: &SessionInfo, + account: v01::ProductAccountId, + payload: Vec, + ) -> Result<[u8; 64], AuthorityError> { + let calling_product_id = account.dot_ns_identifier.clone(); + self.call( + cx, + session, + StatementStoreProductSignRequest { + calling_product_id, + account, + payload, + }, + ) + .await + .map_err(remote_authority_error)? + .map_err(remote_authority_error) + } + + /// Forward a product-device Chat v2 operation without exposing wallet key material. + pub(super) async fn remote_product_device_chat( + &self, + cx: &CallContext, + session: &SessionInfo, + request: ProductDeviceChatAuthorityRequest, + ) -> Result { + let (calling_product_id, operation) = match request { + ProductDeviceChatAuthorityRequest::Bind { + calling_product_id, + derivation_index, + peer_identity_account_id, + peer_chat_public_key, + .. + } => ( + calling_product_id, + SsoProductDeviceChatOperation::Bind { + derivation_index, + peer_identity_account_id, + peer_chat_public_key, + }, + ), + ProductDeviceChatAuthorityRequest::Seal { + calling_product_id, + peer_chat_public_key, + cipher_suite, + plaintext, + } => ( + calling_product_id, + SsoProductDeviceChatOperation::Seal { + peer_chat_public_key, + cipher_suite, + plaintext, + }, + ), + ProductDeviceChatAuthorityRequest::Open { + calling_product_id, + peer_chat_public_key, + cipher_suite, + combined_ciphertext, + } => ( + calling_product_id, + SsoProductDeviceChatOperation::Open { + peer_chat_public_key, + cipher_suite, + combined_ciphertext, + }, + ), + ProductDeviceChatAuthorityRequest::SignRequestProof { + calling_product_id, + product_account_id, + payload, + } => ( + calling_product_id, + SsoProductDeviceChatOperation::SignRequestProof { + derivation_index: product_account_id.derivation_index, + payload, + }, + ), + ProductDeviceChatAuthorityRequest::Identity { calling_product_id } => { + (calling_product_id, SsoProductDeviceChatOperation::Identity) + } + ProductDeviceChatAuthorityRequest::VerifyPeerDevice { + calling_product_id, + peer_identity_account_id, + peer_chat_public_key, + peer_device_account_id, + proof, + } => ( + calling_product_id, + SsoProductDeviceChatOperation::VerifyPeerDevice { + peer_identity_account_id, + peer_chat_public_key, + peer_device_account_id, + proof, + }, + ), + }; + self.call( + cx, + session, + ProductRequest { + calling_product_id, + payload: operation, + }, + ) + .await + .map_err(|error| { + ProductDeviceChatAuthorityError::Unavailable(remote_authority_error(error).to_string()) + })? + .map_err(|error| match error { + v01::HostProductDeviceChatError::NotConnected => { + ProductDeviceChatAuthorityError::Disconnected + } + v01::HostProductDeviceChatError::Rejected => ProductDeviceChatAuthorityError::Rejected, + v01::HostProductDeviceChatError::InvalidPeerKey => { + ProductDeviceChatAuthorityError::InvalidPeerKey + } + v01::HostProductDeviceChatError::InvalidCiphertext => { + ProductDeviceChatAuthorityError::InvalidCiphertext + } + v01::HostProductDeviceChatError::Unknown { reason } => { + ProductDeviceChatAuthorityError::Unavailable(reason) + } + }) + } /// Ask the paired signing host to allocate product resources, caching any /// returned allowance keys. @@ -698,6 +829,7 @@ impl PairingHost { .await?; } SsoAllocatedResource::SmartContractAllowance => {} + SsoAllocatedResource::ProductStatementStoreAllowance => {} SsoAllocatedResource::AutoSigning { product_root_private_key, ring_vrf_domain_entropy, diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index bb09d90e2..9712d8bce 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -42,8 +42,9 @@ pub(crate) use sso_service::SigningHostSsoService; use super::authority::{ AuthorityError, AuthoritySession, BulletinAllowanceKey, CreateTransactionAuthorityRequest, - ProductAuthority, SignPayloadAuthorityRequest, SignRawAuthorityRequest, - StatementStoreAllowanceKey, authority_session_validation_id, + ProductAuthority, ProductDeviceChatAuthorityError, ProductDeviceChatAuthorityRequest, + SignPayloadAuthorityRequest, SignRawAuthorityRequest, StatementStoreAllowanceKey, + authority_session_validation_id, execute_product_device_chat, }; use super::ring_vrf_registry::RingVrfRegistryStore; use super::{RuntimeServices, connected_session_ui_info, validate_vrf_transcript}; @@ -1012,6 +1013,50 @@ impl ProductAuthority for SigningHost { sign_from_entropy(&entropy, &request.payload.message) } + async fn product_device_chat( + &self, + _cx: &CallContext, + session: &AuthoritySession, + request: ProductDeviceChatAuthorityRequest, + ) -> Result { + self.require_current_session(session) + .map_err(|_| ProductDeviceChatAuthorityError::Disconnected)?; + let request = match request { + ProductDeviceChatAuthorityRequest::SignRequestProof { + calling_product_id, + product_account_id, + payload, + } => { + if product_account_id.dot_ns_identifier != calling_product_id { + return Err(ProductDeviceChatAuthorityError::Unavailable( + "product account does not belong to the calling product".to_string(), + )); + } + let keypair = self.product_keypair(&product_account_id).map_err(|error| { + ProductDeviceChatAuthorityError::Unavailable(error.to_string()) + })?; + let signature = keypair + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &payload, &keypair.public) + .to_bytes(); + return Ok(v01::HostProductDeviceChatResponse::RequestProofSigned { signature }); + } + request => request, + }; + let entropy = self + .root_entropy() + .map_err(|error| ProductDeviceChatAuthorityError::Unavailable(error.to_string()))?; + let (identity, identity_chat_private_key) = + sso_responder::derive_responder_identity(&entropy, self.network_suffix()) + .map_err(|error| ProductDeviceChatAuthorityError::Unavailable(error.to_string()))?; + let identity_chat_private_key = Zeroizing::new(identity_chat_private_key); + execute_product_device_chat( + &identity_chat_private_key, + identity.statement_public_key, + request, + ) + } + async fn allocate_resources( &self, _cx: &CallContext, @@ -1061,6 +1106,18 @@ impl ProductAuthority for SigningHost { .grant_auto_signing(session, &product_id) .map(|_| v01::AllocationOutcome::Allocated) .map_err(sso_responder::AllowanceAllocationError::Authority), + v01::AllocatableResource::ProductStatementStoreAllowance(index) => { + sso_responder::allocate_product_statement_store_allowance( + &self.services, + self, + session, + &product_id, + &index, + OnExistingAllowancePolicy::Increase, + ) + .await + .map(|()| v01::AllocationOutcome::Allocated) + } }; match outcome { Ok(outcome) => outcomes.push(outcome), @@ -1330,14 +1387,19 @@ mod tests { SR25519_SIGNING_CONTEXT, raw_payload_bytes, }; use crate::host_logic::extrinsic::tests::split_v4; + use crate::host_logic::permissions::PermissionsService; use crate::host_logic::product_account::{ derive_identity_keypair, derive_product_keypair, derive_ring_vrf_entropy, derive_root_keypair_from_entropy, index_bytes, }; - use crate::host_logic::sso::messages::ProductRequest; + use crate::host_logic::sso::messages::{ + ProductDeviceChatResponse, ProductRequest, RemoteMessage, RemoteMessageData, + SsoProductDeviceChatOperation, v1, + }; use crate::host_logic::transaction::{ extrinsic_payload_extensions, extrinsic_payload_preimage, }; + use crate::runtime::sso_service::Dispatch; use crate::runtime::statement_allowance::collection::PersonhoodCollection; use crate::test_support::{StubPlatform, test_spawner}; use truapi::api::{Account, Entropy, ResourceAllocation, Signing}; @@ -1345,14 +1407,20 @@ mod tests { HostAccountCreateProofRequest, HostAccountGetAliasRequest, HostAccountRegisterRingVrfKeyRequest, HostAccountRingVrfSignRequest, }; - use truapi::versioned::account::{HostAccountGetError, HostAccountGetRequest}; + use truapi::versioned::account::{ + HostAccountGetError, HostAccountGetRequest, HostProductDeviceChatError, + HostProductDeviceChatRequest, HostProductDeviceChatResponse, + }; use truapi::versioned::entropy::HostDeriveEntropyRequest; use truapi::versioned::resource_allocation::{ HostRequestResourceAllocationRequest, HostRequestResourceAllocationResponse, }; use truapi::versioned::signing::{HostSignRawError, HostSignRawRequest, HostSignRawResponse}; use truapi::{CallContext, CallError, v01}; - use truapi_platform::{HostInfo, Platform, PlatformInfo, ProductContext, SigningHostConfig}; + use truapi_platform::{ + HostInfo, PermissionAuthorizationRequest, PermissionAuthorizationStatus, Platform, + PlatformInfo, ProductContext, SigningHostConfig, + }; use verifiable::ring::RingDomainSize; const ENTROPY: [u8; 16] = [0xAB; 16]; @@ -1445,6 +1513,436 @@ mod tests { ) } + #[test] + fn statement_allowance_decisions_survive_runtime_restart_and_remain_scoped() { + let platform = Arc::new(StubPlatform { + resource_allocation_confirmed: true, + chain_connect_error: Some("offline"), + ..Default::default() + }); + let selector = Some(v01::DerivationIndex::Index(0)); + let request = PermissionAuthorizationRequest::StatementStoreAllowance { + derivation_index: selector.clone(), + }; + futures::executor::block_on(async { + let (services, authority) = signing_runtime_with_platform(platform.clone()); + authority + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let first = product_runtime(services, authority.clone()); + let session = authority.current_session().unwrap(); + first + .require_statement_store_allowance(&session, selector.clone()) + .await + .unwrap(); + drop(first); + drop(authority); + + let (services, authority) = signing_runtime_with_platform(platform.clone()); + authority + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let restarted = product_runtime(services.clone(), authority.clone()); + let session = authority.current_session().unwrap(); + restarted + .require_statement_store_allowance(&session, selector.clone()) + .await + .unwrap(); + assert_eq!( + platform.resource_allocation_reviews.lock().unwrap().len(), + 1 + ); + assert_eq!( + restarted + .permission_authorization_status(request.clone()) + .await + .unwrap(), + PermissionAuthorizationStatus::Authorized + ); + for separate in [ + PermissionAuthorizationRequest::StatementStoreAllowance { + derivation_index: None, + }, + PermissionAuthorizationRequest::StatementStoreAllowance { + derivation_index: Some(v01::DerivationIndex::Index(1)), + }, + PermissionAuthorizationRequest::ChatAuthority, + PermissionAuthorizationRequest::IdentityDisclosure, + ] { + assert_eq!( + restarted + .permission_authorization_status(separate) + .await + .unwrap(), + PermissionAuthorizationStatus::NotDetermined + ); + } + let other = product_runtime_for(services.clone(), authority.clone(), "other.dot"); + assert_eq!( + other + .permission_authorization_status(request.clone()) + .await + .unwrap(), + PermissionAuthorizationStatus::NotDetermined + ); + + // A connection with a different artifact store must not inherit a + // decision even with the same product id and the same authority. + let mut adapters = crate::host_core::ConnectionAdapters::from_services(&services); + adapters.platform = Arc::new(StubPlatform::default()); + let other_artifact = ProductRuntimeHost::from_services( + services, + adapters, + authority, + ProductContext::new("myapp.dot".to_string()).unwrap(), + ); + assert_eq!( + other_artifact + .permission_authorization_status(request.clone()) + .await + .unwrap(), + PermissionAuthorizationStatus::NotDetermined + ); + + restarted + .set_permission_authorization_status( + request.clone(), + PermissionAuthorizationStatus::Denied, + ) + .await + .unwrap(); + let result = ResourceAllocation::request( + &restarted, + &CallContext::default(), + HostRequestResourceAllocationRequest::V1( + v01::HostRequestResourceAllocationRequest { + resources: vec![v01::AllocatableResource::ProductStatementStoreAllowance( + v01::DerivationIndex::Index(0), + )], + }, + ), + ) + .await; + assert!(result.is_err()); + assert!(platform.sent_rpc.lock().unwrap().is_empty()); + assert_eq!( + platform.resource_allocation_reviews.lock().unwrap().len(), + 1 + ); + assert_eq!( + restarted + .permission_authorization_status(request.clone()) + .await + .unwrap(), + PermissionAuthorizationStatus::Denied + ); + restarted + .set_permission_authorization_status( + request, + PermissionAuthorizationStatus::NotDetermined, + ) + .await + .unwrap(); + restarted + .require_statement_store_allowance(&session, selector) + .await + .unwrap(); + assert_eq!( + platform.resource_allocation_reviews.lock().unwrap().len(), + 2 + ); + }); + } + + #[test] + fn explicit_statement_increases_each_prompt_and_initial_approval_also_grants_ensure() { + let platform = Arc::new(StubPlatform { + resource_allocation_confirmed: true, + chain_connect_error: Some("offline"), + ..Default::default() + }); + let (services, authority) = signing_runtime_with_platform(platform.clone()); + futures::executor::block_on(async { + authority + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let runtime = product_runtime(services, authority.clone()); + let session = authority.current_session().unwrap(); + for expected_reviews in 1..=2 { + // Chain availability is independent of consent: a failed + // provisioning attempt must not force a second grant prompt. + ResourceAllocation::request( + &runtime, + &CallContext::default(), + HostRequestResourceAllocationRequest::V1( + v01::HostRequestResourceAllocationRequest { + resources: vec![v01::AllocatableResource::StatementStoreAllowance], + }, + ), + ) + .await + .unwrap(); + assert_eq!( + platform + .resource_allocation_reviews + .lock() + .expect("reviews") + .len(), + expected_reviews + ); + runtime + .require_statement_store_allowance(&session, None) + .await + .unwrap(); + assert_eq!( + platform + .resource_allocation_reviews + .lock() + .expect("reviews") + .len(), + expected_reviews + ); + } + }); + } + + #[test] + fn cancelling_an_explicit_increase_preserves_the_implicit_allowance_grant() { + let platform = Arc::new(StubPlatform::default()); + let (services, authority) = signing_runtime_with_platform(platform.clone()); + futures::executor::block_on(async { + authority + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let runtime = product_runtime(services, authority.clone()); + let grant = PermissionAuthorizationRequest::StatementStoreAllowance { + derivation_index: None, + }; + runtime + .set_permission_authorization_status( + grant.clone(), + PermissionAuthorizationStatus::Authorized, + ) + .await + .unwrap(); + assert!( + ResourceAllocation::request( + &runtime, + &CallContext::default(), + HostRequestResourceAllocationRequest::V1( + v01::HostRequestResourceAllocationRequest { + resources: vec![v01::AllocatableResource::StatementStoreAllowance], + } + ) + ) + .await + .is_err() + ); + assert_eq!( + runtime + .permission_authorization_status(grant) + .await + .unwrap(), + PermissionAuthorizationStatus::Authorized + ); + runtime + .require_statement_store_allowance(&authority.current_session().unwrap(), None) + .await + .unwrap(); + assert_eq!( + platform + .resource_allocation_reviews + .lock() + .expect("reviews") + .len(), + 1 + ); + assert!(platform.sent_rpc.lock().expect("rpc").is_empty()); + }); + } + + #[test] + fn administration_during_explicit_review_wins_over_the_confirmation() { + use futures::FutureExt; + for (before, administrative) in [ + ( + PermissionAuthorizationStatus::NotDetermined, + PermissionAuthorizationStatus::Denied, + ), + ( + PermissionAuthorizationStatus::Authorized, + PermissionAuthorizationStatus::NotDetermined, + ), + ] { + let (release, gate) = futures::channel::oneshot::channel(); + let platform = Arc::new(StubPlatform { + resource_allocation_confirmed: true, + ..Default::default() + }); + *platform + .resource_allocation_confirmation_gate + .lock() + .expect("gate") = Some(gate); + let (services, authority) = signing_runtime_with_platform(platform.clone()); + futures::executor::block_on(async { + authority + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let runtime = product_runtime(services, authority); + let grant = PermissionAuthorizationRequest::StatementStoreAllowance { + derivation_index: None, + }; + runtime + .set_permission_authorization_status(grant.clone(), before) + .await + .unwrap(); + let cx = CallContext::default(); + let allocation = ResourceAllocation::request( + &runtime, + &cx, + HostRequestResourceAllocationRequest::V1( + v01::HostRequestResourceAllocationRequest { + resources: vec![v01::AllocatableResource::StatementStoreAllowance], + }, + ), + ); + futures::pin_mut!(allocation); + assert!(allocation.as_mut().now_or_never().is_none()); + runtime + .set_permission_authorization_status(grant.clone(), administrative) + .await + .unwrap(); + release.send(()).unwrap(); + assert!(allocation.await.is_err()); + assert_eq!( + runtime + .permission_authorization_status(grant) + .await + .unwrap(), + administrative + ); + assert!(platform.sent_rpc.lock().expect("rpc").is_empty()); + }); + } + } + + #[test] + fn statement_allowance_storage_failure_never_prompts_or_allocates() { + let platform = Arc::new(StubPlatform { + local_storage_error: Some("storage unavailable"), + resource_allocation_confirmed: true, + ..Default::default() + }); + let (services, authority) = + signing_runtime_with_platform(Arc::new(StubPlatform::default())); + futures::executor::block_on(async { + authority + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let mut adapters = crate::host_core::ConnectionAdapters::from_services(&services); + adapters.platform = platform.clone(); + let runtime = ProductRuntimeHost::from_services( + services, + adapters, + authority, + ProductContext::new("myapp.dot".to_string()).unwrap(), + ); + let result = ResourceAllocation::request( + &runtime, + &CallContext::default(), + HostRequestResourceAllocationRequest::V1( + v01::HostRequestResourceAllocationRequest { + resources: vec![v01::AllocatableResource::StatementStoreAllowance], + }, + ), + ) + .await; + assert!(result.is_err()); + assert!( + platform + .resource_allocation_reviews + .lock() + .unwrap() + .is_empty() + ); + assert!(platform.sent_rpc.lock().unwrap().is_empty()); + }); + } + + #[test] + fn statement_consent_cannot_survive_same_account_reactivation() { + use futures::FutureExt; + for explicit in [false, true] { + let (release, gate) = futures::channel::oneshot::channel(); + let platform = Arc::new(StubPlatform { + resource_allocation_confirmed: true, + ..Default::default() + }); + *platform + .resource_allocation_confirmation_gate + .lock() + .expect("gate lock") = Some(gate); + let (services, authority) = signing_runtime_with_platform(platform.clone()); + futures::executor::block_on(async { + authority + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let runtime = product_runtime(services, authority.clone()); + let session = authority.current_session().unwrap(); + let consent = async { + if explicit { + ResourceAllocation::request( + &runtime, + &CallContext::default(), + HostRequestResourceAllocationRequest::V1( + v01::HostRequestResourceAllocationRequest { + resources: vec![ + v01::AllocatableResource::StatementStoreAllowance, + ], + }, + ), + ) + .await + .map(|_| ()) + .map_err(|error| format!("{error:?}")) + } else { + runtime + .require_statement_store_allowance(&session, None) + .await + } + }; + futures::pin_mut!(consent); + assert!(consent.as_mut().now_or_never().is_none()); + authority.disconnect().await; + authority + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + release.send(()).unwrap(); + assert!(consent.await.is_err()); + assert_eq!( + runtime + .permission_authorization_status( + PermissionAuthorizationRequest::StatementStoreAllowance { + derivation_index: None + }, + ) + .await + .unwrap(), + PermissionAuthorizationStatus::NotDetermined + ); + assert!(platform.sent_rpc.lock().expect("rpc lock").is_empty()); + }); + } + } + fn vrf_request(product_id: &str) -> v01::HostAccountSignVrfRequest { v01::HostAccountSignVrfRequest { account: v01::ProductAccountId { @@ -2645,6 +3143,410 @@ mod tests { ); } + #[test] + fn product_chat_request_proof_signs_unframed_payload() { + let (services, activation) = signing_runtime_with_platform(Arc::new(StubPlatform { + chat_authority_confirmed: true, + ..StubPlatform::default() + })); + futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let runtime = product_runtime(services, activation); + let cx = CallContext::default(); + let payload = b"canonical chat request proof".to_vec(); + let request = truapi::versioned::account::HostProductDeviceChatRequest::V1( + v01::HostProductDeviceChatRequest::SignRequestProof { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: v01::DerivationIndex::Index(0), + }, + payload: payload.clone(), + }, + ); + let response = futures::executor::block_on(runtime.product_device_chat(&cx, request)) + .expect("Chat request proof signing succeeds"); + let truapi::versioned::account::HostProductDeviceChatResponse::V1( + v01::HostProductDeviceChatResponse::RequestProofSigned { signature }, + ) = response + else { + panic!("unexpected Chat response"); + }; + let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); + let keypair = derive_product_keypair(&root, "myapp.dot", index_bytes(0)).unwrap(); + let signature = schnorrkel::Signature::from_bytes(&signature).expect("64-byte signature"); + assert!( + keypair + .public + .verify_simple(SR25519_SIGNING_CONTEXT, &payload, &signature) + .is_ok(), + "signature verifies over the canonical unframed payload", + ); + assert!( + keypair + .public + .verify_simple( + SR25519_SIGNING_CONTEXT, + b"canonical chat request proof", + &signature, + ) + .is_err(), + "Chat proof signing never applies wallet-message framing", + ); + } + + #[test] + fn product_chat_username_grant_does_not_authorize_crypto() { + futures::executor::block_on(async { + let platform = Arc::new(StubPlatform::default()); + let (services, activation) = signing_runtime_with_platform(platform.clone()); + activation + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let runtime = product_runtime(services, activation); + runtime + .set_permission_authorization_status( + PermissionAuthorizationRequest::IdentityDisclosure, + PermissionAuthorizationStatus::Authorized, + ) + .await + .unwrap(); + let cx = CallContext::default(); + let request = + HostProductDeviceChatRequest::V1(v01::HostProductDeviceChatRequest::Bind { + product_account_id: product_account(0), + peer_identity_account_id: [0x33; 32], + peer_chat_public_key: x25519_dalek::PublicKey::from( + &x25519_dalek::StaticSecret::from([0x22; 32]), + ) + .to_bytes(), + }); + + for _ in 0..2 { + assert!(matches!( + runtime.product_device_chat(&cx, request.clone()).await, + Err(CallError::Domain(HostProductDeviceChatError::V1( + v01::HostProductDeviceChatError::Rejected + ))) + )); + } + assert_eq!( + platform.chat_authority_reviews.lock().len(), + 1, + "Chat requires its own prompt, then respects the persisted refusal" + ); + assert_eq!( + runtime + .permission_authorization_status(PermissionAuthorizationRequest::ChatAuthority) + .await + .unwrap(), + PermissionAuthorizationStatus::Denied + ); + assert_eq!( + runtime + .permission_authorization_status( + PermissionAuthorizationRequest::IdentityDisclosure, + ) + .await + .unwrap(), + PermissionAuthorizationStatus::Authorized + ); + }); + } + + #[test] + fn product_chat_consent_is_cached_and_revocation_blocks_crypto() { + futures::executor::block_on(async { + let platform = Arc::new(StubPlatform { + chat_authority_confirmed: true, + ..StubPlatform::default() + }); + let (services, activation) = signing_runtime_with_platform(platform.clone()); + activation + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let runtime = product_runtime(services, activation); + runtime + .set_permission_authorization_status( + PermissionAuthorizationRequest::IdentityDisclosure, + PermissionAuthorizationStatus::Authorized, + ) + .await + .unwrap(); + runtime + .set_permission_authorization_status( + PermissionAuthorizationRequest::ChatAuthority, + PermissionAuthorizationStatus::Denied, + ) + .await + .unwrap(); + let cx = CallContext::default(); + let peer_chat_public_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from([0x22; 32])) + .to_bytes(); + let bind = HostProductDeviceChatRequest::V1(v01::HostProductDeviceChatRequest::Bind { + product_account_id: product_account(0), + peer_identity_account_id: [0x33; 32], + peer_chat_public_key, + }); + assert!(matches!( + runtime.product_device_chat(&cx, bind.clone()).await, + Err(CallError::Domain(HostProductDeviceChatError::V1( + v01::HostProductDeviceChatError::Rejected + ))) + )); + assert!(platform.chat_authority_reviews.lock().is_empty()); + + runtime + .set_permission_authorization_status( + PermissionAuthorizationRequest::ChatAuthority, + PermissionAuthorizationStatus::NotDetermined, + ) + .await + .unwrap(); + assert!(matches!( + runtime + .product_device_chat(&cx, bind.clone()) + .await + .unwrap(), + HostProductDeviceChatResponse::V1( + v01::HostProductDeviceChatResponse::IdentityBinding { .. } + ) + )); + let plaintext = b"Chat consent protects private messages".to_vec(); + let seal = HostProductDeviceChatRequest::V1(v01::HostProductDeviceChatRequest::Seal { + product_account_id: product_account(0), + peer_chat_public_key, + cipher_suite: v01::HostProductDeviceChatCipherSuite::LegacyV2, + plaintext: plaintext.clone(), + }); + let HostProductDeviceChatResponse::V1(v01::HostProductDeviceChatResponse::Sealed { + combined_ciphertext, + }) = runtime + .product_device_chat(&cx, seal.clone()) + .await + .unwrap() + else { + panic!("expected sealed Chat message"); + }; + let open = HostProductDeviceChatRequest::V1(v01::HostProductDeviceChatRequest::Open { + product_account_id: product_account(0), + peer_chat_public_key, + cipher_suite: v01::HostProductDeviceChatCipherSuite::LegacyV2, + combined_ciphertext, + }); + assert_eq!( + runtime + .product_device_chat(&cx, open.clone()) + .await + .unwrap(), + HostProductDeviceChatResponse::V1(v01::HostProductDeviceChatResponse::Opened { + plaintext + }) + ); + assert_eq!(platform.chat_authority_reviews.lock().len(), 1); + + runtime + .set_permission_authorization_status( + PermissionAuthorizationRequest::ChatAuthority, + PermissionAuthorizationStatus::Denied, + ) + .await + .unwrap(); + for request in [bind, seal, open] { + assert!(matches!( + runtime.product_device_chat(&cx, request).await, + Err(CallError::Domain(HostProductDeviceChatError::V1( + v01::HostProductDeviceChatError::Rejected + ))) + )); + } + assert_eq!( + platform.chat_authority_reviews.lock().len(), + 1, + "revocation must not be overridden by another prompt" + ); + }); + } + + async fn sso_chat( + service: &super::sso_service::SigningHostSsoService, + payload: SsoProductDeviceChatOperation, + ) -> ProductDeviceChatResponse { + let message = RemoteMessage::request( + "chat-consent".to_string(), + ProductRequest { + calling_product_id: "myapp.dot".to_string(), + payload, + }, + ); + let Dispatch::Response(answer) = service.dispatch(service.current_session(), message).await + else { + panic!("expected SSO response"); + }; + let RemoteMessageData::V1(v1::RemoteMessage::ProductDeviceChatResponse(response)) = + answer.message.data + else { + panic!("expected SSO Chat response"); + }; + response.payload + } + + #[test] + fn sso_chat_username_grant_does_not_authorize_crypto() { + futures::executor::block_on(async { + let platform = Arc::new(StubPlatform::default()); + let (_, activation) = signing_runtime_with_platform(platform.clone()); + activation + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let service = super::sso_service::SigningHostSsoService::new(activation); + let permissions = + PermissionsService::new(platform.as_ref(), platform.as_ref(), "myapp.dot"); + permissions + .set_authorization_status( + &PermissionAuthorizationRequest::IdentityDisclosure, + PermissionAuthorizationStatus::Authorized, + ) + .await + .unwrap(); + let request = SsoProductDeviceChatOperation::Bind { + derivation_index: v01::DerivationIndex::Index(0), + peer_identity_account_id: [0x33; 32], + peer_chat_public_key: x25519_dalek::PublicKey::from( + &x25519_dalek::StaticSecret::from([0x22; 32]), + ) + .to_bytes(), + }; + + for _ in 0..2 { + assert_eq!( + sso_chat(&service, request.clone()).await, + Err(v01::HostProductDeviceChatError::Rejected) + ); + } + assert_eq!( + platform.chat_authority_reviews.lock().len(), + 1, + "SSO Chat requires its own prompt, then respects the persisted refusal" + ); + assert_eq!( + permissions + .authorization_status(&PermissionAuthorizationRequest::ChatAuthority) + .await + .unwrap(), + PermissionAuthorizationStatus::Denied + ); + assert_eq!( + permissions + .authorization_status(&PermissionAuthorizationRequest::IdentityDisclosure) + .await + .unwrap(), + PermissionAuthorizationStatus::Authorized + ); + }); + } + + #[test] + fn sso_chat_consent_is_cached_and_revocation_blocks_crypto() { + futures::executor::block_on(async { + let platform = Arc::new(StubPlatform { + chat_authority_confirmed: true, + ..StubPlatform::default() + }); + let (_, activation) = signing_runtime_with_platform(platform.clone()); + activation + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let service = super::sso_service::SigningHostSsoService::new(activation); + let permissions = + PermissionsService::new(platform.as_ref(), platform.as_ref(), "myapp.dot"); + permissions + .set_authorization_status( + &PermissionAuthorizationRequest::IdentityDisclosure, + PermissionAuthorizationStatus::Authorized, + ) + .await + .unwrap(); + permissions + .set_authorization_status( + &PermissionAuthorizationRequest::ChatAuthority, + PermissionAuthorizationStatus::Denied, + ) + .await + .unwrap(); + let peer_chat_public_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from([0x22; 32])) + .to_bytes(); + let bind = SsoProductDeviceChatOperation::Bind { + derivation_index: v01::DerivationIndex::Index(0), + peer_identity_account_id: [0x33; 32], + peer_chat_public_key, + }; + assert_eq!( + sso_chat(&service, bind.clone()).await, + Err(v01::HostProductDeviceChatError::Rejected) + ); + assert!(platform.chat_authority_reviews.lock().is_empty()); + + permissions + .set_authorization_status( + &PermissionAuthorizationRequest::ChatAuthority, + PermissionAuthorizationStatus::NotDetermined, + ) + .await + .unwrap(); + assert!(matches!( + sso_chat(&service, bind.clone()).await.unwrap(), + v01::HostProductDeviceChatResponse::IdentityBinding { .. } + )); + let plaintext = b"SSO Chat consent protects private messages".to_vec(); + let seal = SsoProductDeviceChatOperation::Seal { + peer_chat_public_key, + cipher_suite: v01::HostProductDeviceChatCipherSuite::LegacyV2, + plaintext: plaintext.clone(), + }; + let v01::HostProductDeviceChatResponse::Sealed { + combined_ciphertext, + } = sso_chat(&service, seal.clone()).await.unwrap() + else { + panic!("expected sealed SSO Chat message"); + }; + let open = SsoProductDeviceChatOperation::Open { + peer_chat_public_key, + cipher_suite: v01::HostProductDeviceChatCipherSuite::LegacyV2, + combined_ciphertext, + }; + assert_eq!( + sso_chat(&service, open.clone()).await.unwrap(), + v01::HostProductDeviceChatResponse::Opened { plaintext } + ); + assert_eq!(platform.chat_authority_reviews.lock().len(), 1); + + permissions + .set_authorization_status( + &PermissionAuthorizationRequest::ChatAuthority, + PermissionAuthorizationStatus::Denied, + ) + .await + .unwrap(); + for request in [bind, seal, open] { + assert_eq!( + sso_chat(&service, request).await, + Err(v01::HostProductDeviceChatError::Rejected) + ); + } + assert_eq!( + platform.chat_authority_reviews.lock().len(), + 1, + "revocation must not be overridden by another SSO prompt" + ); + }); + } + #[test] fn reactivation_invalidates_prior_session_snapshot() { let (_services, authority) = signing_runtime(); diff --git a/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs b/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs index aa3888230..6fe69310f 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs @@ -7,9 +7,10 @@ //! `statement_allowance::renewal`, either once (`renew_now`) or on a periodic //! tick (`start_renewal_loop`). //! -//! All signing hosts record the ledger during allocation. The resident renewal -//! driver belongs to the native host API; browser hosts currently allocate on -//! demand without starting that driver. +//! Only host-owned wallet/paired-device targets are renewed in the background. +//! Product allowance decisions belong to product connection storage, which may +//! be artifact-scoped and is unavailable here. They are ensured on demand by an +//! authorized product request; old unscoped product ledger entries are pruned. #[cfg(not(target_arch = "wasm32"))] use std::sync::Arc; @@ -19,17 +20,22 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use futures::lock::Mutex; +#[cfg(any(test, not(target_arch = "wasm32")))] use parity_scale_codec::{Decode, Encode}; #[cfg(not(target_arch = "wasm32"))] use tracing::debug; #[cfg(any(test, not(target_arch = "wasm32")))] use tracing::info; +#[cfg(any(test, not(target_arch = "wasm32")))] use tracing::warn; +#[cfg(any(test, not(target_arch = "wasm32")))] use truapi_platform::{CoreStorage, CoreStorageKey}; +#[cfg(any(test, not(target_arch = "wasm32")))] use super::SigningHost; #[cfg(not(target_arch = "wasm32"))] use super::sso_responder::current_unix_secs; +#[cfg(any(test, not(target_arch = "wasm32")))] use crate::host_logic::product_account::derive_root_keypair_from_entropy; #[cfg(any(test, not(target_arch = "wasm32")))] use crate::host_logic::product_account::{derive_identity_keypair, derive_sr25519_hard_path}; @@ -57,6 +63,7 @@ const CLOCK_FAILURE_TICK_DELAY: Duration = Duration::from_secs(3_600); /// Entropy-derived variants are recipes, not raw account ids, so the ledger /// survives root-entropy rotation (the CLI rotates auto-managed accounts on /// slot exhaustion). +#[cfg(any(test, not(target_arch = "wasm32")))] #[derive(Clone, Debug, PartialEq, Eq, Encode, Decode)] pub enum StatementRenewalTarget { /// `//allowance//statement-store//{product_id}` from the active root entropy. @@ -75,6 +82,19 @@ pub enum StatementRenewalTarget { }, } +#[cfg(any(test, not(target_arch = "wasm32")))] +impl StatementRenewalTarget { + /// These legacy entries cannot identify the artifact-scoped decision that + /// authorized them. Never infer authorization from host-global storage. + fn is_product_grant(&self) -> bool { + match self { + Self::ProductStatementAllowance { .. } => true, + Self::Account { label, .. } => label.starts_with("product-account:"), + Self::WalletSso => false, + } + } +} + /// One persisted ledger entry. /// /// A derivation recipe resolves under whatever root entropy is active, so it @@ -82,12 +102,14 @@ pub enum StatementRenewalTarget { /// not re-derive, so it records the root public key that promised it and is /// ignored under any other identity: without that, a later account would spend /// its own slot-table capacity keeping a previous account's peer allowed. +#[cfg(any(test, not(target_arch = "wasm32")))] #[derive(Clone, Debug, PartialEq, Eq, Encode, Decode)] struct LedgerEntry { target: StatementRenewalTarget, owner: Option<[u8; 32]>, } +#[cfg(any(test, not(target_arch = "wasm32")))] impl LedgerEntry { /// Record `target` under `owner`, which only raw account ids retain. fn new(target: StatementRenewalTarget, owner: [u8; 32]) -> Self { @@ -108,6 +130,7 @@ impl LedgerEntry { /// Root public key of the identity rooted at `entropy`, used to own raw ledger /// entries. +#[cfg(any(test, not(target_arch = "wasm32")))] fn owner_key(entropy: &[u8]) -> Result<[u8; 32], String> { derive_root_keypair_from_entropy(entropy) .map(|pair| pair.public.to_bytes()) @@ -122,6 +145,7 @@ pub(super) struct RenewalState { registration_lock: Mutex<()>, /// Serializes read-modify-write cycles on the ledger so a concurrent /// allocation cannot drop another's entry. + #[cfg(any(test, not(target_arch = "wasm32")))] ledger_lock: Mutex<()>, #[cfg(not(target_arch = "wasm32"))] loop_started: AtomicBool, @@ -139,6 +163,7 @@ impl RenewalState { &self.registration_lock } + #[cfg(any(test, not(target_arch = "wasm32")))] fn ledger_lock(&self) -> &Mutex<()> { &self.ledger_lock } @@ -165,6 +190,7 @@ impl RenewalState { /// the pass: the entries are recipes and raw account ids that /// [`track_targets`] rebuilds on the next allocation or pairing, so refusing to /// renew anything is strictly worse than starting over. +#[cfg(any(test, not(target_arch = "wasm32")))] async fn read_entries(storage: &(impl CoreStorage + ?Sized)) -> Result, String> { let Some(blob) = storage .read_core_storage(CoreStorageKey::StatementRenewalTargets) @@ -184,12 +210,19 @@ async fn read_entries(storage: &(impl CoreStorage + ?Sized)) -> Result, owner: [u8; 32], new_targets: Vec, ) -> Result<(), String> { + if new_targets + .iter() + .any(StatementRenewalTarget::is_product_grant) + { + return Err("Product statement allowances require an authorized product request; background renewal cannot access artifact-scoped permissions".to_string()); + } let _guard = ledger_lock.lock().await; let mut entries = read_entries(storage).await?; let mut changed = false; @@ -233,6 +266,7 @@ async fn untrack_account( Ok(true) } +#[cfg(any(test, not(target_arch = "wasm32")))] async fn write_entries( storage: &(impl CoreStorage + ?Sized), entries: &[LedgerEntry], @@ -243,6 +277,7 @@ async fn write_entries( .map_err(|err| format!("renewal ledger write failed: {}", err.reason)) } +#[cfg(any(test, not(target_arch = "wasm32")))] fn decode_entries(blob: &[u8]) -> Result, String> { let mut input = blob; let entries = Vec::::decode(&mut input) @@ -302,6 +337,7 @@ fn resolve_target( } /// Record `targets` in the ledger under the active identity. +#[cfg(any(test, not(target_arch = "wasm32")))] pub(super) async fn track( signing_host: &SigningHost, targets: Vec, @@ -382,7 +418,7 @@ async fn owned_targets( let (owned, foreign): (Vec<_>, Vec<_>) = read_entries(storage) .await? .into_iter() - .partition(|entry| entry.is_owned_by(owner)); + .partition(|entry| entry.is_owned_by(owner) && !entry.target.is_product_grant()); let pruned: Vec = foreign .iter() .map(|entry| target_label(&entry.target)) @@ -390,7 +426,7 @@ async fn owned_targets( if !foreign.is_empty() { warn!( dropped = ?pruned, - "pruning renewal targets promised by a previous identity" + "pruning foreign or unscoped product renewal targets" ); write_entries(storage, &owned).await?; } @@ -663,18 +699,26 @@ mod tests { fn concurrent_tracks_do_not_drop_an_entry() { let storage = YieldingStorage::default(); let ledger_lock = lock(); + let first_target = StatementRenewalTarget::Account { + account_id: [8; 32], + label: "device:08".to_string(), + }; + let second_target = StatementRenewalTarget::Account { + account_id: [9; 32], + label: "device:09".to_string(), + }; futures::executor::block_on(async { let (first, second) = futures::join!( - track_targets(&storage, &ledger_lock, OWNER, vec![product("a.dot")]), - track_targets(&storage, &ledger_lock, OWNER, vec![product("b.dot")]), + track_targets(&storage, &ledger_lock, OWNER, vec![first_target.clone()]), + track_targets(&storage, &ledger_lock, OWNER, vec![second_target.clone()]), ); first.unwrap(); second.unwrap(); let mut targets = read_targets(&storage, OWNER).await.unwrap(); targets.sort_by_key(|target| format!("{target:?}")); - assert_eq!(targets, vec![product("a.dot"), product("b.dot")]); + assert_eq!(targets, vec![first_target, second_target]); }); } @@ -777,14 +821,19 @@ mod tests { let (pruned, tracked) = futures::join!( owned_targets(&storage, &ledger_lock, OWNER), - track_targets(&storage, &ledger_lock, OWNER, vec![product("a.dot")]), + track_targets( + &storage, + &ledger_lock, + OWNER, + vec![StatementRenewalTarget::WalletSso] + ), ); pruned.unwrap(); tracked.unwrap(); assert_eq!( read_targets(&storage, OWNER).await.unwrap(), - vec![product("a.dot")], + vec![StatementRenewalTarget::WalletSso], "the concurrently tracked target was overwritten by the prune" ); }); @@ -805,20 +854,25 @@ mod tests { track_targets(&storage, &lock(), OTHER_OWNER, vec![device]) .await .unwrap(); - track_targets(&storage, &lock(), OWNER, vec![product("a.dot")]) - .await - .unwrap(); + track_targets( + &storage, + &lock(), + OWNER, + vec![StatementRenewalTarget::WalletSso], + ) + .await + .unwrap(); let (targets, pruned) = owned_targets(&storage, &lock(), OWNER).await.unwrap(); - assert_eq!(targets, vec![product("a.dot")]); + assert_eq!(targets, vec![StatementRenewalTarget::WalletSso]); // Reported, not just dropped: the pass is a host's only view of the // ledger, so a silent prune is one it cannot notice or re-track. assert_eq!(pruned, vec!["device".to_string()]); // Dropped, not merely skipped, so the cost is paid once. assert_eq!( read_entries(&storage).await.unwrap(), - vec![LedgerEntry::new(product("a.dot"), OWNER)] + vec![LedgerEntry::new(StatementRenewalTarget::WalletSso, OWNER)] ); }); } @@ -849,14 +903,19 @@ mod tests { let storage = MemStorage::default(); futures::executor::block_on(async { - track_targets(&storage, &lock(), OWNER, vec![product("a.dot")]) - .await - .unwrap(); + track_targets( + &storage, + &lock(), + OWNER, + vec![StatementRenewalTarget::WalletSso], + ) + .await + .unwrap(); let after_seeding = storage.writes(); let (targets, _pruned) = owned_targets(&storage, &lock(), OWNER).await.unwrap(); - assert_eq!(targets, vec![product("a.dot")]); + assert_eq!(targets, vec![StatementRenewalTarget::WalletSso]); // Every tick calls this; rewriting the ledger each time would be waste. assert_eq!(storage.writes(), after_seeding); }); @@ -919,7 +978,7 @@ mod tests { &storage, &lock(), OWNER, - vec![StatementRenewalTarget::WalletSso, product("a.dot")], + vec![StatementRenewalTarget::WalletSso], ) .await .unwrap(); @@ -928,7 +987,7 @@ mod tests { &lock(), OWNER, vec![ - product("a.dot"), + StatementRenewalTarget::WalletSso, StatementRenewalTarget::Account { account_id: [9; 32], label: "device".to_string(), @@ -942,7 +1001,6 @@ mod tests { read_targets(&storage, OWNER).await.unwrap(), vec![ StatementRenewalTarget::WalletSso, - product("a.dot"), StatementRenewalTarget::Account { account_id: [9; 32], label: "device".to_string(), @@ -985,17 +1043,62 @@ mod tests { .write_core_storage(CoreStorageKey::StatementRenewalTargets, vec![0xff; 3]) .await .unwrap(); - track_targets(&storage, &lock(), OWNER, vec![product("a.dot")]) - .await - .unwrap(); + track_targets( + &storage, + &lock(), + OWNER, + vec![StatementRenewalTarget::WalletSso], + ) + .await + .unwrap(); assert_eq!( read_targets(&storage, OWNER).await.unwrap(), - vec![product("a.dot")] + vec![StatementRenewalTarget::WalletSso] ); }); } + #[test] + fn product_grants_cannot_bypass_artifact_revocation_through_renewal() { + let storage = MemStorage::default(); + let legacy = product("a.dot"); + let product_account = StatementRenewalTarget::Account { + account_id: [8; 32], + label: "product-account:a.dot".to_string(), + }; + let device = StatementRenewalTarget::Account { + account_id: [9; 32], + label: "device:09".to_string(), + }; + futures::executor::block_on(async { + for target in [&legacy, &product_account] { + assert!( + track_targets(&storage, &lock(), OWNER, vec![target.clone()]) + .await + .is_err() + ); + } + // Seed the old on-disk format: an upgrade must stop existing promises, + // not merely prevent new ones from being recorded. + write_entries( + &storage, + &[ + LedgerEntry::new(legacy, OWNER), + LedgerEntry::new(product_account, OWNER), + LedgerEntry::new(StatementRenewalTarget::WalletSso, OWNER), + LedgerEntry::new(device.clone(), OWNER), + ], + ) + .await + .unwrap(); + let (targets, pruned) = owned_targets(&storage, &lock(), OWNER).await.unwrap(); + assert_eq!(targets, vec![StatementRenewalTarget::WalletSso, device]); + assert_eq!(pruned, vec!["product:a.dot", "product-account:a.dot"]); + assert_eq!(read_targets(&storage, OWNER).await.unwrap(), targets); + }); + } + #[test] fn product_target_resolves_to_allocation_derivation() { let entropy = [7u8; 32]; @@ -1111,7 +1214,7 @@ mod tests { &storage, &lock(), OWNER, - vec![device.clone(), product("a.dot")], + vec![device.clone(), StatementRenewalTarget::WalletSso], ) .await .unwrap(); @@ -1119,11 +1222,11 @@ mod tests { // The recipe resolves under any identity; the raw account does not. assert_eq!( read_targets(&storage, OWNER).await.unwrap(), - vec![device, product("a.dot")] + vec![device, StatementRenewalTarget::WalletSso] ); assert_eq!( read_targets(&storage, OTHER_OWNER).await.unwrap(), - vec![product("a.dot")] + vec![StatementRenewalTarget::WalletSso] ); }); } diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index d2e155969..9d12d6526 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -62,7 +62,7 @@ const BULLETIN_AUTHORIZATION_WAIT: std::time::Duration = std::time::Duration::fr /// Upper bound on undecodable request ids acknowledged within one serve loop. const MAX_DECODE_FAILURE_REQUEST_IDS: usize = 1024; -fn derive_responder_identity( +pub(super) fn derive_responder_identity( entropy: &[u8], network_suffix: &str, ) -> Result<(ResponderIdentity, [u8; 32]), ProductAccountError> { @@ -615,17 +615,56 @@ pub(super) async fn allocate_statement_store_allowance( product_id: &str, policy: OnExistingAllowancePolicy, ) -> Result, AllowanceAllocationError> { - use super::allowance_renewal::{self, StatementRenewalTarget}; + signing_host.require_current_session(session)?; + let entropy = signing_host.root_entropy()?; + let allowance = + derive_sr25519_hard_path(&entropy, &["allowance", "statement-store", product_id])?; + register_statement_store_target( + services, + signing_host, + session, + product_id, + allowance.public.to_bytes(), + policy, + ) + .await?; + Ok(allowance.secret.to_bytes().to_vec()) +} + +pub(super) async fn allocate_product_statement_store_allowance( + services: &RuntimeServices, + signing_host: &SigningHost, + session: &AuthoritySession, + product_id: &str, + derivation_index: &v01::DerivationIndex, + policy: OnExistingAllowancePolicy, +) -> Result<(), AllowanceAllocationError> { + signing_host.require_current_session(session)?; + let target = signing_host + .product_keypair(&v01::ProductAccountId { + dot_ns_identifier: product_id.to_string(), + derivation_index: derivation_index.clone(), + })? + .public + .to_bytes(); + register_statement_store_target(services, signing_host, session, product_id, target, policy) + .await +} + +async fn register_statement_store_target( + services: &RuntimeServices, + signing_host: &SigningHost, + session: &AuthoritySession, + product_id: &str, + target: [u8; 32], + policy: OnExistingAllowancePolicy, +) -> Result<(), AllowanceAllocationError> { use crate::runtime::statement_allowance::{ self, PooledRegistrationParams, allocated_in, find_including_rings, register_statement_account_pooled, scan_collections, }; signing_host.require_current_session(session)?; - let entropy = signing_host.root_entropy()?; - let allowance = - derive_sr25519_hard_path(&entropy, &["allowance", "statement-store", product_id])?; - let target = allowance.public.to_bytes(); let candidates = signing_host.reserved_person_collection_candidates(session)?; let client = services .statement_store @@ -639,13 +678,8 @@ pub(super) async fn allocate_statement_store_allowance( // Held from the scan through the submission, not just around the submission: // the scan is what picks the free slot, so a renewal pass scanning in the gap - // would choose the same one. Released on the early return below, which - // submits nothing. + // would choose the same one. let _registration = signing_host.renewal.registration_lock().lock().await; - - // One read of the period's slot tables, reused below rather than rescanned: - // when an allowance is already recorded on chain neither a proof nor a - // submission is needed, and a ring snapshot pages in every member key. let scans = scan_collections( rpc, &chain.metadata, @@ -656,6 +690,7 @@ pub(super) async fn allocate_statement_store_allowance( reuse_existing, ) .await?; + signing_host.require_current_session(session)?; if let Some((collection, seq)) = allocated_in(&scans) { debug!( %product_id, @@ -664,76 +699,63 @@ pub(super) async fn allocate_statement_store_allowance( %collection, "statement-store allowance already allocated" ); + } else { + // Every ring back to index 0, because a membership that stopped being + // re-included still proves against the ring that holds it. + let memberships = find_including_rings(rpc, &chain.metadata, &candidates, u32::MAX).await?; + if memberships.is_empty() { + return Err(AllowanceAllocationError::MissingPersonhoodMembership { + resource: "statement-store", + }); + } signing_host.require_current_session(session)?; - return Ok(allowance.secret.to_bytes().to_vec()); - } - - // Every ring back to index 0, because a membership that stopped being - // re-included still proves against the ring that holds it. - let memberships = find_including_rings(rpc, &chain.metadata, &candidates, u32::MAX).await?; - if memberships.is_empty() { - return Err(AllowanceAllocationError::MissingPersonhoodMembership { - resource: "statement-store", - }); - } - signing_host.require_current_session(session)?; - let outcome = register_statement_account_pooled( - rpc, - &chain.metadata, - &chain.state, - &scans, - &memberships, - PooledRegistrationParams { - target: &target, - period, - network_suffix: &network_suffix, - reuse_existing, - // Connecting a product must not revoke another product's allowance. - // A full period is reported as exhaustion; reclaiming space is the - // renewal pass's job, which only ever replaces for its own ledger. - allow_eviction: false, - protected: &[], - }, - ) - .await?; - match outcome { - statement_allowance::RegistrationOutcome::Registered { - block_hash, - seq, - ring_index, - collection, - } => { - debug!( - %product_id, - %block_hash, + let outcome = register_statement_account_pooled( + rpc, + &chain.metadata, + &chain.state, + &scans, + &memberships, + PooledRegistrationParams { + target: &target, + period, + network_suffix: &network_suffix, + reuse_existing, + // Connecting a product must not revoke another product's allowance. + // A full period is reported as exhaustion; reclaiming space is the + // renewal pass's job, which only ever replaces for its own ledger. + allow_eviction: false, + protected: &[], + }, + ) + .await?; + match outcome { + statement_allowance::RegistrationOutcome::Registered { + block_hash, seq, ring_index, - %collection, - "registered statement-store allowance" - ); - } - statement_allowance::RegistrationOutcome::AlreadyAllocated { seq, collection } => { - debug!( - %product_id, - seq, - %collection, - "statement-store allowance already allocated" - ); + collection, + } => { + debug!( + %product_id, + %block_hash, + seq, + ring_index, + %collection, + "registered statement-store allowance" + ); + } + statement_allowance::RegistrationOutcome::AlreadyAllocated { seq, collection } => { + debug!( + %product_id, + seq, + %collection, + "statement-store allowance already allocated" + ); + } } } signing_host.require_current_session(session)?; - if let Err(reason) = allowance_renewal::track( - signing_host, - vec![StatementRenewalTarget::ProductStatementAllowance { - product_id: product_id.to_string(), - }], - ) - .await - { - warn!(%product_id, %reason, "failed to record statement-store renewal target"); - } - signing_host.require_current_session(session)?; - Ok(allowance.secret.to_bytes().to_vec()) + Ok(()) } #[cfg(not(target_arch = "wasm32"))] @@ -1056,7 +1078,7 @@ mod tests { /// rather than passing quietly. #[cfg(not(target_arch = "wasm32"))] #[test] - fn an_existing_allowance_is_served_without_touching_the_ring() { + fn repeated_implicit_provisioning_reuses_existing_allowance_without_submission() { use futures::FutureExt; use crate::host_logic::product_account::derive_sr25519_hard_path; @@ -1106,35 +1128,58 @@ mod tests { "state_getStorage", format!(r#""0x{}""#, hex::encode(&slot_entry)), ), + ( + "state_getStorage", + format!(r#""0x{}""#, hex::encode(b"paseo".to_vec().encode())), + ), + ( + "state_getStorage", + format!(r#""0x{}""#, hex::encode(&slot_entry)), + ), + ( + "state_getRuntimeVersion", + r#"{"specVersion":1000000,"transactionVersion":1}"#.to_string(), + ), + ( + "chain_getBlockHash", + format!(r#""0x{}""#, hex::encode([0u8; 32])), + ), + ( + "RuntimeViewFunction_execute_view_function", + format!( + r#""0x{}""#, + hex::encode(Ok::, ()>(20u32.encode()).encode()), + ), + ), ], ..Default::default() }); - let (services, signing_host) = signing_fixture(platform.clone()); + let (_services, signing_host) = signing_fixture(platform.clone()); // Bounded, because the failure mode of losing the early return is a // wait on a chain read the stub deliberately does not answer — an // unbounded test would hang instead of reporting. The bound is generous // because it is catching a hang, not asserting latency. - let secret = futures::executor::block_on(async { + futures::executor::block_on(async { let session = signing_host.current_session().unwrap(); - futures::select! { - result = allocate_statement_store_allowance( - &services, - &signing_host, + let cx = truapi::CallContext::default(); + for _ in 0..2 { + let allocation = signing_host.statement_store_allowance_key( + &cx, &session, - product_id, - OnExistingAllowancePolicy::Ignore, - ) - .fuse() => result, - _ = futures_timer::Delay::new(std::time::Duration::from_secs(30)).fuse() => { - panic!("allocation blocked on a chain read it should not have made") + product_id.to_string(), + ); + futures::pin_mut!(allocation); + let response = futures::select! { + result = allocation.fuse() => result, + _ = futures_timer::Delay::new(std::time::Duration::from_secs(30)).fuse() => { + panic!("allocation blocked on a chain read it should not have made") + } } + .expect("existing allowance succeeds"); + assert_eq!(response.public_key, allowance.public.to_bytes()); } - }) - .expect("an existing allowance is returned"); - - assert_eq!(secret, allowance.secret.to_bytes().to_vec()); - + }); let sent = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); let methods: Vec = sent .iter() @@ -1160,15 +1205,6 @@ mod tests { .any(|method| method.starts_with("author_submit")), "an extrinsic was submitted for an allowance already in place: {methods:?}" ); - // The suffix and one slot read answered it; the scan stopped at the first match. - assert_eq!( - methods - .iter() - .filter(|method| *method == "state_getStorage") - .count(), - 2, - "expected one suffix and one slot read: {methods:?}" - ); } #[test] diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs index b4fd13990..049c7bf3f 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs @@ -4,32 +4,39 @@ use std::sync::Arc; use tracing::warn; -use truapi::latest as api; +use truapi::{latest as api, v01}; use truapi_platform::{ - CreateTransactionReview, ResourceAllocationReview, SignPayloadReview, SignRawReview, - UserConfirmationReview, + CreateTransactionReview, PermissionAuthorizationStatus, ResourceAllocationReview, + SignPayloadReview, SignRawReview, StatementStoreProductSignReview, UserConfirmationReview, + normalize_product_identifier, }; use super::SigningHost; use super::sso_responder::{ - AllowanceAllocationError, allocate_bulletin_allowance, allocate_smart_contract_allowance, + AllowanceAllocationError, allocate_bulletin_allowance, + allocate_product_statement_store_allowance, allocate_smart_contract_allowance, allocate_statement_store_allowance, }; +use crate::host_logic::permissions::PermissionsService; use crate::host_logic::product_account::{ derive_ring_vrf_domain_entropy, product_public_key_to_address, }; use crate::host_logic::sso::messages::{ CreateAccountProofResponse, CreateTransactionLegacyPayload, CreateTransactionPayload, CreateTransactionRequest, CreateTransactionResponse, CreateTransactionWithLegacyAccountRequest, - GetAccountAliasResponse, ListRingVrfKeysResponse, OnExistingAllowancePolicy, ProductRequest, - ProductSubtreeRequest, ProductSubtreeResponse, RegisterRingVrfKeyResponse, - ResourceAllocationRequest, ResourceAllocationResponse, RingVrfSignResponse, - SignRawWithLegacyAccountRequest, SignRawWithLegacyAccountResponse, SignRequest, SignResponse, - SignVrfResponse, SsoAllocatedResource, SsoAllocationOutcome, + GetAccountAliasResponse, ListRingVrfKeysResponse, OnExistingAllowancePolicy, + ProductDeviceChatResponse, ProductRequest, ProductSubtreeRequest, ProductSubtreeResponse, + RegisterRingVrfKeyResponse, ResourceAllocationRequest, ResourceAllocationResponse, + RingVrfSignResponse, SignRawWithLegacyAccountRequest, SignRawWithLegacyAccountResponse, + SignRequest, SignResponse, SignVrfResponse, SsoAllocatedResource, SsoAllocationOutcome, + SsoProductDeviceChatOperation, StatementStoreProductSignRequest, + StatementStoreProductSignResponse, }; use crate::host_logic::sso::wire::ResponseOutcome; +use crate::host_logic::statement_store::validate_unsigned_statement_signing_payload; use crate::runtime::authority::{ AuthoritySession, CreateTransactionAuthorityRequest, ProductAuthority, + ProductDeviceChatAuthorityError, ProductDeviceChatAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, }; use crate::runtime::sso_service::{SsoReply, SsoRequestContext}; @@ -228,6 +235,22 @@ impl SigningHostSsoService { }, )) } + api::AllocatableResource::ProductStatementStoreAllowance(index) => { + allocate_product_statement_store_allowance( + services, + signing_host, + session, + calling_product_id, + &index, + on_existing, + ) + .await + .map(|()| { + SsoAllocationOutcome::Allocated( + SsoAllocatedResource::ProductStatementStoreAllowance, + ) + }) + } } } } @@ -506,6 +529,156 @@ impl SigningHostSsoService { .ring_vrf_sign(&cx.call, &cx.session, request) .await } + /// Sign a canonical unsigned Statement Store payload with a product account. + async fn statement_store_product_sign( + &self, + cx: &SsoRequestContext, + request: StatementStoreProductSignRequest, + ) -> StatementStoreProductSignResponse { + let calling_product_id = normalize_product_identifier(&request.calling_product_id) + .map_err(|_| "invalid calling product identifier".to_string())?; + let mut account = request.account; + let account_product_id = normalize_product_identifier(&account.dot_ns_identifier) + .map_err(|_| "invalid product account identifier".to_string())?; + if account_product_id != calling_product_id { + return Err("product account does not belong to the calling product".to_string()); + } + account.dot_ns_identifier = calling_product_id; + validate_unsigned_statement_signing_payload(&request.payload) + .map_err(|error| error.to_string())?; + self.confirm(UserConfirmationReview::StatementStoreProductSign( + StatementStoreProductSignReview { + account: account.clone(), + payload: request.payload.clone(), + }, + )) + .await?; + self.signing_host + .sign_statement_store_product_payload(&cx.call, &cx.session, account, request.payload) + .await + .map_err(|error| error.to_string()) + } + + /// Perform a Chat identity operation without exposing wallet key material. + async fn product_device_chat( + &self, + cx: &SsoRequestContext, + request: ProductRequest, + ) -> ProductDeviceChatResponse { + let calling_product_id = normalize_product_identifier(&request.calling_product_id) + .map_err(|_| v01::HostProductDeviceChatError::Unknown { + reason: "invalid calling product identifier".to_string(), + })?; + let permissions = PermissionsService::new( + self.signing_host.platform.as_ref(), + self.signing_host.platform.as_ref(), + &calling_product_id, + ); + if permissions + .check_or_prompt_chat_authority() + .await + .map_err(|error| v01::HostProductDeviceChatError::Unknown { + reason: error.reason, + })? + != PermissionAuthorizationStatus::Authorized + { + return Err(v01::HostProductDeviceChatError::Rejected); + } + + let authority_request = match request.payload { + SsoProductDeviceChatOperation::Bind { + derivation_index, + peer_identity_account_id, + peer_chat_public_key, + } => { + let product_account = api::ProductAccountId { + dot_ns_identifier: calling_product_id.clone(), + derivation_index: derivation_index.clone(), + }; + let device_account_id = self + .signing_host + .product_keypair(&product_account) + .map_err(|error| v01::HostProductDeviceChatError::Unknown { + reason: error.to_string(), + })? + .public + .to_bytes(); + ProductDeviceChatAuthorityRequest::Bind { + calling_product_id, + device_account_id, + derivation_index, + peer_identity_account_id, + peer_chat_public_key, + } + } + SsoProductDeviceChatOperation::Seal { + peer_chat_public_key, + cipher_suite, + plaintext, + } => ProductDeviceChatAuthorityRequest::Seal { + calling_product_id, + peer_chat_public_key, + cipher_suite, + plaintext, + }, + SsoProductDeviceChatOperation::Open { + peer_chat_public_key, + cipher_suite, + combined_ciphertext, + } => ProductDeviceChatAuthorityRequest::Open { + calling_product_id, + peer_chat_public_key, + cipher_suite, + combined_ciphertext, + }, + SsoProductDeviceChatOperation::SignRequestProof { + derivation_index, + payload, + } => ProductDeviceChatAuthorityRequest::SignRequestProof { + product_account_id: api::ProductAccountId { + dot_ns_identifier: calling_product_id.clone(), + derivation_index, + }, + calling_product_id, + payload, + }, + SsoProductDeviceChatOperation::Identity => { + ProductDeviceChatAuthorityRequest::Identity { calling_product_id } + } + SsoProductDeviceChatOperation::VerifyPeerDevice { + peer_identity_account_id, + peer_chat_public_key, + peer_device_account_id, + proof, + } => ProductDeviceChatAuthorityRequest::VerifyPeerDevice { + calling_product_id, + peer_identity_account_id, + peer_chat_public_key, + peer_device_account_id, + proof, + }, + }; + self.signing_host + .product_device_chat(&cx.call, &cx.session, authority_request) + .await + .map_err(|error| match error { + ProductDeviceChatAuthorityError::Disconnected => { + v01::HostProductDeviceChatError::NotConnected + } + ProductDeviceChatAuthorityError::Rejected => { + v01::HostProductDeviceChatError::Rejected + } + ProductDeviceChatAuthorityError::InvalidPeerKey => { + v01::HostProductDeviceChatError::InvalidPeerKey + } + ProductDeviceChatAuthorityError::InvalidCiphertext => { + v01::HostProductDeviceChatError::InvalidCiphertext + } + ProductDeviceChatAuthorityError::Unavailable(reason) => { + v01::HostProductDeviceChatError::Unknown { reason } + } + }) + } } #[cfg(test)] diff --git a/rust/crates/truapi-server/src/runtime/sso_pairing.rs b/rust/crates/truapi-server/src/runtime/sso_pairing.rs index d5cb2eb64..2d9726baf 100644 --- a/rust/crates/truapi-server/src/runtime/sso_pairing.rs +++ b/rust/crates/truapi-server/src/runtime/sso_pairing.rs @@ -107,10 +107,9 @@ impl<'a> SsoPairingFlow<'a> { read_last_processed_pairing_statement(self.host.platform.as_ref()) .await .map_err(|reason| self.fail_before_pairing(reason))?; - // Pairing success statements are retained by statement-store. Reusing a - // previous pairing identity means reusing its topic, where the only - // retained response may be the last processed success. Rotate before - // presenting QR so every explicit login waits on a fresh wallet scan. + // Pairing success statements are retained by statement-store. Persist + // only a one-way fingerprint: the statement ciphertext and the + // session's X25519 secret must never coexist at rest. if reused_identity { debug!("regenerating stored pairing device identity"); pairing_identity = create_fresh_pairing_device_identity(self.host.platform.as_ref()) @@ -332,7 +331,7 @@ async fn write_last_processed_pairing_statement( if let Err(err) = storage .write_core_storage( CoreStorageKey::LastProcessedPairingStatement, - statement.to_vec(), + pairing_statement_fingerprint(statement), ) .await { @@ -340,6 +339,14 @@ async fn write_last_processed_pairing_statement( } } +fn pairing_statement_fingerprint(statement: &[u8]) -> Vec { + blake2b_simd::Params::new() + .hash_length(32) + .hash(statement) + .as_bytes() + .to_vec() +} + #[instrument(skip_all, fields(runtime.method = "sso.auth_session.clear"))] async fn clear_auth_session(storage: &(impl CoreStorage + ?Sized)) { if let Err(err) = storage @@ -526,7 +533,9 @@ fn handle_v2_pairing_result( parse_new_statements_result("pairing".to_string(), value).map_err(|err| err.to_string())?; let mut pending = false; for statement in page.statements { - if last_processed_statement == Some(statement.as_slice()) { + if last_processed_statement + .is_some_and(|fingerprint| fingerprint == pairing_statement_fingerprint(&statement)) + { continue; } match PairingProgress::from_v2_statement(&statement, core_encryption_secret_key)? { @@ -1115,10 +1124,11 @@ mod tests { }, }); + let fingerprint = pairing_statement_fingerprint(&statement); let ignored = handle_v2_pairing_result( &page, bootstrap.encryption_secret_key, - Some(statement.as_slice()), + Some(fingerprint.as_slice()), ) .unwrap(); assert!(ignored.is_none()); diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs index 3e3300cee..b1bb5f94e 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs @@ -678,7 +678,7 @@ mod tests { /// Run `scan_slot_excluding` for `[0x22; 32]` against a scripted period /// whose slot occupancy is `slots`. - fn scripted_find(slots: &[Option<[u8; 32]>]) -> SlotSelection { + fn scripted_find(slots: &[Option<[u8; 32]>], reuse_existing: bool) -> SlotSelection { let metadata = test_fixtures::people(); let entries: Vec = slots .iter() @@ -697,12 +697,23 @@ mod tests { period: 7, target: &[0x22; 32], excluded: &[], - reuse_existing: true, + reuse_existing, }, )) .unwrap() } + #[test] + fn an_explicit_increase_chooses_an_additional_slot_instead_of_reusing_quota() { + let mut slots = [None; SLOTS]; + slots[0] = Some([0x22; 32]); + assert_eq!( + scripted_find(&slots, true), + SlotSelection::AlreadyAllocated(0) + ); + assert_eq!(scripted_find(&slots, false), SlotSelection::Free(1)); + } + /// The scan bound is whatever Asset Hub declares, not a compiled-in constant, /// and it bounds how many keys a full scan hashes and reads. /// @@ -724,7 +735,7 @@ mod tests { #[test] fn an_empty_period_offers_the_first_slot() { - assert_eq!(scripted_find(&[None; SLOTS]), SlotSelection::Free(0)); + assert_eq!(scripted_find(&[None; SLOTS], true), SlotSelection::Free(0)); } #[test] @@ -732,12 +743,15 @@ mod tests { let mut slots = [None; SLOTS]; slots[2] = Some([0x22; 32]); - assert_eq!(scripted_find(&slots), SlotSelection::AlreadyAllocated(2)); + assert_eq!( + scripted_find(&slots, true), + SlotSelection::AlreadyAllocated(2) + ); } #[test] fn a_table_filled_by_other_accounts_reports_full_rather_than_erroring() { - let SlotSelection::Full { max, occupied } = scripted_find(&[Some([0x99; 32]); SLOTS]) + let SlotSelection::Full { max, occupied } = scripted_find(&[Some([0x99; 32]); SLOTS], true) else { panic!("a full table should report Full"); }; diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index 602895732..193d5c508 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -358,6 +358,9 @@ impl ProductRuntimeHost { .authority .current_session() .ok_or(StatementProofFailure::NoSession)?; + self.require_statement_store_allowance(&session, None) + .await + .map_err(StatementProofFailure::UnableToSign)?; let cx = remote_authority_context(cx); let allowance = remote_authority_call( &cx, @@ -366,6 +369,9 @@ impl ProductRuntimeHost { ) .await .map_err(statement_authority_failure)?; + self.check_statement_store_allowance(&session, None) + .await + .map_err(StatementProofFailure::UnableToSign)?; create_statement_proof_with_key(statement, &allowance) } } @@ -583,6 +589,7 @@ mod tests { let payload = statement_payload(statement.clone()); let (allowance_secret, expected_signer) = allowance_key(11); let platform = Arc::new(StubPlatform { + resource_allocation_confirmed: true, sso_response_script: Some(sso_success_response_script( &session, crate::host_logic::sso::messages::RemoteMessage { @@ -612,7 +619,7 @@ mod tests { ); host.test_session_state().set_session(session.clone()); let cx = CallContext::with_request_id("proof-auth-1".to_string()); - let request = RemoteStatementStoreCreateProofAuthorizedRequest::V1(statement); + let request = RemoteStatementStoreCreateProofAuthorizedRequest::V1(statement.clone()); let response = futures::executor::block_on(StatementStore::create_proof_authorized( &host, &cx, request, @@ -625,6 +632,22 @@ mod tests { }; assert_eq!(signer, expected_signer); assert_sr25519_signature(signer, signature, &payload); + futures::executor::block_on(host.set_permission_authorization_status( + truapi_platform::PermissionAuthorizationRequest::StatementStoreAllowance { + derivation_index: None, + }, + truapi_platform::PermissionAuthorizationStatus::Denied, + )) + .unwrap(); + // Cached private material must not bypass a revoked product decision. + assert!( + futures::executor::block_on(StatementStore::create_proof_authorized( + &host, + &cx, + RemoteStatementStoreCreateProofAuthorizedRequest::V1(statement), + )) + .is_err() + ); let message = submitted_remote_message(&platform, &session); let crate::host_logic::sso::messages::RemoteMessageData::V1( diff --git a/rust/crates/truapi-server/src/runtime/tests.rs b/rust/crates/truapi-server/src/runtime/tests.rs index 6ee0643f7..2a5642f18 100644 --- a/rust/crates/truapi-server/src/runtime/tests.rs +++ b/rust/crates/truapi-server/src/runtime/tests.rs @@ -2429,22 +2429,38 @@ fn resource_allocation_rejects_without_session() { } #[test] -fn resource_allocation_rejects_when_user_declines() { - let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); +fn resource_allocation_remembers_denial_without_provisioning() { + let platform = stub_platform(); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); install_pairing_session(&host, session_info()); - let cx = CallContext::default(); - let err = futures::executor::block_on(ResourceAllocation::request( - &host, - &cx, - resource_allocation_request(), - )) - .unwrap_err(); - match err { - CallError::Domain(HostRequestResourceAllocationError::V1( - v01::ResourceAllocationError::Unknown { reason }, - )) => assert_eq!(reason, "User rejected resource allocation"), - other => panic!("expected user-rejected resource allocation error, got {other:?}"), - } + futures::executor::block_on(async { + for _ in 0..2 { + assert!( + ResourceAllocation::request( + &host, + &CallContext::default(), + resource_allocation_request(), + ) + .await + .is_err() + ); + } + assert_eq!( + host.permission_authorization_status( + truapi_platform::PermissionAuthorizationRequest::StatementStoreAllowance { + derivation_index: None, + }, + ) + .await + .unwrap(), + truapi_platform::PermissionAuthorizationStatus::Denied + ); + }); + assert_eq!( + platform.resource_allocation_reviews.lock().unwrap().len(), + 1 + ); + assert!(platform.sent_rpc.lock().unwrap().is_empty()); } #[test] diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 9eba767be..ab2d30b1d 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -25,7 +25,7 @@ use truapi::v01; use truapi::versioned::account::{HostAccountCreateProofRequest, HostAccountGetAliasRequest}; use truapi::versioned::resource_allocation::HostRequestResourceAllocationRequest; use truapi_platform::{ - AccountAccessReview, AuthPresenter, AuthState, ChainProvider, + AccountAccessReview, AuthPresenter, AuthState, ChainProvider, ChatAuthorityReview, CoreStorage as PlatformCoreStorage, CoreStorageKey, Features as PlatformFeatures, HostInfo, JsonRpcConnection, LocaleHost, Navigation as PlatformNavigation, Notifications as PlatformNotifications, PairingHostConfig, Permissions as PlatformPermissions, @@ -95,6 +95,9 @@ pub(crate) struct StubPlatform { pub(crate) identity_disclosure_confirmed: bool, pub(crate) identity_disclosure_error: Option<&'static str>, pub(crate) identity_disclosure_calls: Arc, + pub(crate) chat_authority_confirmed: bool, + pub(crate) chat_authority_error: Option<&'static str>, + pub(crate) chat_authority_reviews: Arc>>, pub(crate) sign_payload_confirmed: bool, pub(crate) sign_payload_error: Option<&'static str>, pub(crate) sign_raw_confirmed: bool, @@ -1015,6 +1018,8 @@ struct RecordingConnection { sent: Arc>>, responses: Vec, method_responses: Vec<(&'static str, String)>, + /// Method scripts must not replay requests from a previously closed connection. + method_requests: Arc>>, sso_response_script: Option, auth_states: Arc>>, pairing_success_response: bool, @@ -1156,6 +1161,12 @@ fn sso_scripted_responses( impl JsonRpcConnection for RecordingConnection { fn send(&self, request: String) { + if !self.method_responses.is_empty() { + self.method_requests + .lock() + .expect("connection rpc list mutex poisoned") + .push(request.clone()); + } self.sent .lock() .expect("rpc list mutex poisoned") @@ -1301,7 +1312,10 @@ impl JsonRpcConnection for RecordingConnection { return sso_scripted_responses(self.sent.clone(), script); } if !self.method_responses.is_empty() { - return method_keyed_responses(self.sent.clone(), self.method_responses.clone()); + return method_keyed_responses( + self.method_requests.clone(), + self.method_responses.clone(), + ); } if self.responses.is_empty() { Box::pin(futures::stream::pending()) @@ -1480,6 +1494,7 @@ impl ChainProvider for StubPlatform { sent: self.sent_rpc.clone(), responses: self.rpc_responses.clone(), method_responses: self.rpc_method_responses.clone(), + method_requests: Arc::default(), sso_response_script: self.sso_response_script.clone(), auth_states: self.auth_states.clone(), pairing_success_response: self.pairing_success_response, @@ -1563,6 +1578,10 @@ impl UserConfirmation for StubPlatform { self.identity_disclosure_confirmed, ) } + UserConfirmationReview::ChatAuthority(review) => { + self.chat_authority_reviews.lock().push(review); + (self.chat_authority_error, self.chat_authority_confirmed) + } UserConfirmationReview::ResourceAllocation(review) => { self.resource_allocation_reviews .lock() diff --git a/rust/crates/truapi-server/tests/wire_result_shape.rs b/rust/crates/truapi-server/tests/wire_result_shape.rs index 69ea4d824..ddf9d36c9 100644 --- a/rust/crates/truapi-server/tests/wire_result_shape.rs +++ b/rust/crates/truapi-server/tests/wire_result_shape.rs @@ -28,7 +28,7 @@ use std::sync::Arc; use parity_scale_codec::{Decode, Encode}; -use truapi::{CallError, v01}; +use truapi::{CallError, v01, versioned::account}; use truapi_server::core::TrUApiCore; use truapi_server::frame::{ @@ -445,6 +445,32 @@ fn malformed_result_subscription_start_interrupts_with_malformed_frame() { } } +#[test] +fn product_device_chat_reaches_the_account_authority() { + let core = make_core(); + let request = + account::HostProductDeviceChatRequest::V1(v01::HostProductDeviceChatRequest::Bind { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "dotli.dot".to_string(), + derivation_index: v01::DerivationIndex::Index(0), + }, + peer_identity_account_id: [0x55; 32], + peer_chat_public_key: [ + 0x0f, 0xaa, 0x68, 0x4e, 0xd2, 0x88, 0x67, 0xb9, 0x7f, 0x4a, 0x6a, 0x2d, 0xee, 0x5d, + 0xf8, 0xce, 0x97, 0x4e, 0x76, 0xb7, 0x01, 0x8e, 0x3f, 0x22, 0xa1, 0xc4, 0xcf, 0x26, + 0x78, 0x57, 0x0f, 0x20, + ], + }); + + assert_request_returns_domain_error( + &core, + "p:product-device-chat", + "account_product_device_chat", + request.encode(), + account::HostProductDeviceChatError::V1(v01::HostProductDeviceChatError::NotConnected), + ); +} + /// A chain follow that cannot reach its provider must end with the failure, /// not with `Ok(())`. A clean end reaches the product as `complete`, which /// reads as a chain that simply stopped having blocks to report. diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index 316239a79..52e287a73 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -12,8 +12,9 @@ use crate::versioned::account::{ HostAccountRingVrfSignRequest, HostAccountRingVrfSignResponse, HostAccountSignVrfError, HostAccountSignVrfRequest, HostAccountSignVrfResponse, HostGetLegacyAccountsError, HostGetLegacyAccountsRequest, HostGetLegacyAccountsResponse, HostGetUserIdError, - HostGetUserIdRequest, HostGetUserIdResponse, HostRequestLoginError, HostRequestLoginRequest, - HostRequestLoginResponse, + HostGetUserIdRequest, HostGetUserIdResponse, HostProductDeviceChatError, + HostProductDeviceChatRequest, HostProductDeviceChatResponse, HostRequestLoginError, + HostRequestLoginRequest, HostRequestLoginResponse, }; use crate::{CallContext, CallError, Subscription}; use crate::{wire, wire_trait}; @@ -275,6 +276,38 @@ pub trait Account: Send + Sync { Err(CallError::unavailable()) } + /// Bind a product account as a Chat v2 device, or seal/open identity-route + /// payloads without exposing the wallet Chat identity secret. + /// + /// ```ts + /// const productContext = await truapi.system.getProductContext(); + /// assert(productContext.isOk(), "getProductContext failed:", productContext); + /// + /// const result = await truapi.account.deviceChat({ + /// tag: "Bind", + /// value: { + /// productAccountId: { + /// dotNsIdentifier: productContext.value.productId, + /// derivationIndex: { tag: "Index", value: 0 }, + /// }, + /// peerIdentityAccountId: + /// "0x5555555555555555555555555555555555555555555555555555555555555555", + /// peerChatPublicKey: + /// "0x0faa684ed28867b97f4a6a2dee5df8ce974e76b7018e3f22a1c4cf2678570f20", + /// }, + /// }); + /// assert(result.isOk(), "deviceChat failed:", result); + /// console.log("Chat identity binding:", result.value); + /// ``` + #[wire(id = 11)] + async fn product_device_chat( + &self, + _cx: &CallContext, + _request: HostProductDeviceChatRequest, + ) -> Result> { + Err(CallError::unavailable()) + } + /// List non-product accounts the user owns. /// /// Current hosts do not expose non-product accounts, so the list is empty. diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 8a8a1d210..6b5360ebb 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -185,6 +185,12 @@ pub mod latest { /// Per-resource allocation outcomes. pub type HostRequestResourceAllocationResponse = LatestOf; + /// Product-device Chat v2 identity request. + pub type HostProductDeviceChatRequest = + LatestOf; + /// Product-device Chat v2 identity result. + pub type HostProductDeviceChatResponse = + LatestOf; /// Extrinsic payload signing request for a product account. pub type HostSignPayloadRequest = LatestOf; /// Signing operation result. diff --git a/rust/crates/truapi/src/v01/account.rs b/rust/crates/truapi/src/v01/account.rs index 171e76bd3..143f27366 100644 --- a/rust/crates/truapi/src/v01/account.rs +++ b/rust/crates/truapi/src/v01/account.rs @@ -413,3 +413,153 @@ pub enum HostAccountSignVrfError { reason: String, }, } + +/// Cipher suite used by product-device Chat identity-route operations. +/// +/// Legacy v2 preserves current mobile interoperability. Context-bound v1 +/// authenticates the product/network, both account roles, route, and direction. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostProductDeviceChatCipherSuite { + /// Existing Chat v2 CryptoKit-compatible empty-context HKDF and AEAD. + LegacyV2, + /// Domain-separated encryption for peers that explicitly support it. + ContextBoundV1 { + /// Peer account corresponding to `peer_chat_public_key`. + peer_account_id: [u8; 32], + /// Statement channel carrying the ciphertext. + channel_id: [u8; 32], + }, +} + +/// Product-device Chat v2 identity operation. +/// +/// The wallet Chat identity secret and derived shared key remain host-private. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostProductDeviceChatRequest { + /// Resolve the product account as a Chat device and bind it to the wallet identity. + Bind { + /// Product account becoming a Chat device. + product_account_id: ProductAccountId, + /// Peer wallet identity account used for directional routing. + peer_identity_account_id: [u8; 32], + /// Peer's X25519 Chat identity public key. + peer_chat_public_key: [u8; 32], + }, + /// Seal identity-route plaintext for the peer with a host-generated nonce. + Seal { + /// Product account requesting the operation. + product_account_id: ProductAccountId, + /// Peer's X25519 Chat identity public key. + peer_chat_public_key: [u8; 32], + /// Explicit cipher suite; secure callers must never silently downgrade. + cipher_suite: HostProductDeviceChatCipherSuite, + /// Identity-route plaintext. + plaintext: Vec, + }, + /// Open an identity-route combined nonce/ciphertext/tag value. + Open { + /// Product account requesting the operation. + product_account_id: ProductAccountId, + /// Peer's X25519 Chat identity public key. + peer_chat_public_key: [u8; 32], + /// Explicit cipher suite; must match the sender's selected suite. + cipher_suite: HostProductDeviceChatCipherSuite, + /// Nonce-prefixed ChaCha20-Poly1305 ciphertext and tag. + combined_ciphertext: Vec, + }, + /// Sign the canonical Chat first-contact proof payload without wallet-message framing. + SignRequestProof { + /// Product account proving ownership of the Chat device. + product_account_id: ProductAccountId, + /// Canonical SCALE-encoded Chat request proof payload. + payload: Vec, + }, + /// Read the authorized wallet's public Chat identity for incoming requests. + Identity { + /// Product account requesting the operation. + product_account_id: ProductAccountId, + }, + /// Verify a peer's identity-to-device binding without exposing shared keys. + VerifyPeerDevice { + /// Product account requesting the operation. + product_account_id: ProductAccountId, + /// Peer identity whose binding is being checked. + peer_identity_account_id: [u8; 32], + /// Peer's independently resolved X25519 Chat identity public key. + peer_chat_public_key: [u8; 32], + /// Device account authenticated by the signed contact request. + peer_device_account_id: [u8; 32], + /// Keyed identity binding carried by that request. + proof: [u8; 32], + }, +} + +/// Result of a product-device Chat v2 identity operation. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostProductDeviceChatResponse { + /// Wallet identity binding and deterministic peer routes. + IdentityBinding { + /// Wallet's canonical identity account. + identity_account_id: [u8; 32], + /// Keyed proof binding the wallet identity to the product device. + proof: [u8; 32], + /// Wallet-to-peer session identifier. + wallet_own_session_id: [u8; 32], + /// Peer-to-wallet session identifier. + peer_own_session_id: [u8; 32], + /// Wallet-to-peer contact-request channel. + wallet_outgoing_channel_id: [u8; 32], + /// Peer-to-wallet contact-request channel. + wallet_incoming_channel_id: [u8; 32], + }, + /// Sealed identity-route payload. + Sealed { + /// Nonce-prefixed ChaCha20-Poly1305 ciphertext and tag. + combined_ciphertext: Vec, + }, + /// Opened identity-route payload. + Opened { + /// Authenticated plaintext. + plaintext: Vec, + }, + /// Raw sr25519 signature over a canonical Chat request proof payload. + RequestProofSigned { + /// Unframed 64-byte sr25519 signature. + signature: [u8; 64], + }, + /// Public Chat identity of the authorized wallet. + Identity { + /// Canonical wallet identity account. + identity_account_id: [u8; 32], + /// X25519 Chat identity public key; never private key material. + chat_public_key: [u8; 32], + }, + /// Result of verifying a peer identity-to-device binding. + PeerDeviceVerified { + /// Whether the supplied binding matches the authenticated shared key. + valid: bool, + }, +} + +/// Product-device Chat v2 identity failure. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, derive_more::Display)] +pub enum HostProductDeviceChatError { + /// No account-authority session is connected. + #[display("not connected")] + NotConnected, + /// The user or Host rejected the operation. + #[display("rejected")] + Rejected, + /// The peer X25519 public key is invalid. + #[display("invalid peer key")] + InvalidPeerKey, + /// The ciphertext failed structural or authentication checks. + #[display("invalid ciphertext")] + InvalidCiphertext, + /// The Host could not complete the operation. + #[display("unknown: {reason}")] + Unknown { + /// Human-readable failure reason. + reason: String, + }, +} diff --git a/rust/crates/truapi/src/v01/resource_allocation.rs b/rust/crates/truapi/src/v01/resource_allocation.rs index d9a1ef59a..85fc3f654 100644 --- a/rust/crates/truapi/src/v01/resource_allocation.rs +++ b/rust/crates/truapi/src/v01/resource_allocation.rs @@ -22,6 +22,9 @@ pub enum AllocatableResource { SmartContractAllowance(DerivationIndex), /// Permission to sign on the product's behalf without per-call user prompts. AutoSigning, + /// Current UTC-day Statement Store allowance whose target is the product + /// account selected by this derivation index. + ProductStatementStoreAllowance(DerivationIndex), } /// Outcome of allocating a single resource (RFC 0010). diff --git a/rust/crates/truapi/src/versioned/account.rs b/rust/crates/truapi/src/versioned/account.rs index 2c7c2a1c0..1abbfb524 100644 --- a/rust/crates/truapi/src/versioned/account.rs +++ b/rust/crates/truapi/src/versioned/account.rs @@ -35,4 +35,7 @@ truapi_macros::versioned_type! { pub enum HostGetUserIdRequest { V1 } pub enum HostGetUserIdResponse { V1 => v01::HostGetUserIdResponse } pub enum HostGetUserIdError { V1 => v01::HostGetUserIdError } + pub enum HostProductDeviceChatRequest { V1 => v01::HostProductDeviceChatRequest } + pub enum HostProductDeviceChatResponse { V1 => v01::HostProductDeviceChatResponse } + pub enum HostProductDeviceChatError { V1 => v01::HostProductDeviceChatError } }