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
390 changes: 387 additions & 3 deletions Cargo.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ confidence-threshold = 0.8
# dependency, which MPL-2.0 permits without affecting the MIT outbound licence.
# Scoped per crate so MPL-2.0 stays disallowed everywhere else.
exceptions = [
# Ring-VRF implementation shared with the Individuality runtimes and Nova.
# The Classpath exception permits linking it without changing this crate's
# MIT outbound licence.
{ name = "verifiable", allow = ["GPL-3.0-or-later WITH Classpath-exception-2.0"] },
{ name = "uniffi", allow = ["MPL-2.0"] },
{ name = "uniffi_bindgen", allow = ["MPL-2.0"] },
{ name = "uniffi_core", allow = ["MPL-2.0"] },
Expand Down
2 changes: 2 additions & 0 deletions rust/crates/truapi-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ derive_more = { version = "2", features = ["debug", "display", "error"] }
futures = "0.3"
futures-timer = "3"
parity-scale-codec = { version = "3", features = ["derive"] }
scale-decode = { version = "0.16", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "1"
Expand All @@ -60,6 +61,7 @@ tracing = "0.1"
# `registry` + `std` only: pulls the Registry + per-layer filter/reload, but
# not `env-filter` (which drags in `regex`, heavy on wasm).
tracing-subscriber = { version = "0.3", default-features = false, features = ["registry", "std"] }
verifiable = { git = "https://github.com/paritytech/verifiable.git", rev = "19b03deb89b8dea484b143afc8bbe048a74bc1ff" }

[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
subxt = { version = "0.50.2", default-features = false, features = ["native"] }
Expand Down
16 changes: 10 additions & 6 deletions rust/crates/truapi-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,9 @@ stage in the frame path; the host's `Platform` impl is the syscall floor.
`ProductRuntimeHost` handles everything role-neutral (id normalization,
permission gating, confirmation, soft product-key derivation), then delegates
the wallet-authority tail (`sign_*`, `create_transaction`, `account_alias`,
`allocate_resources`, `derive_entropy`) through an `Arc<dyn ProductAuthority>`
handle with an `AuthoritySession` snapshot the role revalidates before touching
key material.
`create_proof`, `allocate_resources`, `derive_entropy`) through an
`Arc<dyn ProductAuthority>` handle with an `AuthoritySession` snapshot the
role revalidates before touching key material.

### Permission flow

Expand Down Expand Up @@ -185,9 +185,13 @@ role-specific lifecycle, so no method exists on a role that can't mean it:
signing-host liveness monitoring.
- **`SigningHost`** (wallet-local): signs on device from local BIP-39 entropy,
no pairing flow. `signing_host/local_activation.rs` establishes a session
from host-held secret material. Extrinsic signing / transaction construction /
ring-VRF aliases / resource allocation currently return `Unavailable` pending
chain-metadata and on-chain support.
from host-held secret material. It derives the same full- and lite-person
Bandersnatch keys as Nova, resolves RFC-0004 `RingLocation` values against
the chain's `Members` pallet, and pins membership, ring pages, exponent, and
revision reads to one finalized block before creating an alias or proof.
Full personhood is preferred over lite personhood. Extrinsic-payload signing
and resource allocation still return `Unavailable` pending chain-metadata
and on-chain support.

`host_logic` stays pure: the orchestrators above call into it for codecs,
session/SSO crypto, key derivation, and permission policy, while all I/O
Expand Down
9 changes: 5 additions & 4 deletions rust/crates/truapi-server/src/host_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,10 @@ impl PairingHostAdmin for PairingHostRuntime {
/// Owns the shared services plus signing-host state. There is no pairing flow,
/// so pairing cancellation is not present here.
///
/// Raw-bytes signing and product entropy are implemented; extrinsic-payload
/// signing, transaction construction, ring-VRF aliases, and resource allocation
/// return an `Unavailable` error pending chain-metadata and on-chain support.
/// Raw-bytes signing, transaction construction, product entropy, and RFC-0004
/// ring-VRF aliases/proofs are implemented; extrinsic-payload signing and
/// resource allocation return an `Unavailable` error pending chain-metadata
/// and on-chain support.
pub struct SigningHostRuntime {
services: Arc<RuntimeServices>,
signing_host: Arc<SigningHostRole>,
Expand All @@ -207,7 +208,7 @@ impl SigningHostRuntime {
config.bulletin_chain_genesis_hash,
spawner,
);
let signing_host = SigningHostRole::new(platform);
let signing_host = SigningHostRole::new(services.clone());
Self {
services,
signing_host,
Expand Down
200 changes: 186 additions & 14 deletions rust/crates/truapi-server/src/runtime/signing_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//! for the session, zeroized on disconnect.

mod local_activation;
mod ring_vrf;

use std::sync::{Arc, Mutex};

Expand All @@ -18,7 +19,7 @@ use super::authority::{
SignPayloadAuthorityRequest, SignRawAuthorityRequest, StatementStoreAllowanceKey,
authority_session, require_current_session,
};
use super::connected_session_ui_info;
use super::{RuntimeServices, connected_session_ui_info};
use crate::host_logic::entropy::derive_product_entropy;
use crate::host_logic::extrinsic::{Sr25519Signer, build_signed_extrinsic_v4};
use crate::host_logic::product_account::{
Expand All @@ -28,10 +29,16 @@ use crate::host_logic::product_account::{
use crate::host_logic::session::SessionState;
use crate::host_logic::sso::messages::RingVrfError;
use crate::runtime::auth_state::AuthStateMachine;
use ring_vrf::{
ChainRingResolver, MemberCandidate, PersonKey, RingResolver, alias_from_entropy, context_bytes,
create_proof, key_for_collection, member_from_entropy, person_entropy,
};

use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse};
use truapi::{CallContext, CallError, v01};
use truapi_platform::{Platform, ProductContext, normalize_product_identifier};
#[cfg(test)]
use truapi_platform::Platform;
use truapi_platform::{ProductContext, normalize_product_identifier};
use zeroize::Zeroizing;

const BYTES_WRAP_PREFIX: &[u8] = b"<Bytes>";
Expand All @@ -41,15 +48,32 @@ const BYTES_WRAP_SUFFIX: &[u8] = b"</Bytes>";
pub(crate) struct SigningHost {
session_state: Arc<SessionState>,
auth_state: AuthStateMachine,
ring_resolver: Arc<dyn RingResolver>,
/// Root BIP-39 entropy held only while a session is active.
root_entropy: Mutex<Option<Zeroizing<Vec<u8>>>>,
}

impl SigningHost {
pub(crate) fn new(platform: Arc<dyn Platform>) -> Arc<Self> {
pub(crate) fn new(services: Arc<RuntimeServices>) -> Arc<Self> {
let platform = services.platform.clone();
let ring_resolver = ChainRingResolver::new(services.chain.clone());
Arc::new(Self {
session_state: SessionState::new(),
auth_state: AuthStateMachine::new(platform),
ring_resolver,
root_entropy: Mutex::new(None),
})
}

#[cfg(test)]
fn new_with_ring_resolver(
platform: Arc<dyn Platform>,
ring_resolver: Arc<dyn RingResolver>,
) -> Arc<Self> {
Arc::new(Self {
session_state: SessionState::new(),
auth_state: AuthStateMachine::new(platform),
ring_resolver,
root_entropy: Mutex::new(None),
})
}
Expand Down Expand Up @@ -88,6 +112,34 @@ impl SigningHost {
derive_product_keypair(&root, &product_id, account.derivation_index)
.map_err(product_authority_error)
}

fn person_entropy(
&self,
session: &AuthoritySession,
key: PersonKey,
) -> Result<Zeroizing<[u8; 32]>, RingVrfError> {
require_current_session(&self.session_state, session)?;
let root = self.root_entropy()?;
Ok(person_entropy(&root, key))
}

fn member_candidates(
&self,
session: &AuthoritySession,
) -> Result<[MemberCandidate; 2], RingVrfError> {
let full_entropy = self.person_entropy(session, PersonKey::Full)?;
let lite_entropy = self.person_entropy(session, PersonKey::Lite)?;
Ok([
MemberCandidate {
key: PersonKey::Full,
member: member_from_entropy(&full_entropy)?,
},
MemberCandidate {
key: PersonKey::Lite,
member: member_from_entropy(&lite_entropy)?,
},
])
}
}

#[async_trait::async_trait]
Expand Down Expand Up @@ -213,22 +265,44 @@ impl ProductAuthority for SigningHost {
async fn account_alias(
&self,
_cx: &CallContext,
_session: &AuthoritySession,
_request: AccountAliasAuthorityRequest,
session: &AuthoritySession,
request: AccountAliasAuthorityRequest,
) -> Result<v01::ContextualAlias, RingVrfError> {
Err(RingVrfError::Unknown {
reason: "signing host: ring-VRF alias derivation not yet implemented".to_string(),
require_current_session(&self.session_state, session)?;
let collection = self.ring_resolver.validate(&request.ring_location).await?;
let context = context_bytes(&request.context);
let entropy = self.person_entropy(session, key_for_collection(&collection))?;
let alias = alias_from_entropy(&entropy, &context)?;
Ok(v01::ContextualAlias {
context,
alias: alias.to_vec(),
})
}

async fn create_proof(
&self,
_cx: &CallContext,
_session: &AuthoritySession,
_request: CreateProofAuthorityRequest,
session: &AuthoritySession,
request: CreateProofAuthorityRequest,
) -> Result<v01::HostAccountCreateProofResponse, RingVrfError> {
Err(RingVrfError::Unknown {
reason: "signing host: ring-VRF proof generation not yet implemented".to_string(),
let candidates = self.member_candidates(session)?;
let resolved = self
.ring_resolver
.resolve(&request.ring_location, &candidates)
.await?;
// Reject a stale request if the local session disconnected or changed
// while its chain snapshot was being resolved.
let entropy = self.person_entropy(session, resolved.selected.key)?;
let context = context_bytes(&request.context);
let (proof, alias) = create_proof(&entropy, &resolved, &context, &request.message)?;
Ok(v01::HostAccountCreateProofResponse {
proof,
contextual_alias: v01::ContextualAlias {
context,
alias: alias.to_vec(),
},
ring_index: resolved.ring_index,
ring_revision: resolved.ring_revision,
})
}

Expand Down Expand Up @@ -382,10 +456,16 @@ mod tests {
use std::sync::Arc;

use super::super::authority::{
AuthorityError, CreateTransactionAuthorityRequest, SignRawAuthorityRequest,
AccountAliasAuthorityRequest, AuthorityError, CreateProofAuthorityRequest,
CreateTransactionAuthorityRequest, SignRawAuthorityRequest,
};
use super::super::{ProductAuthority, ProductRuntimeHost, RuntimeServices, SigningHostRole};
use super::{BYTES_WRAP_PREFIX, BYTES_WRAP_SUFFIX, LocalActivation, raw_payload_bytes};
use super::ring_vrf::{
MemberCandidate, PersonKey, ResolvedRing, RingResolver, member_from_entropy, person_entropy,
};
use super::{
BYTES_WRAP_PREFIX, BYTES_WRAP_SUFFIX, LocalActivation, RingVrfError, raw_payload_bytes,
};
use crate::host_logic::extrinsic::tests::split_v4;
use crate::host_logic::product_account::{
derive_product_keypair, derive_root_keypair_from_entropy,
Expand All @@ -397,9 +477,35 @@ mod tests {
use truapi::versioned::signing::{HostSignRawError, HostSignRawRequest, HostSignRawResponse};
use truapi::{CallContext, CallError, v01};
use truapi_platform::{HostInfo, PlatformInfo, ProductContext, SigningHostConfig};
use verifiable::ring::RingDomainSize;

const ENTROPY: [u8; 16] = [0xAB; 16];

#[derive(Clone)]
struct StubRingResolver {
collection: [u8; 32],
ring: ResolvedRing,
}

#[async_trait::async_trait]
impl RingResolver for StubRingResolver {
async fn validate(&self, _location: &v01::RingLocation) -> Result<[u8; 32], RingVrfError> {
Ok(self.collection)
}

async fn resolve(
&self,
_location: &v01::RingLocation,
candidates: &[MemberCandidate],
) -> Result<ResolvedRing, RingVrfError> {
assert!(
candidates.contains(&self.ring.selected),
"signing host offered the selected person key"
);
Ok(self.ring.clone())
}
}

fn signing_runtime() -> (Arc<RuntimeServices>, Arc<SigningHostRole>) {
// Auto-confirm raw signing so the role-neutral confirmation gate does
// not reject before reaching the signing authority.
Expand All @@ -424,7 +530,7 @@ mod tests {
config.bulletin_chain_genesis_hash,
test_spawner(),
);
let signing_host = SigningHostRole::new(platform);
let signing_host = SigningHostRole::new(services.clone());
(services, signing_host)
}

Expand All @@ -451,6 +557,72 @@ mod tests {
)
}

#[test]
fn ring_alias_and_proof_share_the_selected_person_key() {
let full_entropy = person_entropy(&ENTROPY, PersonKey::Full);
let full_member = member_from_entropy(&full_entropy).expect("full-person member");
let selected = MemberCandidate {
key: PersonKey::Full,
member: full_member,
};
let resolver = Arc::new(StubRingResolver {
collection: *b"pop:polkadot.network/people ",
ring: ResolvedRing {
selected,
ring_index: 7,
ring_revision: 11,
domain_size: RingDomainSize::Domain11,
members: vec![full_member],
},
});
let platform: Arc<dyn truapi_platform::Platform> = Arc::new(StubPlatform::default());
let authority = SigningHostRole::new_with_ring_resolver(platform, resolver);
futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec()))
.expect("activation succeeds");
let session = authority.current_session().expect("active session");
let cx = CallContext::new();
let context = v01::ProductProofContext {
product_id: "myapp.dot".to_string(),
suffix: b"account".to_vec(),
};
let ring_location = v01::RingLocation {
chain_id: [0x22; 32],
junctions: vec![
v01::RingLocationJunction::PalletInstance(42),
v01::RingLocationJunction::CollectionId(
b"pop:polkadot.network/people ".to_vec(),
),
],
};

let alias = futures::executor::block_on(authority.account_alias(
&cx,
&session,
AccountAliasAuthorityRequest {
calling_product_id: "myapp.dot".to_string(),
context: context.clone(),
ring_location: ring_location.clone(),
},
))
.expect("alias succeeds");
let proof = futures::executor::block_on(authority.create_proof(
&cx,
&session,
CreateProofAuthorityRequest {
calling_product_id: "myapp.dot".to_string(),
context,
ring_location,
message: b"prove me".to_vec(),
},
))
.expect("proof succeeds");

assert!(!proof.proof.is_empty());
assert_eq!(proof.contextual_alias, alias);
assert_eq!(proof.ring_index, 7);
assert_eq!(proof.ring_revision, 11);
}

#[test]
fn activate_then_sign_raw_verifies_against_derived_product_key() {
let (services, activation) = signing_runtime();
Expand Down
Loading