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
17 changes: 15 additions & 2 deletions crates/orbyn-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,26 @@ pub use service_key::{
};
pub use trust::{Peer, PinnedKeys, uuid_bytes};

// Every type of another Engine crate that appears in this crate's public signatures or fields, so
// a host that depends on this crate alone (the app may not name `orbyn-contracts`, rule D6) can
// name it. They are the same types, not copies. Left out on purpose: the planner's `Item` and
// `ItemError` (planner only), and the request and response bodies this crate never exposes.
pub use orbyn_contracts::{
ApprovalNonce, ConfirmApproval, CreateApproval, DeleteZone, Device, DeviceApp, DeviceList,
DevicePlatform, KeyEnvelope, KeyRotation, NewKeyEnvelope, PendingApproval, ProtectionChain,
ProtectionLevel, ProtectionLink, Record, RecordChanged, RemoveDevice, SetProtection,
UploadChanges, UploadResult, ZoneChanges, ZoneList,
};

use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use crypt::Place;
use orbyn_auth_client as auth;
use orbyn_contracts as wire;
use orbyn_crypto::{CryptoError, DeviceKeys, DevicePublic, SymmetricKey};
use orbyn_sync::{Clock, Fields, Stamp, merge, merge_remote};
use orbyn_crypto::{CryptoError, SymmetricKey};
pub use orbyn_crypto::{DeviceKeys, DevicePublic};
use orbyn_sync::{Clock, merge, merge_remote};
pub use orbyn_sync::{Fields, Stamp};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use trust::Trust;
Expand Down
61 changes: 61 additions & 0 deletions crates/orbyn-client/tests/reexports.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//! The engine's public signatures name types of other Engine crates. A host that depends on
//! `orbyn-client` alone (the app may not name `orbyn-contracts`, rule D6) names them from here.

use orbyn_client::{
ApprovalNonce, ConfirmApproval, CreateApproval, DeleteZone, Device, DeviceApp, DeviceKeys,
DeviceList, DevicePlatform, DevicePublic, Engine, Fields, KeyEnvelope, KeyRotation,
NewKeyEnvelope, PendingApproval, ProtectionChain, ProtectionLevel, ProtectionLink, Record,
RecordChanged, RemoveDevice, SetProtection, Stamp, UploadChanges, UploadResult, ZoneChanges,
ZoneList,
};

/// Each wire type the engine's methods take or return is nameable here: this compiles only if the
/// signature below, written with the re-exported names, matches the engine's own.
#[allow(dead_code, clippy::type_complexity)]
fn signatures(engine: &mut Engine, device: &Device, list: &DeviceList) {
let _: fn(&Engine, &ZoneList) -> _ = Engine::check_zones;
let _: fn(&mut Engine, &str) -> Result<Option<UploadChanges>, _> = Engine::build_upload;
let _: fn(&mut Engine, &str, &UploadResult) -> Result<(), _> = Engine::upload_succeeded;
let _: fn(&Engine, &Device) -> Result<RemoveDevice, _> = Engine::sign_removal;
let _: fn(&Engine, &str) -> Result<DeleteZone, _> = Engine::delete_zone_proof;
let _ = (engine, device, list);
}

#[allow(dead_code)]
type Named = (
ApprovalNonce,
ConfirmApproval,
CreateApproval,
DeviceApp,
DevicePlatform,
KeyEnvelope,
KeyRotation,
NewKeyEnvelope,
PendingApproval,
ProtectionChain,
ProtectionLevel,
ProtectionLink,
Record,
RecordChanged,
SetProtection,
ZoneChanges,
);

#[test]
fn the_re_exports_are_the_contract_and_crypto_types_themselves() {
let keys = DeviceKeys::generate().expect("keys");
let public: DevicePublic = keys.public();
let engine = Engine::new(
keys,
"0a0a0a0a-0000-4000-8000-000000000001",
"0000000a-0000-4000-8000-00000000000a",
)
.expect("engine");
assert_eq!(engine.device_keys().public(), public);
let mut engine = engine;
let stamp: Stamp = engine.tick(1_700_000_000_000);
let mut fields = Fields::default();
fields.set("a", serde_json::json!(1), stamp);
assert_eq!(DeviceApp::Engineering.as_str(), "engineering");
assert_eq!(DevicePlatform::Windows.as_str(), "windows");
}
11 changes: 11 additions & 0 deletions crates/orbyn-device-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,14 @@ pub use signup::PendingSignUp;
pub use status::{SyncState, SyncStatus};
pub use store::{KeyStore, MemoryStore, StoredIdentity, StoredSession};
pub use sync::{SyncReport, ZoneReport};

// Everything of another Engine crate that this crate's public API exposes, so the app names it
// from here and never depends on `orbyn-contracts` (rule D6). They are the same types, not copies.
// `orbyn-client` re-exports the contract types the engine's own signatures use.
pub use orbyn_client::{
Applied, ClientError, Device, DeviceApp, DeviceKeys, DeviceList, DevicePlatform, DevicePublic,
Engine, Fields, Local, Peer, PendingApproval, Quarantined, RefusedEnvelope, RejectedFields,
Snapshot, Stamp, Trashed,
};
pub use orbyn_contracts::{DeviceRemoval, DeviceState};
pub use zeroize::Zeroizing;
114 changes: 114 additions & 0 deletions crates/orbyn-device-client/tests/single_crate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
//! The app may not name `orbyn-contracts` (rule D6): every type that this crate's public API
//! exposes must be nameable from this crate alone. This file imports from `orbyn_device_client`
//! and nothing else, and a second test checks that it stays that way.

use orbyn_device_client::{
Applied, ClientConfig, ClientError, Device, DeviceApp, DeviceKeys, DeviceList, DevicePlatform,
DevicePublic, DeviceRemoval, DeviceState, Engine, Fields, KeyStore, Local, MemoryStore, Peer,
PendingApproval, Quarantined, RefusedEnvelope, RejectedFields, Snapshot, Stamp, StoredIdentity,
SyncReport, Trashed, Zeroizing, ZoneReport,
};

/// A host's `ClientConfig` is written with the re-exported platform and app types.
fn config() -> ClientConfig {
ClientConfig {
device_name: "Laptop".to_owned(),
platform: DevicePlatform::Linux,
app: Some(DeviceApp::Engineering),
}
}

/// Every type is usable in a signature, which is how a host holds them.
#[allow(dead_code)]
type Held<'a> = (
Option<&'a Peer>,
Option<&'a Local>,
Option<&'a Quarantined>,
Option<&'a Trashed>,
Option<&'a RejectedFields>,
Option<&'a RefusedEnvelope>,
Option<&'a ClientError>,
Option<&'a PendingApproval>,
Option<&'a DeviceRemoval>,
Option<&'a DevicePublic>,
);

#[test]
fn a_host_names_the_device_types_from_this_crate_alone() {
let config = config();
assert_eq!(config.platform, DevicePlatform::Linux);
assert_eq!(config.app, Some(DeviceApp::Engineering));
assert_eq!(DeviceApp::Planner.as_str(), "planner");
assert_eq!(DevicePlatform::Macos.as_str(), "macos");

// A device list is read with the re-exported types.
let list = DeviceList {
devices: Vec::<Device>::new(),
removals: Vec::<DeviceRemoval>::new(),
};
assert!(list.devices.is_empty());
assert_ne!(DeviceState::Pending, DeviceState::Trusted);
}

#[test]
fn a_host_drives_the_engine_and_a_key_store_with_the_re_exported_types() {
let keys = DeviceKeys::generate().expect("keys");
let mut engine = Engine::new(
keys,
"0a0a0a0a-0000-4000-8000-000000000001",
"0000000a-0000-4000-8000-00000000000a",
)
.expect("engine");
let stamp: Stamp = engine.tick(1_700_000_000_000);
let mut fields = Fields::default();
fields.set("name", serde_json::json!("x"), stamp);
assert!(engine.zones().next().is_none());
let snapshot: Snapshot = engine.snapshot();
assert_eq!(snapshot.device_id, "0000000a-0000-4000-8000-00000000000a");
assert_eq!(Applied::default().changed.len(), 0);
let report = SyncReport {
zones: vec![ZoneReport::default()],
..SyncReport::default()
};
assert_eq!(report.zones.len(), 1);

// A host's key store holds the identity with the re-exported wiping type.
let store = MemoryStore::new();
store
.save(&StoredIdentity {
device_keys: DeviceKeys::generate().expect("keys").to_bytes(),
account_id: "a".into(),
device_id: "d".into(),
email: "e@example.com".into(),
session: None,
vault_key: Zeroizing::new([1u8; 32]),
})
.expect("save");
assert!(store.load().expect("load").is_some());
}

#[test]
fn this_file_never_names_another_engine_crate() {
let source = include_str!("single_crate.rs");
// Built from pieces so this check does not match itself.
for name in [
"contracts",
"crypto",
"sync",
"auth_client",
"zeroize",
"planner",
] {
let needle = format!("orbyn_{name}");
let uses = source
.lines()
.filter(|l| !l.trim_start().starts_with("//") && !l.contains("let needle"))
.filter(|l| l.contains(&needle) && !l.contains("for name in"))
.count();
assert_eq!(uses, 0, "{needle} is named in this file");
}
let zeroize_use = source
.lines()
.any(|l| l.trim_start().starts_with("use zeroize"));
assert!(!zeroize_use);
}
16 changes: 16 additions & 0 deletions docs/device-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,22 @@ The label is the device's own signed claim, never proof of which app is running.

The account's protection chain is not signed by the client; the host's first protection setting follows the rules in [architecture.md](architecture.md).

## One crate to depend on

The app may not depend on or name `orbyn-contracts` (rule D6). Every type of another Engine crate that this crate's public API exposes is therefore **re-exported from `orbyn-device-client`** (the same types, not copies), and `orbyn-client` re-exports the ones its own signatures use:

| From | Re-exported by `orbyn-device-client` | Why |
| --- | --- | --- |
| `orbyn-contracts` | `DevicePlatform`, `DeviceApp`, `DeviceState`, `Device`, `DeviceList`, `DeviceRemoval`, `PendingApproval` | `ClientConfig`, `SessionInfo`, `devices()`, `pending_approval()` and what a device list holds |
| `orbyn-client` | `Engine`, `Snapshot`, `Applied`, `RejectedFields`, `RefusedEnvelope`, `ClientError`, `Peer`, `Local`, `Quarantined`, `Trashed` | `open_engine`, `sync_all`, `SyncReport` and the engine's own return types |
| `orbyn-sync` (through `orbyn-client`) | `Fields`, `Stamp` | staging and reading records |
| `orbyn-crypto` (through `orbyn-client`) | `DeviceKeys`, `DevicePublic` | `StoredIdentity::keys()`, device keys |
| `zeroize` | `Zeroizing` | `Password`, `StoredIdentity` |

`orbyn-client` re-exports, besides those, the other contract types its `Engine` methods take or return (`ZoneList`, `UploadChanges`, `UploadResult`, `RemoveDevice`, `DeleteZone`, `KeyEnvelope`, `NewKeyEnvelope`, `KeyRotation`, `ApprovalNonce`, `CreateApproval`, `ConfirmApproval`, `ProtectionChain`, `ProtectionLink`, `ProtectionLevel`, `SetProtection`, `Record`, `RecordChanged`, `ZoneChanges`).

Left out on purpose: the planner's `Item` and `ItemError` (planner only); the request and response bodies this crate sends and reads internally (`RegisterFinish`, `EmailVerify` and the like) and `orbyn-auth-client`, which no public signature uses; `ApproverPairing` and `DevicePairing`, which `ApproverApproval` and `JoinedApproval` wrap. `DeviceError` carries only strings, numbers and a duration. `tests/single_crate.rs` here and `orbyn-client/tests/reexports.rs` use these types through the one crate, and a check fails if the first names another Engine crate.

### Tests

`orbyn-server/tests/device_client.rs` runs the client over a real loopback socket against the real server on Postgres: two devices, the zone key, a sealed record that the server holds only as ciphertext, a conflict merged both ways, the snapshot round trip, a pending device refused, the whole approval through the client API (digits compared, confirmed and finished), a deny from either side, wrong digits typed, a relabelled listing refused by the server's commitment check, a removal and the last device that stays, offline work kept and sent later, and the notices stream hearing another device's change and being rejected when the session is revoked. `tests/notices.rs` serves the stream by hand to check the sign-in message, the pong, the reconnect and the stop.
Loading