diff --git a/crates/orbyn-client/src/lib.rs b/crates/orbyn-client/src/lib.rs index 386be0f..e2db9f8 100644 --- a/crates/orbyn-client/src/lib.rs +++ b/crates/orbyn-client/src/lib.rs @@ -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; diff --git a/crates/orbyn-client/tests/reexports.rs b/crates/orbyn-client/tests/reexports.rs new file mode 100644 index 0000000..51460b5 --- /dev/null +++ b/crates/orbyn-client/tests/reexports.rs @@ -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, _> = Engine::build_upload; + let _: fn(&mut Engine, &str, &UploadResult) -> Result<(), _> = Engine::upload_succeeded; + let _: fn(&Engine, &Device) -> Result = Engine::sign_removal; + let _: fn(&Engine, &str) -> Result = 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"); +} diff --git a/crates/orbyn-device-client/src/lib.rs b/crates/orbyn-device-client/src/lib.rs index 4abeff7..fda006e 100644 --- a/crates/orbyn-device-client/src/lib.rs +++ b/crates/orbyn-device-client/src/lib.rs @@ -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; diff --git a/crates/orbyn-device-client/tests/single_crate.rs b/crates/orbyn-device-client/tests/single_crate.rs new file mode 100644 index 0000000..3ec66d1 --- /dev/null +++ b/crates/orbyn-device-client/tests/single_crate.rs @@ -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::::new(), + removals: Vec::::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); +} diff --git a/docs/device-client.md b/docs/device-client.md index a41adbe..9689b8b 100644 --- a/docs/device-client.md +++ b/docs/device-client.md @@ -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.