Merge branch 'wasm' into wasm-onboarding
This commit is contained in:
@@ -1,9 +1,6 @@
|
||||
//! Typed HTTP client for the sync API (docs/03, api/openapi.yaml).
|
||||
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use kv_proto::{
|
||||
AccountInfo, ApiError, Changes, DeviceEnroll, DeviceInfo, Envelope, ItemPut, ItemRecord, JoinBundle, JoinCreate,
|
||||
@@ -40,10 +37,6 @@ pub struct ApiClient {
|
||||
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 {
|
||||
@@ -104,7 +97,15 @@ impl ApiClient {
|
||||
}
|
||||
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())?;
|
||||
let h = signing::sign_request_async(
|
||||
self.signer.as_ref(),
|
||||
method.as_str(),
|
||||
&url,
|
||||
&body,
|
||||
&session,
|
||||
crate::unix_now(),
|
||||
)
|
||||
.await?;
|
||||
for (k, v) in h.pairs() {
|
||||
req = req.header(k, v);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
//! `If-Match` (on 412: pull, merge, retry), pull again, then publish a new manifest. Every pulled record is
|
||||
//! checked against the local base for per-item replay ([`crate::replay`]).
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use kv_crypto::{
|
||||
aead::SymKey,
|
||||
keys::{open_item, seal_item, AccountKeyset, SealedItem},
|
||||
@@ -48,11 +46,15 @@ pub struct SyncContext<'a> {
|
||||
}
|
||||
|
||||
fn now() -> i64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)
|
||||
crate::unix_now() as i64
|
||||
}
|
||||
|
||||
fn rfc3339_now() -> String {
|
||||
time::OffsetDateTime::now_utc().format(&time::format_description::well_known::Rfc3339).unwrap_or_default()
|
||||
// Not `OffsetDateTime::now_utc()`: it panics on wasm32-unknown-unknown.
|
||||
time::OffsetDateTime::from_unix_timestamp(now())
|
||||
.ok()
|
||||
.and_then(|t| t.format(&time::format_description::well_known::Rfc3339).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn crypto(e: kv_crypto::CryptoError) -> SyncError {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Sync engine (milestone M1, kestrove-spec/docs/03).
|
||||
//!
|
||||
//! Responsibilities:
|
||||
//! * [`transport`]: HTTPS with rustls, TLS 1.3 only, SPKI pinning (primary + backup pin).
|
||||
//! * [`transport`]: HTTPS with rustls, TLS 1.3 only, SPKI pinning (primary + backup pin). On wasm32 (the web
|
||||
//! vault) reqwest uses the browser's `fetch` and TLS instead, and pins do not apply.
|
||||
//! * [`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
|
||||
@@ -26,6 +27,8 @@ pub mod wire;
|
||||
|
||||
pub use client::{ApiClient, LoginOutcome};
|
||||
pub use engine::{SyncContext, SyncReport};
|
||||
use std::{future::Future, pin::Pin};
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -73,13 +76,39 @@ impl From<kv_store::StoreError> for SyncError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Implemented by each platform: Secure Enclave, StrongBox, TPM or software fallback.
|
||||
/// Future returned by [`DeviceSigner::sign_async`]. `Send` on native targets; not on wasm32, where
|
||||
/// it wraps a WebCrypto promise (the browser runs the core on one thread).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub type SignFuture<'a> = Pin<Box<dyn Future<Output = Result<Vec<u8>, SyncError>> + Send + 'a>>;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub type SignFuture<'a> = Pin<Box<dyn Future<Output = Result<Vec<u8>, SyncError>> + 'a>>;
|
||||
|
||||
/// Implemented by each platform: Secure Enclave, StrongBox, TPM, WebCrypto or software fallback.
|
||||
pub trait DeviceSigner: Send + Sync {
|
||||
fn device_id(&self) -> Uuid;
|
||||
/// SEC1 uncompressed P-256 public key.
|
||||
fn public_key(&self) -> Vec<u8>;
|
||||
/// ECDSA P-256 / SHA-256 signature (IEEE P1363 r||s) over `message`.
|
||||
fn sign(&self, message: &[u8]) -> Result<Vec<u8>, SyncError>;
|
||||
/// Asynchronous signing, used by [`ApiClient`]. Defaults to [`DeviceSigner::sign`]; the web vault
|
||||
/// overrides it because WebCrypto only signs asynchronously.
|
||||
fn sign_async<'a>(&'a self, message: &'a [u8]) -> SignFuture<'a> {
|
||||
Box::pin(std::future::ready(self.sign(message)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds since the Unix epoch. `SystemTime::now()` panics on wasm32-unknown-unknown, so the
|
||||
/// browser build asks JavaScript's clock.
|
||||
pub fn unix_now() -> u64 {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
(js_sys::Date::now() / 1000.0) as u64
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Server endpoint plus pinned SPKI SHA-256 hashes (base64).
|
||||
|
||||
@@ -53,6 +53,21 @@ pub fn sign_request(
|
||||
sign_request_with_nonce(signer, method, target_uri, body, session, now_unix, &nonce)
|
||||
}
|
||||
|
||||
/// [`sign_request`] through [`DeviceSigner::sign_async`] (the path [`crate::ApiClient`] uses).
|
||||
pub async fn sign_request_async(
|
||||
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>());
|
||||
let unsigned = Unsigned::new(signer, method, target_uri, body, session, now_unix, &nonce);
|
||||
let sig = signer.sign_async(unsigned.base.as_bytes()).await?;
|
||||
unsigned.finish(&sig)
|
||||
}
|
||||
|
||||
fn sign_request_with_nonce(
|
||||
signer: &dyn DeviceSigner,
|
||||
method: &str,
|
||||
@@ -62,21 +77,49 @@ fn sign_request_with_nonce(
|
||||
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())));
|
||||
let unsigned = Unsigned::new(signer, method, target_uri, body, session, now_unix, nonce);
|
||||
let sig = signer.sign(unsigned.base.as_bytes())?;
|
||||
unsigned.finish(&sig)
|
||||
}
|
||||
|
||||
/// Everything but the signature: the signature base and the header values around it.
|
||||
struct Unsigned {
|
||||
digest: String,
|
||||
session: String,
|
||||
ts: String,
|
||||
params: String,
|
||||
base: String,
|
||||
}
|
||||
|
||||
impl Unsigned {
|
||||
fn new(
|
||||
signer: &dyn DeviceSigner,
|
||||
method: &str,
|
||||
target_uri: &str,
|
||||
body: &[u8],
|
||||
session: &str,
|
||||
now_unix: u64,
|
||||
nonce: &str,
|
||||
) -> Self {
|
||||
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);
|
||||
Self { digest, session: session.to_string(), ts, params, base }
|
||||
}
|
||||
|
||||
fn finish(self, sig: &[u8]) -> Result<SignedHeaders, SyncError> {
|
||||
if sig.len() != 64 {
|
||||
return Err(SyncError::Signer(format!("device signature must be 64 bytes (r||s), got {}", sig.len())));
|
||||
}
|
||||
Ok(SignedHeaders {
|
||||
content_digest: self.digest,
|
||||
session: self.session,
|
||||
timestamp: self.ts,
|
||||
signature_input: signature_input_header(&self.params),
|
||||
signature: signature_header(sig),
|
||||
})
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -7,13 +7,18 @@
|
||||
//! 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).
|
||||
//!
|
||||
//! On wasm32 (the web vault) reqwest sends through the browser's `fetch`: the browser does TLS and the
|
||||
//! page is served from the same origin as the API, so pinning and the rustls config are native only.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::{
|
||||
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
|
||||
Engine,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use rustls::{
|
||||
client::{
|
||||
danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
|
||||
@@ -22,6 +27,7 @@ use rustls::{
|
||||
pki_types::{CertificateDer, ServerName, UnixTime},
|
||||
CertificateError, DigitallySignedStruct, OtherError, RootCertStore, SignatureScheme,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{ServerConfig, SyncError};
|
||||
@@ -48,22 +54,26 @@ pub fn parse_pin(pin: &str) -> Result<[u8; 32], SyncError> {
|
||||
raw.try_into().map_err(|_| SyncError::Config(format!("pin is not a SHA-256 hash: {pin}")))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// `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))?))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
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())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[derive(Debug)]
|
||||
struct PinningVerifier {
|
||||
inner: Arc<WebPkiServerVerifier>,
|
||||
pins: Vec<[u8; 32]>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl ServerCertVerifier for PinningVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
@@ -112,6 +122,7 @@ impl ServerCertVerifier for PinningVerifier {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// 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<_>, _>>()?;
|
||||
@@ -156,6 +167,7 @@ pub fn normalize_base_url(raw: &str) -> Result<String, SyncError> {
|
||||
Ok(url.as_str().trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
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)?)
|
||||
@@ -167,6 +179,18 @@ pub fn http_client(cfg: &ServerConfig) -> Result<reqwest::Client, SyncError> {
|
||||
.map_err(|e| SyncError::Config(e.to_string()))
|
||||
}
|
||||
|
||||
/// Browser `fetch`: TLS, timeouts and certificate checks belong to the browser. Pins are ignored.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn http_client(_cfg: &ServerConfig) -> Result<reqwest::Client, SyncError> {
|
||||
reqwest::Client::builder().build().map_err(|e| SyncError::Config(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn classify(e: reqwest::Error) -> SyncError {
|
||||
SyncError::Network(e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// 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);
|
||||
|
||||
Reference in New Issue
Block a user