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 Ed25519ctx with a non-empty context per role; for Schnorr or generic hash-then-sign, hash tag || message rather than message alone. 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}