Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Device pairing protocol. #318

Merged
merged 26 commits into from
Feb 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
df86cba
Start sketching device pairing protocol.
tmpfs Feb 3, 2024
6fc0ce7
Preparing websockets and noise transport.
tmpfs Feb 3, 2024
40683fc
Prepare pairing packet encoding.
tmpfs Feb 4, 2024
9206947
Prepare noise protocol handshake logic.
tmpfs Feb 4, 2024
28e455b
Initial pairing relay service and stub test spec.
tmpfs Feb 4, 2024
1655221
Rename module and types.
tmpfs Feb 4, 2024
05bda52
Pairing transport encrypt/decrypt.
tmpfs Feb 4, 2024
94436c3
Register device on offer side.
tmpfs Feb 4, 2024
f28c8b0
Initial pairing protocol test spec.
tmpfs Feb 4, 2024
b8bac21
Ignore broken device test specs for now.
tmpfs Feb 4, 2024
6fe334c
Rename server route to more generic relay.
tmpfs Feb 4, 2024
3740dba
Require device signature for fetch account.
tmpfs Feb 4, 2024
5b536a4
Shutdown channel for pairing protocol event loops.
tmpfs Feb 4, 2024
4645438
Improve pairing protocol test spec.
tmpfs Feb 4, 2024
32ccacf
Restore device revoke test spec.
tmpfs Feb 4, 2024
08d23d4
Tidy pairing types.
tmpfs Feb 4, 2024
e474fdc
Move device revoke test spec.
tmpfs Feb 4, 2024
ab249db
Error on bad state in pairing protocol.
tmpfs Feb 4, 2024
27c9d02
Split test specs due to DEVICE_SIGNER static.
tmpfs Feb 4, 2024
5ba0432
Move enrollment module.
tmpfs Feb 5, 2024
a11b1ff
Device pairing docs draft.
tmpfs Feb 5, 2024
4420729
Update pairing docs.
tmpfs Feb 5, 2024
e696751
Add pre-shared key to URL.
tmpfs Feb 5, 2024
29a45a6
Use pre-shared symmetric key in pairing protocol.
tmpfs Feb 5, 2024
d6cd139
Update docs.
tmpfs Feb 5, 2024
35f0cf6
Update docs.
tmpfs Feb 5, 2024
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
18 changes: 18 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name = "sos"
version = "0.9.0"
edition = "2021"
rust-version = "1.68.2"
rust-version = "1.75.0"
description = "Distributed, encrypted database for private secrets."
homepage = "https://saveoursecrets.com"
license = "MIT OR Apache-2.0"
Expand Down
29 changes: 29 additions & 0 deletions doc/sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,35 @@ deny = [
]
```

## Device Pairing

Adding a new device to an existing account consists of a device that is already authenticated to the account which we call the **offering device** and another device which is not authenticated called the **accepting device**.

Pairing the devices is performed using an untrusted relay server. Communication between the devices exposes sensitive information (the account signing key) which must not be exposed to the server so device pairing uses the [noise protocol](https://noiseprotocol.org/) to ensure the communication between the devices is private. The protocol includes a pre-shared symmetric key in the **sharing URL** so that the server cannot forge client connections.

The **sharing URL** needs to be transferred between the offering device and the accepting device; this can be done by scanning a QR code or typing in the URL. The **sharing URL** uses the `data:` scheme to differentiate from the HTTP/S schemes.

### Pairing Protocol

1) Offering device generates a noise protocol session keypair and encodes the relay server URL and noise protocol public key in a **pairing URL** that can be shared with the accepting device.
2) Once the **pairing URL** has been received by the accepting device it begins the noise protocol handshake.
3) After completing the noise protocol handshake the accepting device encrypts and sends a **trusted device** to the offering side. The **trusted device** contains meta data about the device and the public key of the device's signing key.
4) When the offering device receives the **trusted device** it updates the server(s) to trust the new device and sends the encrypted account signing key in reply.
5) The pairing protocol is complete when the accepting device receives the account signing key.

### Device Enrollment

Once the pairing protocol is finished the accepting device can perform device enrollment as it has both the account signing key and a device signing key whose public key has been added as a trusted device to the server(s).

1) Fetch account event logs and write the account to disc.
2) Finish device enrollment by authenticating to the account using the primary password.

## Device Revocation

If a device is lost or stolen the device can be revoked which will remove the device from the list of trusted devices preventing it from syncing with servers.

Whilst server endpoints will return a **forbidden** response for untrusted device signatures; if the lost or stolen device was unlocked and the account was authenticated the owner must consider all of their secrets compromised.

## Event Logs

Several events logs are stored on both the client and server so that complete deterministic, incremental synchronization is possible for an account.
Expand Down
5 changes: 4 additions & 1 deletion tests/device_revoke/main.rs → tests/pairing/main.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
#[cfg(not(target_arch = "wasm32"))]
mod revoke;
mod pairing_protocol;

#[cfg(not(target_arch = "wasm32"))]
mod pairing_websocket_shutdown;

#[cfg(not(target_arch = "wasm32"))]
pub use sos_test_utils as test_utils;
49 changes: 13 additions & 36 deletions tests/device_enroll/enroll.rs → tests/pairing/pairing_protocol.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
use anyhow::Result;

use crate::test_utils::{
assert_local_remote_events_eq, mock, simulate_device, spawn, teardown,
};
use sos_net::{
client::{NetworkAccount, RemoteSync},
sdk::prelude::*,
assert_local_remote_events_eq, mock, run_pairing_protocol,
simulate_device, spawn, teardown,
};
use anyhow::Result;
use sos_net::{client::RemoteSync, sdk::prelude::*};

/// Tests enrolling a new device and syncing the device event log
/// including the newly enrolled device back on to a primary device.
/// Tests the protocol for pairing devices.
#[tokio::test]
async fn device_enroll() -> Result<()> {
const TEST_ID: &str = "device_enroll";
async fn pairing_protocol() -> Result<()> {
const TEST_ID: &str = "pairing_protocol";
//crate::test_utils::init_tracing();

// Spawn a backend server and wait for it to be listening
Expand All @@ -21,6 +17,8 @@ async fn device_enroll() -> Result<()> {
// Prepare mock devices
let mut primary_device =
simulate_device(TEST_ID, 2, Some(&server)).await?;
let origin = primary_device.origin.clone();
let folders = primary_device.folders.clone();

// Create a secret in the primary owner which won't exist
// in the second device
Expand All @@ -31,31 +29,9 @@ async fn device_enroll() -> Result<()> {
.await?;
assert!(result.sync_error.is_none());

let password = primary_device.password.clone();
let key: AccessKey = password.into();
let origin = primary_device.origin.clone();
let signing_key = primary_device.owner.account_signer().await?;
let data_dir = primary_device.dirs.clients.get(1).cloned().unwrap();
let folders = primary_device.folders.clone();

// Need to clear the data directory for the second client
// as simulate_device() copies all the account data and
// the identity folder must not exist to enroll a new device
std::fs::remove_dir_all(&data_dir)?;
std::fs::create_dir(&data_dir)?;

// Start enrollment by fetching the account data
// from the remote server
let enrollment = NetworkAccount::enroll_device(
origin.clone(),
signing_key,
Some(data_dir),
)
.await?;

// Complete device enrollment by authenticating
// to the new account
let mut enrolled_account = enrollment.finish(&key).await?;
// Run the pairing protocol to completion.
let mut enrolled_account =
run_pairing_protocol(&mut primary_device, TEST_ID).await?;

// Sync on the original device to fetch the updated device logs
assert!(primary_device.owner.sync().await.is_none());
Expand Down Expand Up @@ -92,6 +68,7 @@ async fn device_enroll() -> Result<()> {
)
.await?;

// Sign out all devices
primary_device.owner.sign_out().await?;
enrolled_account.sign_out().await?;

Expand Down
58 changes: 58 additions & 0 deletions tests/pairing/pairing_websocket_shutdown.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
use crate::test_utils::{simulate_device, spawn, teardown};
use anyhow::Result;
use futures::{stream::FuturesUnordered, Future, StreamExt};
use sos_net::{
client::pairing::{self, OfferPairing},
sdk::prelude::*,
};
use std::pin::Pin;
use tokio::sync::mpsc;

/// Tests shutting down the websocket for an
/// offer side of the pairing protocol.
#[tokio::test]
async fn pairing_websocket_shutdown() -> Result<()> {
const TEST_ID: &str = "pairing_websocket_shutdown";
//crate::test_utils::init_tracing();

// Spawn a backend server and wait for it to be listening
let server = spawn(TEST_ID, None, None).await?;

// Prepare mock devices
let mut primary_device =
simulate_device(TEST_ID, 2, Some(&server)).await?;
let origin = primary_device.origin.clone();

{
// Create the offer of device pairing
let (mut offer, offer_stream) = OfferPairing::new(
&mut primary_device.owner,
origin.url().clone(),
)
.await?;

let (offer_shutdown_tx, offer_shutdown_rx) = mpsc::channel::<()>(1);

// Run both sides of the protocol to completion
let mut tasks = FuturesUnordered::<
Pin<Box<dyn Future<Output = pairing::Result<()>>>>,
>::new();

tasks.push(Box::pin(offer.run(offer_stream, offer_shutdown_rx)));
tasks.push(Box::pin(async move {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
offer_shutdown_tx.send(()).await.unwrap();
Ok(())
}));

while let Some(result) = tasks.next().await {
result?;
}
}

primary_device.owner.sign_out().await?;

teardown(TEST_ID).await;

Ok(())
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
use anyhow::Result;

use crate::test_utils::{
assert_local_remote_events_eq, simulate_device, spawn, teardown,
assert_local_remote_events_eq, run_pairing_protocol, simulate_device,
spawn, teardown,
};
use anyhow::Result;
use http::StatusCode;
use sos_net::{
client::{Error as ClientError, NetworkAccount, RemoteSync, SyncError},
client::{Error as ClientError, RemoteSync, SyncError},
sdk::prelude::*,
};

/// Tests enrolling a new device and revoking trust in the device.
/// Tests pairing a new device and revoking trust in the device.
#[tokio::test]
async fn device_revoke() -> Result<()> {
const TEST_ID: &str = "device_revoke";
async fn pairing_device_revoke() -> Result<()> {
const TEST_ID: &str = "pairing_device_revoke";
//crate::test_utils::init_tracing();

// Spawn a backend server and wait for it to be listening
Expand All @@ -21,19 +21,11 @@ async fn device_revoke() -> Result<()> {
// Prepare mock devices
let mut primary_device =
simulate_device(TEST_ID, 2, Some(&server)).await?;

let password = primary_device.password.clone();
let key: AccessKey = password.into();
let origin = primary_device.origin.clone();
let signing_key = primary_device.owner.account_signer().await?;
let data_dir = primary_device.dirs.clients.get(1).cloned().unwrap();
let folders = primary_device.folders.clone();

// Need to clear the data directory for the second client
// as simulate_device() copies all the account data and
// the identity folder must not exist to enroll a new device
std::fs::remove_dir_all(&data_dir)?;
std::fs::create_dir(&data_dir)?;
let mut enrolled_account =
run_pairing_protocol(&mut primary_device, TEST_ID).await?;

// Cannot revoke the current device
let current_device_public_key = primary_device
Expand All @@ -48,19 +40,6 @@ async fn device_revoke() -> Result<()> {
.await;
assert!(matches!(result, Err(ClientError::RevokeDeviceSelf)));

// Start enrollment by fetching the account data
// from the remote server
let enrollment = NetworkAccount::enroll_device(
origin.clone(),
signing_key,
Some(data_dir),
)
.await?;

// Complete device enrollment by authenticating
// to the new account
let mut enrolled_account = enrollment.finish(&key).await?;

// Sync on the original device to fetch the updated device logs
assert!(primary_device.owner.sync().await.is_none());

Expand All @@ -71,10 +50,11 @@ async fn device_revoke() -> Result<()> {
.owner
.revoke_device(&device_public_key)
.await?;

let revoke_error = enrolled_account.revoke_device(
&current_device_public_key).await;


let revoke_error = enrolled_account
.revoke_device(&current_device_public_key)
.await;

if let Err(ClientError::RevokeDeviceSync(mut e)) = revoke_error {
let (_, err) = e.errors.remove(0);
assert!(matches!(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#[cfg(not(target_arch = "wasm32"))]
mod enroll;
mod device_revoke;

#[cfg(not(target_arch = "wasm32"))]
pub use sos_test_utils as test_utils;
5 changes: 5 additions & 0 deletions workspace/net/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ full = [
"hashcheck",
"listen",
"migrate",
"pairing",
"preferences",
"search",
"server",
Expand Down Expand Up @@ -49,6 +50,7 @@ migrate = ["sos-sdk/migrate"]
keychain-access = ["sos-sdk/keychain-access"]
device = ["sos-sdk/device"]
recovery = ["sos-sdk/recovery"]
pairing = ["dep:snow"]
preferences = ["sos-sdk/preferences"]
search = ["sos-sdk/search"]
security-report = ["sos-sdk/security-report"]
Expand Down Expand Up @@ -93,6 +95,9 @@ tokio-tungstenite = { version = "0.21", features = ["rustls-tls-native-roots"] ,
utoipa = { version = "4", features = ["uuid"], optional = true }
utoipa-rapidoc = { version = "3", features = ["axum"], optional = true }

# pairing
snow = { version = "0.9", optional = true }

[dependencies.sos-sdk]
version = "0.9.0"
features = ["sync"]
Expand Down
11 changes: 6 additions & 5 deletions workspace/net/src/client/account/network_account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,20 +103,22 @@ pub struct NetworkAccount {

impl NetworkAccount {
/// Enroll a new device.
#[cfg(feature = "device")]
#[cfg(all(feature = "device", feature = "pairing"))]
pub async fn enroll_device(
origin: Origin,
account_signing_key: BoxedEcdsaSigner,
device_signer: DeviceSigner,
data_dir: Option<PathBuf>,
) -> Result<crate::client::enrollment::DeviceEnrollment> {
use crate::client::{enrollment::DeviceEnrollment, HttpClient};
) -> Result<crate::client::pairing::DeviceEnrollment> {
use crate::client::{pairing::DeviceEnrollment, HttpClient};
use crate::sdk::signer::ed25519::BoxedEd25519Signer;

let address = account_signing_key.address()?;
let mut enrollment = DeviceEnrollment::new(
&address,
data_dir.clone(),
origin.clone(),
device_signer,
data_dir.clone(),
)?;
let device_signing_key = enrollment.device_signing_key.clone();
let device: BoxedEd25519Signer = device_signing_key.into();
Expand All @@ -128,7 +130,6 @@ impl NetworkAccount {
)?;

enrollment.enroll(remote).await?;

Ok(enrollment)
}

Expand Down
2 changes: 1 addition & 1 deletion workspace/net/src/client/account/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ impl RemoteBridge {
let account = self.account.lock().await;
sync::diff(&*account, remote_status).await?
};

// If we need a sync but no local device changes
// try to pull from remote
if let (true, None) = (needs_sync, &local_changes.device) {
Expand Down
1 change: 1 addition & 0 deletions workspace/net/src/client/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ pub enum Error {
RevokeDeviceSync(SyncError<Error>),

/// Error generated trying to parse a device enrollment sharing URL.
#[deprecated]
#[error("invalid share url for device enrollment")]
InvalidShareUrl,

Expand Down
Loading