Skip to main content

ark/
recovery.rs

1//! Types for wallet recovery from seed.
2
3use std::io;
4use std::time::Duration;
5
6use bitcoin::bip32::{Xpriv, Xpub};
7use bitcoin::hashes::{sha256, Hash, HashEngine};
8use bitcoin::secp256k1::{schnorr, Message, PublicKey};
9
10use crate::SECP;
11use crate::encode::{ProtocolDecodingError, ProtocolEncoding, ReadExt, WriteExt};
12
13/// Authorization to list the VTXOs owned by the children of an xpub
14///
15/// It is signed by the private key of the xpub itself, so it proves knowledge
16/// of the xpriv rather than of the xpub alone. It is bound to one server and
17/// is only valid before its expiry UNIX timestamp.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub struct XpubRecoveryAuthorization {
20	expiry: i64,
21	sig: schnorr::Signature,
22}
23
24impl XpubRecoveryAuthorization {
25	const CHALLENGE_MESSAGE_PREFIX: &'static [u8; 32] = b"Ark xpub recovery authorization:";
26
27	/// The furthest in the future an expiry is accepted by [Self::is_valid_now].
28	pub const MAX_LIFETIME: Duration = Duration::from_secs(10 * 60);
29
30	fn signable_message(xpub: &Xpub, server_pubkey: PublicKey, expiry: i64) -> Message {
31		let mut eng = sha256::Hash::engine();
32		eng.input(Self::CHALLENGE_MESSAGE_PREFIX);
33		eng.input(&server_pubkey.serialize());
34		eng.input(&xpub.encode());
35		eng.input(&expiry.to_le_bytes());
36		Message::from_digest(sha256::Hash::from_engine(eng).to_byte_array())
37	}
38
39	pub fn new(
40		xpriv: &Xpriv,
41		server_pubkey: PublicKey,
42		expiry: chrono::DateTime<chrono::Local>,
43	) -> XpubRecoveryAuthorization {
44		let expiry = expiry.timestamp();
45		let xpub = Xpub::from_priv(&SECP, xpriv);
46		let msg = Self::signable_message(&xpub, server_pubkey, expiry);
47		let keypair = xpriv.to_keypair(&SECP);
48		XpubRecoveryAuthorization {
49			expiry: expiry,
50			sig: SECP.sign_schnorr_with_aux_rand(&msg, &keypair, &rand::random()),
51		}
52	}
53
54	/// The time at which this authorization expires
55	pub fn expiry(&self) -> chrono::DateTime<chrono::Local> {
56		chrono::DateTime::from_timestamp_secs(self.expiry)
57			.expect("we guarantee valid timestamp")
58			.with_timezone(&chrono::Local)
59	}
60
61	/// Verify the signature for the xpub and the server
62	pub fn verify(&self, xpub: &Xpub, server_pubkey: PublicKey) -> bool {
63		let msg = Self::signable_message(xpub, server_pubkey, self.expiry);
64		SECP.verify_schnorr(&self.sig, &msg, &xpub.public_key.into()).is_ok()
65	}
66
67	/// Check that the authorization is not expired and doesn't expire further
68	/// than [Self::MAX_LIFETIME] from now
69	///
70	/// Allows for 5 seconds of leeway on both ends for clock skew.
71	pub fn is_valid_now(&self) -> bool {
72		const LEEWAY: Duration = Duration::from_secs(5);
73		let now = chrono::Local::now();
74		let expiry = self.expiry();
75		expiry >= now - LEEWAY && expiry <= now + Self::MAX_LIFETIME + LEEWAY
76	}
77}
78
79impl ProtocolEncoding for XpubRecoveryAuthorization {
80	fn encode<W: io::Write + ?Sized>(&self, w: &mut W) -> Result<(), io::Error> {
81		w.emit_slice(&self.expiry.to_le_bytes())?;
82		self.sig.encode(w)?;
83		Ok(())
84	}
85
86	fn decode<R: io::Read + ?Sized>(r: &mut R) -> Result<Self, ProtocolDecodingError> {
87		Ok(Self {
88			expiry: {
89				let timestamp = i64::from_le_bytes(r.read_byte_array()?);
90				// enforce that timestamp is a valid one
91				let _ = chrono::DateTime::from_timestamp_secs(timestamp)
92					.ok_or_else(|| ProtocolDecodingError::invalid("invalid timestamp"))?;
93				timestamp
94			},
95			sig: ProtocolEncoding::decode(r)?,
96		})
97	}
98}
99
100#[cfg(test)]
101mod test {
102	use bitcoin::NetworkKind;
103	use bitcoin::secp256k1::Keypair;
104
105	use super::*;
106
107	fn new_xpriv(seed: u8) -> Xpriv {
108		Xpriv::new_master(NetworkKind::Test, &[seed; 32]).unwrap()
109	}
110
111	fn server_pubkey(seed: u8) -> PublicKey {
112		Keypair::from_seckey_slice(&SECP, &[seed; 32]).unwrap().public_key()
113	}
114
115	#[test]
116	fn authorization_verifies_for_its_xpub_and_server_only() {
117		let xpriv = new_xpriv(1);
118		let xpub = Xpub::from_priv(&SECP, &xpriv);
119		let expiry = chrono::Local::now() + Duration::from_secs(60);
120		let auth = XpubRecoveryAuthorization::new(&xpriv, server_pubkey(2), expiry);
121
122		assert!(auth.verify(&xpub, server_pubkey(2)));
123		assert!(auth.is_valid_now());
124		assert!(!auth.verify(&xpub, server_pubkey(3)), "bound to the server");
125		let other = Xpub::from_priv(&SECP, &new_xpriv(4));
126		assert!(!auth.verify(&other, server_pubkey(2)), "bound to the xpub");
127
128		let decoded = XpubRecoveryAuthorization::deserialize(&auth.serialize()).unwrap();
129		assert_eq!(decoded, auth);
130	}
131
132	#[test]
133	fn authorization_expiry_window() {
134		let xpriv = new_xpriv(1);
135		let now = chrono::Local::now();
136
137		let expired = XpubRecoveryAuthorization::new(&xpriv, server_pubkey(2),
138			now - Duration::from_secs(60));
139		assert!(!expired.is_valid_now());
140
141		let too_long = XpubRecoveryAuthorization::new(&xpriv, server_pubkey(2),
142			now + XpubRecoveryAuthorization::MAX_LIFETIME + Duration::from_secs(60));
143		assert!(!too_long.is_valid_now());
144	}
145}