Skip to main content

coven_domain/joining/
pairing.rs

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/// The one code an existing device displays. Possession of this code grants
15/// access only to this pairing session; Store credentials remain sealed to the
16/// joining identity the existing device approves.
17#[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    /// Whether `code` carries the device-pairing envelope. This identifies
63    /// which decoder owns a scanned code without accepting or validating its
64    /// payload.
65    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/// The identity submitted after scanning an owner's offer. The whole signed
121/// request is sealed to that offer's ephemeral key before it crosses the LAN.
122#[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/// The joining device's retained side of a pairing attempt. It owns no secret
224/// key bytes; the pending identity stays in the configured key custody and is
225/// addressed by the signed request's public key until the join commits.
226#[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/// The durable user-visible phase of one joining-device enrollment.
244/// Invitation bytes remain private because they contain the sealed Store
245/// admission; callers only need the operation they can resume.
246#[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}