M1: persistent vault, local unlock key, sync client, test vectors
kv-store: SQLite (bundled) and in-memory Store holding only ciphertext envelopes, with base copies for three-way merges and a 10-version history. kv-ffi: VaultCore::open(path); persisted account record; local unlock key (enable/unlock_with_local_key/disable); async sync API (configure_server, set_device_signer, register_account, login, sync_now, logout, devices); DeviceSigner foreign trait; IntegrityAlarm/PinMismatch/... error variants. kv-sync: reqwest + rustls (TLS 1.3 only, ring) with SPKI pinning; OPAQUE client on kv-proto's OpaqueSuite; RFC 9421 signing per kv_proto::httpsig; pull/push with If-Match, field-level three-way merge, edit-beats-delete; signed manifest (canonical CBOR, Merkle root, prev chain, Ed25519) with rollback/fork/Merkle checks as hard integrity alarms. Tested against a wiremock fake server, plus an #[ignore] test against kestrove-server. kv-crypto: gen-vectors example and tests reproducing kestrove-spec/vectors. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -8,5 +8,38 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
kv-proto.workspace = true
|
||||
kv-crypto.workspace = true
|
||||
kv-model.workspace = true
|
||||
kv-store.workspace = true
|
||||
uuid.workspace = true
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
base64.workspace = true
|
||||
sha2.workspace = true
|
||||
rand_core.workspace = true
|
||||
zeroize.workspace = true
|
||||
opaque-ke.workspace = true
|
||||
reqwest.workspace = true
|
||||
rustls.workspace = true
|
||||
webpki-roots.workspace = true
|
||||
rustls-webpki.workspace = true
|
||||
tokio.workspace = true
|
||||
ciborium.workspace = true
|
||||
blake3.workspace = true
|
||||
p256.workspace = true
|
||||
time.workspace = true
|
||||
url.workspace = true
|
||||
wiremock = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
# In-memory fake of kestrove-server for tests (also used by kv-ffi's tests).
|
||||
test-server = ["dep:wiremock"]
|
||||
|
||||
[dev-dependencies]
|
||||
kv-sync = { path = ".", features = ["test-server"] }
|
||||
rcgen.workspace = true
|
||||
tokio-rustls.workspace = true
|
||||
tempfile.workspace = true
|
||||
rustls.workspace = true
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
//! Typed HTTP client for the sync API (docs/03, api/openapi.yaml).
|
||||
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use kv_proto::{
|
||||
AccountInfo, ApiError, Changes, DeviceEnroll, DeviceInfo, ItemPut, ItemRecord, KdfParams, LoginFinish,
|
||||
LoginFinishResponse, LoginStart, LoginStartResponse, LoginStatus, Manifest, PreloginResponse,
|
||||
RegistrationStartResponse, ServerInfo, SetupFinish, SetupStart, Vault,
|
||||
};
|
||||
use reqwest::{Method, StatusCode};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
opaque, signing,
|
||||
transport::{classify, http_client, normalize_base_url},
|
||||
wire::*,
|
||||
DeviceSigner, ServerConfig, SyncError,
|
||||
};
|
||||
|
||||
pub const PAGE_LIMIT: u32 = 500;
|
||||
|
||||
/// Result of an OPAQUE login.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LoginOutcome {
|
||||
/// Session established (and stored in the client).
|
||||
Ok { session: String },
|
||||
/// New device: an enrolled device must approve it (or a TOTP must be supplied), then log in again.
|
||||
PendingApproval,
|
||||
}
|
||||
|
||||
pub struct ApiClient {
|
||||
base: String,
|
||||
http: reqwest::Client,
|
||||
signer: Arc<dyn DeviceSigner>,
|
||||
session: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
fn now_unix() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
pub fn new(cfg: &ServerConfig, signer: Arc<dyn DeviceSigner>) -> Result<Self, SyncError> {
|
||||
Ok(Self {
|
||||
base: normalize_base_url(&cfg.base_url)?,
|
||||
http: http_client(cfg)?,
|
||||
signer,
|
||||
session: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> &str {
|
||||
&self.base
|
||||
}
|
||||
|
||||
pub fn signer(&self) -> &Arc<dyn DeviceSigner> {
|
||||
&self.signer
|
||||
}
|
||||
|
||||
pub fn session(&self) -> Option<String> {
|
||||
self.session.lock().unwrap_or_else(|p| p.into_inner()).clone()
|
||||
}
|
||||
|
||||
pub fn set_session(&self, s: Option<String>) {
|
||||
*self.session.lock().unwrap_or_else(|p| p.into_inner()) = s;
|
||||
}
|
||||
|
||||
async fn send(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<Vec<u8>>,
|
||||
if_match: Option<u64>,
|
||||
signed: bool,
|
||||
) -> Result<(StatusCode, Vec<u8>), SyncError> {
|
||||
let url = format!("{}{}", self.base, path);
|
||||
let body = body.unwrap_or_default();
|
||||
let mut req = self.http.request(method.clone(), &url);
|
||||
if !body.is_empty() {
|
||||
req = req.header("content-type", "application/json");
|
||||
}
|
||||
if let Some(rev) = if_match {
|
||||
req = req.header("if-match", format!("\"{rev}\""));
|
||||
}
|
||||
if signed {
|
||||
let session = self.session().ok_or(SyncError::NotAuthenticated)?;
|
||||
let h = signing::sign_request(self.signer.as_ref(), method.as_str(), &url, &body, &session, now_unix())?;
|
||||
for (k, v) in h.pairs() {
|
||||
req = req.header(k, v);
|
||||
}
|
||||
}
|
||||
let resp = req.body(body).send().await.map_err(classify)?;
|
||||
let status = resp.status();
|
||||
let bytes = resp.bytes().await.map_err(classify)?.to_vec();
|
||||
if status.is_success() {
|
||||
return Ok((status, bytes));
|
||||
}
|
||||
let api: Option<ApiError> = serde_json::from_slice(&bytes).ok();
|
||||
let (code, message) = api.map(|e| (e.code, e.message)).unwrap_or_default();
|
||||
Err(match status {
|
||||
StatusCode::PRECONDITION_FAILED => SyncError::RevisionConflict,
|
||||
StatusCode::CONFLICT => SyncError::VersionConflict,
|
||||
StatusCode::NOT_FOUND => SyncError::NotFound,
|
||||
StatusCode::UNAUTHORIZED if signed => {
|
||||
self.set_session(None);
|
||||
SyncError::NotAuthenticated
|
||||
}
|
||||
_ => SyncError::Server { status: status.as_u16(), code, message },
|
||||
})
|
||||
}
|
||||
|
||||
async fn json<T: DeserializeOwned>(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<&impl Serialize>,
|
||||
if_match: Option<u64>,
|
||||
signed: bool,
|
||||
) -> Result<T, SyncError> {
|
||||
let body = body.map(serde_json::to_vec).transpose().map_err(|e| SyncError::Protocol(e.to_string()))?;
|
||||
let (_, bytes) = self.send(method, path, body, if_match, signed).await?;
|
||||
serde_json::from_slice(&bytes).map_err(|e| SyncError::Protocol(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
async fn no_content(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<&impl Serialize>,
|
||||
signed: bool,
|
||||
) -> Result<(), SyncError> {
|
||||
let body = body.map(serde_json::to_vec).transpose().map_err(|e| SyncError::Protocol(e.to_string()))?;
|
||||
self.send(method, path, body, None, signed).await.map(|_| ())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- unauthenticated
|
||||
|
||||
pub async fn server_info(&self) -> Result<ServerInfo, SyncError> {
|
||||
self.json(Method::GET, PATH_SERVER_INFO, None::<&()>, None, false).await
|
||||
}
|
||||
|
||||
/// Account id and KDF parameters of the (single) account: what a new device needs to derive
|
||||
/// `AUTH_INPUT` before it can build KE1.
|
||||
pub async fn prelogin(&self) -> Result<PreloginResponse, SyncError> {
|
||||
self.json(Method::GET, PATH_PRELOGIN, None::<&()>, None, false).await
|
||||
}
|
||||
|
||||
/// OPAQUE registration plus account creation (first device only, needs the setup token).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn register(
|
||||
&self,
|
||||
setup_token: &str,
|
||||
account_id: Uuid,
|
||||
kdf: &KdfParams,
|
||||
auth_input: &[u8],
|
||||
enc_pk: Vec<u8>,
|
||||
sig_pk: Vec<u8>,
|
||||
wrapped_keyset: kv_proto::Envelope,
|
||||
device: DeviceEnroll,
|
||||
) -> Result<(), SyncError> {
|
||||
let reg = opaque::registration_start(auth_input)?;
|
||||
let start =
|
||||
SetupStart { setup_token: setup_token.to_string(), account_id, registration_request: reg.request.clone() };
|
||||
let resp: RegistrationStartResponse =
|
||||
self.json(Method::POST, PATH_SETUP_START, Some(&start), None, false).await?;
|
||||
let upload = reg.finish(auth_input, &resp.registration_response)?;
|
||||
let req = SetupFinish {
|
||||
setup_token: setup_token.to_string(),
|
||||
account_id,
|
||||
kdf: kdf.clone(),
|
||||
opaque_registration_upload: upload,
|
||||
enc_pk,
|
||||
sig_pk,
|
||||
wrapped_keyset,
|
||||
device,
|
||||
};
|
||||
self.no_content(Method::POST, PATH_SETUP_FINISH, Some(&req), false).await
|
||||
}
|
||||
|
||||
/// OPAQUE login. On success the session is stored in this client.
|
||||
pub async fn login(
|
||||
&self,
|
||||
account_id: Uuid,
|
||||
auth_input: &[u8],
|
||||
device: DeviceEnroll,
|
||||
totp: Option<String>,
|
||||
) -> Result<LoginOutcome, SyncError> {
|
||||
let start = opaque::login_start(auth_input)?;
|
||||
let req = LoginStart { account_id, ke1: start.ke1.clone(), device };
|
||||
let resp: LoginStartResponse = self.json(Method::POST, PATH_LOGIN_START, Some(&req), None, false).await?;
|
||||
let done = start.finish(auth_input, &resp.ke2)?;
|
||||
let fin = LoginFinish { login_id: resp.login_id, ke3: done.ke3, totp };
|
||||
let resp: LoginFinishResponse = match self.json(Method::POST, PATH_LOGIN_FINISH, Some(&fin), None, false).await
|
||||
{
|
||||
Err(SyncError::Server { status: 401, .. }) => return Err(SyncError::BadCredentials),
|
||||
other => other?,
|
||||
};
|
||||
match (resp.status, resp.session) {
|
||||
(LoginStatus::Ok, Some(s)) => {
|
||||
self.set_session(Some(s.clone()));
|
||||
Ok(LoginOutcome::Ok { session: s })
|
||||
}
|
||||
(LoginStatus::Ok, None) => Err(SyncError::Protocol("login ok without a session".into())),
|
||||
(LoginStatus::PendingApproval, _) => Ok(LoginOutcome::PendingApproval),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- authenticated
|
||||
|
||||
pub async fn logout(&self) -> Result<(), SyncError> {
|
||||
let r = self.no_content(Method::POST, PATH_LOGOUT, None::<&()>, true).await;
|
||||
self.set_session(None);
|
||||
r
|
||||
}
|
||||
|
||||
pub async fn account(&self) -> Result<AccountInfo, SyncError> {
|
||||
self.json(Method::GET, PATH_ACCOUNT, None::<&()>, None, true).await
|
||||
}
|
||||
|
||||
pub async fn devices(&self) -> Result<Vec<DeviceInfo>, SyncError> {
|
||||
self.json(Method::GET, PATH_DEVICES, None::<&()>, None, true).await
|
||||
}
|
||||
|
||||
/// Approves a pending device (this device must be approved itself).
|
||||
pub async fn approve_device(&self, device: Uuid) -> Result<(), SyncError> {
|
||||
self.no_content(Method::POST, &format!("{PATH_DEVICES}/{device}/approve"), None::<&()>, true).await
|
||||
}
|
||||
|
||||
/// Revokes a device permanently and ends its sessions.
|
||||
pub async fn revoke_device(&self, device: Uuid) -> Result<(), SyncError> {
|
||||
self.no_content(Method::DELETE, &format!("{PATH_DEVICES}/{device}"), None::<&()>, true).await
|
||||
}
|
||||
|
||||
pub async fn vaults(&self) -> Result<Vec<Vault>, SyncError> {
|
||||
self.json(Method::GET, PATH_VAULTS, None::<&()>, None, true).await
|
||||
}
|
||||
|
||||
pub async fn create_vault(&self, v: &Vault) -> Result<(), SyncError> {
|
||||
self.no_content(Method::POST, PATH_VAULTS, Some(v), true).await
|
||||
}
|
||||
|
||||
pub async fn changes(&self, vault: Uuid, since: u64) -> Result<Changes, SyncError> {
|
||||
let path = format!("{PATH_VAULTS}/{vault}/changes?since={since}&limit={PAGE_LIMIT}");
|
||||
self.json(Method::GET, &path, None::<&()>, None, true).await
|
||||
}
|
||||
|
||||
pub async fn put_item(&self, vault: Uuid, item: Uuid, base: u64, body: &ItemPut) -> Result<ItemRecord, SyncError> {
|
||||
self.json(Method::PUT, &format!("{PATH_VAULTS}/{vault}/items/{item}"), Some(body), Some(base), true).await
|
||||
}
|
||||
|
||||
pub async fn delete_item(&self, vault: Uuid, item: Uuid, base: u64) -> Result<ItemRecord, SyncError> {
|
||||
self.json(Method::DELETE, &format!("{PATH_VAULTS}/{vault}/items/{item}"), None::<&()>, Some(base), true).await
|
||||
}
|
||||
|
||||
/// `None` when the vault has no manifest yet.
|
||||
pub async fn get_manifest(&self, vault: Uuid) -> Result<Option<Manifest>, SyncError> {
|
||||
match self.json(Method::GET, &format!("{PATH_VAULTS}/{vault}/manifest"), None::<&()>, None, true).await {
|
||||
Ok(m) => Ok(Some(m)),
|
||||
Err(SyncError::NotFound) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn put_manifest(&self, vault: Uuid, m: &Manifest) -> Result<(), SyncError> {
|
||||
self.no_content(Method::PUT, &format!("{PATH_VAULTS}/{vault}/manifest"), Some(m), true).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
//! The client sync algorithm (docs/03 "Sync algorithm", "Conflict resolution", "Integrity").
|
||||
//!
|
||||
//! Per vault: pull `/changes` from the cursor, verify the signed manifest, push pending edits with
|
||||
//! `If-Match` (on 412: pull, merge, retry), pull again, then publish a new manifest.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use kv_crypto::{
|
||||
aead::SymKey,
|
||||
keys::{open_item, seal_item, AccountKeyset, SealedItem},
|
||||
};
|
||||
use kv_proto::{ItemPut, ItemRecord, Vault};
|
||||
use kv_store::{HistoryEntry, LocalItem, Store, SyncState, VaultRow};
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
client::ApiClient,
|
||||
manifest::{self, ManifestBody, ManifestError},
|
||||
merge::merge3,
|
||||
SyncError,
|
||||
};
|
||||
|
||||
/// What one `sync` did. Counts are summed over all vaults.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SyncReport {
|
||||
/// Records received from the server and applied.
|
||||
pub pulled: u32,
|
||||
/// Local edits the server accepted.
|
||||
pub pushed: u32,
|
||||
/// Items where both sides changed and a three-way merge ran.
|
||||
pub merged: u32,
|
||||
/// Items an edit brought back after a delete on the other side (edit beats delete).
|
||||
pub restored: Vec<Uuid>,
|
||||
/// Vaults synced successfully.
|
||||
pub vaults_synced: u32,
|
||||
/// Remote vaults ignored because they have no valid signed manifest yet.
|
||||
pub vaults_skipped: Vec<Uuid>,
|
||||
}
|
||||
|
||||
pub struct SyncContext<'a> {
|
||||
pub api: &'a ApiClient,
|
||||
pub store: &'a dyn Store,
|
||||
pub keyset: &'a AccountKeyset,
|
||||
pub device_id: Uuid,
|
||||
}
|
||||
|
||||
fn now() -> i64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn rfc3339_now() -> String {
|
||||
time::OffsetDateTime::now_utc().format(&time::format_description::well_known::Rfc3339).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn crypto(e: kv_crypto::CryptoError) -> SyncError {
|
||||
SyncError::Crypto(e.to_string())
|
||||
}
|
||||
|
||||
/// Decrypts an item record's payload into JSON.
|
||||
pub fn open_record(
|
||||
account_id: Uuid,
|
||||
vault_id: Uuid,
|
||||
vk: &SymKey,
|
||||
record: &ItemRecord,
|
||||
) -> Result<Option<Value>, SyncError> {
|
||||
let (Some(k), Some(p)) = (&record.wrapped_item_key, &record.payload) else { return Ok(None) };
|
||||
if record.deleted {
|
||||
return Ok(None);
|
||||
}
|
||||
let sealed = SealedItem { wrapped_item_key: k.clone(), payload: p.clone() };
|
||||
let pt = open_item(account_id, vault_id, record.item_id, vk, &sealed).map_err(crypto)?;
|
||||
serde_json::from_slice(&pt).map(Some).map_err(|e| SyncError::Crypto(format!("item JSON: {e}")))
|
||||
}
|
||||
|
||||
/// Encrypts item JSON into a new (local) record with a fresh item key.
|
||||
pub fn seal_record(
|
||||
account_id: Uuid,
|
||||
vault_id: Uuid,
|
||||
vk: &SymKey,
|
||||
item_id: Uuid,
|
||||
json: &Value,
|
||||
device_id: Uuid,
|
||||
) -> Result<ItemRecord, SyncError> {
|
||||
let pt = zeroize::Zeroizing::new(serde_json::to_vec(json).map_err(|e| SyncError::Protocol(e.to_string()))?);
|
||||
let sealed = seal_item(account_id, vault_id, item_id, vk, &pt).map_err(crypto)?;
|
||||
Ok(ItemRecord {
|
||||
item_id,
|
||||
revision: 0,
|
||||
deleted: false,
|
||||
wrapped_item_key: Some(sealed.wrapped_item_key),
|
||||
payload: Some(sealed.payload),
|
||||
updated_by: device_id,
|
||||
updated_at: rfc3339_now(),
|
||||
})
|
||||
}
|
||||
|
||||
fn history_entry(r: &ItemRecord) -> Option<HistoryEntry> {
|
||||
Some(HistoryEntry {
|
||||
revision: r.revision,
|
||||
wrapped_item_key: r.wrapped_item_key.clone()?,
|
||||
payload: r.payload.clone()?,
|
||||
saved_at: now(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The server's view of a vault: the base (last confirmed) version of every item.
|
||||
fn server_view(store: &dyn Store, vault: Uuid) -> Result<Vec<ItemRecord>, SyncError> {
|
||||
Ok(store.list(vault)?.into_iter().filter_map(|i| i.base).collect())
|
||||
}
|
||||
|
||||
impl SyncContext<'_> {
|
||||
fn account_id(&self) -> Uuid {
|
||||
self.keyset.account_id
|
||||
}
|
||||
|
||||
/// Syncs every vault. Vaults with an integrity alarm are skipped and reported as an error
|
||||
/// after the others have synced.
|
||||
pub async fn sync_all(&self) -> Result<SyncReport, SyncError> {
|
||||
let mut report = SyncReport::default();
|
||||
self.reconcile_vault_list(&mut report).await?;
|
||||
|
||||
let mut alarm = None;
|
||||
for row in self.store.vaults()? {
|
||||
if row.integrity_alarm {
|
||||
alarm.get_or_insert(SyncError::Integrity {
|
||||
vault_id: row.vault_id,
|
||||
reason: "an earlier sync detected tampering; syncing this vault is stopped".into(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
match self.sync_vault(row.vault_id, &mut report).await {
|
||||
Ok(()) => report.vaults_synced += 1,
|
||||
Err(SyncError::Integrity { vault_id, reason }) => {
|
||||
let mut row = self.store.vault(vault_id)?.ok_or(kv_store::StoreError::NotFound)?;
|
||||
row.integrity_alarm = true;
|
||||
self.store.put_vault(&row)?;
|
||||
alarm.get_or_insert(SyncError::Integrity { vault_id, reason });
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
match alarm {
|
||||
Some(e) => Err(e),
|
||||
None => Ok(report),
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads vaults created here; adopts remote vaults that carry a valid signed manifest.
|
||||
async fn reconcile_vault_list(&self, report: &mut SyncReport) -> Result<(), SyncError> {
|
||||
let remote = self.api.vaults().await?;
|
||||
for local in self.store.vaults()? {
|
||||
if remote.iter().any(|v| v.vault_id == local.vault_id) {
|
||||
continue;
|
||||
}
|
||||
let meta = local.meta.clone().ok_or_else(|| SyncError::Protocol("vault without metadata".into()))?;
|
||||
let v = Vault { vault_id: local.vault_id, head_revision: 0, sealed_key: local.sealed_key.clone(), meta };
|
||||
match self.api.create_vault(&v).await {
|
||||
Ok(()) | Err(SyncError::VersionConflict) => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
for rv in remote {
|
||||
if self.store.vault(rv.vault_id)?.is_some() {
|
||||
continue;
|
||||
}
|
||||
// Anyone can HPKE-seal a key to our public key, so a vault is only trusted once a
|
||||
// manifest signed by the account key vouches for it.
|
||||
if self.keyset.open_vault_key(rv.vault_id, &rv.sealed_key).is_err() {
|
||||
report.vaults_skipped.push(rv.vault_id);
|
||||
continue;
|
||||
}
|
||||
let signed = match self.api.get_manifest(rv.vault_id).await? {
|
||||
Some(m) => manifest::verify(&self.keyset.sig_public(), &m, rv.vault_id).is_ok(),
|
||||
None => false,
|
||||
};
|
||||
if !signed {
|
||||
report.vaults_skipped.push(rv.vault_id);
|
||||
continue;
|
||||
}
|
||||
self.store.put_vault(&VaultRow::new(rv.vault_id, rv.sealed_key, Some(rv.meta)))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sync_vault(&self, vault: Uuid, report: &mut SyncReport) -> Result<(), SyncError> {
|
||||
let row = self.store.vault(vault)?.ok_or(kv_store::StoreError::NotFound)?;
|
||||
let vk = self.keyset.open_vault_key(vault, &row.sealed_key).map_err(crypto)?;
|
||||
|
||||
self.pull(vault, &vk, report).await?;
|
||||
let mut remote_manifest = self.verify_manifest(vault).await;
|
||||
if matches!(remote_manifest, Err(SyncError::ManifestAhead)) {
|
||||
// Someone wrote between our pull and the manifest fetch; one more pull must catch up.
|
||||
self.pull(vault, &vk, report).await?;
|
||||
remote_manifest = self.verify_manifest(vault).await;
|
||||
}
|
||||
let remote_manifest = match remote_manifest {
|
||||
Err(SyncError::ManifestAhead) => {
|
||||
return Err(self.alarm(vault, "manifest describes revisions the server will not send"))
|
||||
}
|
||||
other => other?,
|
||||
};
|
||||
|
||||
let pushed = self.push(vault, &vk, report).await?;
|
||||
if pushed > 0 || remote_manifest.is_none() {
|
||||
self.pull(vault, &vk, report).await?;
|
||||
self.publish_manifest(vault).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn alarm(&self, vault_id: Uuid, reason: impl Into<String>) -> SyncError {
|
||||
SyncError::Integrity { vault_id, reason: reason.into() }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- pull
|
||||
|
||||
async fn pull(&self, vault: Uuid, vk: &SymKey, report: &mut SyncReport) -> Result<(), SyncError> {
|
||||
loop {
|
||||
let cursor = self.store.cursor(vault)?;
|
||||
let page = self.api.changes(vault, cursor).await?;
|
||||
let mut last = cursor;
|
||||
for rec in &page.items {
|
||||
if rec.revision <= last {
|
||||
return Err(self.alarm(vault, "server sent changes out of order"));
|
||||
}
|
||||
last = rec.revision;
|
||||
self.apply_remote(vault, vk, rec, report)?;
|
||||
report.pulled += 1;
|
||||
}
|
||||
let more = page.more && !page.items.is_empty();
|
||||
let new_cursor = if more { last } else { last.max(page.head_revision) };
|
||||
if new_cursor < cursor {
|
||||
return Err(self.alarm(vault, "server head revision went backwards"));
|
||||
}
|
||||
self.store.set_cursor(vault, new_cursor)?;
|
||||
if !more {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_remote(
|
||||
&self,
|
||||
vault: Uuid,
|
||||
vk: &SymKey,
|
||||
remote: &ItemRecord,
|
||||
report: &mut SyncReport,
|
||||
) -> Result<(), SyncError> {
|
||||
let Some(local) = self.store.get(vault, remote.item_id)? else {
|
||||
return Ok(self.store.put_remote(vault, remote)?);
|
||||
};
|
||||
if remote.revision <= local.base_revision() {
|
||||
return Ok(()); // our own write coming back, or a replay of something older
|
||||
}
|
||||
if local.state == SyncState::Clean {
|
||||
return Ok(self.store.put_remote(vault, remote)?);
|
||||
}
|
||||
self.resolve_conflict(vault, vk, local, remote, report)
|
||||
}
|
||||
|
||||
/// `local` is pending and `remote` is a newer server version of the same item.
|
||||
fn resolve_conflict(
|
||||
&self,
|
||||
vault: Uuid,
|
||||
vk: &SymKey,
|
||||
local: LocalItem,
|
||||
remote: &ItemRecord,
|
||||
report: &mut SyncReport,
|
||||
) -> Result<(), SyncError> {
|
||||
let id = remote.item_id;
|
||||
let rebased = |record: ItemRecord| LocalItem {
|
||||
record: ItemRecord { revision: remote.revision, ..record },
|
||||
state: SyncState::Pending,
|
||||
base: Some(remote.clone()),
|
||||
};
|
||||
match (local.record.deleted, remote.deleted) {
|
||||
(true, true) => self.store.put_remote(vault, remote)?,
|
||||
(true, false) => {
|
||||
// Edit beats delete: the remote edit wins, the item is restored.
|
||||
self.store.put_remote(vault, remote)?;
|
||||
report.restored.push(id);
|
||||
}
|
||||
(false, true) => {
|
||||
// Our edit beats their delete: keep ours and push it on top of the tombstone.
|
||||
self.store.put(vault, &rebased(local.record))?;
|
||||
report.restored.push(id);
|
||||
}
|
||||
(false, false) => {
|
||||
let acct = self.account_id();
|
||||
let base = match &local.base {
|
||||
Some(b) => open_record(acct, vault, vk, b)?,
|
||||
None => None,
|
||||
};
|
||||
let mine =
|
||||
open_record(acct, vault, vk, &local.record)?.ok_or(SyncError::Protocol("empty item".into()))?;
|
||||
let theirs = open_record(acct, vault, vk, remote)?.ok_or(SyncError::Protocol("empty item".into()))?;
|
||||
let out = merge3(base.as_ref(), &mine, &theirs);
|
||||
if out.local_lost {
|
||||
if let Some(h) = history_entry(&local.record) {
|
||||
self.store.push_history(vault, id, &h)?;
|
||||
}
|
||||
}
|
||||
if out.remote_lost {
|
||||
if let Some(h) = history_entry(remote) {
|
||||
self.store.push_history(vault, id, &h)?;
|
||||
}
|
||||
}
|
||||
if out.merged == theirs {
|
||||
self.store.put_remote(vault, remote)?;
|
||||
} else {
|
||||
let sealed = seal_record(acct, vault, vk, id, &out.merged, self.device_id)?;
|
||||
self.store.put(vault, &rebased(sealed))?;
|
||||
}
|
||||
report.merged += 1;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- manifest
|
||||
|
||||
/// Verifies the server's manifest against what this device has seen and pulled.
|
||||
/// Returns `None` if the vault has no manifest yet.
|
||||
async fn verify_manifest(&self, vault: Uuid) -> Result<Option<ManifestBody>, SyncError> {
|
||||
let mut row = self.store.vault(vault)?.ok_or(kv_store::StoreError::NotFound)?;
|
||||
let integrity = |e: ManifestError| SyncError::Integrity { vault_id: vault, reason: e.to_string() };
|
||||
let Some(m) = self.api.get_manifest(vault).await? else {
|
||||
return if row.manifest_version > 0 { Err(integrity(ManifestError::Missing)) } else { Ok(None) };
|
||||
};
|
||||
let body = manifest::verify(&self.keyset.sig_public(), &m, vault).map_err(integrity)?;
|
||||
let hash = manifest::manifest_hash(&m.body);
|
||||
manifest::check_chain(row.manifest_version, row.manifest_hash, &body, hash).map_err(integrity)?;
|
||||
|
||||
if body.head_revision > row.cursor {
|
||||
return Err(SyncError::ManifestAhead);
|
||||
}
|
||||
if body.head_revision == row.cursor && manifest::merkle_root_of(&server_view(self.store, vault)?) != body.items
|
||||
{
|
||||
return Err(integrity(ManifestError::MerkleMismatch));
|
||||
}
|
||||
if body.version > row.manifest_version {
|
||||
row.manifest_version = body.version;
|
||||
row.manifest_hash = Some(hash);
|
||||
self.store.put_vault(&row)?;
|
||||
}
|
||||
Ok(Some(body))
|
||||
}
|
||||
|
||||
async fn publish_manifest(&self, vault: Uuid) -> Result<(), SyncError> {
|
||||
for _ in 0..3 {
|
||||
let mut row = self.store.vault(vault)?.ok_or(kv_store::StoreError::NotFound)?;
|
||||
let body = ManifestBody {
|
||||
vault_id: vault,
|
||||
version: row.manifest_version + 1,
|
||||
head_revision: row.cursor,
|
||||
items: manifest::merkle_root_of(&server_view(self.store, vault)?),
|
||||
prev: if row.manifest_version == 0 { [0; 32] } else { row.manifest_hash.unwrap_or([0; 32]) },
|
||||
device_id: self.device_id,
|
||||
created_at: now() as u64,
|
||||
};
|
||||
let m = body.sign(self.keyset);
|
||||
match self.api.put_manifest(vault, &m).await {
|
||||
Ok(()) => {
|
||||
row.manifest_version = body.version;
|
||||
row.manifest_hash = Some(manifest::manifest_hash(&m.body));
|
||||
return self.store.put_vault(&row).map_err(Into::into);
|
||||
}
|
||||
Err(SyncError::VersionConflict) => {
|
||||
// Another device published first: verify theirs (advances our chain), then retry.
|
||||
match self.verify_manifest(vault).await {
|
||||
Ok(_) | Err(SyncError::ManifestAhead) => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
// Lost the race three times; the next sync publishes.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- push
|
||||
|
||||
async fn push(&self, vault: Uuid, vk: &SymKey, report: &mut SyncReport) -> Result<u32, SyncError> {
|
||||
let mut pushed = 0;
|
||||
for _round in 0..4 {
|
||||
let pending = self.store.pending(vault)?;
|
||||
if pending.is_empty() {
|
||||
break;
|
||||
}
|
||||
let mut conflict = false;
|
||||
for item in pending {
|
||||
let base = item.base_revision();
|
||||
let id = item.record.item_id;
|
||||
let result = if item.record.deleted {
|
||||
if base == 0 {
|
||||
self.store.remove(vault, id)?;
|
||||
continue;
|
||||
}
|
||||
self.api.delete_item(vault, id, base).await
|
||||
} else {
|
||||
let (Some(k), Some(p)) = (&item.record.wrapped_item_key, &item.record.payload) else {
|
||||
return Err(SyncError::Protocol(format!("pending item {id} has no ciphertext")));
|
||||
};
|
||||
let body = ItemPut { wrapped_item_key: k.clone(), payload: p.clone() };
|
||||
self.api.put_item(vault, id, base, &body).await
|
||||
};
|
||||
match result {
|
||||
Ok(confirmed) => {
|
||||
self.confirm(vault, &item, confirmed)?;
|
||||
pushed += 1;
|
||||
report.pushed += 1;
|
||||
}
|
||||
Err(SyncError::RevisionConflict) => conflict = true,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
if !conflict {
|
||||
break;
|
||||
}
|
||||
// 412: fetch what changed, merge, and retry the remaining pending items.
|
||||
self.pull(vault, vk, report).await?;
|
||||
}
|
||||
Ok(pushed)
|
||||
}
|
||||
|
||||
/// Records the server's acceptance, unless the item was edited again while the request was in flight.
|
||||
fn confirm(&self, vault: Uuid, pushed: &LocalItem, confirmed: ItemRecord) -> Result<(), SyncError> {
|
||||
let current = self.store.get(vault, confirmed.item_id)?;
|
||||
let unchanged = current
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.record.payload == pushed.record.payload && c.record.deleted == pushed.record.deleted);
|
||||
if unchanged || current.is_none() {
|
||||
self.store.put_remote(vault, &confirmed)?;
|
||||
} else if let Some(mut c) = current {
|
||||
c.record.revision = confirmed.revision;
|
||||
c.base = Some(confirmed);
|
||||
c.state = SyncState::Pending;
|
||||
self.store.put(vault, &c)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,72 @@
|
||||
//! Sync engine (milestone M1, kestrove-spec/docs/03).
|
||||
//!
|
||||
//! Responsibilities:
|
||||
//! * `Transport`: HTTPS with rustls, TLS 1.3 only, SPKI pinning (primary + backup pin).
|
||||
//! * OPAQUE login via `opaque-ke` (ristretto255 / SHA-512, identity KSF because the input is already Argon2id-stretched).
|
||||
//! * RFC 9421 request signing with the device key. The key lives in the platform keystore, so signing is
|
||||
//! * [`transport`]: HTTPS with rustls, TLS 1.3 only, SPKI pinning (primary + backup pin).
|
||||
//! * [`opaque`]: OPAQUE login via `opaque-ke` (ristretto255 / SHA-512, identity KSF because the input is already
|
||||
//! Argon2id-stretched).
|
||||
//! * [`signing`]: RFC 9421 request signing with the device key. The key lives in the platform keystore, so signing is
|
||||
//! a callback into platform code ([`DeviceSigner`]).
|
||||
//! * Pull, then push with `If-Match`, then three-way field merge on 412, then manifest verify and publish.
|
||||
//! * [`engine`]: pull, then push with `If-Match`, then three-way field merge on 412 ([`merge`]), then manifest verify
|
||||
//! and publish ([`manifest`]).
|
||||
|
||||
pub mod client;
|
||||
pub mod engine;
|
||||
pub mod manifest;
|
||||
pub mod merge;
|
||||
pub mod opaque;
|
||||
pub mod signing;
|
||||
#[cfg(feature = "test-server")]
|
||||
pub mod test_server;
|
||||
pub mod transport;
|
||||
pub mod wire;
|
||||
|
||||
pub use client::{ApiClient, LoginOutcome};
|
||||
pub use engine::{SyncContext, SyncReport};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SyncError {
|
||||
#[error("network: {0}")]
|
||||
Network(String),
|
||||
#[error("server rejected request: {0}")]
|
||||
Server(String),
|
||||
#[error("integrity alarm on vault {0}: manifest rollback or tampering")]
|
||||
Integrity(Uuid),
|
||||
#[error("server certificate does not match the pinned keys")]
|
||||
PinMismatch,
|
||||
#[error("configuration: {0}")]
|
||||
Config(String),
|
||||
#[error("server returned {status} {code}: {message}")]
|
||||
Server { status: u16, code: String, message: String },
|
||||
#[error("unexpected server response: {0}")]
|
||||
Protocol(String),
|
||||
#[error("integrity alarm on vault {vault_id}: {reason}")]
|
||||
Integrity { vault_id: Uuid, reason: String },
|
||||
#[error("not logged in")]
|
||||
NotAuthenticated,
|
||||
#[error("wrong master password or Secret Key")]
|
||||
BadCredentials,
|
||||
#[error("authentication failed: {0}")]
|
||||
Auth(String),
|
||||
#[error("device signer: {0}")]
|
||||
Signer(String),
|
||||
#[error("local storage: {0}")]
|
||||
Store(String),
|
||||
#[error("crypto: {0}")]
|
||||
Crypto(String),
|
||||
#[error("not found")]
|
||||
NotFound,
|
||||
/// HTTP 412: the item changed on the server since `If-Match`.
|
||||
#[error("revision conflict")]
|
||||
RevisionConflict,
|
||||
/// HTTP 409.
|
||||
#[error("version conflict")]
|
||||
VersionConflict,
|
||||
/// Internal: the manifest is newer than our cursor; pull again.
|
||||
#[error("manifest is ahead of the pulled changes")]
|
||||
ManifestAhead,
|
||||
}
|
||||
|
||||
impl From<kv_store::StoreError> for SyncError {
|
||||
fn from(e: kv_store::StoreError) -> Self {
|
||||
SyncError::Store(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Implemented by each platform: Secure Enclave, StrongBox, TPM or software fallback.
|
||||
@@ -31,8 +79,16 @@ pub trait DeviceSigner: Send + Sync {
|
||||
}
|
||||
|
||||
/// Server endpoint plus pinned SPKI SHA-256 hashes (base64).
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ServerConfig {
|
||||
pub base_url: String,
|
||||
pub spki_pins: Vec<String>,
|
||||
/// Extra trust anchors (DER), e.g. a private CA for a self-hosted server. Empty in normal use.
|
||||
pub extra_roots_der: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl ServerConfig {
|
||||
pub fn new(base_url: impl Into<String>, spki_pins: Vec<String>) -> Self {
|
||||
Self { base_url: base_url.into(), spki_pins, extra_roots_der: vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
//! Signed vault manifest (docs/03 "Integrity and rollback protection").
|
||||
//!
|
||||
//! Body: canonical CBOR (RFC 8949 §4.2.1 deterministic encoding) of a map with text keys:
|
||||
//!
|
||||
//! | key | type | meaning |
|
||||
//! |----------------|-------------|-----------------------------------------------------------|
|
||||
//! | `prev` | bstr (32) | blake3 of the previous manifest body, zeros for version 1 |
|
||||
//! | `items` | bstr (32) | Merkle root (below) |
|
||||
//! | `version` | uint | +1 per publish |
|
||||
//! | `vault_id` | bstr (16) | |
|
||||
//! | `device_id` | bstr (16) | publishing device |
|
||||
//! | `created_at` | uint | unix seconds |
|
||||
//! | `head_revision`| uint | server revision the Merkle root describes |
|
||||
//!
|
||||
//! Merkle tree over every item the server holds (tombstones included), sorted by `item_id` bytes:
|
||||
//! * `content = blake3(cbor([envelope(wrapped_item_key), envelope(payload)]))`, or `blake3(cbor([]))` for a tombstone,
|
||||
//! where `envelope(e)` is the canonical CBOR map `{n, v, ct, alg, enc, kid}` (`n`/`enc` omitted when empty).
|
||||
//! * `leaf = blake3(0x00 || item_id[16] || revision as u64 big-endian || content)`
|
||||
//! * `node = blake3(0x01 || left || right)`; an odd node at the end of a level is promoted unchanged.
|
||||
//! * The root of an empty vault is 32 zero bytes.
|
||||
//!
|
||||
//! `signature = Ed25519(account sig_sk, body)`, `manifest hash = blake3(body)`.
|
||||
|
||||
use kv_crypto::keys::{verify_signature, AccountKeyset};
|
||||
use kv_proto::{Envelope, ItemRecord, Manifest};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ManifestBody {
|
||||
pub vault_id: Uuid,
|
||||
pub version: u64,
|
||||
pub head_revision: u64,
|
||||
pub items: [u8; 32],
|
||||
pub prev: [u8; 32],
|
||||
pub device_id: Uuid,
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
/// Why a manifest was rejected. Every variant is an integrity alarm.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ManifestError {
|
||||
#[error("manifest is not canonical CBOR or has the wrong shape")]
|
||||
Malformed,
|
||||
#[error("manifest signature is invalid")]
|
||||
BadSignature,
|
||||
#[error("manifest belongs to another vault")]
|
||||
WrongVault,
|
||||
#[error("manifest version {got} is older than the last seen {seen} (rollback)")]
|
||||
Rollback { seen: u64, got: u64 },
|
||||
#[error("manifest version {0} differs from the one seen before (fork)")]
|
||||
Fork(u64),
|
||||
#[error("manifest prev hash does not chain to the last seen manifest")]
|
||||
ChainBroken,
|
||||
#[error("manifest Merkle root does not match the items the server sent")]
|
||||
MerkleMismatch,
|
||||
#[error("server has no manifest but one was seen before")]
|
||||
Missing,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- minimal canonical CBOR writer
|
||||
|
||||
mod cbor {
|
||||
pub fn head(out: &mut Vec<u8>, major: u8, n: u64) {
|
||||
let m = major << 5;
|
||||
match n {
|
||||
0..=23 => out.push(m | n as u8),
|
||||
24..=0xff => out.extend_from_slice(&[m | 24, n as u8]),
|
||||
0x100..=0xffff => {
|
||||
out.push(m | 25);
|
||||
out.extend_from_slice(&(n as u16).to_be_bytes())
|
||||
}
|
||||
0x1_0000..=0xffff_ffff => {
|
||||
out.push(m | 26);
|
||||
out.extend_from_slice(&(n as u32).to_be_bytes())
|
||||
}
|
||||
_ => {
|
||||
out.push(m | 27);
|
||||
out.extend_from_slice(&n.to_be_bytes())
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn uint(out: &mut Vec<u8>, n: u64) {
|
||||
head(out, 0, n)
|
||||
}
|
||||
pub fn bytes(out: &mut Vec<u8>, b: &[u8]) {
|
||||
head(out, 2, b.len() as u64);
|
||||
out.extend_from_slice(b);
|
||||
}
|
||||
pub fn text(out: &mut Vec<u8>, s: &str) {
|
||||
head(out, 3, s.len() as u64);
|
||||
out.extend_from_slice(s.as_bytes());
|
||||
}
|
||||
pub fn array(out: &mut Vec<u8>, len: usize) {
|
||||
head(out, 4, len as u64)
|
||||
}
|
||||
pub fn map(out: &mut Vec<u8>, len: usize) {
|
||||
head(out, 5, len as u64)
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical CBOR of an envelope (keys in deterministic order: n, v, ct, alg, enc, kid).
|
||||
pub fn envelope_cbor(e: &Envelope) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(e.ct.len() + 96);
|
||||
let alg = serde_json::to_value(e.alg).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default();
|
||||
let n = 4 + usize::from(!e.n.is_empty()) + usize::from(!e.enc.is_empty());
|
||||
cbor::map(&mut out, n);
|
||||
if !e.n.is_empty() {
|
||||
cbor::text(&mut out, "n");
|
||||
cbor::bytes(&mut out, &e.n);
|
||||
}
|
||||
cbor::text(&mut out, "v");
|
||||
cbor::uint(&mut out, e.v.into());
|
||||
cbor::text(&mut out, "ct");
|
||||
cbor::bytes(&mut out, &e.ct);
|
||||
cbor::text(&mut out, "alg");
|
||||
cbor::text(&mut out, &alg);
|
||||
if !e.enc.is_empty() {
|
||||
cbor::text(&mut out, "enc");
|
||||
cbor::bytes(&mut out, &e.enc);
|
||||
}
|
||||
cbor::text(&mut out, "kid");
|
||||
cbor::bytes(&mut out, e.kid.as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// `blake3(envelope)` for one item record.
|
||||
pub fn content_hash(record: &ItemRecord) -> [u8; 32] {
|
||||
let mut out = Vec::new();
|
||||
match (&record.wrapped_item_key, &record.payload, record.deleted) {
|
||||
(Some(k), Some(p), false) => {
|
||||
cbor::array(&mut out, 2);
|
||||
out.extend_from_slice(&envelope_cbor(k));
|
||||
out.extend_from_slice(&envelope_cbor(p));
|
||||
}
|
||||
_ => cbor::array(&mut out, 0),
|
||||
}
|
||||
*blake3::hash(&out).as_bytes()
|
||||
}
|
||||
|
||||
/// Merkle root over `(item_id, revision, content_hash)`; sorts its input.
|
||||
pub fn merkle_root(mut leaves: Vec<(Uuid, u64, [u8; 32])>) -> [u8; 32] {
|
||||
leaves.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
|
||||
let mut level: Vec<[u8; 32]> = leaves
|
||||
.iter()
|
||||
.map(|(id, rev, h)| {
|
||||
let mut hs = blake3::Hasher::new();
|
||||
hs.update(&[0]);
|
||||
hs.update(id.as_bytes());
|
||||
hs.update(&rev.to_be_bytes());
|
||||
hs.update(h);
|
||||
*hs.finalize().as_bytes()
|
||||
})
|
||||
.collect();
|
||||
if level.is_empty() {
|
||||
return [0; 32];
|
||||
}
|
||||
while level.len() > 1 {
|
||||
level = level
|
||||
.chunks(2)
|
||||
.map(|pair| match pair {
|
||||
[l, r] => {
|
||||
let mut hs = blake3::Hasher::new();
|
||||
hs.update(&[1]);
|
||||
hs.update(l);
|
||||
hs.update(r);
|
||||
*hs.finalize().as_bytes()
|
||||
}
|
||||
[single] => *single,
|
||||
_ => unreachable!(),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
level[0]
|
||||
}
|
||||
|
||||
/// Merkle root of the server's view of a vault, from records with a server revision.
|
||||
pub fn merkle_root_of<'a>(records: impl IntoIterator<Item = &'a ItemRecord>) -> [u8; 32] {
|
||||
merkle_root(records.into_iter().map(|r| (r.item_id, r.revision, content_hash(r))).collect())
|
||||
}
|
||||
|
||||
impl ManifestBody {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(200);
|
||||
cbor::map(&mut out, 7);
|
||||
cbor::text(&mut out, "prev");
|
||||
cbor::bytes(&mut out, &self.prev);
|
||||
cbor::text(&mut out, "items");
|
||||
cbor::bytes(&mut out, &self.items);
|
||||
cbor::text(&mut out, "version");
|
||||
cbor::uint(&mut out, self.version);
|
||||
cbor::text(&mut out, "vault_id");
|
||||
cbor::bytes(&mut out, self.vault_id.as_bytes());
|
||||
cbor::text(&mut out, "device_id");
|
||||
cbor::bytes(&mut out, self.device_id.as_bytes());
|
||||
cbor::text(&mut out, "created_at");
|
||||
cbor::uint(&mut out, self.created_at);
|
||||
cbor::text(&mut out, "head_revision");
|
||||
cbor::uint(&mut out, self.head_revision);
|
||||
out
|
||||
}
|
||||
|
||||
/// Strict decode: the input must be exactly the canonical encoding of a valid body.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self, ManifestError> {
|
||||
use ciborium::Value;
|
||||
let v: Value = ciborium::de::from_reader(bytes).map_err(|_| ManifestError::Malformed)?;
|
||||
let Value::Map(entries) = v else { return Err(ManifestError::Malformed) };
|
||||
let get = |k: &str| entries.iter().find(|(key, _)| key.as_text() == Some(k)).map(|(_, v)| v);
|
||||
let b32 = |k: &str| -> Result<[u8; 32], ManifestError> {
|
||||
get(k).and_then(Value::as_bytes).and_then(|b| b.as_slice().try_into().ok()).ok_or(ManifestError::Malformed)
|
||||
};
|
||||
let id = |k: &str| -> Result<Uuid, ManifestError> {
|
||||
get(k).and_then(Value::as_bytes).and_then(|b| Uuid::from_slice(b).ok()).ok_or(ManifestError::Malformed)
|
||||
};
|
||||
let uint = |k: &str| -> Result<u64, ManifestError> {
|
||||
get(k).and_then(Value::as_integer).and_then(|i| u64::try_from(i).ok()).ok_or(ManifestError::Malformed)
|
||||
};
|
||||
let body = ManifestBody {
|
||||
vault_id: id("vault_id")?,
|
||||
version: uint("version")?,
|
||||
head_revision: uint("head_revision")?,
|
||||
items: b32("items")?,
|
||||
prev: b32("prev")?,
|
||||
device_id: id("device_id")?,
|
||||
created_at: uint("created_at")?,
|
||||
};
|
||||
if body.encode() != bytes {
|
||||
return Err(ManifestError::Malformed);
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
pub fn sign(&self, keyset: &AccountKeyset) -> Manifest {
|
||||
let body = self.encode();
|
||||
let signature = keyset.sign(&body).to_vec();
|
||||
Manifest { version: self.version, body, signature }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn manifest_hash(body: &[u8]) -> [u8; 32] {
|
||||
*blake3::hash(body).as_bytes()
|
||||
}
|
||||
|
||||
/// Signature, shape and vault check. Chain checks are in [`check_chain`].
|
||||
pub fn verify(sig_pk: &[u8; 32], m: &Manifest, vault_id: Uuid) -> Result<ManifestBody, ManifestError> {
|
||||
let sig: [u8; 64] = m.signature.as_slice().try_into().map_err(|_| ManifestError::BadSignature)?;
|
||||
verify_signature(sig_pk, &m.body, &sig).map_err(|_| ManifestError::BadSignature)?;
|
||||
let body = ManifestBody::decode(&m.body)?;
|
||||
if body.vault_id != vault_id {
|
||||
return Err(ManifestError::WrongVault);
|
||||
}
|
||||
if body.version != m.version {
|
||||
return Err(ManifestError::Malformed);
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Rollback / fork / chain checks against the last manifest this device accepted.
|
||||
pub fn check_chain(
|
||||
seen_version: u64,
|
||||
seen_hash: Option<[u8; 32]>,
|
||||
body: &ManifestBody,
|
||||
hash: [u8; 32],
|
||||
) -> Result<(), ManifestError> {
|
||||
if body.version < seen_version {
|
||||
return Err(ManifestError::Rollback { seen: seen_version, got: body.version });
|
||||
}
|
||||
if body.version == seen_version && seen_version > 0 {
|
||||
return if seen_hash == Some(hash) { Ok(()) } else { Err(ManifestError::Fork(body.version)) };
|
||||
}
|
||||
if body.version == seen_version + 1 {
|
||||
let expected = if seen_version == 0 { [0u8; 32] } else { seen_hash.ok_or(ManifestError::ChainBroken)? };
|
||||
// A device that never saw version 1 (seen = 0) cannot check the chain of a later manifest,
|
||||
// but version 1 itself must start from zeros.
|
||||
if body.prev != expected {
|
||||
return Err(ManifestError::ChainBroken);
|
||||
}
|
||||
}
|
||||
// version > seen + 1: intermediate manifests were replaced on the server; only the signature
|
||||
// and monotonicity can be checked.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use kv_proto::{Alg, FORMAT_VERSION};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn body(version: u64, prev: [u8; 32]) -> ManifestBody {
|
||||
ManifestBody {
|
||||
vault_id: Uuid::from_bytes([1; 16]),
|
||||
version,
|
||||
head_revision: 9,
|
||||
items: [3; 32],
|
||||
prev,
|
||||
device_id: Uuid::from_bytes([2; 16]),
|
||||
created_at: 1_700_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_roundtrip_and_strictness() {
|
||||
let b = body(1, [0; 32]);
|
||||
let enc = b.encode();
|
||||
assert_eq!(ManifestBody::decode(&enc).unwrap(), b);
|
||||
// Same content via a generic (non-canonical key order) encoder is rejected.
|
||||
let mut generic = Vec::new();
|
||||
ciborium::ser::into_writer(
|
||||
&ciborium::Value::Map(vec![
|
||||
(ciborium::Value::Text("version".into()), ciborium::Value::Integer(1.into())),
|
||||
(ciborium::Value::Text("prev".into()), ciborium::Value::Bytes(vec![0; 32])),
|
||||
]),
|
||||
&mut generic,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ManifestBody::decode(&generic), Err(ManifestError::Malformed));
|
||||
let mut trailing = enc.clone();
|
||||
trailing.push(0);
|
||||
assert!(ManifestBody::decode(&trailing).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_verify() {
|
||||
let ks = AccountKeyset::generate(Uuid::new_v4());
|
||||
let m = body(1, [0; 32]).sign(&ks);
|
||||
assert!(verify(&ks.sig_public(), &m, Uuid::from_bytes([1; 16])).is_ok());
|
||||
assert_eq!(verify(&ks.sig_public(), &m, Uuid::nil()), Err(ManifestError::WrongVault));
|
||||
let mut forged = m.clone();
|
||||
forged.body[5] ^= 1;
|
||||
assert_eq!(verify(&ks.sig_public(), &forged, Uuid::from_bytes([1; 16])), Err(ManifestError::BadSignature));
|
||||
let other = AccountKeyset::generate(Uuid::new_v4());
|
||||
assert_eq!(verify(&other.sig_public(), &m, Uuid::from_bytes([1; 16])), Err(ManifestError::BadSignature));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chain_rules() {
|
||||
let b1 = body(1, [0; 32]);
|
||||
let h1 = manifest_hash(&b1.encode());
|
||||
assert!(check_chain(0, None, &b1, h1).is_ok());
|
||||
assert!(check_chain(1, Some(h1), &b1, h1).is_ok());
|
||||
let b2 = body(2, h1);
|
||||
let h2 = manifest_hash(&b2.encode());
|
||||
assert!(check_chain(1, Some(h1), &b2, h2).is_ok());
|
||||
assert_eq!(check_chain(2, Some(h2), &b1, h1), Err(ManifestError::Rollback { seen: 2, got: 1 }));
|
||||
assert_eq!(check_chain(1, Some(h1), &body(2, [9; 32]), [0; 32]), Err(ManifestError::ChainBroken));
|
||||
assert_eq!(check_chain(2, Some(h2), &body(2, h1), [8; 32]), Err(ManifestError::Fork(2)));
|
||||
assert!(check_chain(1, Some(h1), &body(5, [7; 32]), [0; 32]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merkle() {
|
||||
assert_eq!(merkle_root(vec![]), [0; 32]);
|
||||
let a = (Uuid::from_bytes([1; 16]), 1, [1; 32]);
|
||||
let b = (Uuid::from_bytes([2; 16]), 2, [2; 32]);
|
||||
let c = (Uuid::from_bytes([3; 16]), 3, [3; 32]);
|
||||
let r = merkle_root(vec![c, a, b]);
|
||||
assert_eq!(r, merkle_root(vec![a, b, c]), "order independent");
|
||||
assert_ne!(r, merkle_root(vec![a, b]));
|
||||
assert_ne!(r, merkle_root(vec![a, b, (c.0, 4, c.2)]), "revision bound");
|
||||
assert_ne!(r, merkle_root(vec![a, b, (c.0, 3, [4; 32])]), "content bound");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_cbor_is_deterministic() {
|
||||
let e = Envelope {
|
||||
v: FORMAT_VERSION,
|
||||
alg: Alg::XChaCha20Poly1305,
|
||||
kid: Uuid::nil(),
|
||||
n: vec![1; 24],
|
||||
ct: vec![2; 3],
|
||||
enc: vec![],
|
||||
};
|
||||
let enc = envelope_cbor(&e);
|
||||
assert_eq!(&enc[..3], &[0xa5, 0x61, b'n']);
|
||||
let v: ciborium::Value = ciborium::de::from_reader(enc.as_slice()).unwrap();
|
||||
assert_eq!(v.as_map().unwrap().len(), 5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Three-way, field-level merge of decrypted items (docs/03 "Conflict resolution").
|
||||
//!
|
||||
//! Items are merged as JSON objects so every top-level field of `kv_model::Item` (and any field a
|
||||
//! newer client adds) is a merge unit. `id` and `created_at` come from the base, `modified_at` is the
|
||||
//! maximum of both sides.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Side {
|
||||
Local,
|
||||
Remote,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MergeOutcome {
|
||||
pub merged: Value,
|
||||
/// Sides that lost at least one field; their full version belongs in history.
|
||||
pub local_lost: bool,
|
||||
pub remote_lost: bool,
|
||||
/// Fields both sides changed differently.
|
||||
pub conflicting_fields: Vec<String>,
|
||||
}
|
||||
|
||||
const FIXED: [&str; 3] = ["id", "created_at", "modified_at"];
|
||||
|
||||
fn modified_at(v: &Value) -> i64 {
|
||||
v.get("modified_at").and_then(Value::as_i64).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// `base` is `None` when the common ancestor is unknown; every differing field is then a conflict.
|
||||
pub fn merge3(base: Option<&Value>, local: &Value, remote: &Value) -> MergeOutcome {
|
||||
let empty = Value::Object(Map::new());
|
||||
let base = base.unwrap_or(&empty);
|
||||
let winner = if modified_at(local) > modified_at(remote) { Side::Local } else { Side::Remote };
|
||||
|
||||
let (Some(l), Some(r)) = (local.as_object(), remote.as_object()) else {
|
||||
// Not objects: whole-value last-writer-wins.
|
||||
let merged = if winner == Side::Local { local.clone() } else { remote.clone() };
|
||||
return MergeOutcome {
|
||||
merged,
|
||||
local_lost: winner == Side::Remote && local != remote,
|
||||
remote_lost: winner == Side::Local && local != remote,
|
||||
conflicting_fields: vec![],
|
||||
};
|
||||
};
|
||||
let b = base.as_object().cloned().unwrap_or_default();
|
||||
|
||||
let mut keys: Vec<&String> = l.keys().chain(r.keys()).chain(b.keys()).collect();
|
||||
keys.sort();
|
||||
keys.dedup();
|
||||
|
||||
let mut out = Map::new();
|
||||
let mut conflicting = Vec::new();
|
||||
for k in keys {
|
||||
if FIXED.contains(&k.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let (bv, lv, rv) = (b.get(k), l.get(k), r.get(k));
|
||||
let pick = if lv == rv {
|
||||
lv
|
||||
} else if lv == bv {
|
||||
rv
|
||||
} else if rv == bv {
|
||||
lv
|
||||
} else {
|
||||
conflicting.push(k.clone());
|
||||
if winner == Side::Local {
|
||||
lv
|
||||
} else {
|
||||
rv
|
||||
}
|
||||
};
|
||||
if let Some(v) = pick {
|
||||
out.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let id = b.get("id").or_else(|| l.get("id")).or_else(|| r.get("id")).cloned();
|
||||
if let Some(id) = id {
|
||||
out.insert("id".into(), id);
|
||||
}
|
||||
let created = b.get("created_at").or_else(|| l.get("created_at")).or_else(|| r.get("created_at")).cloned();
|
||||
if let Some(c) = created {
|
||||
out.insert("created_at".into(), c);
|
||||
}
|
||||
out.insert("modified_at".into(), Value::from(modified_at(local).max(modified_at(remote))));
|
||||
|
||||
let lost = !conflicting.is_empty();
|
||||
MergeOutcome {
|
||||
merged: Value::Object(out),
|
||||
local_lost: lost && winner == Side::Remote,
|
||||
remote_lost: lost && winner == Side::Local,
|
||||
conflicting_fields: conflicting,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn disjoint_changes_both_kept() {
|
||||
let base =
|
||||
json!({"id": "x", "title": "Bank", "password": "a", "notes": null, "created_at": 1, "modified_at": 10});
|
||||
let local =
|
||||
json!({"id": "x", "title": "Bank", "password": "b", "notes": null, "created_at": 1, "modified_at": 20});
|
||||
let remote =
|
||||
json!({"id": "x", "title": "My Bank", "password": "a", "notes": "n", "created_at": 1, "modified_at": 15});
|
||||
let m = merge3(Some(&base), &local, &remote);
|
||||
assert_eq!(m.merged["password"], "b");
|
||||
assert_eq!(m.merged["title"], "My Bank");
|
||||
assert_eq!(m.merged["notes"], "n");
|
||||
assert_eq!(m.merged["modified_at"], 20);
|
||||
assert!(!m.local_lost && !m.remote_lost && m.conflicting_fields.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_field_later_wins() {
|
||||
let base = json!({"id": "x", "password": "a", "modified_at": 10});
|
||||
let local = json!({"id": "x", "password": "local", "modified_at": 30});
|
||||
let remote = json!({"id": "x", "password": "remote", "modified_at": 20});
|
||||
let m = merge3(Some(&base), &local, &remote);
|
||||
assert_eq!(m.merged["password"], "local");
|
||||
assert!(m.remote_lost && !m.local_lost);
|
||||
assert_eq!(m.conflicting_fields, vec!["password".to_string()]);
|
||||
|
||||
let m = merge3(Some(&base), &json!({"id": "x", "password": "local", "modified_at": 5}), &remote);
|
||||
assert_eq!(m.merged["password"], "remote");
|
||||
assert!(m.local_lost);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_field_and_unknown_fields() {
|
||||
let base = json!({"id": "x", "totp": "T", "future_field": 1, "modified_at": 1});
|
||||
let local = json!({"id": "x", "future_field": 1, "modified_at": 2});
|
||||
let remote = json!({"id": "x", "totp": "T", "future_field": 2, "modified_at": 3});
|
||||
let m = merge3(Some(&base), &local, &remote);
|
||||
assert!(m.merged.get("totp").is_none(), "local removal kept");
|
||||
assert_eq!(m.merged["future_field"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_base_everything_conflicts() {
|
||||
let m = merge3(None, &json!({"a": 1, "modified_at": 1}), &json!({"a": 2, "modified_at": 2}));
|
||||
assert_eq!(m.merged["a"], 2);
|
||||
assert!(m.local_lost);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! OPAQUE-3DH client (RFC 9807) via `opaque-ke` 3.
|
||||
//!
|
||||
//! Suite: ristretto255 OPRF and key group, TripleDH, SHA-512, identity KSF. The KSF is the
|
||||
//! identity because the "password" is `AUTH_INPUT`, which Argon2id has already stretched (docs/02).
|
||||
//! The suite and conventions are defined once in [`kv_proto::opaque`] and shared with the server.
|
||||
|
||||
use opaque_ke::{
|
||||
ClientLogin, ClientLoginFinishParameters, ClientRegistration, ClientRegistrationFinishParameters,
|
||||
CredentialResponse, RegistrationResponse,
|
||||
};
|
||||
use rand_core::OsRng;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::SyncError;
|
||||
|
||||
pub use kv_proto::opaque::OpaqueSuite as Suite;
|
||||
|
||||
fn proto(e: opaque_ke::errors::ProtocolError) -> SyncError {
|
||||
SyncError::Auth(format!("OPAQUE: {e:?}"))
|
||||
}
|
||||
|
||||
/// First half of registration. Send `request` to the server.
|
||||
pub struct RegistrationStart {
|
||||
pub request: Vec<u8>,
|
||||
state: ClientRegistration<Suite>,
|
||||
}
|
||||
|
||||
pub fn registration_start(auth_input: &[u8]) -> Result<RegistrationStart, SyncError> {
|
||||
let r = ClientRegistration::<Suite>::start(&mut OsRng, auth_input).map_err(proto)?;
|
||||
Ok(RegistrationStart { request: r.message.serialize().to_vec(), state: r.state })
|
||||
}
|
||||
|
||||
impl RegistrationStart {
|
||||
/// Returns the `RegistrationUpload` bytes for `SetupRequest.opaque_registration_upload`.
|
||||
pub fn finish(self, auth_input: &[u8], response: &[u8]) -> Result<Vec<u8>, SyncError> {
|
||||
let resp = RegistrationResponse::<Suite>::deserialize(response).map_err(proto)?;
|
||||
let r = self
|
||||
.state
|
||||
.finish(&mut OsRng, auth_input, resp, ClientRegistrationFinishParameters::default())
|
||||
.map_err(proto)?;
|
||||
Ok(r.message.serialize().to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
/// First half of login. Send `ke1` to the server.
|
||||
pub struct LoginStart {
|
||||
pub ke1: Vec<u8>,
|
||||
state: ClientLogin<Suite>,
|
||||
}
|
||||
|
||||
pub struct LoginFinished {
|
||||
pub ke3: Vec<u8>,
|
||||
pub session_key: Zeroizing<Vec<u8>>,
|
||||
}
|
||||
|
||||
pub fn login_start(auth_input: &[u8]) -> Result<LoginStart, SyncError> {
|
||||
let r = ClientLogin::<Suite>::start(&mut OsRng, auth_input).map_err(proto)?;
|
||||
Ok(LoginStart { ke1: r.message.serialize().to_vec(), state: r.state })
|
||||
}
|
||||
|
||||
impl LoginStart {
|
||||
/// A wrong password (or a server without our record) fails here with `BadCredentials`.
|
||||
pub fn finish(self, auth_input: &[u8], ke2: &[u8]) -> Result<LoginFinished, SyncError> {
|
||||
let resp = CredentialResponse::<Suite>::deserialize(ke2).map_err(proto)?;
|
||||
let r = self.state.finish(auth_input, resp, ClientLoginFinishParameters::default()).map_err(|e| match e {
|
||||
opaque_ke::errors::ProtocolError::InvalidLoginError => SyncError::BadCredentials,
|
||||
other => proto(other),
|
||||
})?;
|
||||
Ok(LoginFinished { ke3: r.message.serialize().to_vec(), session_key: Zeroizing::new(r.session_key.to_vec()) })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use opaque_ke::{ServerLogin, ServerLoginStartParameters, ServerRegistration, ServerSetup};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn register_then_login() {
|
||||
let setup = ServerSetup::<Suite>::new(&mut OsRng);
|
||||
let pwd = [42u8; 32];
|
||||
let cred_id = b"account";
|
||||
|
||||
let reg = registration_start(&pwd).unwrap();
|
||||
let req = opaque_ke::RegistrationRequest::<Suite>::deserialize(®.request).unwrap();
|
||||
let resp = ServerRegistration::<Suite>::start(&setup, req, cred_id).unwrap();
|
||||
let upload = reg.finish(&pwd, &resp.message.serialize()).unwrap();
|
||||
let record = ServerRegistration::<Suite>::finish(opaque_ke::RegistrationUpload::deserialize(&upload).unwrap());
|
||||
|
||||
let login = login_start(&pwd).unwrap();
|
||||
let ke1 = opaque_ke::CredentialRequest::<Suite>::deserialize(&login.ke1).unwrap();
|
||||
let srv = ServerLogin::start(
|
||||
&mut OsRng,
|
||||
&setup,
|
||||
Some(record.clone()),
|
||||
ke1,
|
||||
cred_id,
|
||||
ServerLoginStartParameters::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let done = login.finish(&pwd, &srv.message.serialize()).unwrap();
|
||||
let fin = srv.state.finish(opaque_ke::CredentialFinalization::deserialize(&done.ke3).unwrap()).unwrap();
|
||||
assert_eq!(fin.session_key.to_vec(), *done.session_key);
|
||||
|
||||
// Wrong password.
|
||||
let login = login_start(&[1u8; 32]).unwrap();
|
||||
let ke1 = opaque_ke::CredentialRequest::<Suite>::deserialize(&login.ke1).unwrap();
|
||||
let srv = ServerLogin::start(&mut OsRng, &setup, Some(record), ke1, cred_id, Default::default()).unwrap();
|
||||
assert!(matches!(login.finish(&[1u8; 32], &srv.message.serialize()), Err(SyncError::BadCredentials)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! RFC 9421 HTTP Message Signatures with the device key (docs/02 "Authentication to the server").
|
||||
//!
|
||||
//! The exact profile (label, components, nonce) lives in [`kv_proto::httpsig`] and is shared with
|
||||
//! the server. Covered: `"@method" "@target-uri" "content-digest" "kv-session" "kv-ts"`, plus
|
||||
//! `created`, `keyid` (device id), `alg="ecdsa-p256-sha256"` and a random `nonce`.
|
||||
//! The signature is IEEE P1363 `r || s` (64 bytes).
|
||||
|
||||
use base64::{
|
||||
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
|
||||
Engine,
|
||||
};
|
||||
pub use kv_proto::httpsig::{
|
||||
content_digest, signature_base, signature_params, HEADER_CONTENT_DIGEST as HDR_CONTENT_DIGEST,
|
||||
HEADER_SESSION as HDR_SESSION, HEADER_SIGNATURE as HDR_SIGNATURE, HEADER_SIGNATURE_INPUT as HDR_SIGNATURE_INPUT,
|
||||
HEADER_TS as HDR_TIMESTAMP, LABEL,
|
||||
};
|
||||
use kv_proto::httpsig::{signature_header, signature_input_header};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{DeviceSigner, SyncError};
|
||||
|
||||
/// Headers to attach to one request.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SignedHeaders {
|
||||
pub content_digest: String,
|
||||
pub session: String,
|
||||
pub timestamp: String,
|
||||
pub signature_input: String,
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
impl SignedHeaders {
|
||||
pub fn pairs(&self) -> [(&'static str, &str); 5] {
|
||||
[
|
||||
(HDR_CONTENT_DIGEST, &self.content_digest),
|
||||
(HDR_SESSION, &self.session),
|
||||
(HDR_TIMESTAMP, &self.timestamp),
|
||||
(HDR_SIGNATURE_INPUT, &self.signature_input),
|
||||
(HDR_SIGNATURE, &self.signature),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sign_request(
|
||||
signer: &dyn DeviceSigner,
|
||||
method: &str,
|
||||
target_uri: &str,
|
||||
body: &[u8],
|
||||
session: &str,
|
||||
now_unix: u64,
|
||||
) -> Result<SignedHeaders, SyncError> {
|
||||
let nonce = URL_SAFE_NO_PAD.encode(kv_crypto::random_bytes::<16>());
|
||||
sign_request_with_nonce(signer, method, target_uri, body, session, now_unix, &nonce)
|
||||
}
|
||||
|
||||
fn sign_request_with_nonce(
|
||||
signer: &dyn DeviceSigner,
|
||||
method: &str,
|
||||
target_uri: &str,
|
||||
body: &[u8],
|
||||
session: &str,
|
||||
now_unix: u64,
|
||||
nonce: &str,
|
||||
) -> Result<SignedHeaders, SyncError> {
|
||||
let digest = content_digest(body);
|
||||
let ts = now_unix.to_string();
|
||||
let params = signature_params(now_unix as i64, signer.device_id(), nonce);
|
||||
let base = signature_base(&method.to_ascii_uppercase(), target_uri, &digest, session, &ts, ¶ms);
|
||||
let sig = signer.sign(base.as_bytes())?;
|
||||
if sig.len() != 64 {
|
||||
return Err(SyncError::Signer(format!("device signature must be 64 bytes (r||s), got {}", sig.len())));
|
||||
}
|
||||
Ok(SignedHeaders {
|
||||
content_digest: digest,
|
||||
session: session.to_string(),
|
||||
timestamp: ts,
|
||||
signature_input: signature_input_header(¶ms),
|
||||
signature: signature_header(&sig),
|
||||
})
|
||||
}
|
||||
|
||||
/// Software P-256 device key: for tests and as the desktop fallback when no TPM is available.
|
||||
pub struct SoftwareSigner {
|
||||
id: Uuid,
|
||||
key: p256::ecdsa::SigningKey,
|
||||
}
|
||||
|
||||
impl SoftwareSigner {
|
||||
pub fn generate(id: Uuid) -> Self {
|
||||
Self { id, key: p256::ecdsa::SigningKey::random(&mut rand_core::OsRng) }
|
||||
}
|
||||
|
||||
/// From a 32-byte P-256 scalar.
|
||||
pub fn from_secret(id: Uuid, secret: &[u8]) -> Result<Self, SyncError> {
|
||||
let key = p256::ecdsa::SigningKey::from_slice(secret).map_err(|e| SyncError::Signer(e.to_string()))?;
|
||||
Ok(Self { id, key })
|
||||
}
|
||||
|
||||
pub fn secret_bytes(&self) -> zeroize::Zeroizing<Vec<u8>> {
|
||||
zeroize::Zeroizing::new(self.key.to_bytes().to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceSigner for SoftwareSigner {
|
||||
fn device_id(&self) -> Uuid {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn public_key(&self) -> Vec<u8> {
|
||||
self.key.verifying_key().to_encoded_point(false).as_bytes().to_vec()
|
||||
}
|
||||
|
||||
fn sign(&self, message: &[u8]) -> Result<Vec<u8>, SyncError> {
|
||||
use p256::ecdsa::signature::Signer;
|
||||
let sig: p256::ecdsa::Signature = self.key.sign(message);
|
||||
Ok(sig.to_bytes().to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
/// Server-side check, used by the test server and handy for the server crate's own tests.
|
||||
pub fn verify_request(
|
||||
p256_pk_sec1: &[u8],
|
||||
method: &str,
|
||||
target_uri: &str,
|
||||
body: &[u8],
|
||||
headers: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Result<Uuid, SyncError> {
|
||||
use p256::ecdsa::signature::Verifier;
|
||||
let bad = |m: &str| SyncError::Server { status: 401, code: "bad_signature".into(), message: m.into() };
|
||||
let digest = headers(HDR_CONTENT_DIGEST).ok_or_else(|| bad("missing content-digest"))?;
|
||||
if digest != content_digest(body) {
|
||||
return Err(bad("content-digest mismatch"));
|
||||
}
|
||||
let session = headers(HDR_SESSION).ok_or_else(|| bad("missing session"))?;
|
||||
let ts = headers(HDR_TIMESTAMP).ok_or_else(|| bad("missing timestamp"))?;
|
||||
let input = headers(HDR_SIGNATURE_INPUT).ok_or_else(|| bad("missing signature-input"))?;
|
||||
let params = input.strip_prefix(&format!("{LABEL}=")).ok_or_else(|| bad("signature label"))?;
|
||||
let key_id = params
|
||||
.split(";keyid=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.and_then(|s| Uuid::parse_str(s).ok())
|
||||
.ok_or_else(|| bad("keyid"))?;
|
||||
let sig = headers(HDR_SIGNATURE).ok_or_else(|| bad("missing signature"))?;
|
||||
let sig_b64 =
|
||||
sig.strip_prefix(&format!("{LABEL}=:")).and_then(|s| s.strip_suffix(':')).ok_or_else(|| bad("signature"))?;
|
||||
let sig_raw = STANDARD.decode(sig_b64).map_err(|_| bad("signature b64"))?;
|
||||
let base = signature_base(method, target_uri, &digest, &session, &ts, params);
|
||||
let vk = p256::ecdsa::VerifyingKey::from_sec1_bytes(p256_pk_sec1).map_err(|_| bad("device key"))?;
|
||||
let sig = p256::ecdsa::Signature::from_slice(&sig_raw).map_err(|_| bad("signature encoding"))?;
|
||||
vk.verify(base.as_bytes(), &sig).map_err(|_| bad("signature invalid"))?;
|
||||
Ok(key_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn headers_follow_the_shared_profile() {
|
||||
let signer = SoftwareSigner::generate(Uuid::parse_str("11111111-2222-4333-8444-555555555555").unwrap());
|
||||
let h = sign_request_with_nonce(
|
||||
&signer,
|
||||
"put",
|
||||
"https://v.example/v1/x",
|
||||
b"{}",
|
||||
"sess",
|
||||
1_700_000_000,
|
||||
"AAAAAAAAAAAAAAAAAAAAAA",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(h.content_digest, "sha-256=:RBNvo1WzZ4oRRq0W9+hknpT7T8If536DEMBg9hyq/4o=:");
|
||||
assert_eq!(h.timestamp, "1700000000");
|
||||
assert_eq!(
|
||||
h.signature_input,
|
||||
r#"kv=("@method" "@target-uri" "content-digest" "kv-session" "kv-ts");created=1700000000;keyid="11111111-2222-4333-8444-555555555555";alg="ecdsa-p256-sha256";nonce="AAAAAAAAAAAAAAAAAAAAAA""#
|
||||
);
|
||||
assert!(h.signature.starts_with("kv=:") && h.signature.ends_with(':'));
|
||||
// Fresh nonce per request.
|
||||
let a = sign_request(&signer, "GET", "https://v.example/v1/x", b"", "s", 1).unwrap();
|
||||
let b = sign_request(&signer, "GET", "https://v.example/v1/x", b"", "s", 1).unwrap();
|
||||
assert_ne!(a.signature_input, b.signature_input);
|
||||
assert!(kv_proto::httpsig::valid_nonce(
|
||||
a.signature_input.split("nonce=\"").nth(1).unwrap().trim_end_matches('"')
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_and_verify() {
|
||||
let signer = SoftwareSigner::generate(Uuid::new_v4());
|
||||
let url = "https://vault.example.com/v1/vaults";
|
||||
let h = sign_request(&signer, "get", url, b"", "s1", 42).unwrap();
|
||||
let lookup = |name: &str| h.pairs().iter().find(|(k, _)| *k == name).map(|(_, v)| v.to_string());
|
||||
assert_eq!(verify_request(&signer.public_key(), "GET", url, b"", &lookup).unwrap(), signer.device_id());
|
||||
// Different body or URL fails.
|
||||
assert!(verify_request(&signer.public_key(), "GET", url, b"x", &lookup).is_err());
|
||||
assert!(verify_request(&signer.public_key(), "GET", "https://evil/v1/vaults", b"", &lookup).is_err());
|
||||
let other = SoftwareSigner::generate(Uuid::new_v4());
|
||||
assert!(verify_request(&other.public_key(), "GET", url, b"", &lookup).is_err());
|
||||
// Round-trip of the software key.
|
||||
let again = SoftwareSigner::from_secret(signer.device_id(), &signer.secret_bytes()).unwrap();
|
||||
assert_eq!(again.public_key(), signer.public_key());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
//! An in-memory fake of kestrove-server on top of `wiremock`, for tests (feature `test-server`).
|
||||
//!
|
||||
//! It runs real OPAQUE, verifies RFC 9421 signatures and implements the item / changes / manifest
|
||||
//! semantics of docs/03, plus hooks to misbehave (roll back the manifest, hide changes, …).
|
||||
|
||||
// Handlers short-circuit with the error response itself; size does not matter in a test helper.
|
||||
#![allow(clippy::result_large_err)]
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
sync::{Arc, Mutex, MutexGuard},
|
||||
};
|
||||
|
||||
use kv_proto::{
|
||||
AccountInfo, ApiError, Changes, DeviceInfo, Envelope, ItemPut, ItemRecord, KdfParams, LoginFinish,
|
||||
LoginFinishResponse, LoginStart, LoginStartResponse, LoginStatus, Manifest, PreloginResponse,
|
||||
RegistrationStartResponse, ServerInfo, SetupFinish, SetupStart, Vault,
|
||||
};
|
||||
use opaque_ke::{
|
||||
CredentialFinalization, CredentialRequest, RegistrationRequest, RegistrationUpload, ServerLogin,
|
||||
ServerLoginStartParameters, ServerRegistration, ServerSetup,
|
||||
};
|
||||
use rand_core::OsRng;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use uuid::Uuid;
|
||||
use wiremock::{matchers::any, Mock, MockServer, Request, Respond, ResponseTemplate};
|
||||
|
||||
use crate::{opaque::Suite, signing};
|
||||
|
||||
pub struct Account {
|
||||
pub account_id: Uuid,
|
||||
pub kdf: KdfParams,
|
||||
pub record: ServerRegistration<Suite>,
|
||||
pub enc_pk: Vec<u8>,
|
||||
pub sig_pk: Vec<u8>,
|
||||
pub wrapped_keyset: Envelope,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct VaultState {
|
||||
pub head: u64,
|
||||
pub sealed_key: Option<Envelope>,
|
||||
pub meta: Option<Envelope>,
|
||||
pub items: BTreeMap<Uuid, ItemRecord>,
|
||||
pub manifest: Option<Manifest>,
|
||||
/// Every manifest ever stored, oldest first (for rollback attacks).
|
||||
pub manifest_history: Vec<Manifest>,
|
||||
}
|
||||
|
||||
pub struct Device {
|
||||
pub p256_pk: Vec<u8>,
|
||||
pub approved: bool,
|
||||
pub name: String,
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
pub struct State {
|
||||
pub setup: ServerSetup<Suite>,
|
||||
pub setup_token: Option<String>,
|
||||
pub account: Option<Account>,
|
||||
pub devices: HashMap<Uuid, Device>,
|
||||
pub sessions: HashMap<String, Uuid>,
|
||||
logins: HashMap<String, (Vec<u8>, Uuid)>,
|
||||
pub vaults: BTreeMap<Uuid, VaultState>,
|
||||
/// New devices need approval (else they are auto-approved).
|
||||
pub require_approval: bool,
|
||||
/// Requests seen, as "METHOD /path".
|
||||
pub log: Vec<String>,
|
||||
/// Item ids `/changes` silently omits (a hiding attack).
|
||||
pub hide_items: Vec<Uuid>,
|
||||
/// When set, `GET /manifest` serves this instead of the latest.
|
||||
pub serve_manifest: Option<Manifest>,
|
||||
/// Number of upcoming item writes to reject with 412 regardless of revision.
|
||||
pub force_412: u32,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn new(setup_token: &str) -> Self {
|
||||
Self {
|
||||
setup: ServerSetup::new(&mut OsRng),
|
||||
setup_token: Some(setup_token.into()),
|
||||
account: None,
|
||||
devices: HashMap::new(),
|
||||
sessions: HashMap::new(),
|
||||
logins: HashMap::new(),
|
||||
vaults: BTreeMap::new(),
|
||||
require_approval: false,
|
||||
log: vec![],
|
||||
hide_items: vec![],
|
||||
serve_manifest: None,
|
||||
force_412: 0,
|
||||
base_url: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes an item as another device would (bumps the head).
|
||||
pub fn write_item(&mut self, vault: Uuid, record: ItemRecord) -> u64 {
|
||||
let v = self.vaults.entry(vault).or_default();
|
||||
v.head += 1;
|
||||
let rec = ItemRecord { revision: v.head, ..record };
|
||||
v.items.insert(rec.item_id, rec);
|
||||
v.head
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TestServer {
|
||||
pub server: Arc<MockServer>,
|
||||
pub state: Arc<Mutex<State>>,
|
||||
}
|
||||
|
||||
pub const SETUP_TOKEN: &str = "test-setup-token";
|
||||
|
||||
impl TestServer {
|
||||
pub async fn start() -> Self {
|
||||
let server = MockServer::start().await;
|
||||
let mut st = State::new(SETUP_TOKEN);
|
||||
st.base_url = server.uri();
|
||||
let state = Arc::new(Mutex::new(st));
|
||||
Mock::given(any()).respond_with(Handler { state: state.clone() }).mount(&server).await;
|
||||
Self { server: Arc::new(server), state }
|
||||
}
|
||||
|
||||
pub fn url(&self) -> String {
|
||||
self.server.uri()
|
||||
}
|
||||
|
||||
pub fn state(&self) -> MutexGuard<'_, State> {
|
||||
self.state.lock().unwrap_or_else(|p| p.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
struct Handler {
|
||||
state: Arc<Mutex<State>>,
|
||||
}
|
||||
|
||||
fn json<T: Serialize>(status: u16, v: &T) -> ResponseTemplate {
|
||||
ResponseTemplate::new(status).set_body_json(v)
|
||||
}
|
||||
|
||||
fn err(status: u16, code: &str) -> ResponseTemplate {
|
||||
json(status, &ApiError { code: code.into(), message: code.into() })
|
||||
}
|
||||
|
||||
fn parse<T: DeserializeOwned>(req: &Request) -> Result<T, ResponseTemplate> {
|
||||
serde_json::from_slice(&req.body).map_err(|_| err(400, "bad_request"))
|
||||
}
|
||||
|
||||
fn if_match(req: &Request) -> Result<u64, ResponseTemplate> {
|
||||
req.headers
|
||||
.get("if-match")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.trim_matches('"').parse().ok())
|
||||
.ok_or_else(|| err(400, "if_match_required"))
|
||||
}
|
||||
|
||||
fn now() -> String {
|
||||
"2026-10-15T00:00:00Z".into()
|
||||
}
|
||||
|
||||
impl Respond for Handler {
|
||||
fn respond(&self, req: &Request) -> ResponseTemplate {
|
||||
let mut st = self.state.lock().unwrap_or_else(|p| p.into_inner());
|
||||
st.log.push(format!("{} {}", req.method, req.url.path()));
|
||||
handle(&mut st, req).unwrap_or_else(|e| e)
|
||||
}
|
||||
}
|
||||
|
||||
fn authenticate(st: &State, req: &Request) -> Result<Uuid, ResponseTemplate> {
|
||||
let header = |name: &str| req.headers.get(name).and_then(|v| v.to_str().ok()).map(str::to_string);
|
||||
let session = header(signing::HDR_SESSION).ok_or_else(|| err(401, "unauthorized"))?;
|
||||
let device = *st.sessions.get(&session).ok_or_else(|| err(401, "unauthorized"))?;
|
||||
let dev = st.devices.get(&device).ok_or_else(|| err(401, "unauthorized"))?;
|
||||
// wiremock rebuilds `req.url` without the port; the signed @target-uri is the URL the client used.
|
||||
let mut target = format!("{}{}", st.base_url, req.url.path());
|
||||
if let Some(q) = req.url.query() {
|
||||
target = format!("{target}?{q}");
|
||||
}
|
||||
let key_id = signing::verify_request(&dev.p256_pk, req.method.as_str(), &target, &req.body, &header)
|
||||
.map_err(|_| err(401, "bad_signature"))?;
|
||||
if key_id != device || !dev.approved {
|
||||
return Err(err(401, "unauthorized"));
|
||||
}
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
fn handle(st: &mut State, req: &Request) -> Result<ResponseTemplate, ResponseTemplate> {
|
||||
let path = req.url.path().to_string();
|
||||
let method = req.method.as_str().to_string();
|
||||
let segs: Vec<&str> = path.trim_start_matches('/').split('/').collect();
|
||||
match (method.as_str(), segs.as_slice()) {
|
||||
("GET", ["v1", "server-info"]) => Ok(json(
|
||||
200,
|
||||
&ServerInfo {
|
||||
version: "test".into(),
|
||||
api: 1,
|
||||
single_tenant: true,
|
||||
setup_open: st.setup_token.is_some(),
|
||||
min_client: "0.1.0".into(),
|
||||
},
|
||||
)),
|
||||
("POST", ["v1", "setup", "start"]) => {
|
||||
let r: SetupStart = parse(req)?;
|
||||
if st.setup_token.as_deref() != Some(r.setup_token.as_str()) {
|
||||
return Err(err(if st.setup_token.is_none() { 404 } else { 401 }, "setup"));
|
||||
}
|
||||
let msg = RegistrationRequest::<Suite>::deserialize(&r.registration_request)
|
||||
.map_err(|_| err(400, "bad_request"))?;
|
||||
let resp = ServerRegistration::<Suite>::start(&st.setup, msg, r.account_id.as_bytes())
|
||||
.map_err(|_| err(400, "bad_request"))?;
|
||||
Ok(json(200, &RegistrationStartResponse { registration_response: resp.message.serialize().to_vec() }))
|
||||
}
|
||||
("POST", ["v1", "setup", "finish"]) => {
|
||||
let r: SetupFinish = parse(req)?;
|
||||
if st.setup_token.as_deref() != Some(r.setup_token.as_str()) {
|
||||
return Err(err(if st.setup_token.is_none() { 404 } else { 401 }, "setup"));
|
||||
}
|
||||
let upload = RegistrationUpload::<Suite>::deserialize(&r.opaque_registration_upload)
|
||||
.map_err(|_| err(400, "bad_request"))?;
|
||||
st.account = Some(Account {
|
||||
account_id: r.account_id,
|
||||
kdf: r.kdf,
|
||||
record: ServerRegistration::finish(upload),
|
||||
enc_pk: r.enc_pk,
|
||||
sig_pk: r.sig_pk,
|
||||
wrapped_keyset: r.wrapped_keyset,
|
||||
});
|
||||
st.devices.insert(
|
||||
r.device.device_id,
|
||||
Device { p256_pk: r.device.p256_pk, approved: true, name: r.device.name, revoked: false },
|
||||
);
|
||||
st.setup_token = None;
|
||||
Ok(ResponseTemplate::new(201))
|
||||
}
|
||||
("GET", ["v1", "auth", "prelogin"]) => {
|
||||
let acct = st.account.as_ref().ok_or_else(|| err(404, "not_found"))?;
|
||||
Ok(json(200, &PreloginResponse { account_id: acct.account_id, kdf: acct.kdf.clone() }))
|
||||
}
|
||||
("POST", ["v1", "auth", "login", "start"]) => {
|
||||
let r: LoginStart = parse(req)?;
|
||||
let acct = st.account.as_ref().filter(|a| a.account_id == r.account_id);
|
||||
let ke1 = CredentialRequest::<Suite>::deserialize(&r.ke1).map_err(|_| err(400, "bad_request"))?;
|
||||
let res = ServerLogin::start(
|
||||
&mut OsRng,
|
||||
&st.setup,
|
||||
acct.map(|a| a.record.clone()),
|
||||
ke1,
|
||||
r.account_id.as_bytes(),
|
||||
ServerLoginStartParameters::default(),
|
||||
)
|
||||
.map_err(|_| err(400, "bad_request"))?;
|
||||
let kdf = acct.map(|a| a.kdf.clone()).ok_or_else(|| err(401, "unauthorized"))?;
|
||||
let require = st.require_approval;
|
||||
st.devices.entry(r.device.device_id).or_insert(Device {
|
||||
p256_pk: r.device.p256_pk,
|
||||
approved: !require,
|
||||
name: r.device.name,
|
||||
revoked: false,
|
||||
});
|
||||
let login_id = Uuid::new_v4().to_string();
|
||||
st.logins.insert(login_id.clone(), (res.state.serialize().to_vec(), r.device.device_id));
|
||||
Ok(json(200, &LoginStartResponse { login_id, ke2: res.message.serialize().to_vec(), kdf }))
|
||||
}
|
||||
("POST", ["v1", "auth", "login", "finish"]) => {
|
||||
let r: LoginFinish = parse(req)?;
|
||||
let (state, device) = st.logins.remove(&r.login_id).ok_or_else(|| err(401, "unauthorized"))?;
|
||||
let login = ServerLogin::<Suite>::deserialize(&state).map_err(|_| err(401, "unauthorized"))?;
|
||||
let ke3 = CredentialFinalization::deserialize(&r.ke3).map_err(|_| err(401, "unauthorized"))?;
|
||||
login.finish(ke3).map_err(|_| err(401, "unauthorized"))?;
|
||||
if !st.devices.get(&device).is_some_and(|d| d.approved) {
|
||||
return Ok(json(
|
||||
200,
|
||||
&LoginFinishResponse { status: LoginStatus::PendingApproval, session: None, expires_at: None },
|
||||
));
|
||||
}
|
||||
let session = Uuid::new_v4().to_string();
|
||||
st.sessions.insert(session.clone(), device);
|
||||
Ok(json(
|
||||
200,
|
||||
&LoginFinishResponse { status: LoginStatus::Ok, session: Some(session), expires_at: Some(now()) },
|
||||
))
|
||||
}
|
||||
_ => {
|
||||
let device = authenticate(st, req)?;
|
||||
authed(st, req, device, &method, &segs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn authed(
|
||||
st: &mut State,
|
||||
req: &Request,
|
||||
device: Uuid,
|
||||
method: &str,
|
||||
segs: &[&str],
|
||||
) -> Result<ResponseTemplate, ResponseTemplate> {
|
||||
match (method, segs) {
|
||||
("POST", ["v1", "auth", "logout"]) => {
|
||||
st.sessions.retain(|_, d| *d != device);
|
||||
Ok(ResponseTemplate::new(204))
|
||||
}
|
||||
("GET", ["v1", "account"]) => {
|
||||
let a = st.account.as_ref().ok_or_else(|| err(404, "not_found"))?;
|
||||
Ok(json(
|
||||
200,
|
||||
&AccountInfo {
|
||||
account_id: a.account_id,
|
||||
kdf: a.kdf.clone(),
|
||||
enc_pk: a.enc_pk.clone(),
|
||||
sig_pk: a.sig_pk.clone(),
|
||||
wrapped_keyset: a.wrapped_keyset.clone(),
|
||||
},
|
||||
))
|
||||
}
|
||||
("GET", ["v1", "devices"]) => {
|
||||
let list: Vec<DeviceInfo> = st
|
||||
.devices
|
||||
.iter()
|
||||
.map(|(id, d)| DeviceInfo {
|
||||
device_id: *id,
|
||||
name: d.name.clone(),
|
||||
platform: kv_proto::Platform::Linux,
|
||||
p256_pk: d.p256_pk.clone(),
|
||||
approved: d.approved,
|
||||
revoked_at: d.revoked.then(now),
|
||||
created_at: now(),
|
||||
current: *id == device,
|
||||
})
|
||||
.collect();
|
||||
Ok(json(200, &list))
|
||||
}
|
||||
("POST", ["v1", "devices", id, "approve"]) => {
|
||||
let id = Uuid::parse_str(id).map_err(|_| err(404, "not_found"))?;
|
||||
let d = st.devices.get_mut(&id).filter(|d| !d.revoked).ok_or_else(|| err(404, "not_found"))?;
|
||||
d.approved = true;
|
||||
Ok(ResponseTemplate::new(204))
|
||||
}
|
||||
("DELETE", ["v1", "devices", id]) => {
|
||||
let id = Uuid::parse_str(id).map_err(|_| err(404, "not_found"))?;
|
||||
let d = st.devices.get_mut(&id).filter(|d| !d.revoked).ok_or_else(|| err(404, "not_found"))?;
|
||||
d.approved = false;
|
||||
d.revoked = true;
|
||||
st.sessions.retain(|_, dev| *dev != id);
|
||||
Ok(ResponseTemplate::new(204))
|
||||
}
|
||||
("GET", ["v1", "vaults"]) => {
|
||||
let list: Vec<Vault> = st
|
||||
.vaults
|
||||
.iter()
|
||||
.filter_map(|(id, v)| {
|
||||
Some(Vault {
|
||||
vault_id: *id,
|
||||
head_revision: v.head,
|
||||
sealed_key: v.sealed_key.clone()?,
|
||||
meta: v.meta.clone()?,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json(200, &list))
|
||||
}
|
||||
("POST", ["v1", "vaults"]) => {
|
||||
let v: Vault = parse(req)?;
|
||||
if st.vaults.get(&v.vault_id).is_some_and(|x| x.sealed_key.is_some()) {
|
||||
return Err(err(409, "version_conflict"));
|
||||
}
|
||||
let e = st.vaults.entry(v.vault_id).or_default();
|
||||
e.sealed_key = Some(v.sealed_key);
|
||||
e.meta = Some(v.meta);
|
||||
Ok(ResponseTemplate::new(201))
|
||||
}
|
||||
("GET", ["v1", "vaults", vid, "changes"]) => {
|
||||
let vid = Uuid::parse_str(vid).map_err(|_| err(404, "not_found"))?;
|
||||
let q: HashMap<String, String> = req.url.query_pairs().into_owned().collect();
|
||||
let since: u64 = q.get("since").and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let limit: usize = q.get("limit").and_then(|s| s.parse().ok()).unwrap_or(500);
|
||||
let hide = st.hide_items.clone();
|
||||
let v = st.vaults.get(&vid).ok_or_else(|| err(404, "not_found"))?;
|
||||
let mut items: Vec<ItemRecord> =
|
||||
v.items.values().filter(|r| r.revision > since && !hide.contains(&r.item_id)).cloned().collect();
|
||||
items.sort_by_key(|r| r.revision);
|
||||
let more = items.len() > limit;
|
||||
items.truncate(limit);
|
||||
Ok(json(200, &Changes { items, head_revision: v.head, more }))
|
||||
}
|
||||
("PUT" | "DELETE", ["v1", "vaults", vid, "items", iid]) => {
|
||||
let vid = Uuid::parse_str(vid).map_err(|_| err(404, "not_found"))?;
|
||||
let iid = Uuid::parse_str(iid).map_err(|_| err(404, "not_found"))?;
|
||||
let base = if_match(req)?;
|
||||
let body: Option<ItemPut> = if method == "PUT" { Some(parse(req)?) } else { None };
|
||||
if st.force_412 > 0 {
|
||||
st.force_412 -= 1;
|
||||
return Err(err(412, "revision_conflict"));
|
||||
}
|
||||
let v = st.vaults.get_mut(&vid).ok_or_else(|| err(404, "not_found"))?;
|
||||
let current = v.items.get(&iid).map_or(0, |r| r.revision);
|
||||
if current != base {
|
||||
return Err(err(412, "revision_conflict"));
|
||||
}
|
||||
if body.is_none() && current == 0 {
|
||||
return Err(err(404, "not_found"));
|
||||
}
|
||||
v.head += 1;
|
||||
let rec = ItemRecord {
|
||||
item_id: iid,
|
||||
revision: v.head,
|
||||
deleted: body.is_none(),
|
||||
wrapped_item_key: body.as_ref().map(|b| b.wrapped_item_key.clone()),
|
||||
payload: body.map(|b| b.payload),
|
||||
updated_by: device,
|
||||
updated_at: now(),
|
||||
};
|
||||
v.items.insert(iid, rec.clone());
|
||||
Ok(json(200, &rec))
|
||||
}
|
||||
("GET", ["v1", "vaults", vid, "manifest"]) => {
|
||||
let vid = Uuid::parse_str(vid).map_err(|_| err(404, "not_found"))?;
|
||||
if let Some(m) = &st.serve_manifest {
|
||||
return Ok(json(200, m));
|
||||
}
|
||||
let v = st.vaults.get(&vid).ok_or_else(|| err(404, "not_found"))?;
|
||||
v.manifest.as_ref().map(|m| json(200, m)).ok_or_else(|| err(404, "not_found"))
|
||||
}
|
||||
("PUT", ["v1", "vaults", vid, "manifest"]) => {
|
||||
let vid = Uuid::parse_str(vid).map_err(|_| err(404, "not_found"))?;
|
||||
let m: Manifest = parse(req)?;
|
||||
let v = st.vaults.get_mut(&vid).ok_or_else(|| err(404, "not_found"))?;
|
||||
if m.version != v.manifest.as_ref().map_or(0, |x| x.version) + 1 {
|
||||
return Err(err(409, "version_conflict"));
|
||||
}
|
||||
v.manifest_history.push(m.clone());
|
||||
v.manifest = Some(m);
|
||||
Ok(ResponseTemplate::new(204))
|
||||
}
|
||||
_ => Err(err(404, "not_found")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
//! HTTPS transport: rustls, TLS 1.3 only, SPKI pinning (docs/02 "Transport").
|
||||
//!
|
||||
//! A pin is the base64 SHA-256 of a certificate's DER `SubjectPublicKeyInfo` (the HPKP
|
||||
//! `pin-sha256` format). Normal WebPKI chain validation always runs first; on top of that, at
|
||||
//! least one certificate the server presents (leaf or intermediate) must match a pin; a root that the
|
||||
//! server does not send cannot be pinned. Configure
|
||||
//! a primary and a backup pin so a key rotation never locks the client out.
|
||||
//!
|
||||
//! Plain `http://` is accepted only for loopback hosts (local development and tests).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::{
|
||||
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
|
||||
Engine,
|
||||
};
|
||||
use rustls::{
|
||||
client::{
|
||||
danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
|
||||
WebPkiServerVerifier,
|
||||
},
|
||||
pki_types::{CertificateDer, ServerName, UnixTime},
|
||||
CertificateError, DigitallySignedStruct, OtherError, RootCertStore, SignatureScheme,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{ServerConfig, SyncError};
|
||||
|
||||
/// Raised inside the TLS handshake when no certificate matches a pin.
|
||||
#[derive(Debug)]
|
||||
pub struct PinMismatch;
|
||||
|
||||
impl std::fmt::Display for PinMismatch {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("server certificate does not match any pinned SPKI hash")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PinMismatch {}
|
||||
|
||||
/// Decodes `sha256/…`, standard or URL-safe base64 pins.
|
||||
pub fn parse_pin(pin: &str) -> Result<[u8; 32], SyncError> {
|
||||
let p = pin.trim().trim_start_matches("sha256/");
|
||||
let raw = STANDARD
|
||||
.decode(p)
|
||||
.or_else(|_| URL_SAFE_NO_PAD.decode(p.trim_end_matches('=')))
|
||||
.map_err(|_| SyncError::Config(format!("pin is not base64: {pin}")))?;
|
||||
raw.try_into().map_err(|_| SyncError::Config(format!("pin is not a SHA-256 hash: {pin}")))
|
||||
}
|
||||
|
||||
/// `base64(SHA-256(SPKI))` of a DER certificate. Useful for tooling and tests.
|
||||
pub fn spki_pin_of(cert_der: &[u8]) -> Result<String, SyncError> {
|
||||
Ok(STANDARD.encode(spki_hash(&CertificateDer::from(cert_der))?))
|
||||
}
|
||||
|
||||
fn spki_hash(cert: &CertificateDer<'_>) -> Result<[u8; 32], SyncError> {
|
||||
let parsed = webpki::EndEntityCert::try_from(cert).map_err(|e| SyncError::Config(format!("certificate: {e}")))?;
|
||||
Ok(Sha256::digest(parsed.subject_public_key_info().as_ref()).into())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PinningVerifier {
|
||||
inner: Arc<WebPkiServerVerifier>,
|
||||
pins: Vec<[u8; 32]>,
|
||||
}
|
||||
|
||||
impl ServerCertVerifier for PinningVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
end_entity: &CertificateDer<'_>,
|
||||
intermediates: &[CertificateDer<'_>],
|
||||
server_name: &ServerName<'_>,
|
||||
ocsp_response: &[u8],
|
||||
now: UnixTime,
|
||||
) -> Result<ServerCertVerified, rustls::Error> {
|
||||
let verified = self.inner.verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now)?;
|
||||
if self.pins.is_empty() {
|
||||
return Ok(verified);
|
||||
}
|
||||
let matched = std::iter::once(end_entity)
|
||||
.chain(intermediates)
|
||||
.filter_map(|c| spki_hash(c).ok())
|
||||
.any(|h| self.pins.contains(&h));
|
||||
if matched {
|
||||
Ok(verified)
|
||||
} else {
|
||||
Err(rustls::Error::InvalidCertificate(CertificateError::Other(OtherError(Arc::new(PinMismatch)))))
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
// Unreachable: the config only enables TLS 1.3.
|
||||
Err(rustls::Error::PeerIncompatible(rustls::PeerIncompatible::Tls12NotOffered))
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
self.inner.verify_tls13_signature(message, cert, dss)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||
self.inner.supported_verify_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the rustls config: TLS 1.3 only, ring provider, Mozilla roots plus `extra_roots`, pin check.
|
||||
pub fn tls_config(pins: &[String], extra_roots: &[Vec<u8>]) -> Result<rustls::ClientConfig, SyncError> {
|
||||
let pins = pins.iter().map(|p| parse_pin(p)).collect::<Result<Vec<_>, _>>()?;
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let mut roots = RootCertStore { roots: webpki_roots::TLS_SERVER_ROOTS.to_vec() };
|
||||
for der in extra_roots {
|
||||
roots.add(CertificateDer::from(der.clone())).map_err(|e| SyncError::Config(format!("root: {e}")))?;
|
||||
}
|
||||
let inner = WebPkiServerVerifier::builder_with_provider(Arc::new(roots), provider.clone())
|
||||
.build()
|
||||
.map_err(|e| SyncError::Config(e.to_string()))?;
|
||||
let mut cfg = rustls::ClientConfig::builder_with_provider(provider)
|
||||
.with_protocol_versions(&[&rustls::version::TLS13])
|
||||
.map_err(|e| SyncError::Config(e.to_string()))?
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(PinningVerifier { inner, pins }))
|
||||
.with_no_client_auth();
|
||||
cfg.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
fn is_loopback(url: &reqwest::Url) -> bool {
|
||||
match url.host() {
|
||||
Some(url::Host::Domain(d)) => d == "localhost" || d.ends_with(".localhost"),
|
||||
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
|
||||
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates the base URL (https, or http on loopback) and normalizes it to end without a slash.
|
||||
pub fn normalize_base_url(raw: &str) -> Result<String, SyncError> {
|
||||
let url = reqwest::Url::parse(raw.trim()).map_err(|e| SyncError::Config(format!("server URL: {e}")))?;
|
||||
match url.scheme() {
|
||||
"https" => {}
|
||||
"http" if is_loopback(&url) => {}
|
||||
_ => return Err(SyncError::Config("server URL must use https".into())),
|
||||
}
|
||||
if url.query().is_some() || url.fragment().is_some() {
|
||||
return Err(SyncError::Config("server URL must not have a query or fragment".into()));
|
||||
}
|
||||
Ok(url.as_str().trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
pub fn http_client(cfg: &ServerConfig) -> Result<reqwest::Client, SyncError> {
|
||||
reqwest::Client::builder()
|
||||
.use_preconfigured_tls(tls_config(&cfg.spki_pins, &cfg.extra_roots_der)?)
|
||||
.https_only(false) // enforced by normalize_base_url (http only on loopback)
|
||||
.connect_timeout(std::time::Duration::from_secs(15))
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.user_agent(concat!("kestrove-core/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.map_err(|e| SyncError::Config(e.to_string()))
|
||||
}
|
||||
|
||||
/// Maps a reqwest error to `PinMismatch` when the handshake failed on our pin check.
|
||||
pub(crate) fn classify(e: reqwest::Error) -> SyncError {
|
||||
let mut src: Option<&(dyn std::error::Error + 'static)> = Some(&e);
|
||||
while let Some(err) = src {
|
||||
if err.downcast_ref::<PinMismatch>().is_some() {
|
||||
return SyncError::PinMismatch;
|
||||
}
|
||||
// `io::Error::source()` skips the wrapped error itself, so look inside explicitly.
|
||||
let tls = err
|
||||
.downcast_ref::<rustls::Error>()
|
||||
.or_else(|| err.downcast_ref::<std::io::Error>().and_then(|io| io.get_ref()?.downcast_ref()));
|
||||
if let Some(rustls::Error::InvalidCertificate(CertificateError::Other(o))) = tls {
|
||||
if o.0.downcast_ref::<PinMismatch>().is_some() {
|
||||
return SyncError::PinMismatch;
|
||||
}
|
||||
}
|
||||
src = err.source();
|
||||
}
|
||||
if format!("{e:?}").contains("PinMismatch") {
|
||||
return SyncError::PinMismatch;
|
||||
}
|
||||
SyncError::Network(e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base_url_rules() {
|
||||
assert_eq!(normalize_base_url("https://vault.example.com/").unwrap(), "https://vault.example.com");
|
||||
assert_eq!(normalize_base_url("http://127.0.0.1:8080").unwrap(), "http://127.0.0.1:8080");
|
||||
assert!(normalize_base_url("http://localhost:3000").is_ok());
|
||||
assert!(normalize_base_url("http://vault.example.com").is_err());
|
||||
assert!(normalize_base_url("ftp://x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_formats() {
|
||||
let raw = [7u8; 32];
|
||||
assert_eq!(parse_pin(&STANDARD.encode(raw)).unwrap(), raw);
|
||||
assert_eq!(parse_pin(&format!("sha256/{}", STANDARD.encode(raw))).unwrap(), raw);
|
||||
assert_eq!(parse_pin(&URL_SAFE_NO_PAD.encode(raw)).unwrap(), raw);
|
||||
assert!(parse_pin("AAAA").is_err());
|
||||
assert!(tls_config(&["nope!".into()], &[]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! API paths (docs/03, api/openapi.yaml). The request/response types live in `kv-proto`.
|
||||
|
||||
pub const PATH_SERVER_INFO: &str = "/v1/server-info";
|
||||
pub const PATH_SETUP_START: &str = "/v1/setup/start";
|
||||
pub const PATH_SETUP_FINISH: &str = "/v1/setup/finish";
|
||||
pub const PATH_PRELOGIN: &str = "/v1/auth/prelogin";
|
||||
pub const PATH_LOGIN_START: &str = "/v1/auth/login/start";
|
||||
pub const PATH_LOGIN_FINISH: &str = "/v1/auth/login/finish";
|
||||
pub const PATH_LOGOUT: &str = "/v1/auth/logout";
|
||||
pub const PATH_ACCOUNT: &str = "/v1/account";
|
||||
pub const PATH_VAULTS: &str = "/v1/vaults";
|
||||
pub const PATH_DEVICES: &str = "/v1/devices";
|
||||
@@ -0,0 +1,524 @@
|
||||
//! End-to-end sync tests against the in-memory fake server (wiremock).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kv_crypto::{
|
||||
aad::{Aad, ObjectType},
|
||||
aead::SymKey,
|
||||
keys::AccountKeyset,
|
||||
};
|
||||
use kv_proto::{DeviceEnroll, ItemRecord, Platform};
|
||||
use kv_store::{MemoryStore, Store, SyncState, VaultRow};
|
||||
use kv_sync::{
|
||||
engine::{open_record, seal_record},
|
||||
signing::SoftwareSigner,
|
||||
test_server::{TestServer, SETUP_TOKEN},
|
||||
ApiClient, DeviceSigner, LoginOutcome, ServerConfig, SyncContext, SyncError, SyncReport,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
const AUTH: [u8; 32] = [7; 32];
|
||||
|
||||
fn test_kdf() -> kv_proto::KdfParams {
|
||||
kv_crypto::kdf::default_params()
|
||||
}
|
||||
|
||||
struct Device {
|
||||
store: MemoryStore,
|
||||
api: ApiClient,
|
||||
keyset: AccountKeyset,
|
||||
signer: Arc<SoftwareSigner>,
|
||||
}
|
||||
|
||||
fn copy_keyset(ks: &AccountKeyset) -> AccountKeyset {
|
||||
let k = SymKey::generate();
|
||||
AccountKeyset::unwrap(ks.account_id, &k, &ks.wrap(&k).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn enroll(signer: &SoftwareSigner) -> DeviceEnroll {
|
||||
DeviceEnroll {
|
||||
device_id: signer.device_id(),
|
||||
name: "test".into(),
|
||||
platform: Platform::Linux,
|
||||
p256_pk: signer.public_key(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Device {
|
||||
fn new(server: &TestServer, keyset: AccountKeyset) -> Self {
|
||||
let signer = Arc::new(SoftwareSigner::generate(Uuid::new_v4()));
|
||||
let api = ApiClient::new(&ServerConfig::new(server.url(), vec![]), signer.clone()).unwrap();
|
||||
Self { store: MemoryStore::new(), api, keyset, signer }
|
||||
}
|
||||
|
||||
/// First device: registers the account and creates a vault locally.
|
||||
async fn first(server: &TestServer) -> (Self, Uuid) {
|
||||
let keyset = AccountKeyset::generate(Uuid::new_v4());
|
||||
let d = Self::new(server, keyset);
|
||||
let wrapped = d.keyset.wrap(&SymKey::generate()).unwrap();
|
||||
d.api
|
||||
.register(
|
||||
SETUP_TOKEN,
|
||||
d.keyset.account_id,
|
||||
&test_kdf(),
|
||||
&AUTH,
|
||||
d.keyset.enc.public_bytes(),
|
||||
d.keyset.sig_public().to_vec(),
|
||||
wrapped,
|
||||
enroll(&d.signer),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(d.login().await, LoginOutcome::Ok { .. }));
|
||||
let vid = Uuid::new_v4();
|
||||
let (vk, sealed) = d.keyset.new_vault_key(vid).unwrap();
|
||||
let meta = vk
|
||||
.seal(br#"{"name":"Personal"}"#, &Aad::build(ObjectType::VaultMeta, d.keyset.account_id, Some(vid), None))
|
||||
.unwrap();
|
||||
d.store.put_vault(&VaultRow::new(vid, sealed, Some(meta))).unwrap();
|
||||
(d, vid)
|
||||
}
|
||||
|
||||
async fn second(server: &TestServer, first: &Device) -> Self {
|
||||
let d = Self::new(server, copy_keyset(&first.keyset));
|
||||
assert!(matches!(d.login().await, LoginOutcome::Ok { .. }));
|
||||
d
|
||||
}
|
||||
|
||||
async fn login(&self) -> LoginOutcome {
|
||||
self.api.login(self.keyset.account_id, &AUTH, enroll(&self.signer), None).await.unwrap()
|
||||
}
|
||||
|
||||
fn ctx(&self) -> SyncContext<'_> {
|
||||
SyncContext { api: &self.api, store: &self.store, keyset: &self.keyset, device_id: self.signer.device_id() }
|
||||
}
|
||||
|
||||
async fn sync(&self) -> Result<SyncReport, SyncError> {
|
||||
self.ctx().sync_all().await
|
||||
}
|
||||
|
||||
fn vk(&self, vid: Uuid) -> SymKey {
|
||||
self.keyset.open_vault_key(vid, &self.store.vault(vid).unwrap().unwrap().sealed_key).unwrap()
|
||||
}
|
||||
|
||||
fn save(&self, vid: Uuid, id: Uuid, item: Value) {
|
||||
let rec = seal_record(self.keyset.account_id, vid, &self.vk(vid), id, &item, self.signer.device_id()).unwrap();
|
||||
self.store.put_local(vid, &rec).unwrap();
|
||||
}
|
||||
|
||||
fn delete(&self, vid: Uuid, id: Uuid) {
|
||||
let mut cur = self.store.get(vid, id).unwrap().unwrap().record;
|
||||
cur.deleted = true;
|
||||
cur.wrapped_item_key = None;
|
||||
cur.payload = None;
|
||||
self.store.put_local(vid, &cur).unwrap();
|
||||
}
|
||||
|
||||
fn read(&self, vid: Uuid, id: Uuid) -> Option<Value> {
|
||||
let item = self.store.get(vid, id).unwrap()?;
|
||||
open_record(self.keyset.account_id, vid, &self.vk(vid), &item.record).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn item(id: Uuid, title: &str, password: &str, modified_at: i64) -> Value {
|
||||
json!({"id": id, "kind": "login", "title": title, "password": password, "created_at": 1, "modified_at": modified_at})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_devices_roundtrip_and_manifest_chain() {
|
||||
let server = TestServer::start().await;
|
||||
let (a, vid) = Device::first(&server).await;
|
||||
let id = Uuid::new_v4();
|
||||
a.save(vid, id, item(id, "Bank", "pw1", 10));
|
||||
|
||||
let r = a.sync().await.unwrap();
|
||||
assert_eq!(r.pushed, 1);
|
||||
assert_eq!(a.store.get(vid, id).unwrap().unwrap().state, SyncState::Clean);
|
||||
let m1 = server.state().vaults[&vid].manifest.clone().unwrap();
|
||||
// First sync publishes v1 (new vault) …
|
||||
assert_eq!(m1.version, 1);
|
||||
|
||||
let b = Device::second(&server, &a).await;
|
||||
let r = b.sync().await.unwrap();
|
||||
assert_eq!(r.vaults_synced, 1);
|
||||
assert_eq!(b.read(vid, id).unwrap()["password"], "pw1");
|
||||
assert_eq!(b.store.vault(vid).unwrap().unwrap().manifest_version, 1);
|
||||
|
||||
b.save(vid, id, item(id, "Bank", "pw2", 20));
|
||||
assert_eq!(b.sync().await.unwrap().pushed, 1);
|
||||
assert_eq!(server.state().vaults[&vid].manifest.as_ref().unwrap().version, 2);
|
||||
|
||||
a.sync().await.unwrap();
|
||||
assert_eq!(a.read(vid, id).unwrap()["password"], "pw2");
|
||||
assert_eq!(a.store.vault(vid).unwrap().unwrap().manifest_version, 2);
|
||||
// Nothing to do: no new manifest is published.
|
||||
a.sync().await.unwrap();
|
||||
assert_eq!(server.state().vaults[&vid].manifest.as_ref().unwrap().version, 2);
|
||||
// The server only ever saw ciphertext.
|
||||
let raw = serde_json::to_string(&server.state().vaults[&vid].items).unwrap();
|
||||
assert!(!raw.contains("pw2") && !raw.contains("Bank"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_edits_merge_field_by_field() {
|
||||
let server = TestServer::start().await;
|
||||
let (a, vid) = Device::first(&server).await;
|
||||
let id = Uuid::new_v4();
|
||||
a.save(vid, id, json!({"id": id, "kind": "login", "title": "Mail", "username": "me", "password": "p0", "notes": "n0", "created_at": 1, "modified_at": 10}));
|
||||
a.sync().await.unwrap();
|
||||
let b = Device::second(&server, &a).await;
|
||||
b.sync().await.unwrap();
|
||||
|
||||
// A changes username + password (later), B changes notes + password (earlier).
|
||||
a.save(vid, id, json!({"id": id, "kind": "login", "title": "Mail", "username": "me2", "password": "pA", "notes": "n0", "created_at": 1, "modified_at": 30}));
|
||||
b.save(vid, id, json!({"id": id, "kind": "login", "title": "Mail", "username": "me", "password": "pB", "notes": "nB", "created_at": 1, "modified_at": 20}));
|
||||
a.sync().await.unwrap();
|
||||
|
||||
let r = b.sync().await.unwrap();
|
||||
assert_eq!(r.merged, 1, "B hit 412 and merged");
|
||||
assert_eq!(r.pushed, 1);
|
||||
let merged = b.read(vid, id).unwrap();
|
||||
assert_eq!(merged["username"], "me2");
|
||||
assert_eq!(merged["notes"], "nB");
|
||||
assert_eq!(merged["password"], "pA", "later modified_at wins the same field");
|
||||
assert_eq!(merged["modified_at"], 30);
|
||||
// B's losing version is kept in history.
|
||||
let hist = b.store.history(vid, id).unwrap();
|
||||
assert_eq!(hist.len(), 1);
|
||||
let lost = open_record(
|
||||
b.keyset.account_id,
|
||||
vid,
|
||||
&b.vk(vid),
|
||||
&ItemRecord {
|
||||
item_id: id,
|
||||
revision: hist[0].revision,
|
||||
deleted: false,
|
||||
wrapped_item_key: Some(hist[0].wrapped_item_key.clone()),
|
||||
payload: Some(hist[0].payload.clone()),
|
||||
updated_by: Uuid::nil(),
|
||||
updated_at: String::new(),
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(lost["password"], "pB");
|
||||
|
||||
a.sync().await.unwrap();
|
||||
assert_eq!(a.read(vid, id).unwrap(), merged);
|
||||
assert!(a.store.pending(vid).unwrap().is_empty() && b.store.pending(vid).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_beats_delete_both_ways() {
|
||||
let server = TestServer::start().await;
|
||||
let (a, vid) = Device::first(&server).await;
|
||||
let (x, y) = (Uuid::new_v4(), Uuid::new_v4());
|
||||
a.save(vid, x, item(x, "X", "x", 1));
|
||||
a.save(vid, y, item(y, "Y", "y", 1));
|
||||
a.sync().await.unwrap();
|
||||
let b = Device::second(&server, &a).await;
|
||||
b.sync().await.unwrap();
|
||||
|
||||
// x: A deletes, B edits (B syncs second → B's edit is pushed over the tombstone).
|
||||
// y: A edits, B deletes (B syncs second → A's edit wins, B restores y).
|
||||
a.delete(vid, x);
|
||||
a.save(vid, y, item(y, "Y", "y-edited", 5));
|
||||
a.sync().await.unwrap();
|
||||
b.save(vid, x, item(x, "X", "x-edited", 5));
|
||||
b.delete(vid, y);
|
||||
let r = b.sync().await.unwrap();
|
||||
assert_eq!(r.restored.len(), 2);
|
||||
assert_eq!(b.read(vid, x).unwrap()["password"], "x-edited");
|
||||
assert_eq!(b.read(vid, y).unwrap()["password"], "y-edited");
|
||||
|
||||
a.sync().await.unwrap();
|
||||
assert_eq!(a.read(vid, x).unwrap()["password"], "x-edited");
|
||||
assert!(!server.state().vaults[&vid].items[&x].deleted);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_and_never_synced_items() {
|
||||
let server = TestServer::start().await;
|
||||
let (a, vid) = Device::first(&server).await;
|
||||
let (x, local_only) = (Uuid::new_v4(), Uuid::new_v4());
|
||||
a.save(vid, x, item(x, "X", "x", 1));
|
||||
a.sync().await.unwrap();
|
||||
a.delete(vid, x);
|
||||
a.save(vid, local_only, item(local_only, "L", "l", 1));
|
||||
a.delete(vid, local_only);
|
||||
a.sync().await.unwrap();
|
||||
assert!(server.state().vaults[&vid].items[&x].deleted);
|
||||
assert!(!server.state().vaults[&vid].items.contains_key(&local_only));
|
||||
assert!(a.store.get(vid, local_only).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manifest_rollback_is_an_integrity_alarm() {
|
||||
let server = TestServer::start().await;
|
||||
let (a, vid) = Device::first(&server).await;
|
||||
let id = Uuid::new_v4();
|
||||
for (i, pw) in ["1", "2", "3"].iter().enumerate() {
|
||||
a.save(vid, id, item(id, "T", pw, i as i64));
|
||||
a.sync().await.unwrap();
|
||||
}
|
||||
let history = server.state().vaults[&vid].manifest_history.clone();
|
||||
assert_eq!(history.len(), 3);
|
||||
server.state().serve_manifest = Some(history[0].clone());
|
||||
|
||||
let err = a.sync().await.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, SyncError::Integrity { vault_id, ref reason } if vault_id == vid && reason.contains("rollback")),
|
||||
"{err}"
|
||||
);
|
||||
assert!(a.store.vault(vid).unwrap().unwrap().integrity_alarm);
|
||||
|
||||
// Syncing that vault stays stopped even after the server "fixes" itself.
|
||||
server.state().serve_manifest = None;
|
||||
a.save(vid, id, item(id, "T", "4", 9));
|
||||
assert!(matches!(a.sync().await, Err(SyncError::Integrity { .. })));
|
||||
assert!(server.state().vaults[&vid].items[&id].revision < 4, "nothing was pushed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forged_or_foreign_manifest_is_rejected() {
|
||||
let server = TestServer::start().await;
|
||||
let (a, vid) = Device::first(&server).await;
|
||||
a.sync().await.unwrap();
|
||||
|
||||
let mut forged = server.state().vaults[&vid].manifest.clone().unwrap();
|
||||
forged.signature[0] ^= 1;
|
||||
server.state().serve_manifest = Some(forged);
|
||||
let err = a.sync().await.unwrap_err();
|
||||
assert!(matches!(err, SyncError::Integrity { ref reason, .. } if reason.contains("signature")), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hidden_item_breaks_merkle_root() {
|
||||
let server = TestServer::start().await;
|
||||
let (a, vid) = Device::first(&server).await;
|
||||
let (x, y) = (Uuid::new_v4(), Uuid::new_v4());
|
||||
a.save(vid, x, item(x, "X", "x", 1));
|
||||
a.save(vid, y, item(y, "Y", "y", 1));
|
||||
a.sync().await.unwrap();
|
||||
|
||||
let b = Device::new(&server, copy_keyset(&a.keyset));
|
||||
b.login().await;
|
||||
server.state().hide_items = vec![y];
|
||||
let err = b.sync().await.unwrap_err();
|
||||
assert!(matches!(err, SyncError::Integrity { ref reason, .. } if reason.contains("Merkle")), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsigned_remote_vault_is_ignored() {
|
||||
let server = TestServer::start().await;
|
||||
let (a, vid) = Device::first(&server).await;
|
||||
a.sync().await.unwrap();
|
||||
// The server injects a vault whose key it sealed to our public key itself.
|
||||
let evil = Uuid::new_v4();
|
||||
let sealed = kv_crypto::seal::seal_to(
|
||||
a.keyset.enc.id,
|
||||
&a.keyset.enc.public_bytes(),
|
||||
&[0u8; 48],
|
||||
&Aad::build(ObjectType::VaultKey, a.keyset.account_id, Some(evil), None),
|
||||
)
|
||||
.unwrap();
|
||||
{
|
||||
let mut st = server.state();
|
||||
let v = st.vaults.entry(evil).or_default();
|
||||
v.sealed_key = Some(sealed.clone());
|
||||
v.meta = Some(sealed);
|
||||
}
|
||||
let r = a.sync().await.unwrap();
|
||||
assert_eq!(r.vaults_skipped, vec![evil]);
|
||||
assert!(a.store.vault(evil).unwrap().is_none());
|
||||
assert!(a.store.vault(vid).unwrap().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pagination_and_writes_from_elsewhere() {
|
||||
let server = TestServer::start().await;
|
||||
let (a, vid) = Device::first(&server).await;
|
||||
a.sync().await.unwrap();
|
||||
// 520 items written by "another device" directly into the server.
|
||||
let vk = a.vk(vid);
|
||||
let ids: Vec<Uuid> = (0..520).map(|_| Uuid::new_v4()).collect();
|
||||
for id in &ids {
|
||||
let rec = seal_record(a.keyset.account_id, vid, &vk, *id, &item(*id, "bulk", "p", 1), Uuid::nil()).unwrap();
|
||||
server.state().write_item(vid, rec);
|
||||
}
|
||||
let r = a.sync().await.unwrap();
|
||||
assert_eq!(r.pulled, 520);
|
||||
assert_eq!(a.store.list(vid).unwrap().len(), 520);
|
||||
assert_eq!(a.store.cursor(vid).unwrap(), 520);
|
||||
let changes_calls = server.state().log.iter().filter(|l| l.contains("/changes")).count();
|
||||
assert!(changes_calls >= 2, "paged");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_412_is_bounded() {
|
||||
let server = TestServer::start().await;
|
||||
let (a, vid) = Device::first(&server).await;
|
||||
let id = Uuid::new_v4();
|
||||
a.save(vid, id, item(id, "T", "p", 1));
|
||||
server.state().force_412 = 100;
|
||||
let r = a.sync().await.unwrap();
|
||||
assert_eq!(r.pushed, 0);
|
||||
assert_eq!(a.store.pending(vid).unwrap().len(), 1, "still pending for the next sync");
|
||||
server.state().force_412 = 0;
|
||||
assert_eq!(a.sync().await.unwrap().pushed, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_errors() {
|
||||
let server = TestServer::start().await;
|
||||
let (a, _) = Device::first(&server).await;
|
||||
// Wrong OPAQUE password.
|
||||
let err = a.api.login(a.keyset.account_id, &[0u8; 32], enroll(&a.signer), None).await.unwrap_err();
|
||||
assert!(matches!(err, SyncError::BadCredentials), "{err}");
|
||||
// Setup is closed after the first account.
|
||||
let err = a
|
||||
.api
|
||||
.register(
|
||||
SETUP_TOKEN,
|
||||
Uuid::new_v4(),
|
||||
&test_kdf(),
|
||||
&AUTH,
|
||||
vec![],
|
||||
vec![],
|
||||
a.keyset.wrap(&SymKey::generate()).unwrap(),
|
||||
enroll(&a.signer),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, SyncError::NotFound | SyncError::Server { .. }), "{err}");
|
||||
|
||||
// No session → NotAuthenticated without touching the network.
|
||||
let fresh = Device::new(&server, copy_keyset(&a.keyset));
|
||||
assert!(matches!(fresh.sync().await, Err(SyncError::NotAuthenticated)));
|
||||
// A revoked session → 401 → NotAuthenticated, and the session is dropped.
|
||||
server.state().sessions.clear();
|
||||
assert!(matches!(a.sync().await, Err(SyncError::NotAuthenticated)));
|
||||
assert!(a.api.session().is_none());
|
||||
|
||||
// New device needing approval.
|
||||
server.state().require_approval = true;
|
||||
let c = Device::new(&server, copy_keyset(&a.keyset));
|
||||
assert_eq!(c.login().await, LoginOutcome::PendingApproval);
|
||||
server.state().devices.get_mut(&c.signer.device_id()).unwrap().approved = true;
|
||||
assert!(matches!(c.login().await, LoginOutcome::Ok { .. }));
|
||||
// The account endpoint gives a new device what it needs to unlock.
|
||||
let info = c.api.account().await.unwrap();
|
||||
assert_eq!(info.sig_pk, a.keyset.sig_public().to_vec());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- TLS pinning
|
||||
|
||||
mod tls {
|
||||
use std::sync::Arc;
|
||||
|
||||
use kv_sync::{signing::SoftwareSigner, transport::spki_pin_of, ApiClient, ServerConfig, SyncError};
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpListener,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
struct Pki {
|
||||
ca_der: Vec<u8>,
|
||||
leaf_der: Vec<u8>,
|
||||
leaf_key: Vec<u8>,
|
||||
}
|
||||
|
||||
fn pki() -> Pki {
|
||||
let ca_key = rcgen::KeyPair::generate().unwrap();
|
||||
let mut ca = rcgen::CertificateParams::new(vec![]).unwrap();
|
||||
ca.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
|
||||
ca.distinguished_name.push(rcgen::DnType::CommonName, "Kestrove Test CA");
|
||||
let ca_cert = ca.self_signed(&ca_key).unwrap();
|
||||
let leaf_key = rcgen::KeyPair::generate().unwrap();
|
||||
let leaf = rcgen::CertificateParams::new(vec!["localhost".into()]).unwrap();
|
||||
let leaf_cert = leaf.signed_by(&leaf_key, &ca_cert, &ca_key).unwrap();
|
||||
Pki { ca_der: ca_cert.der().to_vec(), leaf_der: leaf_cert.der().to_vec(), leaf_key: leaf_key.serialize_der() }
|
||||
}
|
||||
|
||||
/// Minimal HTTPS server answering every request with a ServerInfo JSON. Returns the port.
|
||||
async fn serve(p: &Pki, tls13_only: bool) -> u16 {
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let versions: &[&rustls::SupportedProtocolVersion] =
|
||||
if tls13_only { &[&rustls::version::TLS13] } else { &[&rustls::version::TLS12] };
|
||||
let cfg = rustls::ServerConfig::builder_with_provider(provider)
|
||||
.with_protocol_versions(versions)
|
||||
.unwrap()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(
|
||||
// Like a real server, send the chain (here leaf + issuing CA).
|
||||
vec![CertificateDer::from(p.leaf_der.clone()), CertificateDer::from(p.ca_der.clone())],
|
||||
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(p.leaf_key.clone())),
|
||||
)
|
||||
.unwrap();
|
||||
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(cfg));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((sock, _)) = listener.accept().await {
|
||||
let acceptor = acceptor.clone();
|
||||
tokio::spawn(async move {
|
||||
let Ok(mut tls) = acceptor.accept(sock).await else { return };
|
||||
let mut buf = [0u8; 4096];
|
||||
let _ = tls.read(&mut buf).await;
|
||||
let body =
|
||||
r#"{"version":"t","api":1,"single_tenant":true,"setup_open":false,"min_client":"0.1.0"}"#;
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
let _ = tls.write_all(resp.as_bytes()).await;
|
||||
let _ = tls.shutdown().await;
|
||||
});
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
fn client(p: &Pki, port: u16, pins: Vec<String>) -> ApiClient {
|
||||
let cfg = ServerConfig {
|
||||
base_url: format!("https://localhost:{port}"),
|
||||
spki_pins: pins,
|
||||
extra_roots_der: vec![p.ca_der.clone()],
|
||||
};
|
||||
ApiClient::new(&cfg, Arc::new(SoftwareSigner::generate(Uuid::new_v4()))).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pins() {
|
||||
let p = pki();
|
||||
let port = serve(&p, true).await;
|
||||
let leaf_pin = spki_pin_of(&p.leaf_der).unwrap();
|
||||
let ca_pin = spki_pin_of(&p.ca_der).unwrap();
|
||||
let wrong = spki_pin_of(&pki().leaf_der).unwrap();
|
||||
|
||||
// Primary pin on the leaf.
|
||||
assert!(client(&p, port, vec![leaf_pin.clone()]).server_info().await.is_ok());
|
||||
// Backup pin matches (primary is a stale key).
|
||||
assert!(client(&p, port, vec![wrong.clone(), leaf_pin]).server_info().await.is_ok());
|
||||
// Pinning the issuing CA key (it is in the presented chain).
|
||||
assert!(client(&p, port, vec![ca_pin]).server_info().await.is_ok());
|
||||
// Mismatch.
|
||||
let err = client(&p, port, vec![wrong.clone()]).server_info().await.unwrap_err();
|
||||
assert!(matches!(err, SyncError::PinMismatch), "{err:?}");
|
||||
// Without our CA, chain validation fails before pins are even considered.
|
||||
let cfg = ServerConfig::new(format!("https://localhost:{port}"), vec![wrong]);
|
||||
let c = ApiClient::new(&cfg, Arc::new(SoftwareSigner::generate(Uuid::new_v4()))).unwrap();
|
||||
assert!(matches!(c.server_info().await, Err(SyncError::Network(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tls12_is_refused() {
|
||||
let p = pki();
|
||||
let port = serve(&p, false).await;
|
||||
let pin = spki_pin_of(&p.leaf_der).unwrap();
|
||||
assert!(matches!(client(&p, port, vec![pin]).server_info().await, Err(SyncError::Network(_))));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user