Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions crates/orbyn-auth-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ pub const LOGIN_CONTEXT: &[u8] = b"orbyn/opaque-login/v1";
pub const DEVICE_REGISTER_CONTEXT: &str = "orbyn/device-register/v1";
/// What a device signs at sign-in: [`login_message`].
pub const DEVICE_LOGIN_CONTEXT: &str = "orbyn/device-login/v1";
/// What a device that names its app signs at sign-up: [`register_message_v2`].
pub const DEVICE_REGISTER_V2_CONTEXT: &str = "orbyn/device-register/v2";
/// What a device that names its app signs at sign-in: [`login_message_v2`].
pub const DEVICE_LOGIN_V2_CONTEXT: &str = "orbyn/device-login/v2";
/// What a trusted device signs to approve another: [`approval_message`].
pub const DEVICE_APPROVAL_CONTEXT: &str = "orbyn/device-approval/v1";
/// See [`approval_message_v2`].
Expand Down Expand Up @@ -254,6 +258,48 @@ pub fn login_message(login_id: &[u8; 16], keys: &DevicePublic) -> Vec<u8> {
out
}

/// What a device says about itself in the v2 messages: the name it shows (trimmed), its platform
/// and its app, as the `snake_case` words the wire uses (`linux`, `engineering`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Descriptor<'a> {
pub name: &'a str,
pub platform: &'a str,
pub app: &'a str,
}

fn push_descriptor(out: &mut Vec<u8>, d: &Descriptor<'_>) {
push_len_prefixed(out, d.name.as_bytes());
push_len_prefixed(out, d.platform.as_bytes());
push_len_prefixed(out, d.app.as_bytes());
}

/// What a device that names its app signs at sign-up, under [`DEVICE_REGISTER_V2_CONTEXT`]: the
/// [`register_message`] bytes followed by its [`Descriptor`]. The label, the name and the platform
/// are then the device's own signed claim: the server cannot attach another to this key at
/// sign-up, and a swap of any of them breaks the signature.
pub fn register_message_v2(
email: &str,
upload: &[u8],
keys: &DevicePublic,
descriptor: &Descriptor<'_>,
) -> Vec<u8> {
let mut out = register_message(email, upload, keys);
push_descriptor(&mut out, descriptor);
out
}

/// What a device that names its app signs at sign-in, under [`DEVICE_LOGIN_V2_CONTEXT`]: the
/// [`login_message`] bytes followed by its [`Descriptor`].
pub fn login_message_v2(
login_id: &[u8; 16],
keys: &DevicePublic,
descriptor: &Descriptor<'_>,
) -> Vec<u8> {
let mut out = login_message(login_id, keys);
push_descriptor(&mut out, descriptor);
out
}

/// What a device that is already trusted signs when it starts a sign-in, under
/// [`TRUSTED_START_CONTEXT`]: the normalised email, this sign-in's credential
/// request and the time. Bound to the request, so a copy cannot be reused for
Expand Down Expand Up @@ -730,3 +776,87 @@ mod protection_tests {
assert_ne!(a, approval_message(&pairing));
}
}

#[cfg(test)]
mod descriptor_tests {
use super::*;
use orbyn_crypto::DeviceKeys;

fn descriptor() -> Descriptor<'static> {
Descriptor {
name: "Work laptop",
platform: "linux",
app: "engineering",
}
}

#[test]
fn the_v2_messages_extend_the_v1_ones_and_bind_name_platform_and_app() {
let keys = DeviceKeys::generate().expect("keys").public();
let v1 = register_message("a@example.test", b"upload", &keys);
let v2 = register_message_v2("a@example.test", b"upload", &keys, &descriptor());
assert!(v2.starts_with(&v1) && v2.len() > v1.len());
let other_name = Descriptor {
name: "Other",
..descriptor()
};
let other_platform = Descriptor {
platform: "macos",
..descriptor()
};
let other_app = Descriptor {
app: "planner",
..descriptor()
};
for other in [other_name, other_platform, other_app] {
assert_ne!(
v2,
register_message_v2("a@example.test", b"upload", &keys, &other)
);
}
let id = [9u8; 16];
let login_v2 = login_message_v2(&id, &keys, &descriptor());
assert!(login_v2.starts_with(&login_message(&id, &keys)));
for other in [other_name, other_platform, other_app] {
assert_ne!(login_v2, login_message_v2(&id, &keys, &other));
}
}

#[test]
fn a_field_cannot_slide_into_the_next() {
// Length prefixes keep ("ab", "c") apart from ("a", "bc").
let keys = DeviceKeys::generate().expect("keys").public();
let one = Descriptor {
name: "ab",
platform: "c",
app: "engineering",
};
let two = Descriptor {
name: "a",
platform: "bc",
app: "engineering",
};
assert_ne!(
register_message_v2("e", b"u", &keys, &one),
register_message_v2("e", b"u", &keys, &two)
);
}

#[test]
fn a_v1_signature_is_not_a_v2_signature_and_the_reverse() {
let device = DeviceKeys::generate().expect("keys");
let keys = device.public();
let v1 = register_message("a@example.test", b"upload", &keys);
let v2 = register_message_v2("a@example.test", b"upload", &keys, &descriptor());
let signed_v1 = device.sign(DEVICE_REGISTER_CONTEXT, &v1);
let signed_v2 = device.sign(DEVICE_REGISTER_V2_CONTEXT, &v2);
assert!(orbyn_crypto::verify(&keys, DEVICE_REGISTER_CONTEXT, &v1, &signed_v1).is_ok());
assert!(orbyn_crypto::verify(&keys, DEVICE_REGISTER_V2_CONTEXT, &v2, &signed_v2).is_ok());
// Neither passes for the other's context or message, so a label cannot be added to or
// stripped from a signed claim.
assert!(orbyn_crypto::verify(&keys, DEVICE_REGISTER_V2_CONTEXT, &v1, &signed_v1).is_err());
assert!(orbyn_crypto::verify(&keys, DEVICE_REGISTER_CONTEXT, &v2, &signed_v2).is_err());
assert!(orbyn_crypto::verify(&keys, DEVICE_REGISTER_CONTEXT, &v1, &signed_v2).is_err());
assert!(orbyn_crypto::verify(&keys, DEVICE_REGISTER_V2_CONTEXT, &v2, &signed_v1).is_err());
}
}
1 change: 1 addition & 0 deletions crates/orbyn-client/tests/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ fn entry(id: &str, keys: &DeviceKeys, state: wire::DeviceState) -> wire::Device
id: id.into(),
name: id.into(),
platform: wire::DevicePlatform::Web,
app: None,
state,
signing_key: b64(&public.signing),
agreement_key: b64(&public.agreement),
Expand Down
7 changes: 6 additions & 1 deletion crates/orbyn-contracts/bindings/Device.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DeviceApp } from "./DeviceApp";
import type { DevicePlatform } from "./DevicePlatform";
import type { DeviceState } from "./DeviceState";

/**
* A device of the account.
*/
export type Device = { id: string, name: string, platform: DevicePlatform, state: DeviceState, signing_key: string, agreement_key: string,
export type Device = { id: string, name: string, platform: DevicePlatform,
/**
* The app the device signed itself in as, if it said (see [`DeviceApp`]).
*/
app?: DeviceApp, state: DeviceState, signing_key: string, agreement_key: string,
/**
* RFC 3339.
*/
Expand Down
9 changes: 9 additions & 0 deletions crates/orbyn-contracts/bindings/DeviceApp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

/**
* Which Orbyn app a device is. It is **signed into the device's register and login messages**
* (`orbyn/device-register/v2` and `orbyn/device-login/v2`, with the device's name and platform),
* so the server checks the device itself claimed it, and the device list shows it. A device that
* sends no label signs the earlier messages and has none.
*/
export type DeviceApp = "planner" | "engineering";
9 changes: 8 additions & 1 deletion crates/orbyn-contracts/bindings/NewDevice.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DeviceApp } from "./DeviceApp";
import type { DevicePlatform } from "./DevicePlatform";

/**
Expand All @@ -16,4 +17,10 @@ signing_key: string,
/**
* The X25519 public key (32 bytes).
*/
agreement_key: string, };
agreement_key: string,
/**
* The app this device is, when it says. With a label the device's signature covers its
* trimmed name, its platform and the label (`orbyn/device-register/v2`,
* `orbyn/device-login/v2`); without one it signs the v1 messages, as before.
*/
app?: DeviceApp, };
42 changes: 42 additions & 0 deletions crates/orbyn-contracts/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,42 @@ pub enum DevicePlatform {
Linux,
}

impl DevicePlatform {
/// The wire name, as signed into a v2 device message.
pub fn as_str(self) -> &'static str {
match self {
Self::Web => "web",
Self::Ios => "ios",
Self::Android => "android",
Self::Macos => "macos",
Self::Windows => "windows",
Self::Linux => "linux",
}
}
}

/// Which Orbyn app a device is. It is **signed into the device's register and login messages**
/// (`orbyn/device-register/v2` and `orbyn/device-login/v2`, with the device's name and platform),
/// so the server checks the device itself claimed it, and the device list shows it. A device that
/// sends no label signs the earlier messages and has none.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub enum DeviceApp {
Planner,
Engineering,
}

impl DeviceApp {
/// The wire name, as signed into a v2 device message.
pub fn as_str(self) -> &'static str {
match self {
Self::Planner => "planner",
Self::Engineering => "engineering",
}
}
}

/// Whether a device may use the account. A device that signs in is `pending`
/// until a trusted device approves it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
Expand All @@ -37,6 +73,12 @@ pub struct NewDevice {
pub signing_key: String,
/// The X25519 public key (32 bytes).
pub agreement_key: String,
/// The app this device is, when it says. With a label the device's signature covers its
/// trimmed name, its platform and the label (`orbyn/device-register/v2`,
/// `orbyn/device-login/v2`); without one it signs the v1 messages, as before.
#[serde(default)]
#[ts(optional)]
pub app: Option<DeviceApp>,
}

/// `POST /v1/accounts/register/start`. Byte fields are base64url without padding.
Expand Down
6 changes: 5 additions & 1 deletion crates/orbyn-contracts/src/devices.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Device lists, removals and approval wire types.

use super::{DevicePlatform, DeviceState};
use super::{DeviceApp, DevicePlatform, DeviceState};
use serde::{Deserialize, Serialize};
use ts_rs::TS;

Expand All @@ -11,6 +11,10 @@ pub struct Device {
pub id: String,
pub name: String,
pub platform: DevicePlatform,
/// The app the device signed itself in as, if it said (see [`DeviceApp`]).
#[serde(default)]
#[ts(optional)]
pub app: Option<DeviceApp>,
pub state: DeviceState,
pub signing_key: String,
pub agreement_key: String,
Expand Down
79 changes: 79 additions & 0 deletions crates/orbyn-core/src/accounts/device_app.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//! The app label a device may sign into its introduction.

use super::{AccountError, NewDevice, Result};
use orbyn_auth_client::{
DEVICE_LOGIN_CONTEXT, DEVICE_LOGIN_V2_CONTEXT, DEVICE_REGISTER_CONTEXT,
DEVICE_REGISTER_V2_CONTEXT, Descriptor, login_message, login_message_v2, register_message,
register_message_v2,
};

/// The product a device belongs to, when it says so. It is signed with the device's
/// introduction, so a server or relay cannot relabel a device afterwards.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum App {
Planner,
Engineering,
}

impl App {
pub fn as_str(self) -> &'static str {
match self {
Self::Planner => "planner",
Self::Engineering => "engineering",
}
}

pub(super) fn parse(text: &str) -> Result<Self> {
Ok(match text {
"planner" => Self::Planner,
"engineering" => Self::Engineering,
_ => return Err(AccountError::Internal),
})
}

pub(super) fn parse_opt(text: Option<&str>) -> Result<Option<Self>> {
text.map(Self::parse).transpose()
}
}

impl NewDevice {
/// The context and bytes this device's sign-up signature must cover. A device that names its
/// app signs the v2 message, which adds the trimmed name, platform and app; one that does not
/// signs the v1 message, so older clients keep working.
pub(super) fn register_proof(
&self,
name: &str,
email: &str,
upload: &[u8],
) -> (&'static str, Vec<u8>) {
match self.app {
Some(app) => (
DEVICE_REGISTER_V2_CONTEXT,
register_message_v2(email, upload, &self.keys, &self.descriptor(name, app)),
),
None => (
DEVICE_REGISTER_CONTEXT,
register_message(email, upload, &self.keys),
),
}
}

/// As [`Self::register_proof`], for a sign-in.
pub(super) fn login_proof(&self, name: &str, login_id: &[u8; 16]) -> (&'static str, Vec<u8>) {
match self.app {
Some(app) => (
DEVICE_LOGIN_V2_CONTEXT,
login_message_v2(login_id, &self.keys, &self.descriptor(name, app)),
),
None => (DEVICE_LOGIN_CONTEXT, login_message(login_id, &self.keys)),
}
}

fn descriptor<'a>(&self, name: &'a str, app: App) -> Descriptor<'a> {
Descriptor {
name,
platform: self.platform.as_str(),
app: app.as_str(),
}
}
}
6 changes: 4 additions & 2 deletions crates/orbyn-core/src/accounts/devices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
//! Removing a device carries the remover's signature, which every device checks.

use super::{
AccountError, Accounts, Approval, ApprovalView, AuditAction, Client, Device, DeviceState,
AccountError, Accounts, App, Approval, ApprovalView, AuditAction, Client, Device, DeviceState,
Platform, Removal, Result, Session, limits, public_keys,
};
use chrono::{DateTime, Utc};
Expand Down Expand Up @@ -52,6 +52,7 @@ struct DeviceRow {
id: Uuid,
name: String,
platform: String,
app: Option<String>,
state: String,
signing_key: Vec<u8>,
agreement_key: Vec<u8>,
Expand Down Expand Up @@ -91,7 +92,7 @@ struct ApprovalRow {
macro_rules! select_devices {
($tail:literal) => {
concat!(
"SELECT id, name, platform, state, signing_key, agreement_key, created_at, ",
"SELECT id, name, platform, app, state, signing_key, agreement_key, created_at, ",
"approved_by, approval_signature, approval_id, approval_created_at, ",
"approval_endorsement_hash FROM devices ",
$tail
Expand All @@ -117,6 +118,7 @@ impl TryFrom<DeviceRow> for Device {
id: row.id,
name: row.name,
platform: Platform::parse(&row.platform)?,
app: App::parse_opt(row.app.as_deref())?,
state: DeviceState::parse(&row.state).map_err(|_| AccountError::Internal)?,
keys: public_keys(&row.signing_key, &row.agreement_key)?,
created_at: row.created_at,
Expand Down
Loading
Loading