From 4e7fb5c6f560b9207abc7b7115b61d244908d40b Mon Sep 17 00:00:00 2001 From: Tran Duc Anh Dang Date: Thu, 12 Nov 2026 07:24:31 +1100 Subject: [PATCH] feat(auth): bind the device description into the approval pairing A device that named its app has its name, platform and app added to the approval commitment and the six digits, and its approver signs the v3 approval message over the same description. The server, the new device and every verifier of the approval chain accept only v3 for such a device, so a changed or dropped label never falls back to an older message. Closes #73. --- crates/orbyn-auth-client/src/lib.rs | 177 ++++++++++++++- crates/orbyn-client/src/described.rs | 37 +++ crates/orbyn-client/src/lib.rs | 1 + crates/orbyn-client/src/pairing.rs | 73 ++++-- crates/orbyn-client/src/trust.rs | 29 +++ crates/orbyn-client/tests/sync.rs | 164 +++++++++++++- crates/orbyn-core/src/accounts/devices.rs | 66 +++++- .../src/accounts/devices/approvals.rs | 6 +- crates/orbyn-core/src/accounts/mod.rs | 20 +- crates/orbyn-server/tests/accounts.rs | 214 ++++++++++++++++++ crates/orbyn-server/tests/device_client.rs | 12 +- docs/architecture.md | 1 + 12 files changed, 748 insertions(+), 52 deletions(-) create mode 100644 crates/orbyn-client/src/described.rs diff --git a/crates/orbyn-auth-client/src/lib.rs b/crates/orbyn-auth-client/src/lib.rs index 02afd20..2bbb560 100644 --- a/crates/orbyn-auth-client/src/lib.rs +++ b/crates/orbyn-auth-client/src/lib.rs @@ -51,6 +51,8 @@ pub const DEVICE_LOGIN_V2_CONTEXT: &str = "orbyn/device-login/v2"; pub const DEVICE_APPROVAL_CONTEXT: &str = "orbyn/device-approval/v1"; /// See [`approval_message_v2`]. pub const DEVICE_APPROVAL_V2_CONTEXT: &str = "orbyn/device-approval/v2"; +/// What an approver signs for a device that named its app: [`approval_message_v3`]. +pub const DEVICE_APPROVAL_V3_CONTEXT: &str = "orbyn/device-approval/v3"; /// What an approver signs since endorsements carry a protection setting: [`approval_message_v2`], /// the approval message plus the hash of the endorsement, so the endorsement cannot be removed or /// swapped without breaking the approval signature. Approvals made before it are still read under @@ -411,11 +413,23 @@ pub struct Pairing<'a> { pub approver: &'a DevicePublic, pub device_id: &'a [u8; 16], pub device: &'a DevicePublic, + /// What the new device says it is (name, platform, app), when it named its app at sign-in. + /// The commitment, the digits and the v3 approval message then cover it, so the person who + /// approves is shown the device that signed in and a server cannot relabel it. It is the + /// device's own signed claim: it says what the device asserted, never which app is running. + pub descriptor: Option>, } -fn pairing_bytes(context: &str, p: &Pairing<'_>) -> Vec { +/// `with_descriptor` adds the device's [`Descriptor`] under a context of its own, so a described +/// pairing and an undescribed one never share bytes. +fn pairing_bytes(context: &str, p: &Pairing<'_>, with_descriptor: bool) -> Vec { let mut out = Vec::with_capacity(8 + context.len() + 16 * 4 + 8 + 128); - push_len_prefixed(&mut out, context.as_bytes()); + match (with_descriptor, &p.descriptor) { + (true, Some(_)) => { + push_len_prefixed(&mut out, format!("{context}/described").as_bytes()); + } + _ => push_len_prefixed(&mut out, context.as_bytes()), + } out.extend_from_slice(p.account_id); out.extend_from_slice(p.approval_id); out.extend_from_slice(&p.created_at.to_be_bytes()); @@ -423,6 +437,9 @@ fn pairing_bytes(context: &str, p: &Pairing<'_>) -> Vec { push_device(&mut out, p.approver); out.extend_from_slice(p.device_id); push_device(&mut out, p.device); + if let (true, Some(descriptor)) = (with_descriptor, &p.descriptor) { + push_descriptor(&mut out, descriptor); + } out } @@ -434,7 +451,7 @@ fn sha256(bytes: &[u8]) -> [u8; 32] { /// What the approver publishes first: a hash of its 32-byte nonce bound to the /// whole approval, so it cannot pick the nonce after seeing the new device's. pub fn approval_commitment(pairing: &Pairing<'_>, approver_nonce: &[u8; 32]) -> [u8; 32] { - let mut bytes = pairing_bytes(APPROVAL_COMMIT_CONTEXT, pairing); + let mut bytes = pairing_bytes(APPROVAL_COMMIT_CONTEXT, pairing, true); bytes.extend_from_slice(approver_nonce); sha256(&bytes) } @@ -448,7 +465,7 @@ pub fn approval_sas( approver_nonce: &[u8; 32], device_nonce: &[u8; 32], ) -> String { - let mut bytes = pairing_bytes(APPROVAL_SAS_CONTEXT, pairing); + let mut bytes = pairing_bytes(APPROVAL_SAS_CONTEXT, pairing, true); bytes.extend_from_slice(approver_nonce); bytes.extend_from_slice(device_nonce); let digest = sha256(&bytes); @@ -462,18 +479,29 @@ pub fn approval_sas( /// confirmed on it that both screens show the same digits. Other devices check /// it when they verify the new device by its approval chain. pub fn approval_message(pairing: &Pairing<'_>) -> Vec { - pairing_bytes(DEVICE_APPROVAL_CONTEXT, pairing) + pairing_bytes(DEVICE_APPROVAL_CONTEXT, pairing, false) } /// What the approver signs under [`DEVICE_APPROVAL_V2_CONTEXT`]: the approval, and the SHA-256 of /// the endorsement it attaches, so a server cannot drop the endorsement or put another in its /// place. `endorsement_hash` is [`endorsement_hash`] of the endorsement bytes as sent. pub fn approval_message_v2(pairing: &Pairing<'_>, endorsement_hash: &[u8; 32]) -> Vec { - let mut out = pairing_bytes(DEVICE_APPROVAL_V2_CONTEXT, pairing); + let mut out = pairing_bytes(DEVICE_APPROVAL_V2_CONTEXT, pairing, false); out.extend_from_slice(endorsement_hash); out } +/// What the approver signs under [`DEVICE_APPROVAL_V3_CONTEXT`] for a device that named its app: +/// the v2 message with the device's name, platform and app, so what the approver was shown is what +/// every verifier checks. `None` when the pairing has no descriptor: a device without a label is +/// approved under v2 or v1. +pub fn approval_message_v3(pairing: &Pairing<'_>, endorsement_hash: &[u8; 32]) -> Option> { + pairing.descriptor?; + let mut out = pairing_bytes(DEVICE_APPROVAL_V3_CONTEXT, pairing, true); + out.extend_from_slice(endorsement_hash); + Some(out) +} + /// The hash of an endorsement that [`approval_message_v2`] covers. pub fn endorsement_hash(endorsement: &[u8]) -> [u8; 32] { sha256(endorsement) @@ -556,6 +584,7 @@ mod tests { approver, device_id: &[2; 16], device, + descriptor: None, }) }; let base = pairing(&[0; 16], &a, &b, 1); @@ -610,6 +639,7 @@ mod pairing_tests { approver, device_id: &[2; 16], device, + descriptor: None, }, &[ra; 32], &[rd; 32], @@ -641,6 +671,7 @@ mod pairing_tests { approver: &a, device_id: &[2; 16], device: &d, + descriptor: None, }; let c = approval_commitment(&pairing, &[4; 32]); assert_ne!(c, approval_commitment(&pairing, &[5; 32])); @@ -768,6 +799,7 @@ mod protection_tests { approver: &keys, device_id: &device, device: &keys, + descriptor: None, }; let a = approval_message_v2(&pairing, &endorsement_hash(b"one")); let b = approval_message_v2(&pairing, &endorsement_hash(b"two")); @@ -860,3 +892,136 @@ mod descriptor_tests { assert!(orbyn_crypto::verify(&keys, DEVICE_REGISTER_V2_CONTEXT, &v2, &signed_v1).is_err()); } } + +#[cfg(test)] +mod described_pairing_tests { + use super::*; + + fn keys(seed: u8) -> DevicePublic { + DevicePublic { + signing: [seed; 32], + agreement: [seed + 1; 32], + } + } + + fn pairing<'a>( + approver: &'a DevicePublic, + device: &'a DevicePublic, + ids: &'a [[u8; 16]; 4], + descriptor: Option>, + ) -> Pairing<'a> { + Pairing { + account_id: &ids[0], + approval_id: &ids[1], + created_at: 1_700_000_000, + approver_id: &ids[2], + approver, + device_id: &ids[3], + device, + descriptor, + } + } + + const D: Descriptor<'static> = Descriptor { + name: "Phone", + platform: "android", + app: "engineering", + }; + + #[test] + fn a_descriptor_changes_the_commitment_the_digits_and_nothing_else_for_old_messages() { + let (a, d) = (keys(1), keys(9)); + let ids = [[1; 16], [2; 16], [3; 16], [4; 16]]; + let plain = pairing(&a, &d, &ids, None); + let described = pairing(&a, &d, &ids, Some(D)); + let (ra, rd) = ([5; 32], [6; 32]); + assert_ne!( + approval_commitment(&plain, &ra), + approval_commitment(&described, &ra) + ); + assert_ne!( + approval_sas(&plain, &ra, &rd), + approval_sas(&described, &ra, &rd) + ); + // The v1 and v2 messages never covered it. + assert_eq!(approval_message(&plain), approval_message(&described)); + let h = endorsement_hash(b"e"); + assert_eq!( + approval_message_v2(&plain, &h), + approval_message_v2(&described, &h) + ); + } + + #[test] + fn each_field_of_the_descriptor_changes_the_commitment_the_digits_and_the_v3_message() { + let (a, d) = (keys(1), keys(9)); + let ids = [[1; 16], [2; 16], [3; 16], [4; 16]]; + let base = pairing(&a, &d, &ids, Some(D)); + let others = [ + Descriptor { + name: "Tablet", + ..D + }, + Descriptor { + platform: "ios", + ..D + }, + Descriptor { + app: "planner", + ..D + }, + ]; + let (ra, rd) = ([5; 32], [6; 32]); + let h = endorsement_hash(b"e"); + for other in others { + let changed = pairing(&a, &d, &ids, Some(other)); + assert_ne!( + approval_commitment(&base, &ra), + approval_commitment(&changed, &ra) + ); + assert_ne!( + approval_sas(&base, &ra, &rd), + approval_sas(&changed, &ra, &rd) + ); + assert_ne!( + approval_message_v3(&base, &h), + approval_message_v3(&changed, &h) + ); + } + } + + #[test] + fn a_described_pairing_has_its_own_context_in_the_bytes() { + let (a, d) = (keys(1), keys(9)); + let ids = [[1; 16], [2; 16], [3; 16], [4; 16]]; + let prefixed = |text: &str| { + let mut out = Vec::new(); + push_len_prefixed(&mut out, text.as_bytes()); + out + }; + let plain = pairing_bytes("ctx", &pairing(&a, &d, &ids, None), true); + assert!(plain.starts_with(&prefixed("ctx"))); + let described = pairing_bytes("ctx", &pairing(&a, &d, &ids, Some(D)), true); + assert!(described.starts_with(&prefixed("ctx/described"))); + // Asked for without a description, the context is the plain one. + let skipped = pairing_bytes("ctx", &pairing(&a, &d, &ids, Some(D)), false); + assert!(skipped.starts_with(&prefixed("ctx"))); + } + + #[test] + fn the_v3_message_needs_a_descriptor_and_has_its_own_context_and_bytes() { + let (a, d) = (keys(1), keys(9)); + let ids = [[1; 16], [2; 16], [3; 16], [4; 16]]; + let h = endorsement_hash(b"e"); + assert_eq!(approval_message_v3(&pairing(&a, &d, &ids, None), &h), None); + let described = pairing(&a, &d, &ids, Some(D)); + let v3 = approval_message_v3(&described, &h).expect("described"); + assert_ne!(v3, approval_message_v2(&described, &h)); + assert_ne!(DEVICE_APPROVAL_V3_CONTEXT, DEVICE_APPROVAL_V2_CONTEXT); + let other_endorsement = endorsement_hash(b"f"); + assert_ne!( + Some(v3), + approval_message_v3(&described, &other_endorsement) + ); + } +} diff --git a/crates/orbyn-client/src/described.rs b/crates/orbyn-client/src/described.rs new file mode 100644 index 0000000..5585e5f --- /dev/null +++ b/crates/orbyn-client/src/described.rs @@ -0,0 +1,37 @@ +//! A device's description as it signed it at sign-in. + +use orbyn_auth_client as auth; +use orbyn_contracts as wire; + +/// A device's name, platform and app as signed into its sign-in. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Described { + name: String, + platform: &'static str, + app: &'static str, +} + +impl Described { + pub(crate) fn new(name: &str, platform: wire::DevicePlatform, app: wire::DeviceApp) -> Self { + Self { + name: name.trim().to_owned(), + platform: platform.as_str(), + app: app.as_str(), + } + } + + /// What the server lists for a device: a description only when it names an app. + pub(crate) fn of(device: &wire::Device) -> Option { + device + .app + .map(|app| Self::new(&device.name, device.platform, app)) + } + + pub(crate) fn view(&self) -> auth::Descriptor<'_> { + auth::Descriptor { + name: &self.name, + platform: self.platform, + app: self.app, + } + } +} diff --git a/crates/orbyn-client/src/lib.rs b/crates/orbyn-client/src/lib.rs index 06260b8..386be0f 100644 --- a/crates/orbyn-client/src/lib.rs +++ b/crates/orbyn-client/src/lib.rs @@ -25,6 +25,7 @@ mod apply; pub mod crypt; +mod described; mod keys; mod pairing; mod planner; diff --git a/crates/orbyn-client/src/pairing.rs b/crates/orbyn-client/src/pairing.rs index 36d03be..eefb152 100644 --- a/crates/orbyn-client/src/pairing.rs +++ b/crates/orbyn-client/src/pairing.rs @@ -41,6 +41,7 @@ //! setting (see `protection`). Format 2, from an approver that predates settings, is still //! accepted and says no setting is known. +use crate::described::Described; use crate::trust::{key32, public_of, uuid_bytes}; use crate::{ClientError, Engine, Result, b64, random, unb64}; use orbyn_auth_client as auth; @@ -71,6 +72,10 @@ struct Bound { approver: DevicePublic, device_id: [u8; 16], device: DevicePublic, + /// The new device's name, platform and app, when it named its app. Then the commitment, the + /// digits and the approval signature cover them (the v3 message). It is the device's own + /// signed claim, so it says what the device asserted, never which app is running. + descriptor: Option, } impl Bound { @@ -83,6 +88,7 @@ impl Bound { approver: &self.approver, device_id: &self.device_id, device: &self.device, + descriptor: self.descriptor.as_ref().map(Described::view), }) } @@ -104,6 +110,12 @@ impl Bound { fn message_v2(&self, endorsement_hash: &[u8; 32]) -> Vec { self.with(|p| auth::approval_message_v2(p, endorsement_hash)) } + + /// The v3 approval message of a device that named its app: the v2 message and the device's + /// description. `None` for a device with no label. + fn message_v3(&self, endorsement_hash: &[u8; 32]) -> Option> { + self.with(|p| auth::approval_message_v3(p, endorsement_hash)) + } } fn fixed32(text: Option<&str>) -> Result<[u8; 32]> { @@ -263,6 +275,7 @@ impl Engine { approver: self.keys.public(), device_id: uuid_bytes(&device.id)?, device: public_of(device).ok_or(ClientError::InvalidInput)?, + descriptor: Described::of(device), }; let nonce = Zeroizing::new(random::<32>()?); let create = wire::CreateApproval { @@ -326,12 +339,14 @@ impl Engine { endorsement.extend_from_slice(&entries); endorsement.extend_from_slice(&endorsement_signature); // The approval signature covers the endorsement, so it cannot be dropped or swapped. - let signature = self.keys.sign( - auth::DEVICE_APPROVAL_V2_CONTEXT, - &pairing - .bound - .message_v2(&auth::endorsement_hash(&endorsement)), - ); + let hash = auth::endorsement_hash(&endorsement); + let signature = match pairing.bound.message_v3(&hash) { + Some(message) => self.keys.sign(auth::DEVICE_APPROVAL_V3_CONTEXT, &message), + None => self.keys.sign( + auth::DEVICE_APPROVAL_V2_CONTEXT, + &pairing.bound.message_v2(&hash), + ), + }; Ok(wire::ConfirmApproval { signature: b64(&signature), endorsement: Some(b64(&endorsement)), @@ -346,6 +361,29 @@ impl Engine { &self, view: &wire::PendingApproval, now_secs: i64, + ) -> Result<(DevicePairing, wire::ApprovalNonce)> { + self.join(view, now_secs, None) + } + + /// As [`Engine::join_approval`] for a device that named its app at sign-in: `name`, + /// `platform` and `app` are what this device signed, so the digits it shows only match the + /// approver's if the approver was shown the same description. + pub fn join_approval_labelled( + &self, + view: &wire::PendingApproval, + now_secs: i64, + name: &str, + platform: wire::DevicePlatform, + app: wire::DeviceApp, + ) -> Result<(DevicePairing, wire::ApprovalNonce)> { + self.join(view, now_secs, Some(Described::new(name, platform, app))) + } + + fn join( + &self, + view: &wire::PendingApproval, + now_secs: i64, + descriptor: Option, ) -> Result<(DevicePairing, wire::ApprovalNonce)> { let me = self.keys.public(); let mine = view.device_id == self.trust.device_id @@ -364,6 +402,7 @@ impl Engine { approver: keys_of(&view.approver_signing_key, &view.approver_agreement_key)?, device_id: uuid_bytes(&self.trust.device_id)?, device: me, + descriptor, }; let nonce: [u8; 32] = random()?; Ok(( @@ -411,15 +450,23 @@ impl Engine { // swapping the endorsement then breaks the signature, and an approval with no endorsement // cannot pass as one. Only an approver that predates this signs the v1 message, and it // cannot make a format 3 endorsement. + // A device that named its app accepts only the v3 message, which covers the description it + // signed in with: an approver that was shown something else cannot have signed it. + let described = pairing.bound.descriptor.is_some(); let v2 = endorsement_bytes.as_deref().is_some_and(|e| { - orbyn_crypto::verify( - &pairing.bound.approver, - auth::DEVICE_APPROVAL_V2_CONTEXT, - &pairing.bound.message_v2(&auth::endorsement_hash(e)), - &signature, - ) - .is_ok() + let hash = auth::endorsement_hash(e); + let (context, message) = match pairing.bound.message_v3(&hash) { + Some(message) => (auth::DEVICE_APPROVAL_V3_CONTEXT, message), + None => ( + auth::DEVICE_APPROVAL_V2_CONTEXT, + pairing.bound.message_v2(&hash), + ), + }; + orbyn_crypto::verify(&pairing.bound.approver, context, &message, &signature).is_ok() }); + if described && !v2 { + return Err(ClientError::Mismatch); + } if !v2 { orbyn_crypto::verify( &pairing.bound.approver, diff --git a/crates/orbyn-client/src/trust.rs b/crates/orbyn-client/src/trust.rs index fe890c2..8eefd48 100644 --- a/crates/orbyn-client/src/trust.rs +++ b/crates/orbyn-client/src/trust.rs @@ -260,6 +260,13 @@ impl Trust { else { return false; }; + let described = device.app.map(|app| { + ( + device.name.trim().to_owned(), + device.platform.as_str(), + app.as_str(), + ) + }); let pairing = auth::Pairing { account_id: &self.account, approval_id: &approval_id, @@ -268,7 +275,29 @@ impl Trust { approver, device_id: &id, device: &public, + descriptor: described.as_ref().map(|d| auth::Descriptor { + name: &d.0, + platform: d.1, + app: d.2, + }), }; + // A device that named its app is approved only under the v3 message, which covers its + // name, platform and app as listed: a server that changes any of them, or drops the label + // to fall back to an older message, makes the approval fail to verify. + if described.is_some() { + let Some(Some(hash)) = device.approval_endorsement_hash.as_deref().map(key32) else { + return false; + }; + return auth::approval_message_v3(&pairing, &hash).is_some_and(|message| { + orbyn_crypto::verify( + approver, + auth::DEVICE_APPROVAL_V3_CONTEXT, + &message, + &signature, + ) + .is_ok() + }); + } // An approval that covers its endorsement says so by the endorsement's hash; one without // is an older approval, signed under the v1 message. A server that drops the hash only // makes the signature fail to verify. diff --git a/crates/orbyn-client/tests/sync.rs b/crates/orbyn-client/tests/sync.rs index 7d5c64a..e602149 100644 --- a/crates/orbyn-client/tests/sync.rs +++ b/crates/orbyn-client/tests/sync.rs @@ -96,6 +96,27 @@ fn view_of( } } +/// The new device joins with the description it signed in with when it named its app. +fn join(new: &Device, view: &wire::PendingApproval) -> (DevicePairing, wire::ApprovalNonce) { + match new.entry.app { + Some(app) => { + new.engine + .join_approval_labelled(view, NOW_S, &new.entry.name, new.entry.platform, app) + } + None => new.engine.join_approval(view, NOW_S), + } + .expect("join") +} + +/// A change a lying server makes to how it lists a device. +type Change<'a> = &'a dyn Fn(&mut wire::Device); + +/// A pending device that named its app at sign-in. +fn labelled(mut device: Device) -> Device { + device.entry.app = Some(wire::DeviceApp::Engineering); + device +} + /// The whole two-way approval of `new` by `approver`, relayed by an honest /// server, with the person confirming on both screens. `list` is what the /// approver sees. Sets and returns `new`'s entry. @@ -105,7 +126,7 @@ fn approve(approver: &mut Device, new: &mut Device, list: &wire::DeviceList) -> .start_approval(&new.entry, NOW_S) .expect("start"); let mut view = view_of(&create, approver, new); - let (pd, nonce) = new.engine.join_approval(&view, NOW_S).expect("join"); + let (pd, nonce) = join(new, &view); view.device_nonce = Some(nonce.nonce); let reveal = pa.reveal(&view).expect("reveal"); view.approver_nonce = Some(reveal.nonce); @@ -355,6 +376,7 @@ fn forged(device: &Device, approver: &str, signer: &DeviceKeys) -> wire::Device approver: &signer.public(), device_id: &uuid_bytes(&device.id).expect("uuid"), device: &device.keys.public(), + descriptor: None, }); e.approved_by = Some(approver.into()); e.approval_signature = Some(b64(&signer.sign(auth::DEVICE_APPROVAL_CONTEXT, &message))); @@ -594,7 +616,7 @@ fn confirmed( .start_approval(&new.entry, NOW_S) .expect("start"); let mut view = view_of(&create, approver, new); - let (pd, nonce) = new.engine.join_approval(&view, NOW_S).expect("join"); + let (pd, nonce) = join(new, &view); view.device_nonce = Some(nonce.nonce); view.approver_nonce = Some(pa.reveal(&view).expect("reveal").nonce); let sas = pd.sas(&view).expect("sas"); @@ -664,6 +686,7 @@ fn an_approval_signature_the_approver_did_not_make_is_refused() { approver: &a.keys.public(), device_id: &uuid_bytes(C).expect("uuid"), device: &c.keys.public(), + descriptor: None, }); view.signature = Some(b64(&evil.sign(auth::DEVICE_APPROVAL_CONTEXT, &message))); assert!( @@ -2416,6 +2439,7 @@ fn a_format_2_endorsement_under_a_v1_approval_is_still_accepted_and_says_no_sett approver: &a2.keys.public(), device_id: &uuid_bytes(&d.id).expect("uuid"), device: &d.keys.public(), + descriptor: None, }); view.signature = Some(b64(&a2 .keys @@ -2526,6 +2550,7 @@ fn a_format_3_endorsement_under_a_v1_approval_signature_is_refused() { approver: &a.keys.public(), device_id: &uuid_bytes(&c.id).expect("uuid"), device: &c.keys.public(), + descriptor: None, }); view.signature = Some(b64(&a .keys @@ -2774,3 +2799,138 @@ fn an_old_link_replayed_as_the_successor_is_refused() { State::Verified(Level::Advanced) ); } + +// ---- A device that named its app is approved under the v3 message ------------------------------ + +/// A and B trusted, and C that named its app approved by A. Returns B (who has not looked at +/// C's approval yet, so it has pinned nothing about it) and the list the server shows. +fn labelled_c_approved() -> (Device, wire::DeviceList) { + let mut store = FakeStore::new(10); + let (mut a, b, _) = pair(&mut store); + let mut c = labelled(Device::new(C, a.now)); + let before = list(&[&a, &b, &c]); + approve(&mut a, &mut c, &before); + let all = list(&[&a, &b, &c]); + (b, all) +} + +#[test] +fn a_device_that_named_its_app_is_approved_and_verified_under_its_description() { + let (mut b, all) = labelled_c_approved(); + assert_eq!( + trusted_ids(&mut b, &all), + [A, B, C], + "b verifies c under v3" + ); + + // Any change to what was approved fails the signature, for a verifier that has not pinned c + // already; each case starts from a fresh verifier. + let changes: [(&str, Change); 4] = [ + ("name", &|d| d.name = "Other".into()), + ("platform", &|d| d.platform = wire::DevicePlatform::Ios), + ("app", &|d| d.app = Some(wire::DeviceApp::Planner)), + // Dropping the label must not fall back to an older message. + ("label dropped", &|d| d.app = None), + ]; + for (what, change) in changes { + let (mut b, mut devices) = labelled_c_approved(); + let entry = devices.devices.iter_mut().find(|d| d.id == C).expect("c"); + change(entry); + let ids = trusted_ids(&mut b, &devices); + assert!(!ids.contains(&C.to_owned()), "{what}: {ids:?}"); + } +} + +#[test] +fn the_digits_do_not_match_when_the_approver_was_shown_another_description() { + let mut store = FakeStore::new(10); + let (a, _b, _) = pair(&mut store); + let c = labelled(Device::new(C, a.now)); + let shown_changes: [Change; 4] = [ + &|d| d.name = "Other".into(), + &|d| d.platform = wire::DevicePlatform::Ios, + &|d| d.app = Some(wire::DeviceApp::Planner), + &|d| d.app = None, + ]; + for change in shown_changes { + // The server lists c to the approver with a changed description. + let mut shown = c.entry.clone(); + change(&mut shown); + let (mut pa, create) = a.engine.start_approval(&shown, NOW_S).expect("start"); + let mut view = view_of(&create, &a, &c); + let (pd, nonce) = join(&c, &view); + view.device_nonce = Some(nonce.nonce); + view.approver_nonce = Some(pa.reveal(&view).expect("reveal").nonce); + // c holds the commitment made over another description: it refuses to show digits. + assert!(pd.sas(&view).is_err()); + } +} + +#[test] +fn a_labelled_device_refuses_an_approval_that_does_not_cover_its_description() { + // The approver's own key signs an older message that leaves the description out. + for older in [ + auth::DEVICE_APPROVAL_V2_CONTEXT, + auth::DEVICE_APPROVAL_CONTEXT, + ] { + let mut store = FakeStore::new(10); + let (mut a, _b, _) = pair(&mut store); + let mut c = labelled(Device::new(C, a.now)); + let devices = list(&[&a, &c]); + let (pd, mut view, sas) = confirmed(&mut a, &c, &devices); + let hash = + auth::endorsement_hash(&unb64(view.endorsement.as_deref().expect("endorsement"))); + let public = (a.keys.public(), c.keys.public()); + let pairing = auth::Pairing { + account_id: &uuid_bytes(ACCOUNT).expect("uuid"), + approval_id: &uuid_bytes(&view.id).expect("uuid"), + created_at: view.created_at, + approver_id: &uuid_bytes(A).expect("uuid"), + approver: &public.0, + device_id: &uuid_bytes(C).expect("uuid"), + device: &public.1, + descriptor: None, + }; + let message = if older == auth::DEVICE_APPROVAL_V2_CONTEXT { + auth::approval_message_v2(&pairing, &hash) + } else { + auth::approval_message(&pairing) + }; + view.signature = Some(b64(&a.keys.sign(older, &message))); + if older == auth::DEVICE_APPROVAL_CONTEXT { + // The v1 message needs no endorsement: without one, nothing else would refuse it. + view.endorsement = None; + } + assert!( + c.engine + .finish_approval(pd, &view, Some(&sas), NOW_S + 30) + .is_err(), + "{older}" + ); + assert_eq!(pins(&c).len(), 1, "c trusts only itself"); + } +} + +#[test] +fn the_description_is_trimmed_as_the_server_stores_it() { + let mut store = FakeStore::new(10); + let (a, _b, _) = pair(&mut store); + let mut c = labelled(Device::new(C, a.now)); + c.entry.name = "Phone".into(); + let (mut pa, create) = a.engine.start_approval(&c.entry, NOW_S).expect("start"); + let mut view = view_of(&create, &a, &c); + // The host passes the name as the person typed it; the server stored it trimmed. + let (pd, nonce) = c + .engine + .join_approval_labelled( + &view, + NOW_S, + " Phone ", + c.entry.platform, + wire::DeviceApp::Engineering, + ) + .expect("join"); + view.device_nonce = Some(nonce.nonce); + view.approver_nonce = Some(pa.reveal(&view).expect("reveal").nonce); + assert_eq!(pd.sas(&view).ok(), pa.sas()); +} diff --git a/crates/orbyn-core/src/accounts/devices.rs b/crates/orbyn-core/src/accounts/devices.rs index 5fd0143..4c90962 100644 --- a/crates/orbyn-core/src/accounts/devices.rs +++ b/crates/orbyn-core/src/accounts/devices.rs @@ -19,14 +19,15 @@ //! Removing a device carries the remover's signature, which every device checks. use super::{ - AccountError, Accounts, App, Approval, ApprovalView, AuditAction, Client, Device, DeviceState, - Platform, Removal, Result, Session, limits, public_keys, + AccountError, Accounts, App, Approval, AuditAction, Client, Device, DeviceState, Platform, + Removal, Result, Session, limits, public_keys, }; use chrono::{DateTime, Utc}; use orbyn_auth_client::{ - DEVICE_APPROVAL_CONTEXT, DEVICE_APPROVAL_V2_CONTEXT, DEVICE_REMOVAL_CONTEXT, - MAX_REMOVAL_SEQUENCE, Pairing, approval_commitment, approval_message, approval_message_v2, - approval_sas, endorsement_hash, removal_message, + DEVICE_APPROVAL_CONTEXT, DEVICE_APPROVAL_V2_CONTEXT, DEVICE_APPROVAL_V3_CONTEXT, + DEVICE_REMOVAL_CONTEXT, Descriptor, MAX_REMOVAL_SEQUENCE, Pairing, approval_commitment, + approval_message, approval_message_v2, approval_message_v3, approval_sas, endorsement_hash, + removal_message, }; use orbyn_crypto::DevicePublic; use sqlx::{Postgres, Transaction}; @@ -47,6 +48,30 @@ fn fresh(created_at: i64, now: i64) -> bool { (created_at - now).abs() <= CLOCK_SLACK_SECS } +/// An approval as either of its devices sees it. Each later field is set by +/// one step, in order: the commitment, the new device's nonce, the approver's +/// nonce, then the approver's signature and endorsement. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ApprovalView { + pub id: Uuid, + pub expires_at: DateTime, + pub created_at: i64, + pub approver_id: Uuid, + pub approver_keys: DevicePublic, + pub device_id: Uuid, + pub device_keys: DevicePublic, + /// The device's stored name, platform and app label. The commitment, the digits and the + /// approval signature cover them when the device has a label. + pub device_name: String, + pub device_platform: Platform, + pub device_app: Option, + pub commitment: [u8; 32], + pub device_nonce: Option<[u8; 32]>, + pub approver_nonce: Option<[u8; 32]>, + pub signature: Option<[u8; 64]>, + pub endorsement: Option>, +} + #[derive(sqlx::FromRow)] struct DeviceRow { id: Uuid, @@ -79,6 +104,9 @@ struct ApprovalRow { device_id: Uuid, device_signing: Vec, device_agreement: Vec, + device_name: String, + device_platform: String, + device_app: Option, commitment: Option>, device_nonce: Option>, approver_nonce: Option>, @@ -103,6 +131,7 @@ macro_rules! select_devices { const SELECT_APPROVAL: &str = "SELECT a.id, a.account_id, a.expires_at, a.approval_created_at, a.approver_id, ad.signing_key AS approver_signing, ad.agreement_key AS approver_agreement, a.device_id, d.signing_key AS device_signing, d.agreement_key AS device_agreement, + d.name AS device_name, d.platform AS device_platform, d.app AS device_app, a.commitment, a.device_nonce, a.approver_nonce, a.signature, a.endorsement, a.state, a.expires_at <= now() AS expired FROM device_approvals a @@ -154,6 +183,9 @@ impl TryFrom for ApprovalView { approver_keys: public_keys(&row.approver_signing, &row.approver_agreement)?, device_id: row.device_id, device_keys: public_keys(&row.device_signing, &row.device_agreement)?, + device_name: row.device_name, + device_platform: Platform::parse(&row.device_platform)?, + device_app: App::parse_opt(row.device_app.as_deref())?, commitment: fixed(row.commitment)?.ok_or(AccountError::ApprovalExpired)?, device_nonce: fixed(row.device_nonce)?, approver_nonce: fixed(row.approver_nonce)?, @@ -184,25 +216,41 @@ fn pairing_of<'a>( approver: &view.approver_keys, device_id: &ids.2, device: &view.device_keys, + descriptor: view.device_app.map(|app| Descriptor { + name: &view.device_name, + platform: view.device_platform.as_str(), + app: app.as_str(), + }), } } -/// How an approval signature verified: under the v2 message that covers the endorsement, or the -/// v1 message of an approver that predates it. +/// How an approval signature verified: under the v3 message of a device that named its app, the +/// v2 message that covers the endorsement, or the v1 message of an approver that predates it. #[derive(Clone, Copy, PartialEq, Eq)] enum ApprovalSignature { + V3, V2, V1, } -/// Checks an approver's signature on an approval: the v2 message over the endorsement's hash when -/// an endorsement came with it, else (or when that fails, for an older approver) the v1 message. +/// Checks an approver's signature on an approval. For a device that named its app, only the v3 +/// message over its description and the endorsement's hash counts, and an approval without an +/// endorsement fails: no older message covers what the approver was shown. For any other device, +/// the v2 message over the endorsement's hash when an endorsement came with it, else (or when that +/// fails, for an older approver) the v1 message. fn verify_approval( signer: &DevicePublic, pairing: &Pairing<'_>, endorsement: Option<&[u8]>, signature: &[u8; 64], ) -> Result { + if pairing.descriptor.is_some() { + let message = endorsement + .and_then(|e| approval_message_v3(pairing, &endorsement_hash(e))) + .ok_or(AccountError::InvalidSignature)?; + verify_signed(signer, DEVICE_APPROVAL_V3_CONTEXT, &message, signature)?; + return Ok(ApprovalSignature::V3); + } if let Some(endorsement) = endorsement { let message = approval_message_v2(pairing, &endorsement_hash(endorsement)); if verify_signed(signer, DEVICE_APPROVAL_V2_CONTEXT, &message, signature).is_ok() { diff --git a/crates/orbyn-core/src/accounts/devices/approvals.rs b/crates/orbyn-core/src/accounts/devices/approvals.rs index e6b5e48..3862f68 100644 --- a/crates/orbyn-core/src/accounts/devices/approvals.rs +++ b/crates/orbyn-core/src/accounts/devices/approvals.rs @@ -301,9 +301,11 @@ impl Accounts { tx.commit().await?; return Err(AccountError::InvalidSignature); }; - // Only a v2 signature covers the endorsement, so only then does a hash go with the device. + // Only a v2 or v3 signature covers the endorsement, so only then does a hash go with the device. let endorsement_hash = match (signed, view.endorsement.as_deref()) { - (ApprovalSignature::V2, Some(endorsement)) => Some(endorsement_hash(endorsement)), + (ApprovalSignature::V3 | ApprovalSignature::V2, Some(endorsement)) => { + Some(endorsement_hash(endorsement)) + } _ => None, }; sqlx::query("UPDATE device_approvals SET state = 'used' WHERE id = $1") diff --git a/crates/orbyn-core/src/accounts/mod.rs b/crates/orbyn-core/src/accounts/mod.rs index 0fcb909..7001416 100644 --- a/crates/orbyn-core/src/accounts/mod.rs +++ b/crates/orbyn-core/src/accounts/mod.rs @@ -38,6 +38,7 @@ pub const TOMBSTONE_DAYS: i32 = 400; pub use delete::{FRESH_SIGN_IN_SECS, signed_in_recently}; pub use delete_unused::{DELETE_UNUSED_TTL, REQUEST_JOB_KIND as DELETE_UNUSED_JOB_KIND}; pub use device_app::App; +pub use devices::ApprovalView; pub use limits::{Limit, Limits}; pub use push_tokens::PUSH_TOKEN_DAYS; pub use second_factor::{ManageProof, TotpEnrolment}; @@ -354,25 +355,6 @@ pub struct Removal { pub signature: [u8; 64], } -/// An approval as either of its devices sees it. Each later field is set by -/// one step, in order: the commitment, the new device's nonce, the approver's -/// nonce, then the approver's signature and endorsement. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ApprovalView { - pub id: Uuid, - pub expires_at: DateTime, - pub created_at: i64, - pub approver_id: Uuid, - pub approver_keys: DevicePublic, - pub device_id: Uuid, - pub device_keys: DevicePublic, - pub commitment: [u8; 32], - pub device_nonce: Option<[u8; 32]>, - pub approver_nonce: Option<[u8; 32]>, - pub signature: Option<[u8; 64]>, - pub endorsement: Option>, -} - /// What the audit log records. Never emails, tokens or keys. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum AuditAction { diff --git a/crates/orbyn-server/tests/accounts.rs b/crates/orbyn-server/tests/accounts.rs index 4a8ccbc..869a42c 100644 --- a/crates/orbyn-server/tests/accounts.rs +++ b/crates/orbyn-server/tests/accounts.rs @@ -554,6 +554,7 @@ impl Run { approver: &self.a, device_id: self.b_id.as_bytes(), device: &self.b, + descriptor: None, }) } @@ -10085,3 +10086,216 @@ async fn the_label_survives_the_second_factor_step() { assert!(labels.contains(&json!("engineering")), "{labels:?}"); assert_eq!(devices.len(), 2); } + +// ---- Approving a device that named its app: the description is in the digits and the v3 message - + +/// How a test builds an older approval message for a run. +type Older<'a> = Box Vec + 'a>; + +const DESCRIBED: (&str, &str, &str) = ("Phone", "macos", "engineering"); + +/// A signed up (no label) and B signed in pending under `DESCRIBED`. +async fn described_pair(server: &Server) -> Pair { + let email = email(); + let a = DeviceKeys::generate().expect("keys"); + let a_session = server.register(&email, b"pw", &a).await; + let b = DeviceKeys::generate().expect("keys"); + let (status, b_session) = server + .login_labelled(&email, b"pw", &b, &labelled(DESCRIBED.0, DESCRIBED.2)) + .await; + assert_eq!(status, StatusCode::OK, "{b_session}"); + Pair { + email, + account_id: uuid_of(&a_session["account_id"]), + a, + a_session, + b, + b_session, + } +} + +/// The pairing of `run`, with the description of B unless `plain`. +fn pairing_of<'a>(run: &'a Run, plain: bool) -> auth::Pairing<'a> { + auth::Pairing { + account_id: run.account.as_bytes(), + approval_id: run.id.as_bytes(), + created_at: run.created_at, + approver_id: run.a_id.as_bytes(), + approver: &run.a, + device_id: run.b_id.as_bytes(), + device: &run.b, + descriptor: (!plain).then(|| descriptor(DESCRIBED)), + } +} + +impl Server { + /// Opens an approval of the described B whose commitment covers the description, or not. + async fn start_described(&self, p: &Pair, plain: bool) -> (Run, StatusCode, Value) { + let run = Run { + id: Uuid::new_v4(), + created_at: chrono_now(), + nonce: rand32(), + account: p.account_id, + a_id: uuid_of(&p.a_session["device_id"]), + a: p.a.public(), + b_id: uuid_of(&p.b_session["device_id"]), + b: p.b.public(), + }; + let commitment = auth::approval_commitment(&pairing_of(&run, plain), &run.nonce); + let (status, body) = self + .call( + Method::POST, + "/v1/devices/approvals", + Some(token(&p.a_session)), + Some(json!({ + "approval_id": run.id.to_string(), + "device_id": run.b_id.to_string(), + "created_at": run.created_at, + "commitment": b64(&commitment), + })), + ) + .await; + (run, status, body) + } + + async fn nonce_and_reveal(&self, p: &Pair, run: &Run) -> ([u8; 32], StatusCode, Value) { + let device_nonce = rand32(); + let (status, body) = self + .step( + token(&p.b_session), + run, + "nonce", + json!({"nonce": b64(&device_nonce)}), + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT, "{body}"); + let (status, body) = self + .step( + token(&p.a_session), + run, + "reveal", + json!({"nonce": b64(&run.nonce)}), + ) + .await; + (device_nonce, status, body) + } +} + +#[tokio::test] +#[ignore = "needs DATABASE_URL"] +async fn the_commitment_for_a_described_device_must_cover_its_description() { + let _turn = TURN.lock().await; + let server = server().await; + let p = described_pair(&server).await; + // An approver that was not shown the description commits over the plain pairing: the server + // rebuilds the commitment with the device's stored description and refuses the reveal. + let (run, status, body) = server.start_described(&p, true).await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + let (_, status, body) = server.nonce_and_reveal(&p, &run).await; + assert_eq!( + (status, &body["code"]), + (StatusCode::BAD_REQUEST, &json!("invalid_commitment")) + ); +} + +#[tokio::test] +#[ignore = "needs DATABASE_URL"] +async fn a_described_device_is_approved_only_under_the_v3_message() { + let _turn = TURN.lock().await; + let server = server().await; + let p = described_pair(&server).await; + let endorsement = b"an endorsement".to_vec(); + let hash = auth::endorsement_hash(&endorsement); + let confirm = |context: &str, message: Vec| { + json!({ + "signature": b64(&p.a.sign(context, &message)), + "endorsement": b64(&endorsement), + }) + }; + + // The v2 message (no description) and the v1 message are refused, and each closes its approval. + let wrong: [(&str, Older<'_>); 2] = [ + ( + auth::DEVICE_APPROVAL_V2_CONTEXT, + Box::new(|run| auth::approval_message_v2(&pairing_of(run, true), &hash)), + ), + ( + auth::DEVICE_APPROVAL_CONTEXT, + Box::new(|run| auth::approval_message(&pairing_of(run, true))), + ), + ]; + for (context, message) in wrong { + let (run, status, body) = server.start_described(&p, false).await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + let (_, status, body) = server.nonce_and_reveal(&p, &run).await; + assert_eq!(status, StatusCode::NO_CONTENT, "{body}"); + let (status, body) = server + .step( + token(&p.a_session), + &run, + "confirm", + confirm(context, message(&run)), + ) + .await; + assert_eq!( + (status, &body["code"]), + (StatusCode::BAD_REQUEST, &json!("invalid_signature")), + "{context}" + ); + } + // The v3 message with a description other than B's is refused too. + let (run, _, _) = server.start_described(&p, false).await; + let (_, status, _) = server.nonce_and_reveal(&p, &run).await; + assert_eq!(status, StatusCode::NO_CONTENT); + let other = auth::Pairing { + descriptor: Some(descriptor(("Phone", "macos", "planner"))), + ..pairing_of(&run, false) + }; + let (status, body) = server + .step( + token(&p.a_session), + &run, + "confirm", + confirm( + auth::DEVICE_APPROVAL_V3_CONTEXT, + auth::approval_message_v3(&other, &hash).expect("described"), + ), + ) + .await; + assert_eq!( + (status, &body["code"]), + (StatusCode::BAD_REQUEST, &json!("invalid_signature")) + ); + + // The v3 message over B's own description is accepted, and B's digits (which cover the + // description) finish the approval. + let (run, status, body) = server.start_described(&p, false).await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + let (device_nonce, status, body) = server.nonce_and_reveal(&p, &run).await; + assert_eq!(status, StatusCode::NO_CONTENT, "{body}"); + let v3 = auth::approval_message_v3(&pairing_of(&run, false), &hash).expect("described"); + let (status, body) = server + .step( + token(&p.a_session), + &run, + "confirm", + confirm(auth::DEVICE_APPROVAL_V3_CONTEXT, v3), + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT, "{body}"); + // The digits of the plain pairing are not B's digits. + let plain = auth::approval_sas(&pairing_of(&run, true), &run.nonce, &device_nonce); + let sas = auth::approval_sas(&pairing_of(&run, false), &run.nonce, &device_nonce); + assert_ne!(plain, sas); + let (status, device) = server + .step(token(&p.b_session), &run, "code", json!({"code": sas})) + .await; + assert_eq!(status, StatusCode::OK, "{device}"); + let listed = server.device_list(&p.a_session).await; + let b = listed + .iter() + .find(|d| d["id"] == p.b_session["device_id"]) + .expect("b"); + assert_eq!(b["state"], "trusted"); + assert_eq!(b["approval_endorsement_hash"], json!(b64(&hash))); +} diff --git a/crates/orbyn-server/tests/device_client.rs b/crates/orbyn-server/tests/device_client.rs index 27c83ff..781a309 100644 --- a/crates/orbyn-server/tests/device_client.rs +++ b/crates/orbyn-server/tests/device_client.rs @@ -247,7 +247,17 @@ async fn approve( .await; let step = |name: &str| format!("/v1/devices/approvals/{}/{name}", created.id); let view = current_approval(server, new_token).await; - let (pd, nonce) = new.join_approval(&view, now_secs()).expect("join"); + // These devices named their app, so each joins with the description it signed in with + // (the honest server lists the same name). + let (pd, nonce) = new + .join_approval_labelled( + &view, + now_secs(), + &pending.name, + wire::DevicePlatform::Linux, + wire::DeviceApp::Engineering, + ) + .expect("join"); let (status, body) = server .call(Method::POST, &step("nonce"), new_token, Some(value(&nonce))) .await; diff --git a/docs/architecture.md b/docs/architecture.md index 3f1d84a..2380796 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -88,6 +88,7 @@ Later phases add local storage for `orbyn-client` (SQLite on desktop and mobile) 3. A, holding `r_D`, checks the server shows the same D keys it started with and reveals `r_A` (`POST …/{id}/reveal`). The server checks it against the commitment; a wrong one closes the approval. 4. Both devices show `SAS`: six digits from `SHA-256(orbyn/approval-sas/v1 ‖ pairing ‖ r_A ‖ r_D)`. A also shows D's key fingerprint. Only when the person taps "They match" on A does A sign `orbyn/device-approval/v1` over the pairing and attach its signed list of the devices it trusts (`POST …/{id}/confirm`); before that A wraps no key to D. 5. When the person taps "They match" on D (or types A's digits), D checks `r_A` against the commitment, the approval's age, A's signature and the endorsement, and only then pins A and the devices A endorsed. It sends the digits (`POST …/{id}/code`); the server checks them once and trusts D. + **A device that named its app** (see sign-in above) has its name, platform and app added to the pairing, each length-prefixed, under a context of its own (`…/described`), so the commitment and the digits cover what the approver is shown, and A signs `orbyn/device-approval/v3` instead: the v2 message followed by the same description. D joins with the description it signed in with (`Engine::join_approval_labelled`); A takes it from the device list (`start_approval`); the server rebuilds it from the stored device. A server that shows A another name, platform or app, or none, therefore makes the digits differ and D refuses. For such a device only v3 is accepted, by the server, by D and by every device that later checks the approval chain: a changed or dropped label never falls back to an older message, and an approval with no endorsement fails. The label is the device's own signed claim. It says what the device asserted at sign-in, never which app is actually running, so it is a hint for the person approving and not proof for any decision. A device with no label approves exactly as before. A server that swaps either key, the approval id or a nonce makes the screens differ except by a one-in-a-million guess, and the commitment stops it from choosing `r_A` after seeing `r_D`. A mismatch, a wrong code or a cancel (`POST …/{id}/cancel`, from either device) closes the approval; another try needs a new one, so the server gets one guess per approval. Ten minutes also close it. The device list shows each device's approver, signature, approval id and time, so any device can check the signature later. - **Approving a device again.** The same steps approve a device the server lists as trusted, which replaces its approver, signature, approval id and time. The server cannot tell why and allows it for any device but the approver itself; the approver starts one only from its own view (`start_approval` refuses a device it pins or revoked, whatever state the server shows). This is how a device stranded by a removal (pinned under the removed device) and a device trusted before migration 0006 come back. Migration 0006 closes every open approval and clears old approval signatures; devices trusted before it keep no approval id, so no device verifies them until they are approved again. In an account where no device has a verifiable approval, each device still trusts itself, exactly as an account's first device does at sign-up: the person picks one and approves the others from it, confirming the digits on both screens. `GET /v1/devices/current` can therefore show an approval to a device that is already trusted; the host joins one (`join_approval`) only when the person asked to approve this device again, never on its own. - **Removing a device** needs the remover's signature over the account, the device id, its keys and a removal sequence (one above the highest the remover has counted, at most 2^53 − 1) under `orbyn/device-removal/v1`. It ends the device's sessions and closes its approvals at once, and the device list carries the signed removal (`removals`) so devices drop trust in it. The last trusted device cannot be removed.