Skip to main content

dig_pex/
payment.rs

1//! The **signed payment address** (SPEC §3.4) — how a peer says "pay my earnings here" in a way a
2//! third party can check.
3//!
4//! ## Why a signature is not optional here
5//!
6//! Every other field of a [`PeerEntry`](crate::PeerEntry) is self-correcting: a wrong address simply
7//! fails to dial, and the mTLS handshake proves identity on connect, so a lie costs the liar nothing
8//! but is caught immediately. **A payee field inverts that.** PEX records are *relayed*, so an
9//! unauthenticated payee means the incentive layer pays whoever last forwarded the record rather than
10//! whoever earned it — and the victim never finds out, because a payment that succeeds looks
11//! identical either way. An unauthenticated field naming a payee is a theft primitive, not a
12//! convenience.
13//!
14//! ## The binding: the record proves itself, with no lookup
15//!
16//! A DIG `peer_id` is defined as `SHA-256(TLS SPKI DER)`. That makes a **self-contained** proof
17//! available: the claim carries the peer's **SPKI DER**, a verifier recomputes
18//! `SHA-256(SPKI) == peer_id`, and then checks the signature over the canonical bytes against that
19//! key. No directory, no resolution step, no second identity concept, and no new trust root — which
20//! matters because the parties that most need to check a claim (relays, and any node that received
21//! the record second- or third-hand) are exactly the parties least likely to have a resolution path.
22//!
23//! ## The canonical bytes ([`payment_signing_bytes`])
24//!
25//! ```text
26//! "dig-pex/payment-address/v1\0"
27//!   || u32be(len(peer_id))    || peer_id
28//!   || u32be(len(network_id)) || network_id
29//!   || u32be(len(address))    || address
30//! ```
31//!
32//! Domain-separated (so a signature made for another DIG protocol cannot be replayed in here) and
33//! length-prefixed (so no concatenation of different field values can produce the same bytes).
34//!
35//! **What a valid signature therefore proves:** the holder of the private key whose SPKI hashes to
36//! `peer_id` designated *this* address as its payee *on this network*.
37//!
38//! **What it does NOT prove**, and callers must not assume: that the peer is reachable, that it is
39//! honest, that the address is well-formed or spendable, that the claim is *recent* (`last_seen` is
40//! deliberately outside the signed bytes — see below), or that it has not been superseded by a newer
41//! claim the verifier has not seen.
42//!
43//! ### Why `last_seen` is excluded
44//!
45//! Including it would make every signature expire on the advertiser's next heartbeat, forcing the
46//! *advertised* peer to re-sign continuously and the advertiser to re-request a signature it cannot
47//! produce itself — an availability dependency on the very peer the record exists to route around.
48//! Excluding it makes the claim a durable, cacheable, relayable credential, at the stated cost that a
49//! *revoked* address stays verifiable until the peer's newer claim propagates. Superseding a claim is
50//! therefore the incentive layer's concern (prefer the claim on the freshest first-hand record), not
51//! this signature's.
52
53use base64::engine::general_purpose::STANDARD as BASE64;
54use base64::Engine as _;
55use serde::{Deserialize, Serialize};
56use sha2::{Digest, Sha256};
57
58/// Domain separator prefixed to the canonical bytes, mirroring the `dig-tls` SPKI-binding style: a
59/// signature made for a different DIG protocol can never be reinterpreted as a payment designation.
60const PAYMENT_SIG_CONTEXT: &[u8] = b"dig-pex/payment-address/v1\0";
61
62/// Maximum characters in a payment address (SPEC §3.4.2). A Chia bech32m address is 62 characters;
63/// the headroom accommodates longer address forms without leaving the field unbounded to a hostile
64/// sender.
65pub const PEX_MAX_PAYMENT_ADDRESS_LEN: usize = 128;
66
67/// Maximum characters in the base64 `spki` field. An ECDSA P-256 SPKI DER is 91 bytes (124 base64
68/// characters); the headroom covers other key types without unbounding the field.
69pub const PEX_MAX_PAYMENT_SPKI_LEN: usize = 512;
70
71/// Maximum characters in the base64 `sig` field. An ASN.1 DER P-256 signature is at most 72 bytes
72/// (96 base64 characters).
73pub const PEX_MAX_PAYMENT_SIG_LEN: usize = 256;
74
75/// The signature-verification capability a caller injects (SPEC §3.4.3).
76///
77/// `dig-pex` is deliberately sans-IO and carries no signature crypto of its own: it defines the field
78/// and the canonical bytes, and the embedding node or relay supplies the primitive matching the key
79/// type in use (ECDSA P-256, as `dig-tls` issues today). The `peer_id`-to-key binding is *not*
80/// delegated — [`PaymentClaim::verify`] recomputes `SHA-256(SPKI) == peer_id` itself, so a
81/// permissive verifier can never be talked into naming the wrong payee.
82pub trait SignatureVerifier {
83    /// Whether `signature` is a valid signature over `message` by the public key encoded in
84    /// `spki_der`. MUST return `false` — never panic — on a malformed key or signature.
85    fn verify(&self, spki_der: &[u8], message: &[u8], signature: &[u8]) -> bool;
86}
87
88impl<F> SignatureVerifier for F
89where
90    F: Fn(&[u8], &[u8], &[u8]) -> bool,
91{
92    fn verify(&self, spki_der: &[u8], message: &[u8], signature: &[u8]) -> bool {
93        self(spki_der, message, signature)
94    }
95}
96
97/// Why a claim did not yield a payment address. Every variant means the same thing to a caller —
98/// **there is no payee here** — and they differ only in what to log.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum PaymentClaimError {
101    /// The entry carries no payment claim (the ordinary case for a peer predating SPEC §3.4).
102    NotPresent,
103    /// A field exceeded its cap, or `spki` / `sig` was not valid base64.
104    Malformed,
105    /// `SHA-256(spki)` did not equal the entry's `peer_id` — the claim belongs to a different peer,
106    /// or was substituted by a relay.
107    PeerIdMismatch,
108    /// The key is the peer's, but the signature does not cover this `(peer_id, network_id, address)`
109    /// — a tampered address, or a claim replayed from another network.
110    BadSignature,
111}
112
113impl std::fmt::Display for PaymentClaimError {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        let reason = match self {
116            PaymentClaimError::NotPresent => "no payment claim on this entry",
117            PaymentClaimError::Malformed => "payment claim is malformed or over its caps",
118            PaymentClaimError::PeerIdMismatch => "payment claim key does not hash to peer_id",
119            PaymentClaimError::BadSignature => "payment claim signature does not verify",
120        };
121        f.write_str(reason)
122    }
123}
124
125impl std::error::Error for PaymentClaimError {}
126
127/// The exact bytes a peer signs to designate `address` as its payee (SPEC §3.4.1). Identical when
128/// signing and when verifying — never construct these bytes anywhere else.
129///
130/// Binding all three of `peer_id`, `network_id` and `address` is what makes the signature
131/// non-transferable: it cannot be lifted onto another peer's record, replayed onto another network,
132/// or kept while the address underneath it is rewritten.
133#[must_use]
134pub fn payment_signing_bytes(peer_id: &str, network_id: &str, address: &str) -> Vec<u8> {
135    let mut msg = Vec::with_capacity(
136        PAYMENT_SIG_CONTEXT.len() + 12 + peer_id.len() + network_id.len() + address.len(),
137    );
138    msg.extend_from_slice(PAYMENT_SIG_CONTEXT);
139    for field in [peer_id, network_id, address] {
140        // Length-prefixed so no two different field triples can produce identical bytes. A field
141        // longer than u32::MAX is unrepresentable here and unreachable in practice — the caps in
142        // this module bound every field to a few hundred bytes.
143        let len = u32::try_from(field.len()).unwrap_or(u32::MAX);
144        msg.extend_from_slice(&len.to_be_bytes());
145        msg.extend_from_slice(field.as_bytes());
146    }
147    msg
148}
149
150/// The `peer_id` a TLS SPKI induces — `SHA-256(SPKI DER)` as 64 lowercase hex characters, the same
151/// derivation `dig-tls` performs on connect. This is the function that turns a carried key into a
152/// checkable claim of identity.
153#[must_use]
154pub fn peer_id_for_spki(spki_der: &[u8]) -> String {
155    let digest = Sha256::digest(spki_der);
156    let mut hex = String::with_capacity(64);
157    for byte in digest {
158        use std::fmt::Write as _;
159        let _ = write!(hex, "{byte:02x}");
160    }
161    hex
162}
163
164/// A peer's self-signed designation of where to pay it (SPEC §3.4).
165///
166/// The fields are **private on purpose**: the only way to read the address is
167/// [`verify`](PaymentClaim::verify) (or [`PeerEntry::verified_payment_address`]), so it is not
168/// possible to obtain a payee from this type without having checked it. An unverified payee has no
169/// legitimate use, and a type that cannot hand one out cannot be misused into paying a thief.
170///
171/// [`PeerEntry::verified_payment_address`]: crate::PeerEntry::verified_payment_address
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173pub struct PaymentClaim {
174    /// The claimed payee address (`address` on the wire).
175    #[serde(default)]
176    address: String,
177    /// The peer's TLS SubjectPublicKeyInfo DER, base64 (`spki` on the wire). Hashes to `peer_id`.
178    #[serde(default)]
179    spki: String,
180    /// The signature over [`payment_signing_bytes`], base64 (`sig` on the wire).
181    #[serde(default)]
182    sig: String,
183}
184
185impl PaymentClaim {
186    /// A claim naming `address`, attested by the key in `spki_der` with `signature` over
187    /// [`payment_signing_bytes`]. Producing `signature` is the caller's job — this crate holds no
188    /// private keys and performs no signing.
189    #[must_use]
190    pub fn new(address: impl Into<String>, spki_der: &[u8], signature: &[u8]) -> Self {
191        PaymentClaim {
192            address: address.into(),
193            spki: BASE64.encode(spki_der),
194            sig: BASE64.encode(signature),
195        }
196    }
197
198    /// The attesting key's SPKI DER, decoded. Empty when the field is not valid base64.
199    ///
200    /// Exposed for diagnostics and for re-assembling a claim in tests; reading the key is harmless,
201    /// which is precisely why the *address* is not exposed the same way.
202    #[must_use]
203    pub fn spki_der(&self) -> Vec<u8> {
204        BASE64.decode(&self.spki).unwrap_or_default()
205    }
206
207    /// The raw signature bytes, decoded. Empty when the field is not valid base64.
208    #[must_use]
209    pub fn signature(&self) -> Vec<u8> {
210        BASE64.decode(&self.sig).unwrap_or_default()
211    }
212
213    /// The three wire fields in order, for the entry's advertised-content fingerprint (SPEC §9.1) —
214    /// a re-signed or replaced claim must count as changed content so it re-advertises. Crate-private
215    /// because handing out the address unchecked is exactly what this type prevents.
216    pub(crate) fn wire_parts(&self) -> [&str; 3] {
217        [&self.address, &self.spki, &self.sig]
218    }
219
220    /// Whether every field is within its cap (SPEC §3.4.2). Checked before any decoding so a hostile
221    /// sender cannot make a receiver allocate on an oversized field.
222    #[must_use]
223    pub fn within_caps(&self) -> bool {
224        self.address.len() <= PEX_MAX_PAYMENT_ADDRESS_LEN
225            && self.spki.len() <= PEX_MAX_PAYMENT_SPKI_LEN
226            && self.sig.len() <= PEX_MAX_PAYMENT_SIG_LEN
227    }
228
229    /// The payee address, **only** if this claim is genuinely the one `peer_id` made for
230    /// `network_id` (SPEC §3.4.3).
231    ///
232    /// Three checks, in order, each of which a real attack fails: fields within caps and decodable;
233    /// `SHA-256(spki) == peer_id`, so the key is this peer's and not a relay's; and the signature
234    /// covering [`payment_signing_bytes`], so neither the address nor the network can be rewritten
235    /// underneath it.
236    pub fn verify(
237        &self,
238        peer_id: &str,
239        network_id: &str,
240        verifier: &impl SignatureVerifier,
241    ) -> Result<&str, PaymentClaimError> {
242        if !self.within_caps() {
243            return Err(PaymentClaimError::Malformed);
244        }
245        let (Ok(spki_der), Ok(signature)) = (BASE64.decode(&self.spki), BASE64.decode(&self.sig))
246        else {
247            return Err(PaymentClaimError::Malformed);
248        };
249        if peer_id_for_spki(&spki_der) != peer_id {
250            return Err(PaymentClaimError::PeerIdMismatch);
251        }
252        let message = payment_signing_bytes(peer_id, network_id, &self.address);
253        if !verifier.verify(&spki_der, &message, &signature) {
254            return Err(PaymentClaimError::BadSignature);
255        }
256        Ok(&self.address)
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    /// A verifier that accepts everything — used only to prove that the checks `dig-pex` performs
265    /// ITSELF (caps, base64, the `peer_id` binding) hold even when the injected primitive is useless.
266    struct AcceptAll;
267    impl SignatureVerifier for AcceptAll {
268        fn verify(&self, _spki: &[u8], _msg: &[u8], _sig: &[u8]) -> bool {
269            true
270        }
271    }
272
273    #[test]
274    fn signing_bytes_are_domain_separated_and_length_prefixed() {
275        let bytes = payment_signing_bytes("aa", "mainnet", "xch1");
276        assert!(bytes.starts_with(PAYMENT_SIG_CONTEXT));
277        assert_eq!(
278            bytes,
279            [
280                PAYMENT_SIG_CONTEXT,
281                &0u32.to_be_bytes()[..3],
282                &[2],
283                b"aa",
284                &0u32.to_be_bytes()[..3],
285                &[7],
286                b"mainnet",
287                &0u32.to_be_bytes()[..3],
288                &[4],
289                b"xch1",
290            ]
291            .concat()
292        );
293    }
294
295    /// Length prefixes exist so that shifting a boundary between two fields cannot leave the byte
296    /// stream unchanged — without them, `("ab", "c")` and `("a", "bc")` would sign identically and a
297    /// claim could be re-framed onto a different peer.
298    #[test]
299    fn a_shifted_field_boundary_changes_the_signing_bytes() {
300        assert_ne!(
301            payment_signing_bytes("ab", "c", "x"),
302            payment_signing_bytes("a", "bc", "x")
303        );
304    }
305
306    #[test]
307    fn peer_id_derivation_matches_sha256_of_spki() {
308        // NIST SHA-256 of the empty input, as the fixed vector anchoring the hex encoding.
309        assert_eq!(
310            peer_id_for_spki(b""),
311            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
312        );
313        assert_eq!(peer_id_for_spki(b"spki").len(), 64);
314    }
315
316    /// The `peer_id` binding is enforced by this crate, not by the injected verifier — so even a
317    /// verifier that approves everything cannot make a foreign key name a payee.
318    #[test]
319    fn a_permissive_verifier_cannot_bypass_the_peer_id_binding() {
320        let claim = PaymentClaim::new("xch1payee", b"some-other-key", b"whatever");
321        assert_eq!(
322            claim.verify(&"a".repeat(64), "mainnet", &AcceptAll),
323            Err(PaymentClaimError::PeerIdMismatch)
324        );
325        // ...and with the matching peer_id it does yield, confirming the refusal above is the
326        // binding rather than an unconditional rejection.
327        assert_eq!(
328            claim.verify(&peer_id_for_spki(b"some-other-key"), "mainnet", &AcceptAll),
329            Ok("xch1payee")
330        );
331    }
332
333    #[test]
334    fn an_over_cap_or_undecodable_field_is_malformed() {
335        let spki = b"key";
336        let peer_id = peer_id_for_spki(spki);
337
338        let long = PaymentClaim::new("x".repeat(PEX_MAX_PAYMENT_ADDRESS_LEN + 1), spki, b"s");
339        assert_eq!(
340            long.verify(&peer_id, "mainnet", &AcceptAll),
341            Err(PaymentClaimError::Malformed)
342        );
343
344        // At the cap exactly, the same claim is accepted — a bound tested only from one side proves
345        // only itself.
346        let at_cap = PaymentClaim::new("x".repeat(PEX_MAX_PAYMENT_ADDRESS_LEN), spki, b"s");
347        assert!(at_cap.verify(&peer_id, "mainnet", &AcceptAll).is_ok());
348
349        let bad_b64: PaymentClaim =
350            serde_json::from_str(r#"{"address":"xch1","spki":"!!!","sig":"AA=="}"#).unwrap();
351        assert_eq!(
352            bad_b64.verify(&peer_id, "mainnet", &AcceptAll),
353            Err(PaymentClaimError::Malformed)
354        );
355    }
356
357    #[test]
358    fn wire_field_names_are_frozen() {
359        let json = serde_json::to_string(&PaymentClaim::new("xch1", b"k", b"s")).unwrap();
360        assert_eq!(json, r#"{"address":"xch1","spki":"aw==","sig":"cw=="}"#);
361    }
362
363    /// A closure is accepted wherever the trait is, so an embedder with a one-line verify does not
364    /// need to declare a type.
365    #[test]
366    fn a_closure_is_a_verifier() {
367        let claim = PaymentClaim::new("xch1", b"k", b"s");
368        let peer_id = peer_id_for_spki(b"k");
369        let never = |_: &[u8], _: &[u8], _: &[u8]| false;
370        assert_eq!(
371            claim.verify(&peer_id, "mainnet", &never),
372            Err(PaymentClaimError::BadSignature)
373        );
374    }
375}