Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/auto-signing-domain-entropy-normalized-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@parity/truapi-host": patch
---

The signing host derives the ring-VRF domain entropy of an AutoSigning allocation from the normalized calling product id, the same id its product subtree secret comes from, so both secrets belong to the same product whatever case or spacing the pairing host sent.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.paritytech.polkadotapp.feature_products_api.domain.accountsProtocol

import io.paritytech.polkadotapp.chains.util.Sr25519SecretKey
import io.paritytech.polkadotapp.common.domain.model.DataByteArray
import io.paritytech.polkadotapp.feature_account_api.domain.derivation.DerivationIndex32

sealed interface ApAllocatableResource {
Expand Down Expand Up @@ -30,11 +31,15 @@ sealed interface ApAllocatedResource {

/**
* RFC-0022: secret key of `//product//{productId}`. The hard product junction is what makes handing
* this out safe — it exposes that product's subtree and nothing above it.
* this out safe: it exposes that product's subtree and nothing above it. [ringVrfDomainEntropy] is
* the 32-byte entropy of the product's ring-VRF domain.
*
* Shape only for now: nothing produces this variant until AutoSigning allocation ships.
*/
data class AutoSigning(val productRootSecretKey: Sr25519SecretKey) : ApAllocatedResource
data class AutoSigning(
val productRootSecretKey: Sr25519SecretKey,
val ringVrfDomainEntropy: DataByteArray,
) : ApAllocatedResource
}

enum class OnExistingAllowancePolicy {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import io.paritytech.polkadotapp.feature_products_api.model.scale.ProductDerivat
import io.paritytech.polkadotapp.feature_products_api.model.scale.ProductIdScale
import kotlinx.serialization.Serializable

private const val RING_VRF_DOMAIN_ENTROPY_SIZE_BYTES = 32

@Serializable
sealed class SsoApAllocatableResourceScale {
@Serializable
Expand Down Expand Up @@ -40,12 +42,15 @@ sealed class SsoApAllocatedResourceScale {
@EnumIndex(2)
data object SmartContractAllowance : SsoApAllocatedResourceScale()

// RFC-0022 secret key of //product//{productId}. Shape only — nothing allocates this yet.
// RFC-0022 secret key of //product//{productId} and the entropy of the product's ring-VRF
// domain. Shape only, nothing allocates this yet.
@Serializable
@EnumIndex(3)
class AutoSigning(
@FixedLength(Sr25519SecretKey.SIZE_BYTES)
val productRootSecretKey: ByteArray,
@FixedLength(RING_VRF_DOMAIN_ENTROPY_SIZE_BYTES)
val ringVrfDomainEntropy: ByteArray,
) : SsoApAllocatedResourceScale()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,8 @@ private fun ApAllocatedResource.toScale(): SsoApAllocatedResourceScale = when (t
is ApAllocatedResource.BulletInAllowance ->
SsoApAllocatedResourceScale.BulletInAllowance(slotAccountKey.bytes.value)
ApAllocatedResource.SmartContractAllowance -> SsoApAllocatedResourceScale.SmartContractAllowance
is ApAllocatedResource.AutoSigning -> SsoApAllocatedResourceScale.AutoSigning(productRootSecretKey.bytes.value)
is ApAllocatedResource.AutoSigning ->
SsoApAllocatedResourceScale.AutoSigning(productRootSecretKey.bytes.value, ringVrfDomainEntropy.value)
}

@Suppress("unused")
Expand All @@ -327,7 +328,10 @@ private fun SsoApAllocatedResourceScale.toDomain(): ApAllocatedResource = when (
ApAllocatedResource.BulletInAllowance(SlotAccountKey.fromBytes(DataByteArray(slotAccountKey)).getOrThrow())
SsoApAllocatedResourceScale.SmartContractAllowance -> ApAllocatedResource.SmartContractAllowance
is SsoApAllocatedResourceScale.AutoSigning ->
ApAllocatedResource.AutoSigning(Sr25519SecretKey.fromBytes(DataByteArray(productRootSecretKey)).getOrThrow())
ApAllocatedResource.AutoSigning(
Sr25519SecretKey.fromBytes(DataByteArray(productRootSecretKey)).getOrThrow(),
DataByteArray(ringVrfDomainEntropy),
)
}

// ==================== SigningRequest mappers ====================
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package io.paritytech.polkadotapp.feature_sso_impl.data.model.scale.session

import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.BinaryScale
import kotlinx.serialization.encodeToByteArray
import org.junit.Assert.assertArrayEquals
import org.junit.Test

class SsoResourceAllocationScaleTest {
/**
* The core decodes this as `SsoAllocatedResource::AutoSigning`: the 64-byte key then the 32-byte
* ring-VRF domain entropy, as pinned by `auto_signing_secret_is_fixed_width_on_the_mobile_wire`.
* A reply without the entropy is one the core cannot decode.
*/
@Test
fun `an AutoSigning allocation encodes as the core decodes it`() {
val outcome: SsoApAllocationOutcomeScale = SsoApAllocationOutcomeScale.Allocated(
SsoApAllocatedResourceScale.AutoSigning(
productRootSecretKey = ByteArray(64) { 0x11 },
ringVrfDomainEntropy = ByteArray(32) { 0x22 },
),
)

val expected = byteArrayOf(0x00, 0x03) + ByteArray(64) { 0x11 } + ByteArray(32) { 0x22 }
assertArrayEquals(expected, BinaryScale.encodeToByteArray(outcome))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@ public final class RingVrfEntropyDeriver: BandersnatchEntropyDeriving {
self.index = index
}

/// Entropy of `//{domain}` in the ring-VRF tree, the parent of every key in that domain.
public static func deriveDomainEntropy(domain: String, from rootEntropy: Data) throws -> Data {
let treeRoot = try KeyedHashChainDeriver.deriveRoot(entropy: rootEntropy, rootKey: rootKey)
return try KeyedHashChainDeriver.derive(parent: treeRoot, segments: [.string(domain)])
}

public func deriveEntropy(from rootEntropy: Data) throws -> Data {
let treeRoot = try KeyedHashChainDeriver.deriveRoot(entropy: rootEntropy, rootKey: Self.rootKey)
let domainEntropy = try Self.deriveDomainEntropy(domain: domain, from: rootEntropy)
let indexSegment = KeyedHashChainSegment.index(DerivationIndex32(index: index))
return try KeyedHashChainDeriver.derive(
parent: treeRoot,
segments: [.string(domain), indexSegment]
)
return try KeyedHashChainDeriver.derive(parent: domainEntropy, segments: [indexSegment])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ struct KeyedHashChainDeriverTests {
#expect(try litePerson.deriveEntropy(from: rootEntropy) == expectedLite)
}

@Test("Product ring-VRF domain entropy matches the core's //dim2.paseo vector")
func productDomainEntropyVector() throws {
let expected = try Data(
hexString: "0xe1f3bf6ace06a2620ab57e03ad2dc8c7765d36f01cafbac7682fdbca565ff80f"
)

#expect(
try RingVrfEntropyDeriver.deriveDomainEntropy(domain: "dim2.paseo", from: rootEntropy) == expected
)
}

@Test("ECDH key material matches the //{domain} vectors for chat, sso, and game")
func ecdhDomainVectors() throws {
let deriver = EcdhKeyMaterialDeriver(entropyManager: StubEntropyManager(entropy: rootEntropy))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,15 @@ extension AllocatedResource: ScaleCodable {

extension AutoSigningSecrets: ScaleCodable {
public init(scaleDecoder: any ScaleDecoding) throws {
// `Sr25519SecretKey` is `[u8; 64]` on the wire — raw bytes, no length prefix.
// `[u8; 64]` and `[u8; 32]` on the wire as raw bytes, no length prefix.
let key = try scaleDecoder.readAndConfirm(count: Self.privateKeyLength)
try self.init(productRootPrivateKey: key)
let entropy = try scaleDecoder.readAndConfirm(count: Self.ringVrfDomainEntropyLength)
try self.init(productRootPrivateKey: key, ringVrfDomainEntropy: entropy)
}

public func encode(scaleEncoder: any ScaleEncoding) throws {
// Length invariant enforced by the memberwise init.
// Length invariants enforced by the memberwise init.
scaleEncoder.appendRaw(data: productRootPrivateKey)
scaleEncoder.appendRaw(data: ringVrfDomainEntropy)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,24 +23,36 @@ public enum AllocatedResource: Equatable {

public enum AutoSigningSecretsError: Error, Equatable {
case invalidKeyLength(expected: Int, actual: Int)
case invalidEntropyLength(expected: Int, actual: Int)
}

/// `AutoSigning` payload: the 64-byte expanded sr25519 secret
/// (key ++ nonce) of `//product//{productId}` — enough to sign and soft-derive
/// every account in the product subtree, and nothing beyond it.
/// every account in the product subtree, and nothing beyond it. It also carries the
/// 32-byte entropy of the product's ring-VRF domain.
public struct AutoSigningSecrets: Equatable {
public static let privateKeyLength = 64
public static let ringVrfDomainEntropyLength = 32

public let productRootPrivateKey: Data
public let ringVrfDomainEntropy: Data

public init(productRootPrivateKey: Data) throws {
public init(productRootPrivateKey: Data, ringVrfDomainEntropy: Data) throws {
guard productRootPrivateKey.count == Self.privateKeyLength else {
throw AutoSigningSecretsError.invalidKeyLength(
expected: Self.privateKeyLength,
actual: productRootPrivateKey.count
)
}

guard ringVrfDomainEntropy.count == Self.ringVrfDomainEntropyLength else {
throw AutoSigningSecretsError.invalidEntropyLength(
expected: Self.ringVrfDomainEntropyLength,
actual: ringVrfDomainEntropy.count
)
}

self.productRootPrivateKey = productRootPrivateKey
self.ringVrfDomainEntropy = ringVrfDomainEntropy
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,14 @@ extension ProductAccountHolder: ProductAccountHolding {

public func deriveAutoSigningSecrets(for productId: ProductId) throws -> AutoSigningSecrets {
let path = try ProductDerivationPath.productRoot(productId: productId)
return try AutoSigningSecrets(productRootPrivateKey: derivePrivateKey(at: path))
let entropy = try RingVrfEntropyDeriver.deriveDomainEntropy(
domain: productId,
from: entropyManager.fetchRootEntropy()
)
return try AutoSigningSecrets(
productRootPrivateKey: derivePrivateKey(at: path),
ringVrfDomainEntropy: entropy
)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import Foundation
import KeyDerivation
import SubstrateSdk
import Testing
@testable import Products

private final class FixedEntropyManager: RootEntropyManaging {
private let entropy: Data

init(entropy: Data) {
self.entropy = entropy
}

func fetchRootEntropy() throws -> Data { entropy }
func createRootEntropy(_: Data) throws {}
func hasRootEntropy() throws -> Bool { true }
}

/// The core decodes an `AutoSigning` allocation as `SsoAllocatedResource::AutoSigning`, whose
/// layout `auto_signing_secret_is_fixed_width_on_the_mobile_wire` pins in
/// `rust/crates/truapi/src/host_internal/sso_messages.rs`; the entropy vector is pinned by
/// `product_ring_vrf_domain_entropy_matches_ios_vector` in `host_logic/product_account.rs`.
@Suite("AutoSigning wire encoding")
struct AutoSigningWireEncodingTests {
/// One `Allocated(AutoSigning)` outcome: list length, variant tags, the 64-byte key
/// and the 32-byte ring-VRF domain entropy.
private let outcomesHex = "0x040003"
+ String(repeating: "11", count: 64)
+ String(repeating: "22", count: 32)

private func outcomes() throws -> [AllocationOutcome] {
let secrets = try AutoSigningSecrets(
productRootPrivateKey: Data(repeating: 0x11, count: 64),
ringVrfDomainEntropy: Data(repeating: 0x22, count: 32)
)
return [.allocated(.autoSigning(secrets))]
}

@Test("an AutoSigning allocation encodes as the core decodes it")
func encodesAsTheCore() throws {
let encoder = ScaleEncoder()
try outcomes().encode(scaleEncoder: encoder)

#expect(try encoder.encode() == Data(hexString: outcomesHex))
}

@Test("an AutoSigning allocation decodes back to the same secrets")
func roundTrips() throws {
let decoder = try ScaleDecoder(data: Data(hexString: outcomesHex))

#expect(try [AllocationOutcome](scaleDecoder: decoder) == outcomes())
}

@Test("secrets without a 32-byte ring-VRF domain entropy are rejected")
func rejectsShortEntropy() {
#expect(throws: AutoSigningSecretsError.invalidEntropyLength(expected: 32, actual: 31)) {
try AutoSigningSecrets(
productRootPrivateKey: Data(repeating: 0x11, count: 64),
ringVrfDomainEntropy: Data(repeating: 0x22, count: 31)
)
}
}

@Test("derived AutoSigning secrets carry the core's ring-VRF domain entropy")
func derivesTheCoresDomainEntropy() throws {
let holder = ProductAccountHolder(
entropyManager: FixedEntropyManager(entropy: Data((1 ... 32).map { UInt8($0) }))
)
let expected = try Data(
hexString: "0xe1f3bf6ace06a2620ab57e03ad2dc8c7765d36f01cafbac7682fdbca565ff80f"
)

#expect(try holder.deriveAutoSigningSecrets(for: "dim2.paseo").ringVrfDomainEntropy == expected)
}
}
9 changes: 9 additions & 0 deletions rust/crates/truapi/src/host_logic/product_account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,15 @@ mod tests {
);
}

#[test]
fn product_ring_vrf_domain_entropy_matches_ios_vector() {
let root_entropy: Vec<u8> = (1..=32).collect();
assert_eq!(
hex::encode(derive_ring_vrf_domain_entropy(&root_entropy, "dim2.paseo").unwrap()),
"e1f3bf6ace06a2620ab57e03ad2dc8c7765d36f01cafbac7682fdbca565ff80f"
);
}

#[test]
fn person_ring_vrf_entropy_follows_the_network_suffix() {
// Same seed, one person per network. The vectors come from an
Expand Down
16 changes: 14 additions & 2 deletions rust/crates/truapi/src/runtime/signing_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ use crate::host_logic::entropy::derive_product_entropy;
use crate::host_logic::features::genesis_for;
use crate::host_logic::product_account::{
ProductAccountError, SR25519_SIGNING_CONTEXT, derivation_index_bytes, derive_identity_keypair,
derive_product_keypair, derive_product_subtree_keypair, derive_ring_vrf_entropy,
derive_root_keypair_from_entropy, personhood_product_id,
derive_product_keypair, derive_product_subtree_keypair, derive_ring_vrf_domain_entropy,
derive_ring_vrf_entropy, derive_root_keypair_from_entropy, personhood_product_id,
};
use crate::host_logic::product_account::{
derive_full_person_ring_vrf_entropy, derive_lite_person_ring_vrf_entropy,
Expand Down Expand Up @@ -372,6 +372,18 @@ impl SigningHost {
.map_err(product_authority_error)
}

/// Entropy of the product's ring-VRF domain, from the same normalized product id as
/// [`Self::product_subtree_secret`].
fn ring_vrf_domain_entropy(&self, product_id: &str) -> Result<[u8; 32], AuthorityError> {
let entropy = self.root_entropy()?;
let product_id = normalize_product_identifier(product_id).map_err(|err| {
AuthorityError::Unavailable {
reason: err.to_string(),
}
})?;
derive_ring_vrf_domain_entropy(&entropy, &product_id).map_err(product_authority_error)
}

fn sso_replay_locks(&self) -> &SsoReplayLocks {
&self.sso_replay_locks
}
Expand Down
36 changes: 36 additions & 0 deletions rust/crates/truapi/src/runtime/signing_host/sso_responder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2127,6 +2127,42 @@ mod tests {
);
}

/// The pairing host sends the calling product id as it holds it, so both secrets come
/// from its normalized form or they would not belong to the same product.
#[test]
fn auto_signing_allocation_derives_both_secrets_from_the_normalized_product_id() {
let platform = Arc::new(StubPlatform {
resource_allocation_confirmed: true,
..StubPlatform::default()
});
let (_, signing_host) = signing_fixture(platform);
let expected = SsoAllocatedResource::AutoSigning {
product_root_private_key: signing_host
.product_subtree_secret("myapp.dot")
.expect("product subtree secret derives"),
ring_vrf_domain_entropy: derive_ring_vrf_domain_entropy(&ENTROPY, "myapp.dot")
.expect("ring-VRF domain entropy derives"),
};

let response = answer(
&signing_host,
"alloc-auto-signing-mixed-case",
v1::RemoteMessage::ResourceAllocationRequest(sso_messages::ResourceAllocationRequest {
calling_product_id: "MyApp.dot".to_string(),
resources: vec![api::AllocatableResource::AutoSigning],
on_existing: sso_messages::OnExistingAllowancePolicy::Ignore,
}),
);

let v1::RemoteMessage::ResourceAllocationResponse(response) = response else {
panic!("expected resource allocation response");
};
assert_eq!(
response.payload.unwrap(),
vec![SsoAllocationOutcome::Allocated(expected)]
);
}

fn allocation_after_session_change(replacement: Option<Vec<u8>>) {
use futures::{FutureExt, channel::oneshot};

Expand Down
Loading
Loading