Skip to content
Closed
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/funding-getcash-derivation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@parity/truapi": patch
---

Funding accounts are derived with getcash's scheme under the reserved `fund.<network suffix>` product: that product's entropy for the account's getcash label, taken as a mini secret. The accounts are the funding product's, not getcash's own, so burners getcash already made under its product id are not among them. No product under `fund.<network suffix>` can derive entropy.
114 changes: 79 additions & 35 deletions rust/crates/truapi/src/host_logic/funding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ use parity_scale_codec::{Decode, Encode};
use tracing::warn;
use truapi::latest::{FundingDirection, FundingFailure, HostFundingStatusSubscribeItem};

use crate::host_logic::entropy::{ProductEntropyError, derive_product_entropy};
use crate::host_logic::product_account::{ProductAccountError, derive_root_keypair_from_entropy};
use crate::platform::{CoreStorage, CoreStorageKey};

/// How long a session may stay open before it expires.
Expand Down Expand Up @@ -469,34 +471,55 @@ pub enum FundingAccountKind {
Withdrawal,
}

/// Why a funding account label could not be built.
#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)]
#[display("funding account label is longer than 32 bytes")]
pub struct FundingAccountLabelTooLong;
/// Why a funding account could not be derived.
#[derive(Debug, PartialEq, Eq, derive_more::Display, derive_more::Error)]
pub enum FundingAccountError {
/// The label is longer than the 32 bytes entropy derivation takes.
#[display("funding account label is longer than 32 bytes")]
LabelTooLong,
/// The entropy could not be derived.
#[display("{_0}")]
Entropy(ProductEntropyError),
/// The key could not be derived from the entropy.
#[display("{_0}")]
Key(ProductAccountError),
}

/// Derivation index of the `number`th account of `kind` for `source_id`: the
/// label `onramp:eph:<source>:<n>`, `onramp:rf:<source>:<n>` or
/// `wd:eph:<source>:<n>`, zero-padded to 32 bytes.
///
/// The labels are the ones getcash uses, and `number` counts up from 1 per
/// source, so every account can be found again from the seed alone.
pub fn funding_account_index(
/// The label of the `number`th account of `kind` for `source_id`:
/// `onramp:eph:<source>:<n>`, `onramp:rf:<source>:<n>` or
/// `wd:eph:<source>:<n>`, the labels getcash uses. `number` counts up from 1
/// per source, so every account can be found again from the seed alone.
pub fn funding_account_label(
kind: FundingAccountKind,
source_id: &str,
number: u32,
) -> Result<[u8; 32], FundingAccountLabelTooLong> {
) -> Result<String, FundingAccountError> {
let prefix = match kind {
FundingAccountKind::Deposit => "onramp:eph",
FundingAccountKind::Refund => "onramp:rf",
FundingAccountKind::Withdrawal => "wd:eph",
};
let label = format!("{prefix}:{source_id}:{number}");
let mut index = [0u8; 32];
index
.get_mut(..label.len())
.ok_or(FundingAccountLabelTooLong)?
.copy_from_slice(label.as_bytes());
Ok(index)
(label.len() <= 32)
.then_some(label)
.ok_or(FundingAccountError::LabelTooLong)
}

/// The keypair of the `number`th account of `kind` for `source_id`, derived
/// with getcash's scheme: the funding product's `deriveEntropy` for the
/// account's getcash label, taken as a mini secret. getcash derives under its
/// own product id, so its existing burners are other accounts.
pub fn funding_keypair(
root_entropy: &[u8],
funding_product_id: &str,
kind: FundingAccountKind,
source_id: &str,
number: u32,
) -> Result<schnorrkel::Keypair, FundingAccountError> {
let label = funding_account_label(kind, source_id, number)?;
let entropy = derive_product_entropy(root_entropy, funding_product_id, label.as_bytes())
.map_err(FundingAccountError::Entropy)?;
derive_root_keypair_from_entropy(&entropy).map_err(FundingAccountError::Key)
}

/// Why a session operation failed.
Expand Down Expand Up @@ -850,32 +873,53 @@ mod tests {
);
}

// Funds sit in these accounts, so an index that drifts between releases
// strands them. The bytes are pinned to the labels getcash uses.
// Funds sit in these accounts, so a label that drifts between releases
// strands them. The labels are getcash's, byte for byte, unpadded.
#[test]
fn funding_account_indices_are_the_padded_getcash_labels() {
let padded = |label: &str| {
let mut index = [0u8; 32];
index[..label.len()].copy_from_slice(label.as_bytes());
index
};

fn funding_account_labels_are_getcash_labels() {
assert_eq!(
[
funding_account_index(FundingAccountKind::Deposit, "usdt-assethub", 1),
funding_account_index(FundingAccountKind::Refund, "btc", 2),
funding_account_index(FundingAccountKind::Withdrawal, "dot-assethub", 3),
funding_account_index(FundingAccountKind::Deposit, "x".repeat(40).as_str(), 1),
funding_account_label(FundingAccountKind::Deposit, "usdt-assethub", 1),
funding_account_label(FundingAccountKind::Refund, "btc", 2),
funding_account_label(FundingAccountKind::Withdrawal, "dot-assethub", 3),
funding_account_label(FundingAccountKind::Deposit, "x".repeat(40).as_str(), 1),
],
[
Ok(padded("onramp:eph:usdt-assethub:1")),
Ok(padded("onramp:rf:btc:2")),
Ok(padded("wd:eph:dot-assethub:3")),
Err(FundingAccountLabelTooLong),
Ok("onramp:eph:usdt-assethub:1".to_string()),
Ok("onramp:rf:btc:2".to_string()),
Ok("wd:eph:dot-assethub:3".to_string()),
Err(FundingAccountError::LabelTooLong),
]
);
}

// getcash turns its `deriveEntropy(label)` into the burner with
// `entropyToMiniSecret` and `sr25519CreateDerive(mini)("")`. The keys are
// from those libraries, fed the entropy core's `deriveEntropy` gives
// `fund.dot` for the label (itself pinned to dotli's vector), so the same
// root reaches the same account through either implementation.
#[test]
fn a_funding_key_is_the_one_getcash_derives_from_the_same_entropy() {
let key = |root: &[u8]| {
funding_keypair(root, "fund.dot", FundingAccountKind::Deposit, "usdt-assethub", 1)
.map(|keypair| hex::encode(keypair.public.to_bytes()))
};

assert_eq!(
(
derive_root_keypair_from_entropy(&[7; 32])
.map(|keypair| hex::encode(keypair.public.to_bytes())),
key(&[9; 32]),
),
(
Ok("ae78b88f68f8a3391cd7d1a8908766e1d068b2c1db6244a373e8b643e49d085f".to_string()),
Ok("fefa1fc85ecec3e8efa2cf47672fe85220dfa74c4aeda155b673f414b141b054".to_string()),
)
);
}



#[test]
fn storing_nothing_clears_the_slot() {
let storage = stub_platform();
Expand Down
6 changes: 3 additions & 3 deletions rust/crates/truapi/src/host_logic/product_account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ pub const IDENTITY_LABEL: &str = "uid";
/// domain holds the full and light person keys; the product id is
/// `peopl.<network suffix>`, see [`personhood_product_id`].
pub const PERSONHOOD_LABEL: &str = "peopl";
/// Reserved dotNS label of the funding modality, under whose subtree every
/// funding session's deposit account lives; the product id is
/// `fund.<network suffix>`, see [`funding_product_id`].
/// Reserved dotNS label of the funding modality, whose entropy every funding
/// account is derived from; the product id is `fund.<network suffix>`, see
/// [`funding_product_id`].
pub const FUNDING_LABEL: &str = "fund";
const RING_VRF_ROOT_KEY: &[u8] = b"ring-vrf";

Expand Down
5 changes: 4 additions & 1 deletion rust/crates/truapi/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -858,7 +858,10 @@ impl ProductRuntimeHost {
}

/// Whether `product_id` is the reserved funding product or a subname of it.
/// Its accounts hold users' funds in transit, so only the host derives them.
/// Its entropy is the key to every funding account, which holds users' funds
/// in transit, so no product derives it or uses its accounts. Paired hosts
/// hold the root entropy source and can compute it, as getcash's burners
/// always could be: the host is trusted with funding keys, products are not.
fn is_funding_product(product_id: &str) -> bool {
bare_product_label(product_id) == FUNDING_LABEL
}
Expand Down
5 changes: 5 additions & 0 deletions rust/crates/truapi/src/runtime/capabilities/resources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ impl Entropy for ProductRuntimeHost {
},
)));
};
// The funding accounts are the funding product's entropy for their
// labels, so no product under that name may derive it.
if crate::runtime::is_funding_product(&self.product_id()) {
return Err(CallError::Denied);
}
let entropy = self
.authority
.derive_entropy(&session, &self.product_id(), &context)
Expand Down
Loading
Loading