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}