1use base64::engine::general_purpose::URL_SAFE_NO_PAD;
2use base64::Engine;
3use coven_foundation::code_envelope;
4use coven_foundation::config::CloudProvider;
5use coven_keys::keys::{self, UserKeypair};
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use std::net::SocketAddr;
9
10const PAIRING_CODE_PREFIX: &str = "coven:device-pairing:";
11const PAIRING_REQUEST_DOMAIN: &[u8] = b"coven.device-pairing-request.v1\0";
12const PAIRING_VERSION: u32 = 1;
13
14#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct DevicePairingOffer {
20 version: u32,
21 pairing_public_key: String,
22 endpoints: Vec<SocketAddr>,
23 store_name: String,
24 cloud_provider: CloudProvider,
25 expires_at_unix_seconds: i64,
26}
27
28impl DevicePairingOffer {
29 pub fn new(
30 pairing_key: &UserKeypair,
31 endpoints: Vec<SocketAddr>,
32 store_name: String,
33 cloud_provider: CloudProvider,
34 expires_at_unix_seconds: i64,
35 ) -> Result<Self, DevicePairingError> {
36 if endpoints.is_empty() {
37 return Err(DevicePairingError::NoEndpoint);
38 }
39 if store_name.trim().is_empty() {
40 return Err(DevicePairingError::EmptyStoreName);
41 }
42 Ok(Self {
43 version: PAIRING_VERSION,
44 pairing_public_key: keys::public_key_hex(pairing_key),
45 endpoints,
46 store_name,
47 cloud_provider,
48 expires_at_unix_seconds,
49 })
50 }
51
52 pub fn encode(&self) -> String {
53 code_envelope::encode_code(PAIRING_CODE_PREFIX, self)
54 }
55
56 pub fn decode(code: &str) -> Result<Self, DevicePairingError> {
57 let offer: Self = code_envelope::decode_code(PAIRING_CODE_PREFIX, code)?;
58 offer.validate()?;
59 Ok(offer)
60 }
61
62 pub fn is_pairing_code(code: &str) -> bool {
66 code.trim().starts_with(PAIRING_CODE_PREFIX)
67 }
68
69 pub fn session_id(&self) -> &str {
70 &self.pairing_public_key
71 }
72
73 pub fn endpoints(&self) -> &[SocketAddr] {
74 &self.endpoints
75 }
76
77 pub(crate) fn pairing_public_key(&self) -> &str {
78 &self.pairing_public_key
79 }
80
81 pub fn store_name(&self) -> &str {
82 &self.store_name
83 }
84
85 pub fn cloud_provider(&self) -> &CloudProvider {
86 &self.cloud_provider
87 }
88
89 pub fn expires_at_unix_seconds(&self) -> i64 {
90 self.expires_at_unix_seconds
91 }
92
93 fn validate(&self) -> Result<(), DevicePairingError> {
94 if self.version != PAIRING_VERSION {
95 return Err(DevicePairingError::UnsupportedVersion(self.version));
96 }
97 decode_32("pairing public key", &self.pairing_public_key)?;
98 if self.endpoints.is_empty() {
99 return Err(DevicePairingError::NoEndpoint);
100 }
101 if self.store_name.trim().is_empty() {
102 return Err(DevicePairingError::EmptyStoreName);
103 }
104 Ok(())
105 }
106
107 fn digest(&self) -> [u8; 32] {
108 Sha256::digest(
109 serde_json::to_vec(self).expect("device pairing offer serialization cannot fail"),
110 )
111 .into()
112 }
113
114 fn recipient(&self) -> Result<[u8; 32], DevicePairingError> {
115 keys::ed25519_hex_to_x25519_public_key(&self.pairing_public_key)
116 .map_err(DevicePairingError::Key)
117 }
118}
119
120#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct DevicePairingRequest {
125 version: u32,
126 offer_hash: String,
127 public_key: String,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 provider_account_email: Option<String>,
130 signature: String,
131}
132
133impl DevicePairingRequest {
134 pub fn signed(
135 offer: &DevicePairingOffer,
136 identity: &UserKeypair,
137 provider_account_email: Option<String>,
138 ) -> Self {
139 let mut request = Self {
140 version: PAIRING_VERSION,
141 offer_hash: hex::encode(offer.digest()),
142 public_key: keys::public_key_hex(identity),
143 provider_account_email,
144 signature: String::new(),
145 };
146 request.signature = hex::encode(identity.sign(&request.signing_bytes()));
147 request
148 }
149
150 pub fn public_key(&self) -> &str {
151 &self.public_key
152 }
153
154 pub fn provider_account_email(&self) -> Option<&str> {
155 self.provider_account_email.as_deref()
156 }
157
158 pub fn seal(&self, offer: &DevicePairingOffer) -> Result<Vec<u8>, DevicePairingError> {
159 self.verify(offer)?;
160 let plaintext =
161 serde_json::to_vec(self).expect("device pairing request serialization cannot fail");
162 Ok(keys::seal_box_encrypt(&plaintext, &offer.recipient()?))
163 }
164
165 pub fn open(
166 ciphertext: &[u8],
167 offer: &DevicePairingOffer,
168 pairing_key: &UserKeypair,
169 ) -> Result<Self, DevicePairingError> {
170 if keys::public_key_hex(pairing_key) != offer.pairing_public_key {
171 return Err(DevicePairingError::PairingKeyMismatch);
172 }
173 let plaintext = keys::seal_box_decrypt(ciphertext, &pairing_key.to_x25519_secret_key())?;
174 let request: Self = serde_json::from_slice(&plaintext)?;
175 request.verify(offer)?;
176 Ok(request)
177 }
178
179 fn verify(&self, offer: &DevicePairingOffer) -> Result<(), DevicePairingError> {
180 if self.version != PAIRING_VERSION {
181 return Err(DevicePairingError::UnsupportedVersion(self.version));
182 }
183 if self.offer_hash != hex::encode(offer.digest()) {
184 return Err(DevicePairingError::OfferMismatch);
185 }
186 decode_32("joining public key", &self.public_key)?;
187 decode_64("pairing request signature", &self.signature)?;
188 if !keys::verify_signature_hex(&self.public_key, &self.signature, &self.signing_bytes()) {
189 return Err(DevicePairingError::InvalidSignature);
190 }
191 Ok(())
192 }
193
194 fn signing_bytes(&self) -> Vec<u8> {
195 #[derive(Serialize)]
196 struct SignedFields<'a> {
197 version: u32,
198 offer_hash: &'a str,
199 public_key: &'a str,
200 provider_account_email: &'a Option<String>,
201 }
202 let mut bytes = PAIRING_REQUEST_DOMAIN.to_vec();
203 bytes.extend(
204 serde_json::to_vec(&SignedFields {
205 version: self.version,
206 offer_hash: &self.offer_hash,
207 public_key: &self.public_key,
208 provider_account_email: &self.provider_account_email,
209 })
210 .expect("device pairing request serialization cannot fail"),
211 );
212 bytes
213 }
214}
215
216#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(deny_unknown_fields)]
218pub struct SealedDevicePairingRequest {
219 pub session_id: String,
220 pub ciphertext: String,
221}
222
223#[derive(Clone, Debug, Serialize, Deserialize)]
227#[serde(deny_unknown_fields)]
228pub struct PreparedDevicePairing {
229 offer: DevicePairingOffer,
230 request: DevicePairingRequest,
231 sealed_request: SealedDevicePairingRequest,
232 state: PreparedDevicePairingState,
233}
234
235#[derive(Clone, Debug, Serialize, Deserialize)]
236#[serde(tag = "state", rename_all = "kebab-case", deny_unknown_fields)]
237enum PreparedDevicePairingState {
238 AwaitingInvitation,
239 ProviderAccessPending { invitation: Vec<u8> },
240 LibraryInstallationPending { invitation: Vec<u8> },
241}
242
243#[derive(Clone, Copy, Debug, PartialEq, Eq)]
247pub enum DevicePairingPhase {
248 AwaitingInvitation,
249 ProviderAccessPending,
250 LibraryInstallationPending,
251}
252
253impl PreparedDevicePairing {
254 pub fn phase(&self) -> DevicePairingPhase {
255 match &self.state {
256 PreparedDevicePairingState::AwaitingInvitation => {
257 DevicePairingPhase::AwaitingInvitation
258 }
259 PreparedDevicePairingState::ProviderAccessPending { .. } => {
260 DevicePairingPhase::ProviderAccessPending
261 }
262 PreparedDevicePairingState::LibraryInstallationPending { .. } => {
263 DevicePairingPhase::LibraryInstallationPending
264 }
265 }
266 }
267
268 pub fn pending(
269 layout: &coven_foundation::store_dir::StoreLayout,
270 ) -> Result<Vec<Self>, DevicePairingError> {
271 let mut pending = Vec::new();
272 for (path, bytes) in layout.pending_device_pairing_journals()? {
273 let pairing: Self = serde_json::from_slice(&bytes)?;
274 let expected = layout.pending_device_pairing_path(pairing.offer.session_id())?;
275 if expected != path {
276 return Err(DevicePairingError::PreparedPairingPathMismatch {
277 expected,
278 actual: path,
279 });
280 }
281 pairing.request.verify(&pairing.offer)?;
282 pending.push(pairing);
283 }
284 pending.sort_by(|left, right| left.offer.session_id().cmp(right.offer.session_id()));
285 Ok(pending)
286 }
287
288 pub fn open_or_create(
289 pairing_code: &str,
290 provider_account_email: Option<String>,
291 layout: &coven_foundation::store_dir::StoreLayout,
292 ) -> Result<Self, DevicePairingError> {
293 let offer = DevicePairingOffer::decode(pairing_code)?;
294 let path = layout.pending_device_pairing_path(offer.session_id())?;
295 let file = coven_foundation::atomic_file::AtomicFile::new(path);
296 if let Some(bytes) = file.read_optional()? {
297 let pairing: Self = serde_json::from_slice(&bytes)?;
298 if pairing.offer != offer
299 || pairing.request.provider_account_email != provider_account_email
300 {
301 return Err(DevicePairingError::PreparedPairingMismatch);
302 }
303 pairing.request.verify(&pairing.offer)?;
304 return Ok(pairing);
305 }
306 let identity = keys::mint_pending_identity()?;
307 let request =
308 DevicePairingRequest::signed(&offer, &identity, provider_account_email.clone());
309 let sealed_request = SealedDevicePairingRequest::new(&offer, &request)?;
310 let pairing = Self {
311 offer,
312 request,
313 sealed_request,
314 state: PreparedDevicePairingState::AwaitingInvitation,
315 };
316 file.replace(
317 &serde_json::to_vec(&pairing)
318 .expect("prepared device pairing serialization cannot fail"),
319 )?;
320 Ok(pairing)
321 }
322
323 pub fn offer(&self) -> &DevicePairingOffer {
324 &self.offer
325 }
326
327 pub fn request(&self) -> &DevicePairingRequest {
328 &self.request
329 }
330
331 pub fn sealed_request(&self) -> &SealedDevicePairingRequest {
332 &self.sealed_request
333 }
334
335 pub(crate) fn record_invitation_received(
336 &self,
337 layout: &coven_foundation::store_dir::StoreLayout,
338 invitation: &[u8],
339 ) -> Result<Self, DevicePairingError> {
340 match &self.state {
341 PreparedDevicePairingState::ProviderAccessPending {
342 invitation: durable,
343 }
344 | PreparedDevicePairingState::LibraryInstallationPending {
345 invitation: durable,
346 } if durable == invitation => return Ok(self.clone()),
347 PreparedDevicePairingState::ProviderAccessPending { .. }
348 | PreparedDevicePairingState::LibraryInstallationPending { .. } => {
349 return Err(DevicePairingError::InvitationConflict)
350 }
351 PreparedDevicePairingState::AwaitingInvitation => {}
352 }
353 self.replace_state(
354 layout,
355 PreparedDevicePairingState::ProviderAccessPending {
356 invitation: invitation.to_vec(),
357 },
358 )
359 }
360
361 pub(crate) fn record_library_installation_pending(
362 &self,
363 layout: &coven_foundation::store_dir::StoreLayout,
364 ) -> Result<Self, DevicePairingError> {
365 let invitation = match &self.state {
366 PreparedDevicePairingState::ProviderAccessPending { invitation } => invitation.clone(),
367 PreparedDevicePairingState::LibraryInstallationPending { .. } => {
368 return Ok(self.clone())
369 }
370 PreparedDevicePairingState::AwaitingInvitation => {
371 return Err(DevicePairingError::InvitationMissing)
372 }
373 };
374 self.replace_state(
375 layout,
376 PreparedDevicePairingState::LibraryInstallationPending { invitation },
377 )
378 }
379
380 fn replace_state(
381 &self,
382 layout: &coven_foundation::store_dir::StoreLayout,
383 state: PreparedDevicePairingState,
384 ) -> Result<Self, DevicePairingError> {
385 let pending = Self {
386 offer: self.offer.clone(),
387 request: self.request.clone(),
388 sealed_request: self.sealed_request.clone(),
389 state,
390 };
391 let path = layout.pending_device_pairing_path(self.offer.session_id())?;
392 coven_foundation::atomic_file::AtomicFile::new(path).replace(
393 &serde_json::to_vec(&pending)
394 .expect("prepared device pairing serialization cannot fail"),
395 )?;
396 Ok(pending)
397 }
398
399 pub(crate) fn pending_invitation(&self) -> Option<&[u8]> {
400 match &self.state {
401 PreparedDevicePairingState::AwaitingInvitation => None,
402 PreparedDevicePairingState::ProviderAccessPending { invitation }
403 | PreparedDevicePairingState::LibraryInstallationPending { invitation } => {
404 Some(invitation)
405 }
406 }
407 }
408
409 pub fn finish(
410 &self,
411 layout: &coven_foundation::store_dir::StoreLayout,
412 ) -> Result<(), DevicePairingError> {
413 let path = layout.pending_device_pairing_path(self.offer.session_id())?;
414 coven_foundation::atomic_file::AtomicFile::new(path.clone()).remove()?;
415 coven_foundation::atomic_file::sync_parent_dir_blocking(&path)?;
416 Ok(())
417 }
418
419 pub fn abandon(
420 self,
421 layout: &coven_foundation::store_dir::StoreLayout,
422 ) -> Result<(), DevicePairingError> {
423 keys::discard_pending_identity(self.request.public_key())?;
424 self.finish(layout)
425 }
426}
427
428impl SealedDevicePairingRequest {
429 pub fn new(
430 offer: &DevicePairingOffer,
431 request: &DevicePairingRequest,
432 ) -> Result<Self, DevicePairingError> {
433 Ok(Self {
434 session_id: offer.session_id().to_string(),
435 ciphertext: URL_SAFE_NO_PAD.encode(request.seal(offer)?),
436 })
437 }
438
439 pub fn open(
440 &self,
441 offer: &DevicePairingOffer,
442 pairing_key: &UserKeypair,
443 ) -> Result<DevicePairingRequest, DevicePairingError> {
444 if self.session_id != offer.session_id() {
445 return Err(DevicePairingError::OfferMismatch);
446 }
447 let ciphertext = URL_SAFE_NO_PAD.decode(&self.ciphertext)?;
448 DevicePairingRequest::open(&ciphertext, offer, pairing_key)
449 }
450}
451
452#[derive(Debug, thiserror::Error)]
453pub enum DevicePairingError {
454 #[error("pairing code: {0}")]
455 Code(#[from] code_envelope::EnvelopeError),
456 #[error("unsupported pairing version {0}")]
457 UnsupportedVersion(u32),
458 #[error("pairing offer has no reachable endpoint")]
459 NoEndpoint,
460 #[error("pairing offer has an empty Store name")]
461 EmptyStoreName,
462 #[error("{field}: {source}")]
463 Hex {
464 field: &'static str,
465 source: hex::FromHexError,
466 },
467 #[error("{field} must contain {expected} bytes")]
468 HexLength {
469 field: &'static str,
470 expected: usize,
471 },
472 #[error("pairing key: {0}")]
473 Key(#[from] keys::KeyError),
474 #[error("pairing request JSON: {0}")]
475 Json(#[from] serde_json::Error),
476 #[error("pairing journal: {0}")]
477 Journal(#[from] coven_foundation::atomic_file::FileError),
478 #[error("pairing journal path: {0}")]
479 JournalPath(#[from] coven_foundation::store_dir::PathTokenError),
480 #[error("pairing request ciphertext: {0}")]
481 Ciphertext(#[from] base64::DecodeError),
482 #[error("pairing request names another offer")]
483 OfferMismatch,
484 #[error("pairing request was opened with another session key")]
485 PairingKeyMismatch,
486 #[error("pairing request signature is invalid")]
487 InvalidSignature,
488 #[error("the durable pairing attempt has different immutable inputs")]
489 PreparedPairingMismatch,
490 #[error("the durable pairing attempt already holds another device invitation")]
491 InvitationConflict,
492 #[error("the durable pairing attempt has not received a device invitation")]
493 InvitationMissing,
494 #[error(
495 "the durable pairing path is {}, expected {}",
496 .actual.display(),
497 .expected.display()
498 )]
499 PreparedPairingPathMismatch {
500 expected: std::path::PathBuf,
501 actual: std::path::PathBuf,
502 },
503}
504
505fn decode_32(field: &'static str, value: &str) -> Result<[u8; 32], DevicePairingError> {
506 decode_fixed(field, value)
507}
508
509fn decode_64(field: &'static str, value: &str) -> Result<[u8; 64], DevicePairingError> {
510 decode_fixed(field, value)
511}
512
513fn decode_fixed<const N: usize>(
514 field: &'static str,
515 value: &str,
516) -> Result<[u8; N], DevicePairingError> {
517 let bytes = hex::decode(value).map_err(|source| DevicePairingError::Hex { field, source })?;
518 bytes
519 .try_into()
520 .map_err(|_| DevicePairingError::HexLength { field, expected: N })
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526
527 fn offer(pairing_key: &UserKeypair) -> DevicePairingOffer {
528 DevicePairingOffer::new(
529 pairing_key,
530 vec!["127.0.0.1:24821".parse().expect("loopback endpoint")],
531 "Pairing Test Store".to_string(),
532 CloudProvider::GoogleDrive,
533 1_900_000_000,
534 )
535 .expect("pairing offer")
536 }
537
538 #[test]
539 fn one_scanned_offer_binds_the_signed_joining_identity_and_provider_account() {
540 let pairing_key = UserKeypair::generate();
541 let offer = offer(&pairing_key);
542 let decoded = DevicePairingOffer::decode(&offer.encode()).expect("decode pairing offer");
543 let joining_identity = UserKeypair::generate();
544 let request = DevicePairingRequest::signed(
545 &decoded,
546 &joining_identity,
547 Some("member@example.com".to_string()),
548 );
549 let sealed =
550 SealedDevicePairingRequest::new(&decoded, &request).expect("seal pairing request");
551 let opened = sealed
552 .open(&decoded, &pairing_key)
553 .expect("open pairing request");
554
555 assert_eq!(opened.public_key(), keys::public_key_hex(&joining_identity));
556 assert_eq!(opened.provider_account_email(), Some("member@example.com"));
557 assert_eq!(decoded.store_name(), "Pairing Test Store");
558 assert_eq!(decoded.cloud_provider(), &CloudProvider::GoogleDrive);
559 }
560
561 #[test]
562 fn pairing_offer_recognizes_its_envelope_before_decoding() {
563 assert!(DevicePairingOffer::is_pairing_code(
564 " coven:device-pairing:not-yet-decoded "
565 ));
566 assert!(!DevicePairingOffer::is_pairing_code(
567 "coven:restore-payload"
568 ));
569 assert!(!DevicePairingOffer::is_pairing_code("not-a-coven-code"));
570 }
571
572 #[test]
573 fn a_request_cannot_cross_pairing_sessions() {
574 let first_key = UserKeypair::generate();
575 let first = offer(&first_key);
576 let second_key = UserKeypair::generate();
577 let second = DevicePairingOffer::new(
578 &second_key,
579 vec!["127.0.0.1:24821".parse().expect("loopback endpoint")],
580 "Other Store".to_string(),
581 CloudProvider::Dropbox,
582 1_900_000_000,
583 )
584 .expect("second offer");
585 let request = DevicePairingRequest::signed(&first, &UserKeypair::generate(), None);
586 let mut sealed =
587 SealedDevicePairingRequest::new(&first, &request).expect("seal first request");
588 sealed.session_id = second.session_id().to_string();
589
590 assert!(matches!(
591 sealed.open(&second, &second_key),
592 Err(DevicePairingError::Key(_)) | Err(DevicePairingError::OfferMismatch)
593 ));
594 }
595
596 #[test]
597 fn prepared_pairing_reopens_the_same_pending_identity_and_abandons_it_exactly() {
598 coven_keys::keys::test_keyring::install();
599 let pairing_key = UserKeypair::generate();
600 let offer = offer(&pairing_key);
601 let app = tempfile::tempdir().expect("pairing app directory");
602 let layout = coven_foundation::store_dir::StoreLayout::new(app.path());
603 let first = PreparedDevicePairing::open_or_create(
604 &offer.encode(),
605 Some("member@example.com".to_string()),
606 &layout,
607 )
608 .expect("prepare pairing");
609 let first_pubkey = first.request().public_key().to_string();
610 let reopened = PreparedDevicePairing::open_or_create(
611 &offer.encode(),
612 Some("member@example.com".to_string()),
613 &layout,
614 )
615 .expect("reopen pairing");
616
617 assert_eq!(reopened.request().public_key(), first_pubkey);
618 assert!(matches!(
619 PreparedDevicePairing::open_or_create(
620 &offer.encode(),
621 Some("other@example.com".to_string()),
622 &layout,
623 ),
624 Err(DevicePairingError::PreparedPairingMismatch)
625 ));
626 reopened.abandon(&layout).expect("abandon pairing");
627 assert!(coven_keys::keys::peek_pending_identity(&first_pubkey).is_err());
628 assert!(!layout
629 .pending_device_pairing_path(offer.session_id())
630 .expect("pairing journal path")
631 .exists());
632 }
633
634 #[test]
635 fn received_invitation_is_durable_until_library_installation_finishes() {
636 coven_keys::keys::test_keyring::install();
637 let pairing_key = UserKeypair::generate();
638 let offer = offer(&pairing_key);
639 let app = tempfile::tempdir().expect("pairing app directory");
640 let layout = coven_foundation::store_dir::StoreLayout::new(app.path());
641 let prepared = PreparedDevicePairing::open_or_create(
642 &offer.encode(),
643 Some("member@example.com".to_string()),
644 &layout,
645 )
646 .expect("prepare pairing");
647 let invitation = b"validated sealed invitation";
648
649 let awaiting_provider = prepared
650 .record_invitation_received(&layout, invitation)
651 .expect("record received invitation");
652 assert_eq!(
653 awaiting_provider.phase(),
654 DevicePairingPhase::ProviderAccessPending
655 );
656 awaiting_provider
657 .record_library_installation_pending(&layout)
658 .expect("record pending library installation");
659 let reopened = PreparedDevicePairing::open_or_create(
660 &offer.encode(),
661 Some("member@example.com".to_string()),
662 &layout,
663 )
664 .expect("reopen pairing after process restart");
665 std::fs::write(
666 layout.pending_device_pairings_dir().join(".tmp.crashed"),
667 b"incomplete atomic stage",
668 )
669 .expect("seed interrupted atomic stage");
670 let pending =
671 PreparedDevicePairing::pending(&layout).expect("enumerate pending device enrollments");
672
673 assert_eq!(
674 reopened.phase(),
675 DevicePairingPhase::LibraryInstallationPending
676 );
677 assert_eq!(reopened.pending_invitation(), Some(invitation.as_slice()));
678 assert_eq!(pending.len(), 1);
679 assert_eq!(pending[0].pending_invitation(), Some(invitation.as_slice()));
680 }
681}