You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
637 lines
24 KiB
637 lines
24 KiB
use chrono::{NaiveDateTime, Utc};
|
|
use data_encoding::BASE64URL;
|
|
use derive_more::{Display, From};
|
|
use diesel::prelude::*;
|
|
use serde_json::Value;
|
|
|
|
use crate::{
|
|
api::EmptyResult,
|
|
crypto,
|
|
db::{DbConn, schema::devices},
|
|
error::MapResult,
|
|
util::{format_date, get_uuid},
|
|
};
|
|
use macros::{IdFromParam, UuidFromParam};
|
|
|
|
use super::{AuthRequest, UserId};
|
|
|
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
|
#[diesel(table_name = devices)]
|
|
#[diesel(treat_none_as_null = true)]
|
|
#[diesel(primary_key(uuid, user_uuid))]
|
|
pub struct Device {
|
|
pub uuid: DeviceId,
|
|
pub created_at: NaiveDateTime,
|
|
pub updated_at: NaiveDateTime,
|
|
|
|
pub user_uuid: UserId,
|
|
|
|
pub name: String,
|
|
pub atype: i32, // https://github.com/bitwarden/server/blob/8d547dcc280babab70dd4a3c94ced6a34b12dfbf/src/Core/Enums/DeviceType.cs
|
|
pub push_uuid: Option<PushId>,
|
|
pub push_token: Option<String>,
|
|
|
|
pub refresh_token: String,
|
|
pub twofactor_remember: Option<String>,
|
|
|
|
// Trusted device encryption. The client generates a key pair per device plus a device key
|
|
// that never leaves the device, and stores the three resulting blobs here:
|
|
/// The user key, encrypted with `encrypted_public_key`. This is the copy of the user key that
|
|
/// lets the device unlock the vault without a master password.
|
|
pub encrypted_user_key: Option<String>,
|
|
/// The device public key, encrypted with the user key.
|
|
pub encrypted_public_key: Option<String>,
|
|
/// The device private key, encrypted with the device key. The server never sees the device key.
|
|
pub encrypted_private_key: Option<String>,
|
|
}
|
|
|
|
/// Local methods
|
|
impl Device {
|
|
pub fn new(uuid: DeviceId, user_uuid: UserId, name: String, atype: i32) -> Self {
|
|
let now = Utc::now().naive_utc();
|
|
|
|
Self {
|
|
uuid,
|
|
created_at: now,
|
|
updated_at: now,
|
|
|
|
user_uuid,
|
|
name,
|
|
atype,
|
|
|
|
push_uuid: Some(PushId(get_uuid())),
|
|
push_token: None,
|
|
refresh_token: Device::generate_refresh_token(),
|
|
twofactor_remember: None,
|
|
|
|
encrypted_user_key: None,
|
|
encrypted_public_key: None,
|
|
encrypted_private_key: None,
|
|
}
|
|
}
|
|
|
|
#[inline(always)]
|
|
pub fn generate_refresh_token() -> String {
|
|
crypto::encode_random_bytes::<64>(&BASE64URL)
|
|
}
|
|
|
|
/// A stored key is only usable when it is actually there and non-empty.
|
|
fn present(key: Option<&String>) -> Option<&String> {
|
|
key.filter(|key| !key.is_empty())
|
|
}
|
|
|
|
fn key_json(key: Option<&String>) -> Value {
|
|
match Self::present(key) {
|
|
Some(key) => Value::String(key.clone()),
|
|
None => Value::Null,
|
|
}
|
|
}
|
|
|
|
/// Whether this device holds everything needed to unlock the vault on its own.
|
|
///
|
|
/// A client can drop its device key without telling us, so this only says that the server side
|
|
/// of the trust is complete. See `DeviceExtensions.IsTrusted` upstream.
|
|
pub fn is_trusted(&self) -> bool {
|
|
Self::present(self.encrypted_user_key.as_ref()).is_some()
|
|
&& Self::present(self.encrypted_public_key.as_ref()).is_some()
|
|
&& Self::present(self.encrypted_private_key.as_ref()).is_some()
|
|
}
|
|
|
|
/// The wrapped user key, but only while the whole trust is intact. Handing out one half of an
|
|
/// incomplete set would just make the client fail later in the unlock.
|
|
pub fn trusted_user_key(&self) -> Option<&String> {
|
|
self.is_trusted().then_some(self.encrypted_user_key.as_ref()).flatten()
|
|
}
|
|
|
|
pub fn trusted_private_key(&self) -> Option<&String> {
|
|
self.is_trusted().then_some(self.encrypted_private_key.as_ref()).flatten()
|
|
}
|
|
|
|
/// Whether the device still holds the private key of its own key pair.
|
|
///
|
|
/// That key is wrapped with the device key, which a rotation of the user key does not touch, so
|
|
/// it outlives one. It is what decides whether a device can be handed a freshly wrapped user
|
|
/// key and be trusted again, or whether it has to be set up from scratch.
|
|
pub fn holds_private_key(&self) -> bool {
|
|
Self::present(self.encrypted_private_key.as_ref()).is_some()
|
|
}
|
|
|
|
pub fn to_json(&self) -> Value {
|
|
json!({
|
|
"id": self.uuid,
|
|
"name": self.name,
|
|
"type": self.atype,
|
|
"identifier": self.uuid,
|
|
"creationDate": format_date(&self.created_at),
|
|
"isTrusted": self.is_trusted(),
|
|
"encryptedUserKey": Self::key_json(self.encrypted_user_key.as_ref()),
|
|
"encryptedPublicKey": Self::key_json(self.encrypted_public_key.as_ref()),
|
|
"object":"device"
|
|
})
|
|
}
|
|
|
|
/// Response of `POST /devices/<identifier>/retrieve-keys`, used by the clients to re-wrap the
|
|
/// user key for every trusted device during a key rotation.
|
|
pub fn to_protected_json(&self) -> Value {
|
|
json!({
|
|
"id": self.uuid,
|
|
"name": self.name,
|
|
"type": self.atype,
|
|
"identifier": self.uuid,
|
|
"creationDate": format_date(&self.created_at),
|
|
"encryptedUserKey": Self::key_json(self.encrypted_user_key.as_ref()),
|
|
"encryptedPublicKey": Self::key_json(self.encrypted_public_key.as_ref()),
|
|
"object": "protectedDevice"
|
|
})
|
|
}
|
|
|
|
pub fn refresh_twofactor_remember(&mut self) -> String {
|
|
use crate::auth::{encode_jwt, generate_2fa_remember_claims};
|
|
|
|
let two_factor_remember_claim = generate_2fa_remember_claims(self.uuid.clone(), self.user_uuid.clone());
|
|
let two_factor_remember_string = encode_jwt(&two_factor_remember_claim);
|
|
self.twofactor_remember = Some(two_factor_remember_string.clone());
|
|
|
|
two_factor_remember_string
|
|
}
|
|
|
|
pub fn delete_twofactor_remember(&mut self) {
|
|
self.twofactor_remember = None;
|
|
}
|
|
|
|
// This rely on the fact we only update the device after a successful login
|
|
pub fn is_new(&self) -> bool {
|
|
self.created_at == self.updated_at
|
|
}
|
|
|
|
pub fn is_push_device(&self) -> bool {
|
|
matches!(DeviceType::from_i32(self.atype), DeviceType::Android | DeviceType::Ios)
|
|
}
|
|
|
|
pub fn is_cli(&self) -> bool {
|
|
matches!(DeviceType::from_i32(self.atype), DeviceType::WindowsCLI | DeviceType::MacOsCLI | DeviceType::LinuxCLI)
|
|
}
|
|
|
|
pub fn is_mobile(&self) -> bool {
|
|
matches!(DeviceType::from_i32(self.atype), DeviceType::Android | DeviceType::Ios)
|
|
}
|
|
}
|
|
|
|
pub struct DeviceWithAuthRequest {
|
|
pub device: Device,
|
|
pub pending_auth_request: Option<AuthRequest>,
|
|
}
|
|
|
|
impl DeviceWithAuthRequest {
|
|
pub fn to_json(&self) -> Value {
|
|
let auth_request = match &self.pending_auth_request {
|
|
Some(auth_request) => auth_request.to_json_for_pending_device(),
|
|
None => Value::Null,
|
|
};
|
|
json!({
|
|
"id": self.device.uuid,
|
|
"name": self.device.name,
|
|
"type": self.device.atype,
|
|
"identifier": self.device.uuid,
|
|
"creationDate": format_date(&self.device.created_at),
|
|
"devicePendingAuthRequest": auth_request,
|
|
"isTrusted": self.device.is_trusted(),
|
|
"encryptedPublicKey": Device::key_json(self.device.encrypted_public_key.as_ref()),
|
|
"encryptedUserKey": Device::key_json(self.device.encrypted_user_key.as_ref()),
|
|
"object": "device",
|
|
})
|
|
}
|
|
|
|
pub fn from(c: Device, a: Option<AuthRequest>) -> Self {
|
|
Self {
|
|
device: c,
|
|
pending_auth_request: a,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Database methods
|
|
impl Device {
|
|
pub async fn save(&mut self, update_time: bool, conn: &DbConn) -> EmptyResult {
|
|
if update_time {
|
|
self.updated_at = Utc::now().naive_utc();
|
|
}
|
|
|
|
db_run! { conn:
|
|
sqlite, mysql {
|
|
crate::util::retry(||
|
|
diesel::replace_into(devices::table)
|
|
.values(&*self)
|
|
.execute(conn),
|
|
10,
|
|
).map_res("Error saving device")
|
|
}
|
|
postgresql {
|
|
crate::util::retry(||
|
|
diesel::insert_into(devices::table)
|
|
.values(&*self)
|
|
.on_conflict((devices::uuid, devices::user_uuid))
|
|
.do_update()
|
|
.set(&*self)
|
|
.execute(conn),
|
|
10,
|
|
).map_res("Error saving device")
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
|
conn.run(move |conn| {
|
|
diesel::delete(devices::table.filter(devices::user_uuid.eq(user_uuid)))
|
|
.execute(conn)
|
|
.map_res("Error removing devices for user")
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Invalidates every copy of the user key that is wrapped for one of the user's devices.
|
|
///
|
|
/// Called when the user key itself is replaced and the client did not say what to put in their
|
|
/// place, which leaves all of those copies pointing at a key that no longer unlocks anything. No
|
|
/// device counts as trusted afterwards, so a client that stops here ends up with an extra login
|
|
/// rather than a broken unlock. The device key pairs are deliberately left alone: they are
|
|
/// wrapped with the device key, which a rotation does not touch, so `POST /devices/update-trust`
|
|
/// can hand every device the new user key and restore its trust. Whatever it does not list is
|
|
/// dropped there.
|
|
///
|
|
/// One statement, so there is no half applied state to reason about.
|
|
pub async fn invalidate_wrapped_user_keys(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
|
conn.run(move |conn| {
|
|
diesel::update(devices::table.filter(devices::user_uuid.eq(user_uuid)))
|
|
.set((
|
|
devices::encrypted_user_key.eq::<Option<String>>(None),
|
|
devices::encrypted_public_key.eq::<Option<String>>(None),
|
|
))
|
|
.execute(conn)
|
|
.map_res("Error invalidating the wrapped user keys of the devices")
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Drops every stored key of the named devices, in one statement so it cannot half apply.
|
|
///
|
|
/// The caller has already checked that each id belongs to this user.
|
|
pub async fn untrust_many(user_uuid: &UserId, device_ids: Vec<DeviceId>, conn: &DbConn) -> EmptyResult {
|
|
if device_ids.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
conn.run(move |conn| {
|
|
diesel::update(
|
|
devices::table.filter(devices::user_uuid.eq(user_uuid)).filter(devices::uuid.eq_any(device_ids)),
|
|
)
|
|
.set((
|
|
devices::encrypted_user_key.eq::<Option<String>>(None),
|
|
devices::encrypted_public_key.eq::<Option<String>>(None),
|
|
devices::encrypted_private_key.eq::<Option<String>>(None),
|
|
))
|
|
.execute(conn)
|
|
.map_res("Error untrusting the devices")
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Replaces the trust of every device of the user in one go: the listed ones are re-wrapped for
|
|
/// the current user key, everything else loses whatever it still holds.
|
|
///
|
|
/// This is what both a key rotation and `POST /devices/update-trust` come down to. The caller
|
|
/// has already checked that every id belongs to this user, that none is listed twice, and that
|
|
/// no device is asked to keep a trust it cannot complete; this only writes.
|
|
///
|
|
/// One transaction, so the devices cannot be left split between the old and the new user key,
|
|
/// which is a state no client can tell apart from a working one until an unlock fails.
|
|
pub async fn replace_trust(
|
|
user_uuid: &UserId,
|
|
updates: Vec<(DeviceId, String, String)>,
|
|
conn: &DbConn,
|
|
) -> EmptyResult {
|
|
conn.run(move |conn| {
|
|
conn.transaction(|conn| -> EmptyResult {
|
|
let keep: Vec<DeviceId> = updates.iter().map(|(device_id, ..)| device_id.clone()).collect();
|
|
|
|
// Whatever the untouched devices hold wraps the previous user key, or is one half of
|
|
// a trust that was never finished. Either way it unlocks nothing and must not stay.
|
|
let cleared = (
|
|
devices::encrypted_user_key.eq::<Option<String>>(None),
|
|
devices::encrypted_public_key.eq::<Option<String>>(None),
|
|
devices::encrypted_private_key.eq::<Option<String>>(None),
|
|
);
|
|
let outdated = devices::table.filter(devices::user_uuid.eq(&user_uuid));
|
|
let _: () = if keep.is_empty() {
|
|
diesel::update(outdated).set(cleared).execute(conn)
|
|
} else {
|
|
diesel::update(outdated.filter(devices::uuid.ne_all(keep))).set(cleared).execute(conn)
|
|
}
|
|
.map_res("Error untrusting the devices left out of the rotation")?;
|
|
|
|
// The device key pair is deliberately not touched here: it is wrapped with the
|
|
// device key, which the server never sees and a rotation never changes.
|
|
for (device_id, encrypted_user_key, encrypted_public_key) in updates {
|
|
let _: () = diesel::update(
|
|
devices::table.filter(devices::uuid.eq(device_id)).filter(devices::user_uuid.eq(&user_uuid)),
|
|
)
|
|
.set((
|
|
devices::encrypted_user_key.eq(Some(encrypted_user_key)),
|
|
devices::encrypted_public_key.eq(Some(encrypted_public_key)),
|
|
))
|
|
.execute(conn)
|
|
.map_res("Error rotating the wrapped user key of a device")?;
|
|
}
|
|
|
|
Ok(())
|
|
})
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn find_by_uuid_and_user(uuid: &DeviceId, user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
|
|
conn.run(move |conn| {
|
|
devices::table
|
|
.filter(devices::uuid.eq(uuid))
|
|
.filter(devices::user_uuid.eq(user_uuid))
|
|
.first::<Self>(conn)
|
|
.ok()
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn find_with_auth_request_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<DeviceWithAuthRequest> {
|
|
let devices = Self::find_by_user(user_uuid, conn).await;
|
|
let mut result = Vec::new();
|
|
for device in devices {
|
|
let auth_request = AuthRequest::find_by_user_and_requested_device(user_uuid, &device.uuid, conn).await;
|
|
result.push(DeviceWithAuthRequest::from(device, auth_request));
|
|
}
|
|
result
|
|
}
|
|
|
|
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
|
conn.run(move |conn| {
|
|
devices::table.filter(devices::user_uuid.eq(user_uuid)).load::<Self>(conn).expect("Error loading devices")
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn find_by_uuid(uuid: &DeviceId, conn: &DbConn) -> Option<Self> {
|
|
conn.run(move |conn| devices::table.filter(devices::uuid.eq(uuid)).first::<Self>(conn).ok()).await
|
|
}
|
|
|
|
pub async fn clear_push_token_by_uuid(uuid: &DeviceId, conn: &DbConn) -> EmptyResult {
|
|
conn.run(move |conn| {
|
|
diesel::update(devices::table)
|
|
.filter(devices::uuid.eq(uuid))
|
|
.set(devices::push_token.eq::<Option<String>>(None))
|
|
.execute(conn)
|
|
.map_res("Error removing push token")
|
|
})
|
|
.await
|
|
}
|
|
pub async fn find_by_refresh_token(refresh_token: &str, conn: &DbConn) -> Option<Self> {
|
|
conn.run(move |conn| devices::table.filter(devices::refresh_token.eq(refresh_token)).first::<Self>(conn).ok())
|
|
.await
|
|
}
|
|
|
|
pub async fn find_latest_active_by_user(user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
|
|
conn.run(move |conn| {
|
|
devices::table
|
|
.filter(devices::user_uuid.eq(user_uuid))
|
|
.order(devices::updated_at.desc())
|
|
.first::<Self>(conn)
|
|
.ok()
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn find_push_devices_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
|
conn.run(move |conn| {
|
|
devices::table
|
|
.filter(devices::user_uuid.eq(user_uuid))
|
|
.filter(devices::push_token.is_not_null())
|
|
.load::<Self>(conn)
|
|
.expect("Error loading push devices")
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn check_user_has_push_device(user_uuid: &UserId, conn: &DbConn) -> bool {
|
|
conn.run(move |conn| {
|
|
devices::table
|
|
.filter(devices::user_uuid.eq(user_uuid))
|
|
.filter(devices::push_token.is_not_null())
|
|
.count()
|
|
.first::<i64>(conn)
|
|
.ok()
|
|
.unwrap_or(0)
|
|
!= 0
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn rotate_refresh_tokens_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
|
// Generate a new token per device.
|
|
// We cannot do a single UPDATE with one value because each device needs a unique token.
|
|
let devices = Self::find_by_user(user_uuid, conn).await;
|
|
for mut device in devices {
|
|
device.refresh_token = Device::generate_refresh_token();
|
|
device.save(false, conn).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Display)]
|
|
pub enum DeviceType {
|
|
#[display("Android")]
|
|
Android = 0,
|
|
#[display("iOS")]
|
|
Ios = 1,
|
|
#[display("Chrome Extension")]
|
|
ChromeExtension = 2,
|
|
#[display("Firefox Extension")]
|
|
FirefoxExtension = 3,
|
|
#[display("Opera Extension")]
|
|
OperaExtension = 4,
|
|
#[display("Edge Extension")]
|
|
EdgeExtension = 5,
|
|
#[display("Windows")]
|
|
WindowsDesktop = 6,
|
|
#[display("macOS")]
|
|
MacOsDesktop = 7,
|
|
#[display("Linux")]
|
|
LinuxDesktop = 8,
|
|
#[display("Chrome")]
|
|
ChromeBrowser = 9,
|
|
#[display("Firefox")]
|
|
FirefoxBrowser = 10,
|
|
#[display("Opera")]
|
|
OperaBrowser = 11,
|
|
#[display("Edge")]
|
|
EdgeBrowser = 12,
|
|
#[display("Internet Explorer")]
|
|
IEBrowser = 13,
|
|
#[display("Unknown Browser")]
|
|
UnknownBrowser = 14,
|
|
#[display("Android")]
|
|
AndroidAmazon = 15,
|
|
#[display("UWP")]
|
|
Uwp = 16,
|
|
#[display("Safari")]
|
|
SafariBrowser = 17,
|
|
#[display("Vivaldi")]
|
|
VivaldiBrowser = 18,
|
|
#[display("Vivaldi Extension")]
|
|
VivaldiExtension = 19,
|
|
#[display("Safari Extension")]
|
|
SafariExtension = 20,
|
|
#[display("SDK")]
|
|
Sdk = 21,
|
|
#[display("Server")]
|
|
Server = 22,
|
|
#[display("Windows CLI")]
|
|
WindowsCLI = 23,
|
|
#[display("macOS CLI")]
|
|
MacOsCLI = 24,
|
|
#[display("Linux CLI")]
|
|
LinuxCLI = 25,
|
|
#[display("DuckDuckGo")]
|
|
DuckDuckGoBrowser = 26,
|
|
}
|
|
|
|
impl DeviceType {
|
|
#[expect(clippy::match_same_arms, reason = "Specifically define 14 and have a fallback for new types")]
|
|
pub fn from_i32(value: i32) -> DeviceType {
|
|
match value {
|
|
0 => DeviceType::Android,
|
|
1 => DeviceType::Ios,
|
|
2 => DeviceType::ChromeExtension,
|
|
3 => DeviceType::FirefoxExtension,
|
|
4 => DeviceType::OperaExtension,
|
|
5 => DeviceType::EdgeExtension,
|
|
6 => DeviceType::WindowsDesktop,
|
|
7 => DeviceType::MacOsDesktop,
|
|
8 => DeviceType::LinuxDesktop,
|
|
9 => DeviceType::ChromeBrowser,
|
|
10 => DeviceType::FirefoxBrowser,
|
|
11 => DeviceType::OperaBrowser,
|
|
12 => DeviceType::EdgeBrowser,
|
|
13 => DeviceType::IEBrowser,
|
|
14 => DeviceType::UnknownBrowser,
|
|
15 => DeviceType::AndroidAmazon,
|
|
16 => DeviceType::Uwp,
|
|
17 => DeviceType::SafariBrowser,
|
|
18 => DeviceType::VivaldiBrowser,
|
|
19 => DeviceType::VivaldiExtension,
|
|
20 => DeviceType::SafariExtension,
|
|
21 => DeviceType::Sdk,
|
|
22 => DeviceType::Server,
|
|
23 => DeviceType::WindowsCLI,
|
|
24 => DeviceType::MacOsCLI,
|
|
25 => DeviceType::LinuxCLI,
|
|
26 => DeviceType::DuckDuckGoBrowser,
|
|
_ => DeviceType::UnknownBrowser,
|
|
}
|
|
}
|
|
|
|
/// Whether a device of this type can answer a login request from another device.
|
|
///
|
|
/// The SDK, the server and the CLIs have no interactive prompt to show the request in, so they
|
|
/// are the ones left out. Matches `LoginApprovingClientTypes` upstream, which allows the
|
|
/// desktop, mobile, web and browser client types.
|
|
pub fn can_approve_login_requests(&self) -> bool {
|
|
!matches!(
|
|
self,
|
|
DeviceType::Sdk | DeviceType::Server | DeviceType::WindowsCLI | DeviceType::MacOsCLI | DeviceType::LinuxCLI
|
|
)
|
|
}
|
|
}
|
|
|
|
#[derive(
|
|
Clone, Debug, DieselNewType, Display, From, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, IdFromParam,
|
|
)]
|
|
pub struct DeviceId(String);
|
|
|
|
#[derive(Clone, Debug, DieselNewType, Display, From, FromForm, Serialize, Deserialize, UuidFromParam)]
|
|
pub struct PushId(pub String);
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn trusted_device() -> Device {
|
|
let mut device = Device::new(String::from("device").into(), String::from("user").into(), String::new(), 9);
|
|
device.encrypted_user_key = Some(String::from("2.user"));
|
|
device.encrypted_public_key = Some(String::from("2.public"));
|
|
device.encrypted_private_key = Some(String::from("2.private"));
|
|
device
|
|
}
|
|
|
|
#[test]
|
|
fn a_device_is_only_trusted_with_all_three_keys() {
|
|
assert!(trusted_device().is_trusted());
|
|
|
|
let keys: [fn(&mut Device) -> &mut Option<String>; 3] = [
|
|
|device| &mut device.encrypted_user_key,
|
|
|device| &mut device.encrypted_public_key,
|
|
|device| &mut device.encrypted_private_key,
|
|
];
|
|
|
|
for key in keys {
|
|
let mut device = trusted_device();
|
|
*key(&mut device) = None;
|
|
assert!(!device.is_trusted());
|
|
|
|
let mut device = trusted_device();
|
|
*key(&mut device) = Some(String::new());
|
|
assert!(!device.is_trusted(), "an empty key is as good as a missing one");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_incomplete_device_hands_out_no_keys_at_all() {
|
|
let mut device = trusted_device();
|
|
assert_eq!(device.trusted_user_key(), Some(&String::from("2.user")));
|
|
assert_eq!(device.trusted_private_key(), Some(&String::from("2.private")));
|
|
|
|
// The public key is not part of the login response, but without it the other two are
|
|
// useless to the client, so it must not get them either.
|
|
device.encrypted_public_key = None;
|
|
assert_eq!(device.trusted_user_key(), None);
|
|
assert_eq!(device.trusted_private_key(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_rotation_leaves_the_device_key_pair_in_place() {
|
|
// What `invalidate_wrapped_user_keys` does: the wrapped user key and the public key go,
|
|
// the private key stays, because the device key that wraps it is untouched by a rotation.
|
|
let mut device = trusted_device();
|
|
device.encrypted_user_key = None;
|
|
device.encrypted_public_key = None;
|
|
|
|
assert!(!device.is_trusted(), "nothing may unlock until the client re-wraps");
|
|
assert!(device.holds_private_key(), "but the device can still be handed a new user key");
|
|
}
|
|
|
|
#[test]
|
|
fn a_device_that_never_had_a_trust_holds_nothing() {
|
|
let device = Device::new(String::from("device").into(), String::from("user").into(), String::new(), 9);
|
|
assert!(!device.holds_private_key());
|
|
|
|
let mut device = trusted_device();
|
|
device.encrypted_private_key = Some(String::new());
|
|
assert!(!device.holds_private_key(), "an empty key is as good as a missing one");
|
|
}
|
|
|
|
#[test]
|
|
fn only_interactive_clients_can_approve_a_login_request() {
|
|
for atype in 0..=26 {
|
|
let device_type = DeviceType::from_i32(atype);
|
|
let expected = !matches!(atype, 21..=25);
|
|
assert_eq!(device_type.can_approve_login_requests(), expected, "device type {atype} ({device_type})");
|
|
}
|
|
}
|
|
}
|
|
|