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:
@@ -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";
|
||||
Reference in New Issue
Block a user