Pitfall
Missing Domain Separator Across Signing Contexts
What can go wrong. When the same signing key is used in multiple protocol roles, signing round-1 commitments vs round-2 packages in a DKG, authenticating API requests vs producing blockchain transactions, or tagging message types in a single protocol, each role must bind its messages to a unique domain-separation tag. If the tag is missing or identical across roles, a signature produced for one role is valid for the other: the same bytes verify against the same key in both contexts. The tag can live at the signing primitive itself (a context string mixed into the hash, such as RFC 8032’s Ed25519ctx) or at the protocol layer (a per-method or per-key purpose marker that gates which API entry-point a key can serve).
Security implication. A malicious party who obtains a signature in role $A$
presents the same bytes as if they had been produced for role $B$. In an MPC threshold
network that exposes both a generic sign() method and a specialized
verify_foreign_transaction() method against the same distributed key, a bridge that
calls verify_foreign_transaction() to confirm that a foreign-chain transaction was
attested by the threshold network can be defeated by a caller who submits the same
payload to sign() instead: the MPC network produces a valid threshold signature
(since sign() is willing to sign arbitrary bytes), and the attacker replays the
resulting signature into the bridge as evidence of a verified foreign transaction. The
bridge has no way to tell the two apart, both signatures verify under the same
threshold public key over the same bytes.
How to avoid. Bind every signature to its protocol role. Two complementary points of enforcement:
- Primitive-level domain separation. Prepend a unique, version-bearing tag to the
message before signing. For Ed25519, use
RFC 8032’s
Ed25519ctxwith a non-empty context per role; for Schnorr or generic hash-then-sign, hashtag || messagerather thanmessagealone. Rotate tags when the protocol version changes so old-version signatures do not retroactively validate under a new role. - Protocol-level domain separation. Tag each distributed key with the purpose it is allowed to serve, and reject at the API entry-point any request that targets a key whose purpose does not match the call.
Example
NEAR MPC DomainPurpose tagging
(Issue #2076, PR #2163)
The NEAR
MPC node exposes a threshold key to three different methods on the contract: sign()
for arbitrary user-supplied payloads, verify_foreign_transaction() for foreign-chain
(Bitcoin, Ethereum) transaction attestation used by bridges, and
request_app_private_key() for confidential key derivation (CKD). All three call paths
can route to the same set of distributed keys. Before the fix, the contract enforced
only that the curve matched the call: any Secp256k1 key could back either sign() or
verify_foreign_transaction(). A caller could therefore submit a foreign-chain
transaction payload to the generic sign() method, collect a threshold signature, and
then replay it to a bridge calling verify_foreign_transaction() against the same key;
the bridge would accept the signature as proof that the foreign transaction had been
attested.
The fix (PR #2163) introduces an explicit
per-domain DomainPurpose enum:
1// FILE: crates/contract-interface/src/types/state.rs — near/mpc (after PR #2163)
2pub enum DomainPurpose {
3 /// Domain is used by `sign()`.
4 Sign,
5 /// Domain is used by `verify_foreign_transaction()`.
6 ForeignTx,
7 /// Domain is used by `request_app_private_key()` (Confidential Key Derivation).
8 CKD,
9}
10
11pub struct DomainConfig {
12 pub id: DomainId,
13 pub scheme: SignatureScheme,
14 pub purpose: Option<DomainPurpose>, // new: purpose tag per domain
15}
Each contract entry-point now requires the target domain to carry the matching purpose
(crates/contract/src/lib.rs):
1// FILE: crates/contract/src/lib.rs — near/mpc (after PR #2163)
2
3// in sign(...)
4if domain_config.purpose != DomainPurpose::Sign {
5 env::panic_str(
6 &InvalidParameters::WrongDomainPurpose { /* ... */ }
7 .message("sign() may only target domains with purpose Sign")
8 .to_string(),
9 );
10}
11
12// in verify_foreign_transaction(...)
13if domain_config.purpose != DomainPurpose::ForeignTx {
14 env::panic_str(
15 &InvalidParameters::WrongDomainPurpose { /* ... */ }
16 .message("verify_foreign_transaction() requires a domain with purpose ForeignTx")
17 .to_string(),
18 );
19}
20
21// in request_app_private_key(...)
22if domain_config.purpose != DomainPurpose::CKD {
23 env::panic_str(
24 &InvalidParameters::WrongDomainPurpose { /* ... */ }
25 .message("request_app_private_key() may only target domains with purpose CKD")
26 .to_string(),
27 );
28}