Skip to main content

canton_admin/
external_party.rs

1//! Allocating a party whose key the participant does not hold.
2//!
3//! An ordinary party is allocated in one call and the participant signs for it
4//! from then on. An *external* party keeps its key elsewhere — which is the
5//! whole point of interactive submission — so allocation is two calls with a
6//! signature in between:
7//!
8//! 1. [`generate_external_party_topology`] hands the participant a public key.
9//!    It replies with the party id it would assign, the **fingerprint** it
10//!    computed for the key, the onboarding topology transactions, and one hash
11//!    over all of them.
12//! 2. The key signs that hash — proving the party controls it — and
13//!    [`allocate_external_party`] submits the transactions with the signature.
14//!
15//! The fingerprint from step 1 is what makes a `canton_signer::Ed25519Key` into
16//! a `Signer`: Canton computes it, so a caller cannot know it before asking.
17//!
18//! [`generate_external_party_topology`]: crate::AdminClient::generate_external_party_topology
19//! [`allocate_external_party`]: crate::AdminClient::allocate_external_party
20
21use canton_proto::com::daml::ledger::api::v2::admin as pb;
22use canton_proto::com::digitalasset::canton::crypto::v30 as crypto;
23use canton_proto::com::digitalasset::canton::protocol::v30 as topo;
24use canton_signer::PublicKey;
25use prost::Message as _;
26use sha2::{Digest as _, Sha256};
27
28/// Canton's `UntypedVersionedMessage`, the envelope every serialized topology
29/// transaction arrives in: the transaction bytes and the protocol version they
30/// were written under. Two fields, so it is declared here rather than vendored.
31#[derive(Clone, PartialEq, prost::Message)]
32struct UntypedVersionedMessage {
33    #[prost(bytes = "vec", tag = "1")]
34    data: Vec<u8>,
35    #[prost(int32, tag = "2")]
36    version: i32,
37}
38
39/// Canton's `HashPurpose.TopologyTransactionSignature`: the purpose a single
40/// topology transaction is hashed under.
41const HASH_PURPOSE_TOPOLOGY_TRANSACTION_SIGNATURE: u32 = 11;
42/// Canton's `HashPurpose.MultiTopologyTransaction`: the purpose the hash over
43/// a set of transactions is built under, which is what one signature covers.
44const HASH_PURPOSE_MULTI_TOPOLOGY_TRANSACTION: u32 = 55;
45/// A Canton hash on the wire is a multihash: SHA-256's code and length, then
46/// the digest.
47const MULTIHASH_SHA256_PREFIX: [u8; 2] = [0x12, 0x20];
48
49fn evidence(digest: &[u8]) -> Vec<u8> {
50    let mut out = Vec::with_capacity(MULTIHASH_SHA256_PREFIX.len() + digest.len());
51    out.extend_from_slice(&MULTIHASH_SHA256_PREFIX);
52    out.extend_from_slice(digest);
53    out
54}
55
56/// The hash of one serialized topology transaction, as Canton computes it:
57/// `SHA-256(purpose ‖ bytes)`, multihash-encoded.
58fn transaction_hash(serialized: &[u8]) -> Vec<u8> {
59    let mut digest = Sha256::new();
60    digest.update(HASH_PURPOSE_TOPOLOGY_TRANSACTION_SIGNATURE.to_be_bytes());
61    digest.update(serialized);
62    evidence(&digest.finalize())
63}
64
65/// The hash one signature covers for a set of transactions, as Canton's
66/// `MultiTransactionSignature.computeCombinedHash` builds it: the purpose,
67/// the number of transactions, then each transaction's hash (length-prefixed)
68/// in ascending order. This is what the participant returns as `multi_hash`,
69/// and recomputing it is what ties the signature to the transactions the
70/// caller can read rather than to whatever the participant chose to hash.
71pub(crate) fn multi_hash(transactions: &[Vec<u8>]) -> Vec<u8> {
72    let mut covered: Vec<Vec<u8>> = transactions.iter().map(|t| transaction_hash(t)).collect();
73    covered.sort();
74    covered.dedup();
75    let mut digest = Sha256::new();
76    digest.update(HASH_PURPOSE_MULTI_TOPOLOGY_TRANSACTION.to_be_bytes());
77    digest.update(
78        u32::try_from(covered.len())
79            .unwrap_or(u32::MAX)
80            .to_be_bytes(),
81    );
82    for hash in &covered {
83        digest.update(u32::try_from(hash.len()).unwrap_or(u32::MAX).to_be_bytes());
84        digest.update(hash);
85    }
86    evidence(&digest.finalize())
87}
88
89fn decode_transaction(serialized: &[u8]) -> canton_core::Result<topo::TopologyTransaction> {
90    let envelope = UntypedVersionedMessage::decode(serialized).map_err(|e| {
91        canton_core::Error::UnexpectedResponse(format!(
92            "an onboarding transaction is not a versioned message: {e}"
93        ))
94    })?;
95    topo::TopologyTransaction::decode(envelope.data.as_slice()).map_err(|e| {
96        canton_core::Error::UnexpectedResponse(format!(
97            "an onboarding transaction (protocol version {}) does not decode: {e}",
98            envelope.version
99        ))
100    })
101}
102
103fn refuse(what: impl std::fmt::Display) -> canton_core::Error {
104    canton_core::Error::UnexpectedResponse(format!(
105        "the participant's onboarding transactions would authorize more than this party's \
106         own key: {what}; refusing to sign them"
107    ))
108}
109
110fn is_the_registered_key(candidate: Option<&crypto::SigningPublicKey>, key: &PublicKey) -> bool {
111    let Some(candidate) = candidate else {
112        return false;
113    };
114    let format_matches = match key.format() {
115        canton_signer::KeyFormat::Raw => candidate.format == crypto::CryptoKeyFormat::Raw as i32,
116        // Any other format: the bytes decide.
117        _ => true,
118    };
119    format_matches && candidate.public_key == key.data()
120}
121
122/// What an onboarding transaction may say, and nothing else: the key's own
123/// namespace delegated to the key itself, the party bound to that one key, and
124/// the party hosted for confirmation (never submission, which would let the
125/// host act as the party without a signature).
126#[allow(deprecated)]
127fn check_transaction(
128    transaction: &topo::TopologyTransaction,
129    party_id: &str,
130    fingerprint: &str,
131    key: &PublicKey,
132) -> canton_core::Result<()> {
133    use topo::topology_mapping::Mapping;
134    if transaction.operation != topo::enums::TopologyChangeOp::AddReplace as i32 {
135        return Err(refuse(format!(
136            "a transaction with operation {} rather than an addition",
137            transaction.operation
138        )));
139    }
140    let mapping = transaction
141        .mapping
142        .as_ref()
143        .and_then(|m| m.mapping.as_ref())
144        .ok_or_else(|| refuse("a transaction without a mapping"))?;
145    match mapping {
146        Mapping::NamespaceDelegation(delegation) => {
147            if delegation.namespace != fingerprint {
148                return Err(refuse(format!(
149                    "a namespace delegation for `{}`, not this key's namespace `{fingerprint}`",
150                    delegation.namespace
151                )));
152            }
153            if !is_the_registered_key(delegation.target_key.as_ref(), key) {
154                return Err(refuse(
155                    "a namespace delegation to a key other than the one being registered",
156                ));
157            }
158        }
159        Mapping::PartyToKeyMapping(mapping) => {
160            if mapping.party != party_id {
161                return Err(refuse(format!(
162                    "a key mapping for `{}`, not `{party_id}`",
163                    mapping.party
164                )));
165            }
166            if mapping.signing_keys.len() != 1
167                || !is_the_registered_key(mapping.signing_keys.first(), key)
168            {
169                return Err(refuse(
170                    "a key mapping naming a signing key other than the one being registered",
171                ));
172            }
173            if mapping.threshold > 1 {
174                return Err(refuse(format!(
175                    "a key mapping with threshold {}",
176                    mapping.threshold
177                )));
178            }
179        }
180        Mapping::PartyToParticipant(hosting) => {
181            if hosting.party != party_id {
182                return Err(refuse(format!(
183                    "a hosting mapping for `{}`, not `{party_id}`",
184                    hosting.party
185                )));
186            }
187            if hosting.threshold > 1 {
188                return Err(refuse(format!(
189                    "a hosting mapping with threshold {}",
190                    hosting.threshold
191                )));
192            }
193            for host in &hosting.participants {
194                let allowed = host.permission
195                    == topo::enums::ParticipantPermission::Confirmation as i32
196                    || host.permission == topo::enums::ParticipantPermission::Observation as i32;
197                if !allowed {
198                    return Err(refuse(format!(
199                        "participant `{}` hosting the party with permission {} (only \
200                         confirmation or observation is a hosted external party)",
201                        host.participant_uid, host.permission
202                    )));
203                }
204            }
205        }
206        _ => return Err(refuse("a mapping of a kind onboarding does not need")),
207    }
208    Ok(())
209}
210
211/// What the participant will do with an external party, once it is asked to.
212///
213/// The result of [`generate_external_party_topology`], and the input to
214/// [`allocate_external_party`]. Nothing has been submitted yet: this is a
215/// proposal, and it becomes real only when the key signs
216/// [`multi_hash`](Self::multi_hash).
217///
218/// [`generate_external_party_topology`]: crate::AdminClient::generate_external_party_topology
219/// [`allocate_external_party`]: crate::AdminClient::allocate_external_party
220#[derive(Clone, Debug)]
221pub struct ExternalPartyTopology {
222    party_id: String,
223    public_key_fingerprint: String,
224    transactions: Vec<Vec<u8>>,
225    multi_hash: Vec<u8>,
226}
227
228impl ExternalPartyTopology {
229    pub(crate) fn from_response(
230        response: pb::GenerateExternalPartyTopologyResponse,
231        public_key: &PublicKey,
232    ) -> canton_core::Result<Self> {
233        if response.multi_hash.is_empty() {
234            return Err(canton_core::Error::UnexpectedResponse(
235                "the participant returned no multi-hash to sign".to_string(),
236            ));
237        }
238        if response.public_key_fingerprint.is_empty() {
239            return Err(canton_core::Error::UnexpectedResponse(
240                "the participant returned no key fingerprint, so nothing could \
241                 sign as this party"
242                    .to_string(),
243            ));
244        }
245        if response.topology_transactions.is_empty() {
246            return Err(canton_core::Error::UnexpectedResponse(
247                "the participant returned no onboarding transactions, so there would be \
248                 nothing to submit and the multi-hash would cover nothing"
249                    .to_string(),
250            ));
251        }
252        // A party id is `<hint>::<namespace>`, and for an external party the
253        // namespace *is* the fingerprint of the key being registered.
254        match response.party_id.rsplit_once("::") {
255            Some((_, namespace)) if namespace == response.public_key_fingerprint => {}
256            Some((_, namespace)) => {
257                return Err(canton_core::Error::UnexpectedResponse(format!(
258                    "the participant offered party `{}`, whose namespace `{namespace}` is not the \
259                     fingerprint `{}` of the key being registered — nothing this caller holds \
260                     could sign as it",
261                    response.party_id, response.public_key_fingerprint
262                )));
263            }
264            None => {
265                return Err(canton_core::Error::UnexpectedResponse(format!(
266                    "the participant returned `{}`, which is not a party id",
267                    response.party_id
268                )));
269            }
270        }
271        // The signature will cover `multi_hash`, so what the hash covers is
272        // what the key authorizes. Recomputed from the transactions the caller
273        // can read, and each of those read: a participant that hashed a
274        // different set, or slipped a mapping of its own into this one (a
275        // second host with submission rights, a delegation to its own key),
276        // is not signed for.
277        let recomputed = multi_hash(&response.topology_transactions);
278        if recomputed != response.multi_hash {
279            return Err(canton_core::Error::UnexpectedResponse(
280                "the participant's multi-hash is not the hash of the onboarding transactions \
281                 it returned; refusing to sign it"
282                    .to_string(),
283            ));
284        }
285        for serialized in &response.topology_transactions {
286            let transaction = decode_transaction(serialized)?;
287            check_transaction(
288                &transaction,
289                &response.party_id,
290                &response.public_key_fingerprint,
291                public_key,
292            )?;
293        }
294        Ok(Self {
295            party_id: response.party_id,
296            public_key_fingerprint: response.public_key_fingerprint,
297            transactions: response.topology_transactions,
298            multi_hash: response.multi_hash,
299        })
300    }
301
302    /// The party id the participant will assign — `<hint>::<namespace>`.
303    ///
304    /// Known before allocation because it is derived from the key, so a caller
305    /// can prepare against it while the signature is still being obtained.
306    #[must_use]
307    pub fn party_id(&self) -> &str {
308        &self.party_id
309    }
310
311    /// The fingerprint Canton computed for the key.
312    ///
313    /// This is what a signature carries in `signed_by`, and what
314    /// `canton_signer::Ed25519Key::into_signer` needs.
315    #[must_use]
316    pub fn public_key_fingerprint(&self) -> &str {
317        &self.public_key_fingerprint
318    }
319
320    /// The hash to sign: one hash over every onboarding transaction, so one
321    /// signature authorizes them all.
322    #[must_use]
323    pub fn multi_hash(&self) -> &[u8] {
324        &self.multi_hash
325    }
326
327    /// The serialized onboarding topology transactions.
328    ///
329    /// Exposed for a caller that wants to inspect what it is about to
330    /// authorize; [`allocate_external_party`] submits them.
331    ///
332    /// [`allocate_external_party`]: crate::AdminClient::allocate_external_party
333    #[must_use]
334    pub fn transactions(&self) -> &[Vec<u8>] {
335        &self.transactions
336    }
337}
338
339#[cfg(test)]
340#[allow(clippy::unwrap_used, clippy::expect_used, deprecated)]
341mod tests {
342    use super::{ExternalPartyTopology, UntypedVersionedMessage, multi_hash};
343    use canton_proto::com::daml::ledger::api::v2::admin as pb;
344    use canton_proto::com::digitalasset::canton::crypto::v30 as crypto;
345    use canton_proto::com::digitalasset::canton::protocol::v30 as topo;
346    use canton_signer::{KeyFormat, KeySpec, PublicKey};
347    use prost::Message as _;
348    use topo::topology_mapping::Mapping;
349
350    const FINGERPRINT: &str = "1220ab";
351    const PARTY: &str = "alice::1220ab";
352
353    fn key() -> PublicKey {
354        PublicKey::new(KeyFormat::Raw, vec![7; 32], KeySpec::EcCurve25519)
355    }
356
357    fn signing_key(bytes: &[u8]) -> crypto::SigningPublicKey {
358        crypto::SigningPublicKey {
359            format: crypto::CryptoKeyFormat::Raw as i32,
360            public_key: bytes.to_vec(),
361            ..Default::default()
362        }
363    }
364
365    fn transaction(mapping: Mapping, operation: topo::enums::TopologyChangeOp) -> Vec<u8> {
366        let transaction = topo::TopologyTransaction {
367            operation: operation as i32,
368            serial: 1,
369            mapping: Some(topo::TopologyMapping {
370                mapping: Some(mapping),
371            }),
372        };
373        UntypedVersionedMessage {
374            data: transaction.encode_to_vec(),
375            version: 30,
376        }
377        .encode_to_vec()
378    }
379
380    fn add(mapping: Mapping) -> Vec<u8> {
381        transaction(mapping, topo::enums::TopologyChangeOp::AddReplace)
382    }
383
384    fn delegation(namespace: &str, to: &[u8]) -> Mapping {
385        Mapping::NamespaceDelegation(topo::NamespaceDelegation {
386            namespace: namespace.to_string(),
387            target_key: Some(signing_key(to)),
388            restriction: Some(topo::namespace_delegation::Restriction::CanSignAllMappings(
389                topo::namespace_delegation::CanSignAllMappings {},
390            )),
391            ..Default::default()
392        })
393    }
394
395    fn key_mapping(party: &str, keys: &[&[u8]]) -> Mapping {
396        Mapping::PartyToKeyMapping(topo::PartyToKeyMapping {
397            party: party.to_string(),
398            threshold: 1,
399            signing_keys: keys.iter().map(|k| signing_key(k)).collect(),
400        })
401    }
402
403    fn hosting(party: &str, hosts: &[(&str, topo::enums::ParticipantPermission)]) -> Mapping {
404        Mapping::PartyToParticipant(topo::PartyToParticipant {
405            party: party.to_string(),
406            threshold: 1,
407            participants: hosts
408                .iter()
409                .map(
410                    |(uid, permission)| topo::party_to_participant::HostingParticipant {
411                        participant_uid: (*uid).to_string(),
412                        permission: *permission as i32,
413                        onboarding: None,
414                    },
415                )
416                .collect(),
417            ..Default::default()
418        })
419    }
420
421    /// What a participant returns for an honest onboarding of `PARTY`.
422    fn honest_set() -> Vec<Vec<u8>> {
423        vec![
424            add(delegation(FINGERPRINT, &[7; 32])),
425            add(key_mapping(PARTY, &[&[7; 32]])),
426            add(hosting(
427                PARTY,
428                &[(
429                    "participant1::1220cc",
430                    topo::enums::ParticipantPermission::Confirmation,
431                )],
432            )),
433        ]
434    }
435
436    fn response(
437        party_id: &str,
438        fingerprint: &str,
439        transactions: Vec<Vec<u8>>,
440    ) -> pb::GenerateExternalPartyTopologyResponse {
441        pb::GenerateExternalPartyTopologyResponse {
442            party_id: party_id.to_string(),
443            public_key_fingerprint: fingerprint.to_string(),
444            multi_hash: multi_hash(&transactions),
445            topology_transactions: transactions,
446        }
447    }
448
449    fn refused(response: pb::GenerateExternalPartyTopologyResponse) -> String {
450        let error = ExternalPartyTopology::from_response(response, &key()).expect_err("refused");
451        assert!(
452            matches!(error, canton_core::Error::UnexpectedResponse(_)),
453            "{error:?}"
454        );
455        error.to_string()
456    }
457
458    /// The ordinary case: the namespace of the party id is the fingerprint of
459    /// the key being registered, the hash is the hash of the transactions,
460    /// and the transactions say what onboarding says.
461    #[test]
462    fn an_honest_onboarding_set_is_accepted() {
463        let topology = ExternalPartyTopology::from_response(
464            response(PARTY, FINGERPRINT, honest_set()),
465            &key(),
466        )
467        .expect("the honest set");
468        assert_eq!(topology.party_id(), PARTY);
469        assert_eq!(topology.public_key_fingerprint(), FINGERPRINT);
470        assert_eq!(topology.multi_hash().len(), 34);
471        assert_eq!(&topology.multi_hash()[..2], &[0x12, 0x20]);
472    }
473
474    /// The multi-hash is Canton's: purpose 55, a count, then each transaction's
475    /// purpose-11 hash length-prefixed in ascending order. Order of the input
476    /// does not matter; the set does.
477    #[test]
478    fn the_multi_hash_is_over_the_sorted_set_of_transaction_hashes() {
479        let set = honest_set();
480        let mut reversed = set.clone();
481        reversed.reverse();
482        assert_eq!(multi_hash(&set), multi_hash(&reversed));
483        assert_ne!(multi_hash(&set), multi_hash(&set[..2]));
484        // A known vector: one empty transaction. SHA-256(BE32(11) ‖ "") is the
485        // transaction hash; the multi-hash is SHA-256(BE32(55) ‖ BE32(1) ‖ BE32(34) ‖ 0x1220 ‖ that).
486        let single = multi_hash(&[Vec::new()]);
487        assert_eq!(single.len(), 34);
488        assert_ne!(&single[2..], &multi_hash(&[vec![1]])[2..]);
489    }
490
491    #[test]
492    fn a_multi_hash_that_is_not_the_hash_of_the_transactions_is_refused() {
493        let mut response = response(PARTY, FINGERPRINT, honest_set());
494        response.multi_hash[10] ^= 0xff;
495        let message = refused(response);
496        assert!(message.contains("multi-hash"), "{message}");
497    }
498
499    #[test]
500    fn a_second_host_with_submission_rights_is_refused() {
501        let mut set = honest_set();
502        set.push(add(hosting(
503            PARTY,
504            &[(
505                "attacker::1220ee",
506                topo::enums::ParticipantPermission::Submission,
507            )],
508        )));
509        let message = refused(response(PARTY, FINGERPRINT, set));
510        assert!(
511            message.contains("submission") || message.contains("permission 1"),
512            "{message}"
513        );
514    }
515
516    #[test]
517    fn a_delegation_to_another_key_is_refused() {
518        let mut set = honest_set();
519        set.push(add(delegation(FINGERPRINT, &[9; 32])));
520        let message = refused(response(PARTY, FINGERPRINT, set));
521        assert!(message.contains("delegation"), "{message}");
522        let set = vec![add(delegation("1220ff", &[7; 32]))];
523        let message = refused(response(PARTY, FINGERPRINT, set));
524        assert!(message.contains("1220ff"), "{message}");
525    }
526
527    #[test]
528    fn a_key_mapping_naming_an_extra_key_or_another_party_is_refused() {
529        let set = vec![add(key_mapping(PARTY, &[&[7; 32], &[9; 32]]))];
530        assert!(refused(response(PARTY, FINGERPRINT, set)).contains("signing key"));
531        let set = vec![add(key_mapping("mallory::1220ab", &[&[7; 32]]))];
532        assert!(refused(response(PARTY, FINGERPRINT, set)).contains("mallory"));
533    }
534
535    #[test]
536    fn a_mapping_onboarding_does_not_need_and_a_removal_are_refused() {
537        let set = vec![add(
538            Mapping::VettedPackages(topo::VettedPackages::default()),
539        )];
540        assert!(refused(response(PARTY, FINGERPRINT, set)).contains("kind"));
541        let set = vec![transaction(
542            delegation(FINGERPRINT, &[7; 32]),
543            topo::enums::TopologyChangeOp::Remove,
544        )];
545        assert!(refused(response(PARTY, FINGERPRINT, set)).contains("operation"));
546    }
547
548    #[test]
549    fn bytes_that_are_not_a_transaction_are_refused() {
550        let message = refused(response(PARTY, FINGERPRINT, vec![vec![0xff, 0xff, 0xff]]));
551        assert!(
552            message.contains("versioned message") || message.contains("decode"),
553            "{message}"
554        );
555    }
556
557    /// And the checks that predate the decoding: a party under a namespace this
558    /// caller's key does not control, and something that is not a party id.
559    #[test]
560    fn a_party_under_a_namespace_this_key_does_not_control_is_refused() {
561        let message = refused(response("alice::1220ff", FINGERPRINT, honest_set()));
562        assert!(
563            message.contains("1220ff") && message.contains("1220ab"),
564            "{message}"
565        );
566    }
567
568    #[test]
569    fn a_response_that_is_not_a_party_id_is_refused() {
570        let message = refused(response("alice", FINGERPRINT, honest_set()));
571        assert!(message.contains("not a party id"), "{message}");
572    }
573}