Interactive submission and external signing
An ordinary submission asks the participant to interpret a command and to authorize it with the party’s key, so the participant holds that key. Interactive submission splits the two. The participant interprets the command and returns the transaction with a hash of it. The party’s key signs the hash wherever it is kept (an HSM, a cloud KMS, an offline machine), and the participant executes the signed transaction without ever seeing the key.
Three crates take part. Interactive submission is on the gRPC client,
CantonClient; the JSON client does not offer it.
| Crate | Its part |
|---|---|
canton-admin | Onboards the external party: AdminClient::generate_external_party_topology and allocate_external_party. |
canton-ledger | Prepare, sign, execute: Prepare, Prepared, Executable, and the CantonClient methods that send them. |
canton-signer | The Signer trait, and Ed25519Key, an in-memory implementation. |
Dependencies and the ed25519 feature
cargo add canton brings all three, as canton::ledger, canton::admin and
canton::signer, with the in-memory Ed25519 key on by default. That key sits in
process memory and is not zeroed on drop: fine for development, tests, and
deployments that accept the risk. Production keys belong in an HSM or KMS
behind your own Signer. To build without the in-memory key type (ring itself stays in the tree,
because the TLS stack uses it):
canton = { version = "0.3", default-features = false }
The trait, the signature types and their validation do not depend on the
feature. On the individual crates, canton-signer has ed25519 on by default;
canton-ledger and canton-admin need only the trait, and their ed25519
feature turns on canton-signer’s.
Onboard an external party
The participant does not hold an external party’s key, so allocating the party takes two calls with a signature in between:
generate_external_party_topology(synchronizer, party_hint, &public_key)returns anExternalPartyTopology: the party id the participant would assign, the fingerprint it computed for the key, the onboarding topology transactions, and one multi-hash over all of them. Nothing is submitted yet.allocate_external_party(synchronizer, &topology, &signer, user_id)signs the multi-hash and submits the transactions with the signature. It returns the party id once the allocation has reached the synchronizer, so the party is usable straight away.
Canton computes the fingerprint, and every signature carries it in
signed_by, so an Ed25519Key cannot sign for the ledger until
into_signer(fingerprint)
turns it into an
Ed25519Signer.
From the live test
(interactive_live.rs),
where ledger is a CantonClient, admin an AdminClient, hint the party
hint and user_id the ledger user that will submit:
let synchronizers = ledger
.connected_synchronizers("")
.await
.expect("the participant lists its synchronizers");
let synchronizer = synchronizers
.first()
.expect("the participant is connected to at least one synchronizer")
.synchronizer_id
.clone();
let key = Ed25519Key::generate().expect("a random source");
let topology = admin
.generate_external_party_topology(&synchronizer, hint, &key.public_key())
.await
.expect("the participant describes the onboarding");
assert!(
topology.party_id().starts_with(hint),
"the party id is built from the hint: {}",
topology.party_id()
);
assert!(
!topology.transactions().is_empty(),
"onboarding needs at least one topology transaction"
);
let signer = key.into_signer(topology.public_key_fingerprint());
let party = admin
.allocate_external_party(&synchronizer, &topology, &signer, &user_id)
.await
.expect("the signed onboarding is accepted");
- Both calls go to the Ledger API port. The live test makes both calls with a
token whose user has
ParticipantAdmin. The pinned Ledger API contract also authorizesAllocateExternalPartyfor an identity-provider admin, or for a token whose own user is theuser_idpassed. A LocalNet without authentication needs nothing beyond the endpoint. user_idgetsact_asrights on the new party; an empty string grants none. Canton derives the ledger user from the token’ssub, which in a client-credentials flow is a service-account id, not the OIDC client id.AdminClient::current_user()under the submitting user’s token returns it.- The SDK reads the onboarding set before it signs for it. It recomputes the multi-hash and decodes each transaction, and accepts only the key’s own namespace delegation, a mapping of the party to that one key, and hosting with confirmation or observation permission. It also refuses a party id whose namespace is not the key’s fingerprint, and an allocation of any party other than the one signed for.
allocate_external_partyis not retried: the request has no idempotency key. If the response is lost, look the party up withAdminClient::get_partiesbefore trying again.
Prepare, sign, execute
Each type is one state of the flow and offers only what is possible in it.
Prepare says what to interpret and goes to
CantonClient::prepare_submission.
Prepared is the interpreted transaction and its hash. Executable is signed,
and so it can be executed. The round trip from the live test, with party and
signer from the onboarding above and command a create that only party
signs:
let prepared = ledger
.prepare_submission(Prepare::new(&party).add_command(command))
.await
.expect("the participant interprets the command");
assert!(
!prepared.hash().is_empty(),
"there must be something to sign"
);
assert_eq!(prepared.act_as(), [party.as_str()].map(String::from));
let executable = prepared
.sign_with(&signer)
.await
.expect("the in-memory key signs");
assert!(
executable.unsigned_parties().is_empty(),
"the only acting party has signed"
);
// Boxed: the response carries a whole transaction, so the future is large
// enough that clippy flags holding it on the stack.
let transaction = Box::pin(ledger.execute_submission_and_wait_for_transaction(executable))
.await
.expect("the participant accepts the signature and commits");
Prepare
Prepare::new(party) acts as one party, Prepare::new_multi(parties) as
several. Add commands with add_command / add_commands, built with
canton_ledger::create / exercise or from generated bindings. Optional:
| Method | Effect |
|---|---|
with_command_id | The command component of the change ID. Left unset, a UUID is generated once at prepare time and carried through execution. |
with_user_id, with_read_as, with_synchronizer_id | As on an ordinary submission. |
with_disclosed_contracts | Explicitly disclosed contracts. A token-standard TokenCommand builds the whole Prepare, disclosures included, with into_prepare(party); see The token standard. |
with_min_ledger_time_abs, with_min_ledger_time_rel | Lower bound for ledger time. Exclusive; the later call wins. |
with_max_record_time | The record time after which the participant refuses the prepared transaction. |
with_hashing_scheme_version, trust_participant_hash, with_verbose_hashing | See Hashing schemes. |
with_package_id_selection_preference, with_prefetch_contract_keys, with_taps_max_passes, without_traffic_cost_estimation | The remaining PrepareSubmissionRequest fields. |
De-duplication and the submission id belong to execution, on Executable.
With Config::with_retry, prepare_submission is retried like a read.
Prepared
hash() is the bytes to sign: the participant’s hash of the transaction,
already checked by the SDK, and not a message to hash again. transaction() is
the interpreted transaction. Show it to whoever authorizes the signature before
signing; the hash proves what is signed, not that it was meant. act_as(),
command_id(), change_id(), cost_estimation(), hashing_scheme_version()
and hashing_details() report the rest. sign_with(&signer) signs as the only
acting party; with several, use sign_as(party, &signer).
Executable
with_deduplication_duration or with_deduplication_offset sets the
de-duplication period. with_submission_id fixes the submission id; left
unset, each attempt gets a fresh one. with_transaction_shape picks the events
the returned transaction carries. A missing or wrong signature is rejected by
the participant, not caught locally. Execute it one of three ways:
CantonClient method | Returns |
|---|---|
execute_submission | () once the participant has accepted the transaction. Acceptance is not commitment: the outcome arrives on the completion stream. |
execute_submission_and_wait | The update id and the completion offset, once the transaction has committed. |
execute_submission_and_wait_for_transaction | The committed transaction, its events shaped by TransactionShape and filtered for the acting and reading parties. |
How long a prepared transaction stays executable
A transaction that depends on time must be executed within the ledger-time
tolerance of the ledger time assigned at preparation, usually about a minute.
One that does not is bounded by the preparation-time skew, on the order of
hours, so hold a Prepared across a long signing ceremony only on purpose.
Past with_max_record_time the participant is certain to refuse it, which is
what makes preparing the same intent again safe.
Several acting parties
The participant requires a signature from every party in act_as:
let prepared = client
.prepare_submission(Prepare::new_multi(vec![alice.clone(), bob.clone()]).add_command(command))
.await?;
let executable = prepared.sign_as(&alice, &alice_signer).await?;
let executable = executable.and_sign_as(&bob, &bob_signer).await?;
assert!(executable.unsigned_parties().is_empty());
let transaction = client.execute_submission_and_wait_for_transaction(executable).await?;
sign_with on a multi-party submission returns Error::InvalidRequest,
since it is ambiguous which party the signature is for, and so does signing as
a party that is not acting. A party whose topology sets a signing threshold
above one calls and_sign_as again with its other keys; the signatures share
that party’s entry on the wire. Executable::with_signature(party, &signature)
attaches a
Signature
produced elsewhere, such as an offline ceremony. It is on Executable, so at
least one party has to sign through a Signer first; when the only signature
comes from such a process, wrap the process in a Signer.
Hashing schemes and the hash check
The signature covers the hash, so the hash decides what is authorized. A participant that returned your transaction next to the hash of a different one would get a valid signature over the other one. The SDK therefore does not take the hash from the response: it recomputes it from the returned transaction and refuses a response whose hash differs (V2 since 0.3.1, V3 since 0.3.2; see the changelog).
Prepare asks for hashing scheme V2 explicitly instead of leaving the choice
to the participant. The scheme is echoed in the response and sent back with
the signature, so the participant verifies under the scheme it hashed with.
| Scheme | What the SDK does | Use it for |
|---|---|---|
| V2 | The default. Recomputes the hash and refuses a mismatch. | Transactions without contract keys. |
| V3 | Recomputes the hash and refuses a mismatch. | Transactions that use contract keys, which V2 cannot hash. |
| Any other (V4 is development-protocol-only) | Refuses the response unless you set Prepare::trust_participant_hash(). | Only with a preparing participant you trust. |
Ask for V3 with
with_hashing_scheme_version,
passing the generated proto enum canton_ledger::proto::interactive::HashingSchemeVersion
(protocol-stable, not SemVer-stable: ADR-0002):
let prepared = ledger
.prepare_submission(
Prepare::new(&party)
.add_command(command)
.with_hashing_scheme_version(HashingSchemeVersion::V3),
)
.await
.expect("the participant prepares under V3 and its hash matches the SDK's");
assert_eq!(prepared.hashing_scheme_version(), HashingSchemeVersion::V3);
prepare_submission returns Error::UnexpectedResponse, and nothing is
signed, when the response has no transaction or no hash; is hashed under an
unknown scheme or one other than requested; has a hash that is not 32 bytes
(every scheme is SHA-256); under V2 or V3, carries a hash that is not the
returned transaction’s, or a transaction that cannot be hashed under that
scheme; or uses any other scheme without the opt-in.
trust_participant_hash()
signs the participant’s hash as returned and logs a warning. An unchecked hash
authorizes whatever that participant hashed. The opt-in does not turn off the
check under V2 and V3.
The hashing is public in
canton_ledger::hashing:
hash_prepared_transaction(prepared, version),
hash_prepared_transaction_v2 and hash_prepared_transaction_v3 return the
32-byte hash or a HashingError. Use them when the service that holds the key
is not the process that prepared: hand it the PreparedTransaction and it can
check the hash itself before signing. To debug a mismatch, prepare with
with_verbose_hashing(true) and read Prepared::hashing_details(). The
participant may refuse, and the format is not stable: read it, do not parse it.
Implementing Signer for an HSM or KMS
Signer has three methods: public_key(), fingerprint() and an async
sign(hash). It is object-safe, so you can hold an Arc<dyn Signer> and pass
&*signer where a &dyn Signer is expected. The rules:
signreceives a hash the participant produced:prepared_transaction_hashfor a submission, the multi-hash for onboarding. Sign those bytes as they are; do not hash them again.fingerprintis the one Canton assigned, fromExternalPartyTopology::public_key_fingerprint(). For onboarding, describe the key withPublicKey::new(KeyFormat, data, KeySpec).- Declare the algorithm and format the key actually used
(
SigningAlgorithm::{Ed25519, EcDsaSha256, EcDsaSha384, MlDsa65},SignatureFormat::{Concat, Der}). Canton parses the bytes by the format, soConcatfor ECDSA, orDerfor EdDSA, fails verification. Signature::newrefuses empty bytes and an Ed25519Concatsignature that is not 64 bytes, so a truncated reply from a signing service fails there with a clear message rather than as a verification failure.
A sketch for an HSM that holds an Ed25519 key, with HsmClient standing in for
your own client. Debug is required; like Ed25519Signer, it shows only the
fingerprint, never key material or credentials:
use std::{fmt, future::Future, pin::Pin};
use canton_ledger::{Error, Result};
use canton_signer::{PublicKey, Signature, SignatureFormat, Signer, SigningAlgorithm};
struct HsmSigner {
hsm: HsmClient,
key_label: String,
public_key: PublicKey,
fingerprint: String,
}
impl fmt::Debug for HsmSigner {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HsmSigner").field("fingerprint", &self.fingerprint).finish_non_exhaustive()
}
}
impl Signer for HsmSigner {
fn public_key(&self) -> PublicKey {
self.public_key.clone()
}
fn fingerprint(&self) -> &str {
&self.fingerprint
}
fn sign(&self, hash: &[u8]) -> Pin<Box<dyn Future<Output = Result<Signature>> + Send + '_>> {
let hash = hash.to_vec();
Box::pin(async move {
let bytes = self
.hsm
.sign_ed25519(&self.key_label, &hash)
.await
.map_err(|e| Error::Connection(format!("HSM signing failed: {e}")))?;
Signature::new(
SignatureFormat::Concat,
bytes,
self.fingerprint.clone(),
SigningAlgorithm::Ed25519,
)
})
}
}
Recovering an ambiguous execute
The change ID (user_id, act_as, command_id) is fixed at preparation, so
executing the same Executable twice is de-duplicated rather than committed
twice, and the de-duplication covers ordinary submissions of the same command
too. Executing consumes the Executable, so take the change ID first, and the
offset to read completions from:
let begin = client.ledger_end().await?;
let executable = prepared.sign_with(&signer).await?;
let change_id = executable.change_id();
client.execute_submission(executable).await?;
let completion = client
.await_completion(&change_id, begin, std::time::Duration::from_secs(60))
.await?;
await_completion
matches on the whole
ChangeId,
and returns Error::CommandRejected if the ledger rejected the transaction and
Error::Timeout if the completion does not arrive in time. With retry enabled,
a retried execute_submission answered with ALREADY_EXISTS is reported as
success, because the earlier attempt is the one that landed;
execute_submission_and_wait reports it as the rejection it is, so recover the
outcome with await_completion, as ordinary submissions do in the
recover_a_submission
example.
Running it against a participant
The live suite
interactive_live.rs
runs this page against a real participant: it onboards a party, runs the round
trip under V2 and V3, creates a keyed contract and exercises it by key under
V3, and checks that a signature from the wrong key is rejected.
# unauthenticated LocalNet
CANTON_TEST_ENDPOINT=http://localhost:3901 \
cargo test -p canton-ledger --test interactive_live -- --nocapture
Against a participant that authenticates, also set CANTON_TEST_TOKEN_URL,
CANTON_TEST_ADMIN_CLIENT_ID / CANTON_TEST_ADMIN_CLIENT_SECRET (a client with
ParticipantAdmin) and CANTON_TEST_CLIENT_ID / CANTON_TEST_CLIENT_SECRET.
The contract-key test needs CANTON_TEST_CONTRACT_KEYS_PKG, after uploading
testdata/contract-keys/contractkeys.dar. CANTON_TEST_REQUIRE_LIVE=1 turns a
skip into a failure. Local development covers
bringing up a participant.
The threat model is in the security review scope, and the Canton versions this is verified on are in the compatibility matrix.