Skip to main content

ark/
mailbox.rs

1//! Types for using the Unified Mailbox feature of the bark server.
2//!
3//! For more information on the mailbox, check the `docs/mailbox.md` file.
4
5use std::time::Duration;
6use std::{fmt, io};
7use std::str::FromStr;
8use bitcoin::hashes::{sha256, Hash, HashEngine};
9use bitcoin::secp256k1::{ecdh, schnorr, Keypair, Message, PublicKey};
10use bitcoin::secp256k1::constants::PUBLIC_KEY_SIZE;
11
12use crate::SECP;
13use crate::encode::{ProtocolDecodingError, ProtocolEncoding, ReadExt, WriteExt};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum MailboxType {
17	ArkoorReceive,
18	RoundParticipationCompleted,
19	LnRecvPendingPayment,
20	RecoveryVtxoId,
21	LnSendFinished,
22}
23
24impl MailboxType {
25	#[inline]
26	pub const fn as_str(self) -> &'static str {
27		match self {
28			MailboxType::ArkoorReceive => "arkoor-receive",
29			MailboxType::RoundParticipationCompleted => "round-participation-completed",
30			MailboxType::LnRecvPendingPayment => "ln-recv-pending",
31			MailboxType::RecoveryVtxoId => "recovery-vtxo-id",
32			MailboxType::LnSendFinished => "ln-send-finished",
33		}
34	}
35
36}
37
38impl fmt::Display for MailboxType {
39	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40		f.write_str(self.as_str())
41	}
42}
43
44impl TryFrom<u32> for MailboxType {
45	type Error = &'static str;
46
47	fn try_from(i: u32) -> Result<Self, Self::Error> {
48		match i {
49			0 => Ok(MailboxType::ArkoorReceive),
50			1 => Ok(MailboxType::RoundParticipationCompleted),
51			2 => Ok(MailboxType::LnRecvPendingPayment),
52			3 => Ok(MailboxType::RecoveryVtxoId),
53			4 => Ok(MailboxType::LnSendFinished),
54			_ => Err("invalid mailbox type"),
55		}
56	}
57}
58
59impl From<MailboxType> for u32 {
60	fn from(t: MailboxType) -> Self {
61		match t {
62			MailboxType::ArkoorReceive => 0,
63			MailboxType::RoundParticipationCompleted => 1,
64			MailboxType::LnRecvPendingPayment => 2,
65			MailboxType::RecoveryVtxoId => 3,
66			MailboxType::LnSendFinished => 4,
67		}
68	}
69}
70
71impl From<MailboxType> for String {
72	fn from(t: MailboxType) -> Self {
73		t.as_str().to_string()
74	}
75}
76
77impl FromStr for MailboxType {
78	type Err = &'static str;
79
80	fn from_str(s: &str) -> Result<Self, Self::Err> {
81		match s {
82			v if v == MailboxType::ArkoorReceive.as_str() => Ok(MailboxType::ArkoorReceive),
83			v if v == MailboxType::RoundParticipationCompleted.as_str() => Ok(MailboxType::RoundParticipationCompleted),
84			v if v == MailboxType::LnRecvPendingPayment.as_str() => Ok(MailboxType::LnRecvPendingPayment),
85			v if v == MailboxType::RecoveryVtxoId.as_str() => Ok(MailboxType::RecoveryVtxoId),
86			v if v == MailboxType::LnSendFinished.as_str() => Ok(MailboxType::LnSendFinished),
87			_ => Err("invalid mailbox type"),
88		}
89	}
90}
91
92
93/// Identifier for a mailbox
94///
95/// Represented as a curve point.
96#[derive(Clone, Debug, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
97pub struct MailboxIdentifier(PublicKey);
98
99impl MailboxIdentifier {
100	/// Convert to public key
101	pub fn as_pubkey(&self) -> PublicKey {
102		self.0
103	}
104
105	/// Convert from a public key
106	pub fn from_pubkey(pubkey: PublicKey) -> Self {
107		Self(pubkey)
108	}
109
110	/// Blind the mailbox id with the server pubkey and the VTXO privkey
111	pub fn to_blinded(
112		&self,
113		server_pubkey: PublicKey,
114		vtxo_key: &Keypair,
115	) -> Result<BlindedMailboxIdentifier, MailboxBlindingError> {
116		BlindedMailboxIdentifier::new(*self, server_pubkey, vtxo_key)
117	}
118
119	/// Unblind a blinded mailbox identifier.
120	///
121	/// Returns [MailboxBlindingError] when the peer-supplied blinded point
122	/// cancels the ECDH tweak (the sum is the point at infinity, which
123	/// libsecp256k1 rejects). Callers must treat this as invalid peer input.
124	pub fn from_blinded(
125		blinded: BlindedMailboxIdentifier,
126		vtxo_pubkey: PublicKey,
127		server_key: &Keypair,
128	) -> Result<MailboxIdentifier, MailboxBlindingError> {
129		let dh = ecdh::shared_secret_point(&vtxo_pubkey, &server_key.secret_key());
130		let neg_dh_pk = point_to_pubkey(&dh).negate(&SECP);
131		let ret = PublicKey::combine_keys(&[&blinded.as_pubkey(), &neg_dh_pk])
132			.map_err(|_| MailboxBlindingError)?;
133		Ok(Self(ret))
134	}
135}
136
137/// The blinded mailbox point and the ECDH tweak sum to the point at
138/// infinity, which is not a valid curve point. This happens when the peer
139/// chose the blinded point equal to the ECDH-derived tweak, so treat it as
140/// an invalid peer input.
141#[derive(Debug, thiserror::Error)]
142#[error("mailbox blinding produced the point at infinity")]
143pub struct MailboxBlindingError;
144
145impl fmt::Display for MailboxIdentifier {
146	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147		write!(f, "{}", self.0)
148	}
149}
150
151#[derive(Debug, thiserror::Error)]
152#[error("invalid mailbox identifier: {0}")]
153pub struct InvalidMailboxIdentifier(String);
154
155impl FromStr for MailboxIdentifier {
156	type Err = InvalidMailboxIdentifier;
157
158	fn from_str(s: &str) -> Result<Self, Self::Err> {
159		let pubkey = PublicKey::from_str(s).map_err(|_| InvalidMailboxIdentifier(s.to_string()))?;
160		Ok(Self(pubkey))
161	}
162}
163
164impl From<PublicKey> for MailboxIdentifier {
165	fn from(pk: PublicKey) -> Self {
166		Self::from_pubkey(pk)
167	}
168}
169
170impl ProtocolEncoding for MailboxIdentifier {
171	fn encode<W: io::Write + ?Sized>(&self, w: &mut W) -> Result<(), io::Error> {
172		w.emit_slice(self.0.serialize().as_slice())
173	}
174
175	fn decode<R: io::Read + ?Sized>(r: &mut R) -> Result<Self, ProtocolDecodingError> {
176		let bytes: [u8; PUBLIC_KEY_SIZE] = r.read_byte_array()?;
177		let pubkey = PublicKey::from_slice(&bytes).map_err(|e| {
178			ProtocolDecodingError::invalid_err(e, "invalid mailbox identifier public key")
179		})?;
180		Ok(Self(pubkey))
181	}
182}
183
184/// Blinded identifier for a mailbox
185///
186/// It is blinded by adding to the mailbox public key point the
187/// Diffie-Hellman secret between the server's key and the VTXO key from
188/// the address.
189///
190/// Represented as a curve point.
191#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
192pub struct BlindedMailboxIdentifier([u8; PUBLIC_KEY_SIZE]);
193
194impl_byte_newtype!(BlindedMailboxIdentifier, PUBLIC_KEY_SIZE);
195
196impl BlindedMailboxIdentifier {
197	pub fn new(
198		mailbox_id: MailboxIdentifier,
199		server_pubkey: PublicKey,
200		vtxo_key: &Keypair,
201	) -> Result<BlindedMailboxIdentifier, MailboxBlindingError> {
202		let dh = ecdh::shared_secret_point(&server_pubkey, &vtxo_key.secret_key());
203		let dh_pk = point_to_pubkey(&dh);
204		let ret = PublicKey::combine_keys(&[&mailbox_id.as_pubkey(), &dh_pk])
205			.map_err(|_| MailboxBlindingError)?;
206		Ok(Self(ret.serialize()))
207	}
208
209	/// Convert to public key
210	pub fn as_pubkey(&self) -> PublicKey {
211		PublicKey::from_slice(&self.0).expect("invalid pubkey")
212	}
213
214	/// Convert from a public key
215	pub fn from_pubkey(pubkey: PublicKey) -> Self {
216		Self(pubkey.serialize())
217	}
218}
219
220impl From<PublicKey> for BlindedMailboxIdentifier {
221	fn from(pk: PublicKey) -> Self {
222		Self::from_pubkey(pk)
223	}
224}
225
226impl ProtocolEncoding for BlindedMailboxIdentifier {
227	fn encode<W: io::Write + ?Sized>(&self, w: &mut W) -> Result<(), io::Error> {
228		w.emit_slice(self.as_ref())
229	}
230
231	fn decode<R: io::Read + ?Sized>(r: &mut R) -> Result<Self, ProtocolDecodingError> {
232		let bytes: [u8; PUBLIC_KEY_SIZE] = r.read_byte_array()?;
233		PublicKey::from_slice(&bytes).map_err(|e| {
234			ProtocolDecodingError::invalid_err(e, "invalid blinded mailbox identifier public key")
235		})?;
236		Ok(Self(bytes))
237	}
238}
239
240/// Authorization to read a VTXO mailbox
241///
242/// It is tied to an expiry UNIX timestamp and is only valid before that time.
243#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
244pub struct MailboxAuthorization {
245	id: MailboxIdentifier,
246	expiry: i64,
247	sig: schnorr::Signature,
248}
249
250impl MailboxAuthorization {
251	const CHALENGE_MESSAGE_PREFIX: &'static [u8; 32] = b"Ark VTXO mailbox authorization: ";
252
253	fn signable_message(expiry: i64) -> Message {
254		let mut eng = sha256::Hash::engine();
255		eng.input(Self::CHALENGE_MESSAGE_PREFIX);
256		eng.input(&expiry.to_le_bytes());
257		Message::from_digest(sha256::Hash::from_engine(eng).to_byte_array())
258	}
259
260	pub fn new(
261		mailbox_key: &Keypair,
262		expiry: chrono::DateTime<chrono::Local>,
263	) -> MailboxAuthorization {
264		let expiry = expiry.timestamp();
265		let msg = Self::signable_message(expiry);
266		MailboxAuthorization {
267			id: MailboxIdentifier::from_pubkey(mailbox_key.public_key()),
268			expiry: expiry,
269			sig: SECP.sign_schnorr_with_aux_rand(&msg, mailbox_key, &rand::random()),
270		}
271	}
272
273	/// The mailbox ID for which this authorization is signed
274	pub fn mailbox(&self) -> MailboxIdentifier {
275		self.id
276	}
277
278	/// The time at which this authorization expires
279	pub fn expiry(&self) -> chrono::DateTime<chrono::Local> {
280		chrono::DateTime::from_timestamp_secs(self.expiry)
281			.expect("we guarantee valid timestamp")
282			.with_timezone(&chrono::Local)
283	}
284
285	/// Verify the signature for the mailbox and block hash
286	pub fn verify(&self) -> bool {
287		let msg = Self::signable_message(self.expiry);
288		SECP.verify_schnorr(&self.sig, &msg, &self.id.as_pubkey().into()).is_ok()
289	}
290
291	/// Check if the authorization is expired
292	///
293	/// Allows for a 5 second expiration to be flexible.
294	pub fn is_expired(&self) -> bool {
295		// Give some leeway so that clients can check against their now() without issue.
296		const LEEWAY: Duration = Duration::from_secs(5);
297		self.expiry() < (chrono::Local::now() - LEEWAY)
298	}
299}
300
301impl ProtocolEncoding for MailboxAuthorization {
302	fn encode<W: io::Write + ?Sized>(&self, w: &mut W) -> Result<(), io::Error> {
303		self.id.encode(w)?;
304		w.emit_slice(&self.expiry.to_le_bytes())?;
305		self.sig.encode(w)?;
306		Ok(())
307	}
308
309	fn decode<R: io::Read + ?Sized>(r: &mut R) -> Result<Self, ProtocolDecodingError> {
310		Ok(Self {
311			id: ProtocolEncoding::decode(r)?,
312			expiry: {
313				let timestamp = i64::from_le_bytes(r.read_byte_array()?);
314				// enforce that timestamp is a valid one
315				let _ = chrono::DateTime::from_timestamp_secs(timestamp)
316					.ok_or_else(|| ProtocolDecodingError::invalid("invalid timestamp"))?;
317				timestamp
318			},
319			sig: ProtocolEncoding::decode(r)?,
320		})
321	}
322}
323
324/// Convert the raw x,y coordinate pair into a [PublicKey]
325fn point_to_pubkey(point: &[u8; 64]) -> PublicKey {
326	//TODO(stevenroose) try to get an official api for this
327	let mut uncompressed = [0u8; 65];
328	uncompressed[0] = 0x04;
329	uncompressed[1..].copy_from_slice(point);
330	PublicKey::from_slice(&uncompressed).expect("invalid uncompressed pk")
331}
332
333#[cfg(test)]
334mod test {
335	use std::time::Duration;
336	use bitcoin::secp256k1::rand;
337	use super::*;
338
339	#[test]
340	fn mailbox_blinding() {
341		let mailbox_key = Keypair::new(&SECP, &mut rand::thread_rng());
342		let server_mailbox_key = Keypair::new(&SECP, &mut rand::thread_rng());
343		let vtxo_key = Keypair::new(&SECP, &mut rand::thread_rng());
344
345		let mailbox = MailboxIdentifier::from_pubkey(mailbox_key.public_key());
346
347		let blinded = mailbox.to_blinded(server_mailbox_key.public_key(), &vtxo_key)
348			.expect("blinding a random mailbox id should succeed");
349
350		let unblinded = MailboxIdentifier::from_blinded(
351			blinded, vtxo_key.public_key(), &server_mailbox_key,
352		).expect("unblinding a valid blinded id should succeed");
353
354		assert_eq!(unblinded, mailbox);
355	}
356
357	/// A peer that chooses `blinded_id` equal to the ECDH-derived tweak
358	/// makes the unblind sum land on the point at infinity. libsecp256k1
359	/// rejects that, so `from_blinded` must surface the error rather than
360	/// panicking. Prior to the fix this reproduced a public gRPC panic on
361	/// `post_arkoor_message`.
362	#[test]
363	fn from_blinded_rejects_point_at_infinity() {
364		let server_mailbox_key = Keypair::new(&SECP, &mut rand::thread_rng());
365		let vtxo_key = Keypair::new(&SECP, &mut rand::thread_rng());
366
367		// The attacker knows both its own vtxo_key and the server's public
368		// mailbox key, so it can compute the ECDH point itself and submit
369		// it as the blinded id.
370		let dh = ecdh::shared_secret_point(
371			&server_mailbox_key.public_key(), &vtxo_key.secret_key(),
372		);
373		let blinded = BlindedMailboxIdentifier::from_pubkey(point_to_pubkey(&dh));
374
375		let res = MailboxIdentifier::from_blinded(
376			blinded, vtxo_key.public_key(), &server_mailbox_key,
377		);
378		assert!(res.is_err(), "expected identity-point error, got {:?}", res);
379	}
380
381	/// Blinding is client-side, but a hostile address that publishes a
382	/// mailbox point equal to the negation of the sender's ECDH tweak
383	/// would push the sum to infinity. `to_blinded` must surface the
384	/// error rather than panicking.
385	#[test]
386	fn to_blinded_rejects_point_at_infinity() {
387		let server_mailbox_key = Keypair::new(&SECP, &mut rand::thread_rng());
388		let vtxo_key = Keypair::new(&SECP, &mut rand::thread_rng());
389
390		let dh = ecdh::shared_secret_point(
391			&server_mailbox_key.public_key(), &vtxo_key.secret_key(),
392		);
393		let neg_dh = point_to_pubkey(&dh).negate(&SECP);
394		let mailbox = MailboxIdentifier::from_pubkey(neg_dh);
395
396		let res = mailbox.to_blinded(server_mailbox_key.public_key(), &vtxo_key);
397		assert!(res.is_err(), "expected identity-point error, got {:?}", res);
398	}
399
400	#[test]
401	fn mailbox_authorization() {
402		let mailbox_key = Keypair::new(&SECP, &mut rand::thread_rng());
403		let mailbox = MailboxIdentifier::from_pubkey(mailbox_key.public_key());
404
405		let expiry = chrono::Local::now() + Duration::from_secs(60);
406		let auth = MailboxAuthorization::new(&mailbox_key, expiry);
407		assert_eq!(auth.mailbox(), mailbox);
408		assert!(auth.verify());
409
410		assert_eq!(auth, MailboxAuthorization::deserialize(&auth.serialize()).unwrap());
411
412		// an old one
413		let decoded = MailboxAuthorization::deserialize_hex("023f6712126b93bd479baec93fa4b6e6eb7aa8100b2e818954a351e2eb459ccbeac3380369000000000163b3184156804eb26ffbad964a70840229c4ac80da5da9f9a7557874c45259af48671aa26f567c3c855092c51a1ceeb8a17c7540abe0a50e89866bdb90ece9").unwrap();
414		assert_eq!(decoded.expiry, 1761818819);
415		assert_eq!(decoded.id.to_string(), "023f6712126b93bd479baec93fa4b6e6eb7aa8100b2e818954a351e2eb459ccbea");
416		assert!(decoded.verify());
417	}
418
419	#[test]
420	fn mailbox_type_round_trip() {
421		let ar = MailboxType::ArkoorReceive;
422		let rpc = MailboxType::RoundParticipationCompleted;
423		let ln = MailboxType::LnRecvPendingPayment;
424		let rvi = MailboxType::RecoveryVtxoId;
425		let lsf = MailboxType::LnSendFinished;
426
427		let cases = [
428			(ar, u32::from(ar), ar.as_str()),
429			(rpc, u32::from(rpc), rpc.as_str()),
430			(ln, u32::from(ln), ln.as_str()),
431			(rvi, u32::from(rvi), rvi.as_str()),
432			(lsf, u32::from(lsf), lsf.as_str()),
433		];
434
435		let mut seen_u32 = std::collections::HashSet::new();
436
437		for (variant, expected_u32, expected_str) in cases {
438			let actual = u32::from(variant);
439			assert_eq!(actual, expected_u32, "wrong u32 for {:?}", variant);
440
441			let actual = String::from(variant);
442			assert_eq!(actual, expected_str, "wrong str for {:?}", variant);
443
444			let round_trip = actual.parse::<MailboxType>().unwrap();
445			assert_eq!(round_trip, variant);
446
447			assert!(seen_u32.insert(expected_u32), "duplicate u32 value: {}", expected_u32);
448		}
449
450		assert!(MailboxType::try_from(cases.len() as u32).is_err());
451		assert!(MailboxType::try_from(u32::MAX).is_err());
452		assert!(MailboxType::try_from(999_999).is_err());
453		assert!(MailboxType::from_str("arkor_receive").is_err()); // typo
454		assert!(MailboxType::from_str("").is_err());
455		assert!(MailboxType::from_str("ARKOOR_RECEIVE").is_err()); // case-sensitive
456	}
457}
458