fix(sync): secure encrypted snapshot lifecycle

This commit is contained in:
2026-07-10 06:24:53 -04:00
parent 556c5ff624
commit 540b901fd6
106 changed files with 18026 additions and 3309 deletions
+9
View File
@@ -6,14 +6,23 @@ license.workspace = true
rust-version.workspace = true
[dependencies]
base64.workspace = true
chacha20poly1305.workspace = true
ed25519-dalek.workspace = true
ely_domain = { path = "../ely_domain" }
fs2.workspace = true
getrandom.workspace = true
hkdf.workspace = true
hmac.workspace = true
hpke.workspace = true
keyring.workspace = true
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
thiserror = { workspace = true }
ureq = { workspace = true, features = ["json"] }
uuid = { workspace = true }
zeroize.workspace = true
[lints]
workspace = true
+229 -21
View File
@@ -1,13 +1,21 @@
use std::time::Duration;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::de::DeserializeOwned;
use ureq::{Agent, AgentBuilder};
use crate::{
SnapshotHeadRef,
auth::BearerToken,
device::{DeviceIdentity, DeviceListResponse, DeviceRegistration},
device_api::{
DeviceApprovalDocument, DeviceApprovalRequest, DeviceRebindChallengeDocument,
DeviceRebindChallengeRequest, DeviceRebindDocument,
},
device_revocation::{DeviceRevocationDocument, DeviceRevocationRequest},
error::SyncClientError,
snapshot::{SnapshotDownload, SnapshotUploadRequest},
vault::SyncVaultDocument,
vault_bootstrap::SyncVaultBootstrapRequest,
};
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
@@ -73,13 +81,16 @@ impl SyncApiClient {
identity: &DeviceIdentity,
idempotency_key: &str,
) -> Result<DeviceRecordDocument, SyncClientError> {
let registration_proof = identity.registration_proof(idempotency_key)?;
let registration = DeviceRegistration {
version: 1,
version: 2,
device_id: &identity.device_id,
public_key: &identity.public_key,
wrapping_public_key: &identity.wrapping_public_key,
device_name: &identity.device_name,
platform: &identity.platform,
idempotency_key,
registration_proof: &registration_proof,
};
let endpoint = self.endpoint("/api/devices/register");
let response = self
@@ -107,6 +118,75 @@ impl SyncApiClient {
read_json_response::<DeviceListResponse>(&endpoint, response)
}
/// Rebind an existing v2 device to a fresh authenticated session.
pub fn rebind_device(
&self,
identity: &DeviceIdentity,
) -> Result<DeviceRebindDocument, SyncClientError> {
let challenge_endpoint = self.endpoint("/api/devices/rebind/challenge");
let challenge_request =
DeviceRebindChallengeRequest { version: 1, device_id: &identity.device_id };
let challenge_response = self
.agent
.post(&challenge_endpoint)
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
.set("Content-Type", "application/json")
.send_json(serde_json::to_value(&challenge_request).map_err(|source| {
SyncClientError::Json { endpoint: challenge_endpoint.clone(), source }
})?);
let challenge = read_json_response::<DeviceRebindChallengeDocument>(
&challenge_endpoint,
challenge_response,
)?;
let now_seconds = current_time_seconds()?;
let rebind_request = challenge.signed_request(identity, now_seconds)?;
let rebind_endpoint = self.endpoint("/api/devices/rebind");
let rebind_response = self
.agent
.post(&rebind_endpoint)
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
.set("Content-Type", "application/json")
.send_json(serde_json::to_value(&rebind_request).map_err(|source| {
SyncClientError::Json { endpoint: rebind_endpoint.clone(), source }
})?);
let document =
read_json_response::<DeviceRebindDocument>(&rebind_endpoint, rebind_response)?;
document.validate(identity, &challenge, current_time_seconds()?)?;
Ok(document)
}
pub fn approve_device(
&self,
request: &DeviceApprovalRequest<'_>,
) -> Result<DeviceApprovalDocument, SyncClientError> {
let endpoint = self.endpoint("/api/devices/approve");
let response =
self.agent
.post(&endpoint)
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
.set("Content-Type", "application/json")
.send_json(serde_json::to_value(request).map_err(|source| {
SyncClientError::Json { endpoint: endpoint.clone(), source }
})?);
read_json_response::<DeviceApprovalDocument>(&endpoint, response)
}
pub fn revoke_device(
&self,
request: &DeviceRevocationRequest,
) -> Result<DeviceRevocationDocument, SyncClientError> {
let endpoint = self.endpoint("/api/devices/revoke");
let response =
self.agent
.post(&endpoint)
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
.set("Content-Type", "application/json")
.send_json(serde_json::to_value(request).map_err(|source| {
SyncClientError::Json { endpoint: endpoint.clone(), source }
})?);
read_json_response::<DeviceRevocationDocument>(&endpoint, response)
}
/// `GET /api/sync/status` — return the worker-side cursor,
/// object, snapshot, and device summary for the authenticated
/// approved device.
@@ -120,13 +200,54 @@ impl SyncApiClient {
read_json_response::<SyncStatusDocument>(&endpoint, response)
}
pub fn current_sync_vault(&self) -> Result<SyncVaultDocument, SyncClientError> {
let endpoint = self.endpoint("/api/sync/vault");
let response = self
.agent
.get(&endpoint)
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
.call();
read_json_response::<SyncVaultDocument>(&endpoint, response)
}
pub fn sync_vault_generation(
&self,
generation: u64,
key_id: &str,
) -> Result<SyncVaultDocument, SyncClientError> {
let endpoint =
self.endpoint(&format!("/api/sync/vault?generation={generation}&key_id={key_id}"));
let response = self
.agent
.get(&endpoint)
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
.call();
read_json_response::<SyncVaultDocument>(&endpoint, response)
}
pub fn bootstrap_sync_vault(
&self,
request: &SyncVaultBootstrapRequest<'_>,
) -> Result<SyncVaultDocument, SyncClientError> {
let endpoint = self.endpoint("/api/sync/vault/bootstrap");
let response =
self.agent
.post(&endpoint)
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
.set("Content-Type", "application/json")
.send_json(serde_json::to_value(request).map_err(|error| {
SyncClientError::Json { endpoint: endpoint.clone(), source: error }
})?);
read_json_response::<SyncVaultDocument>(&endpoint, response)
}
/// `POST /api/sync/snapshot` — push the full per-user state. The
/// worker enforces logical-clock monotonicity, so callers must
/// pass a value strictly greater than the last accepted snapshot.
pub fn upload_snapshot(
&self,
request: &SnapshotUploadRequest<'_>,
) -> Result<SnapshotUploadDocument, SyncClientError> {
) -> Result<SnapshotUploadResult, SyncClientError> {
let endpoint = self.endpoint("/api/sync/snapshot");
let response =
self.agent
@@ -136,7 +257,23 @@ impl SyncApiClient {
.send_json(serde_json::to_value(request).map_err(|error| {
SyncClientError::Json { endpoint: endpoint.clone(), source: error }
})?);
read_json_response::<SnapshotUploadDocument>(&endpoint, response)
match response {
Ok(response) => read_json_from_response::<SnapshotUploadDocument>(&endpoint, response)
.map(SnapshotUploadResult::Committed),
Err(ureq::Error::Status(409, response)) => {
let conflict = read_json_from_response::<SyncSnapshotHeadConflictDocument>(
&endpoint, response,
)?;
if conflict.version != 1 || conflict.error != "sync_snapshot_head_conflict" {
return Err(SyncClientError::DeviceTrust {
reason: "snapshot conflict response is invalid",
});
}
Ok(SnapshotUploadResult::Conflict(conflict))
}
Err(error) => read_json_response::<SnapshotUploadDocument>(&endpoint, Err(error))
.map(SnapshotUploadResult::Committed),
}
}
/// `GET /api/sync/snapshot?snapshot_id=…` — fetch the snapshot for
@@ -145,15 +282,36 @@ impl SyncApiClient {
/// the bytes.
pub fn download_snapshot(
&self,
snapshot_id: &str,
) -> Result<SnapshotDownload, SyncClientError> {
let endpoint = self.endpoint(&format!("/api/sync/snapshot?snapshot_id={snapshot_id}"));
head: &SnapshotHeadRef,
) -> Result<SnapshotDownloadResult, SyncClientError> {
let endpoint = self.endpoint(&format!(
"/api/sync/snapshot?snapshot_id={}&head_revision={}&payload_hash={}",
head.snapshot_id(),
head.revision(),
head.payload_hash(),
));
let response = self
.agent
.get(&endpoint)
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
.call();
read_json_response::<SnapshotDownload>(&endpoint, response)
match response {
Ok(response) => read_json_from_response::<SnapshotDownload>(&endpoint, response)
.map(SnapshotDownloadResult::Downloaded),
Err(ureq::Error::Status(409, response)) => {
let conflict = read_json_from_response::<SyncSnapshotHeadConflictDocument>(
&endpoint, response,
)?;
if conflict.version != 1 || conflict.error != "sync_snapshot_head_conflict" {
return Err(SyncClientError::DeviceTrust {
reason: "snapshot conflict response is invalid",
});
}
Ok(SnapshotDownloadResult::Conflict(conflict))
}
Err(error) => read_json_response::<SnapshotDownload>(&endpoint, Err(error))
.map(SnapshotDownloadResult::Downloaded),
}
}
fn endpoint(&self, path: &str) -> String {
@@ -205,19 +363,55 @@ pub struct SyncObjectStatusDocument {
#[derive(Clone, Debug, serde::Deserialize)]
pub struct SyncSnapshotStatusDocument {
pub total_snapshots: u64,
pub latest: Option<SyncLatestSnapshotDocument>,
pub head: Option<SyncLatestSnapshotDocument>,
}
#[derive(Clone, Debug, serde::Deserialize)]
pub struct SyncLatestSnapshotDocument {
pub head_revision: u64,
pub base_head: Option<SnapshotHeadRef>,
pub snapshot_id: String,
pub payload_hash: String,
pub encryption_version: u32,
pub vault_generation: u64,
pub key_id: String,
pub content_hash: String,
pub logical_clock: u64,
pub device_id: String,
pub size_bytes: u64,
pub created_at: u64,
}
impl SyncLatestSnapshotDocument {
pub fn head_ref(&self) -> Result<SnapshotHeadRef, SyncClientError> {
SnapshotHeadRef::new(
self.head_revision,
self.snapshot_id.clone(),
self.payload_hash.clone(),
)
}
}
#[derive(Clone, Debug)]
pub enum SnapshotUploadResult {
Committed(SnapshotUploadDocument),
Conflict(SyncSnapshotHeadConflictDocument),
}
#[derive(Clone, Debug)]
pub enum SnapshotDownloadResult {
Downloaded(SnapshotDownload),
Conflict(SyncSnapshotHeadConflictDocument),
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SyncSnapshotHeadConflictDocument {
pub version: u32,
pub error: String,
pub current_head: Option<SyncLatestSnapshotDocument>,
}
#[derive(Clone, Debug, serde::Deserialize)]
pub struct SyncDeviceStatusDocument {
pub approved_count: u64,
@@ -230,18 +424,7 @@ fn read_json_response<T: DeserializeOwned>(
response: Result<ureq::Response, ureq::Error>,
) -> Result<T, SyncClientError> {
match response {
Ok(ok) => {
let status = ok.status();
let body = ok.into_string().map_err(|error| SyncClientError::HttpStatus {
endpoint: endpoint.to_string(),
status,
body: error.to_string(),
})?;
serde_json::from_str::<T>(&body).map_err(|error| SyncClientError::Json {
endpoint: endpoint.to_string(),
source: error,
})
}
Ok(ok) => read_json_from_response(endpoint, ok),
Err(ureq::Error::Status(status, raw)) => {
let body = raw.into_string().unwrap_or_default();
Err(SyncClientError::HttpStatus { endpoint: endpoint.to_string(), status, body })
@@ -251,3 +434,28 @@ fn read_json_response<T: DeserializeOwned>(
}
}
}
fn read_json_from_response<T: DeserializeOwned>(
endpoint: &str,
response: ureq::Response,
) -> Result<T, SyncClientError> {
let status = response.status();
let body = response.into_string().map_err(|error| SyncClientError::HttpStatus {
endpoint: endpoint.to_string(),
status,
body: error.to_string(),
})?;
serde_json::from_str::<T>(&body)
.map_err(|source| SyncClientError::Json { endpoint: endpoint.to_string(), source })
}
fn current_time_seconds() -> Result<u64, SyncClientError> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.map_err(|_| SyncClientError::DeviceTrust { reason: "system clock is invalid" })
}
#[cfg(test)]
#[path = "client_tests.rs"]
mod tests;
+107
View File
@@ -0,0 +1,107 @@
use std::{
error::Error,
io::{Read, Write},
net::TcpListener,
thread::{self, JoinHandle},
};
use crate::{
AccountKey, ApiClientConfig, BearerToken, SnapshotCryptoContext, SnapshotDownloadResult,
SnapshotHeadRef, SnapshotPayload, SnapshotUploadRequest, SnapshotUploadResult, SyncApiClient,
};
type TestServer = JoinHandle<std::io::Result<()>>;
#[test]
fn upload_parses_structured_snapshot_head_conflict() -> Result<(), Box<dyn Error>> {
let (base_url, server) = spawn_conflict_server()?;
let client = SyncApiClient::new(
ApiClientConfig::custom(base_url, "auto"),
BearerToken::new("a".repeat(64))?,
)?;
let key = AccountKey::from_bytes([31; 32]);
let context = SnapshotCryptoContext {
user_id: "user-01",
vault_generation: 1,
snapshot_id: "snapshot-local",
schema_rev: 1,
logical_clock: 8,
device_id: "device-local",
head_revision: 1,
base_head: None,
};
let encrypted = key.encrypt(&context, b"local snapshot")?;
let payload = SnapshotPayload::new(encrypted.bytes().to_vec())?;
let request = SnapshotUploadRequest::new("auto", &context, None, &encrypted, &payload)?;
let SnapshotUploadResult::Conflict(conflict) = client.upload_snapshot(&request)? else {
return Err("snapshot upload conflict was not preserved".into());
};
assert_eq!(conflict.current_head.ok_or("missing conflict head")?.head_revision, 7);
join_server(server)
}
#[test]
fn download_parses_structured_snapshot_head_conflict() -> Result<(), Box<dyn Error>> {
let (base_url, server) = spawn_conflict_server()?;
let client = SyncApiClient::new(
ApiClientConfig::custom(base_url, "auto"),
BearerToken::new("a".repeat(64))?,
)?;
let requested = SnapshotHeadRef::new(6, "snapshot-old", "cd".repeat(32))?;
let SnapshotDownloadResult::Conflict(conflict) = client.download_snapshot(&requested)? else {
return Err("snapshot download conflict was not preserved".into());
};
assert_eq!(conflict.current_head.ok_or("missing conflict head")?.snapshot_id, "snapshot-new");
join_server(server)
}
fn spawn_conflict_server() -> Result<(String, TestServer), Box<dyn Error>> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let address = listener.local_addr()?;
let body = serde_json::json!({
"version": 1,
"error": "sync_snapshot_head_conflict",
"current_head": {
"head_revision": 7,
"base_head": {
"revision": 6,
"snapshot_id": "snapshot-old",
"payload_hash": "cd".repeat(32)
},
"snapshot_id": "snapshot-new",
"payload_hash": "ab".repeat(32),
"encryption_version": 2,
"vault_generation": 1,
"key_id": "ef".repeat(32),
"content_hash": "12".repeat(32),
"logical_clock": 9,
"device_id": "device-remote",
"size_bytes": 256,
"created_at": 1
}
})
.to_string();
let server = thread::spawn(move || -> std::io::Result<()> {
let (mut stream, _) = listener.accept()?;
let mut request = [0_u8; 16 * 1024];
let _ = stream.read(&mut request)?;
let response = format!(
"HTTP/1.1 409 Conflict\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(response.as_bytes())?;
stream.flush()
});
Ok((format!("http://{address}"), server))
}
fn join_server(server: TestServer) -> Result<(), Box<dyn Error>> {
match server.join() {
Ok(result) => result.map_err(Into::into),
Err(_) => Err("snapshot conflict server thread panicked".into()),
}
}
@@ -0,0 +1,28 @@
use keyring::{Entry, Error as KeyringError};
use zeroize::Zeroizing;
pub(crate) fn load_secret(
service: &str,
account: &str,
) -> Result<Option<Zeroizing<Vec<u8>>>, String> {
match entry(service, account)?.get_secret() {
Ok(secret) => Ok(Some(Zeroizing::new(secret))),
Err(KeyringError::NoEntry) => Ok(None),
Err(error) => Err(error.to_string()),
}
}
pub(crate) fn save_secret(service: &str, account: &str, secret: &[u8]) -> Result<(), String> {
entry(service, account)?.set_secret(secret).map_err(|error| error.to_string())
}
pub(crate) fn clear_secret(service: &str, account: &str) -> Result<(), String> {
match entry(service, account)?.delete_credential() {
Ok(()) | Err(KeyringError::NoEntry) => Ok(()),
Err(error) => Err(error.to_string()),
}
}
fn entry(service: &str, account: &str) -> Result<Entry, String> {
Entry::new(service, account).map_err(|error| error.to_string())
}
+319 -64
View File
@@ -4,59 +4,82 @@ use std::{
path::Path,
};
use ed25519_dalek::SigningKey;
use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
use hpke::{Deserializable, Kem, Serializable, kem::X25519HkdfSha256};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use zeroize::{Zeroize, Zeroizing};
use crate::error::SyncClientError;
use crate::{
device_secret_store::{DeviceSecretStore, DeviceSecrets},
error::SyncClientError,
};
/// Locally-stable device identity. Constructed once per profile data
/// directory and persisted so reinstalls don't trigger re-approval
/// requests — the same `device_id` is reused across runs.
const PUBLIC_KEY_BYTES: usize = 32;
const MAX_DEVICE_TEXT_CHARS: usize = 128;
/// Public device identity persisted in the profile directory. Both private
/// keys live in the macOS data-protection Keychain under `device_id`.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeviceIdentity {
pub device_id: String,
/// Ed25519 verification key encoded as lowercase hex.
pub public_key: String,
/// RFC 9180 X25519 recipient key encoded as lowercase hex.
pub wrapping_public_key: String,
pub device_name: String,
pub platform: String,
}
impl DeviceIdentity {
/// Load the persisted identity, or create-and-save a new one.
/// The identity file lives at `path`; callers usually pick
/// `<profile_data>/sync/device.json`.
/// Loads a v2 identity and its Keychain secrets. A legacy public-only
/// identity is rotated to a fresh device ID because its private key was
/// never persisted and cannot prove device continuity.
pub fn load_or_create(
path: &Path,
device_name: impl Into<String>,
platform: impl Into<String>,
) -> Result<Self, SyncClientError> {
let device_name = device_name.into();
let platform = platform.into();
match fs::read_to_string(path) {
Ok(contents) => {
let identity: Self = serde_json::from_str(&contents).map_err(|error| {
SyncClientError::TokenStorage(format!(
"device identity is corrupt at {}: {error}",
path.display()
))
})?;
identity.validate()?;
Ok(identity)
}
Ok(contents) => match decode_stored_identity(&contents, path)? {
StoredIdentity::V2(identity) => {
identity.validate()?;
match DeviceSecretStore::new(identity.device_id.clone())?.load()? {
Some(secrets) => {
identity.validate_secrets(&secrets)?;
Ok(identity)
}
None => {
Self::create_and_save(path, identity.device_name, identity.platform)
}
}
}
StoredIdentity::Legacy(identity) => {
identity.validate()?;
Self::create_and_save(path, identity.device_name, identity.platform)
}
},
Err(error) if error.kind() == ErrorKind::NotFound => {
let identity = Self::generate(device_name, platform);
identity.save(path)?;
Ok(identity)
Self::create_and_save(path, device_name, platform)
}
Err(error) => Err(SyncClientError::TokenStorage(error.to_string())),
}
}
pub fn generate(device_name: impl Into<String>, platform: impl Into<String>) -> Self {
let device_id = format!("ely-{}", Uuid::now_v7().simple());
let public_key = public_key_hex();
Self { device_id, public_key, device_name: device_name.into(), platform: platform.into() }
pub fn generate(
device_name: impl Into<String>,
platform: impl Into<String>,
) -> Result<Self, SyncClientError> {
let (identity, secrets) = generate_key_material(device_name.into(), platform.into())?;
DeviceSecretStore::new(identity.device_id.clone())?.save(&secrets)?;
Ok(identity)
}
pub fn save(&self, path: &Path) -> Result<(), SyncClientError> {
self.validate()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(io_err)?;
}
@@ -65,30 +88,180 @@ impl DeviceIdentity {
SyncClientError::TokenStorage(format!("device identity serialize: {error}"))
})?;
fs::write(&tmp, serialized).map_err(io_err)?;
fs::rename(&tmp, path).map_err(io_err)?;
fs::rename(&tmp, path).map_err(io_err)
}
/// Signs exact canonical bytes for device registration and rebind proofs.
pub fn sign_message(&self, message: &[u8]) -> Result<String, SyncClientError> {
self.validate()?;
let secrets = DeviceSecretStore::new(self.device_id.clone())?.load_required()?;
self.validate_secrets(&secrets)?;
Ok(self.sign_message_with_secrets(message, &secrets))
}
fn sign_message_with_secrets(&self, message: &[u8], secrets: &DeviceSecrets) -> String {
let signing_key = SigningKey::from_bytes(secrets.signing_private_key());
hex_string(&signing_key.sign(message).to_bytes())
}
pub(crate) fn validate(&self) -> Result<(), SyncClientError> {
validate_common(
&self.device_id,
&self.public_key,
&self.device_name,
&self.platform,
true,
)?;
let wrapping_public_key = decode_hex_32(
&self.wrapping_public_key,
"device wrapping public key encoding is invalid",
)?;
<X25519HkdfSha256 as Kem>::PublicKey::from_bytes(&wrapping_public_key)
.map_err(|_| key_error("device wrapping public key is invalid"))?;
Ok(())
}
fn validate(&self) -> Result<(), SyncClientError> {
if !is_device_id_shape(&self.device_id) {
return Err(SyncClientError::TokenStorage(
"device_id does not match the Cloudflare worker pattern".to_string(),
));
pub(crate) fn validate_secrets(&self, secrets: &DeviceSecrets) -> Result<(), SyncClientError> {
let expected_signing_public_key =
decode_hex_32(&self.public_key, "device signing public key encoding is invalid")?;
let signing_key = SigningKey::from_bytes(secrets.signing_private_key());
if signing_key.verifying_key().to_bytes() != expected_signing_public_key {
return Err(key_error("device signing private key does not match identity"));
}
if self.public_key.trim().is_empty() {
return Err(SyncClientError::TokenStorage("device public_key is empty".to_string()));
}
if self.device_name.trim().is_empty() {
return Err(SyncClientError::TokenStorage("device_name is empty".to_string()));
}
if self.platform.trim().is_empty() {
return Err(SyncClientError::TokenStorage("platform is empty".to_string()));
let private_key =
<X25519HkdfSha256 as Kem>::PrivateKey::from_bytes(secrets.wrapping_private_key())
.map_err(|_| key_error("device wrapping private key is invalid"))?;
let expected_wrapping_public_key = decode_hex_32(
&self.wrapping_public_key,
"device wrapping public key encoding is invalid",
)?;
if X25519HkdfSha256::sk_to_pk(&private_key).to_bytes().as_slice()
!= expected_wrapping_public_key
{
return Err(key_error("device wrapping private key does not match identity"));
}
Ok(())
}
fn create_and_save(
path: &Path,
device_name: String,
platform: String,
) -> Result<Self, SyncClientError> {
let (identity, secrets) = generate_key_material(device_name, platform)?;
let store = DeviceSecretStore::new(identity.device_id.clone())?;
store.save(&secrets)?;
if let Err(error) = identity.save(path) {
let _ = store.clear();
return Err(error);
}
Ok(identity)
}
}
fn is_device_id_shape(value: &str) -> bool {
pub(crate) fn generate_key_material(
device_name: String,
platform: String,
) -> Result<(DeviceIdentity, DeviceSecrets), SyncClientError> {
let mut signing_private_key = Zeroizing::new([0_u8; PUBLIC_KEY_BYTES]);
getrandom::fill(signing_private_key.as_mut())
.map_err(|_| key_error("secure randomness unavailable"))?;
let signing_key = SigningKey::from_bytes(&signing_private_key);
let mut wrapping_ikm = Zeroizing::new([0_u8; PUBLIC_KEY_BYTES]);
getrandom::fill(wrapping_ikm.as_mut())
.map_err(|_| key_error("secure randomness unavailable"))?;
let (wrapping_private_key, wrapping_public_key) =
X25519HkdfSha256::derive_keypair(wrapping_ikm.as_slice());
let mut wrapping_private_bytes = wrapping_private_key.to_bytes();
let mut stored_wrapping_private_key = [0_u8; PUBLIC_KEY_BYTES];
stored_wrapping_private_key.copy_from_slice(&wrapping_private_bytes);
wrapping_private_bytes.zeroize();
let identity = DeviceIdentity {
device_id: format!("ely-{}", Uuid::now_v7().simple()),
public_key: hex_string(&signing_key.verifying_key().to_bytes()),
wrapping_public_key: hex_string(&wrapping_public_key.to_bytes()),
device_name: device_name.trim().to_string(),
platform: platform.trim().to_string(),
};
identity.validate()?;
let secrets = DeviceSecrets::new(*signing_private_key, stored_wrapping_private_key);
identity.validate_secrets(&secrets)?;
Ok((identity, secrets))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyDeviceIdentity {
device_id: String,
public_key: String,
device_name: String,
platform: String,
}
impl LegacyDeviceIdentity {
fn validate(&self) -> Result<(), SyncClientError> {
validate_common(&self.device_id, &self.public_key, &self.device_name, &self.platform, false)
}
}
enum StoredIdentity {
V2(DeviceIdentity),
Legacy(LegacyDeviceIdentity),
}
fn decode_stored_identity(contents: &str, path: &Path) -> Result<StoredIdentity, SyncClientError> {
let value: serde_json::Value =
serde_json::from_str(contents).map_err(|error| corrupt_identity_error(path, error))?;
if value.get("wrapping_public_key").is_some() {
serde_json::from_value(value)
.map(StoredIdentity::V2)
.map_err(|error| corrupt_identity_error(path, error))
} else {
serde_json::from_value(value)
.map(StoredIdentity::Legacy)
.map_err(|error| corrupt_identity_error(path, error))
}
}
fn validate_common(
device_id: &str,
signing_public_key: &str,
device_name: &str,
platform: &str,
require_canonical_text: bool,
) -> Result<(), SyncClientError> {
if !is_device_id_shape(device_id) {
return Err(key_error("device_id does not match the Cloudflare worker pattern"));
}
let signing_public_key =
decode_hex_32(signing_public_key, "device signing public key encoding is invalid")?;
VerifyingKey::from_bytes(&signing_public_key)
.map_err(|_| key_error("device signing public key is invalid"))?;
validate_device_text(device_name, "device_name is invalid", require_canonical_text)?;
validate_device_text(platform, "platform is invalid", require_canonical_text)?;
Ok(())
}
fn validate_device_text(
value: &str,
reason: &'static str,
require_canonical: bool,
) -> Result<(), SyncClientError> {
let trimmed = value.trim();
if trimmed.is_empty()
|| trimmed.chars().count() > MAX_DEVICE_TEXT_CHARS
|| trimmed.chars().any(char::is_control)
|| (require_canonical && value != trimmed)
{
return Err(key_error(reason));
}
Ok(())
}
pub(crate) fn is_device_id_shape(value: &str) -> bool {
(3..=128).contains(&value.len())
&& value
.as_bytes()
@@ -96,20 +269,52 @@ fn is_device_id_shape(value: &str) -> bool {
.all(|byte| matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b':' | b'-'))
}
fn io_err(error: io::Error) -> SyncClientError {
SyncClientError::TokenStorage(error.to_string())
pub(crate) fn decode_hex_32(
value: &str,
reason: &'static str,
) -> Result<[u8; 32], SyncClientError> {
if value.len() != 64 {
return Err(key_error(reason));
}
let mut bytes = [0_u8; 32];
for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
bytes[index] = (hex_nibble(pair[0]).ok_or_else(|| key_error(reason))? << 4)
| hex_nibble(pair[1]).ok_or_else(|| key_error(reason))?;
}
Ok(bytes)
}
fn public_key_hex() -> String {
let mut seed = [0_u8; 32];
seed[..16].copy_from_slice(Uuid::now_v7().as_bytes());
seed[16..].copy_from_slice(Uuid::now_v7().as_bytes());
let signing_key = SigningKey::from_bytes(&seed);
hex_string(&signing_key.verifying_key().to_bytes())
fn hex_nibble(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
_ => None,
}
}
fn hex_string(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for &byte in bytes {
output.push(char::from(HEX[usize::from(byte >> 4)]));
output.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
output
}
fn key_error(message: impl Into<String>) -> SyncClientError {
SyncClientError::DeviceKeyStorage(message.into())
}
fn corrupt_identity_error(path: &Path, error: serde_json::Error) -> SyncClientError {
SyncClientError::TokenStorage(format!(
"device identity is corrupt at {}: {error}",
path.display()
))
}
fn io_err(error: io::Error) -> SyncClientError {
SyncClientError::TokenStorage(error.to_string())
}
#[derive(Clone, Debug, Serialize)]
@@ -117,6 +322,8 @@ pub struct DeviceRegistration<'a> {
pub version: u32,
pub device_id: &'a str,
pub public_key: &'a str,
pub wrapping_public_key: &'a str,
pub registration_proof: &'a str,
pub device_name: &'a str,
pub platform: &'a str,
pub idempotency_key: &'a str,
@@ -132,9 +339,12 @@ pub struct DeviceListResponse {
#[derive(Clone, Debug, Deserialize)]
pub struct DeviceRecord {
pub device_id: String,
pub public_key: String,
pub wrapping_public_key: Option<String>,
pub device_name: String,
pub platform: String,
pub approval_status: String,
pub current: bool,
pub created_at: u64,
pub approved_at: Option<u64>,
pub last_active_at: Option<u64>,
@@ -150,38 +360,83 @@ impl DeviceRecord {
#[cfg(test)]
mod tests {
use super::*;
use std::env::temp_dir;
#[test]
fn identity_round_trips() -> Result<(), SyncClientError> {
let dir = temp_dir().join(format!("ely-device-{}", Uuid::now_v7().simple()));
let path = dir.join("device.json");
let identity = DeviceIdentity::load_or_create(&path, "Test", "macos")?;
identity.validate()?;
assert_eq!(identity.public_key.len(), 64);
assert!(identity.public_key.as_bytes().iter().all(u8::is_ascii_hexdigit));
fn generated_identity_contains_public_keys_only() -> Result<(), SyncClientError> {
let (identity, _) = generate_key_material(" Test ".to_string(), " macos ".to_string())?;
let value = serde_json::to_value(&identity).map_err(|error| {
SyncClientError::TokenStorage(format!("device identity serialize: {error}"))
})?;
let again = DeviceIdentity::load_or_create(&path, "ignored", "ignored")?;
assert_eq!(identity, again);
assert_eq!(value.as_object().map(serde_json::Map::len), Some(5));
assert_eq!(identity.public_key.len(), 64);
assert_eq!(identity.wrapping_public_key.len(), 64);
assert_eq!(identity.device_name, "Test");
assert_eq!(identity.platform, "macos");
assert!(value.get("private_key").is_none());
Ok(())
}
#[test]
fn device_registration_serializes_worker_schema_version() -> Result<(), SyncClientError> {
fn device_registration_serializes_v2_worker_schema() -> Result<(), SyncClientError> {
let registration = DeviceRegistration {
version: 1,
version: 2,
device_id: "device-01",
public_key: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
public_key: "01",
wrapping_public_key: "02",
registration_proof: "03",
device_name: "MacBook Pro",
platform: "macOS",
idempotency_key: "device-register-device-01",
};
let value = serde_json::to_value(registration).map_err(|error| {
SyncClientError::TokenStorage(format!("device registration serialize: {error}"))
})?;
assert_eq!(value["version"], 1);
assert_eq!(value["version"], 2);
assert_eq!(value["wrapping_public_key"], "02");
assert_eq!(value["registration_proof"], "03");
Ok(())
}
#[test]
fn legacy_identity_is_detected_for_rotation() -> Result<(), SyncClientError> {
let (identity, _) = generate_key_material("Test".to_string(), "macos".to_string())?;
let legacy = serde_json::json!({
"device_id": identity.device_id,
"public_key": identity.public_key,
"device_name": identity.device_name,
"platform": identity.platform,
});
let stored = decode_stored_identity(&legacy.to_string(), Path::new("device.json"))?;
assert!(matches!(stored, StoredIdentity::Legacy(_)));
Ok(())
}
#[cfg(target_os = "macos")]
#[test]
fn legacy_identity_rotates_to_new_device_id() -> Result<(), SyncClientError> {
let dir = std::env::temp_dir().join(format!("ely-device-{}", Uuid::now_v7().simple()));
let path = dir.join("device.json");
fs::create_dir_all(&dir).map_err(io_err)?;
let legacy_device_id = "ely-legacy-device";
let (_, secrets) = generate_key_material("Test".to_string(), "macos".to_string())?;
let signing_key = SigningKey::from_bytes(secrets.signing_private_key());
let legacy = serde_json::json!({
"device_id": legacy_device_id,
"public_key": hex_string(&signing_key.verifying_key().to_bytes()),
"device_name": "Legacy",
"platform": "macos",
});
fs::write(&path, legacy.to_string()).map_err(io_err)?;
let result = (|| {
let identity = DeviceIdentity::load_or_create(&path, "ignored", "ignored")?;
assert_ne!(identity.device_id, legacy_device_id);
assert_eq!(DeviceIdentity::load_or_create(&path, "ignored", "ignored")?, identity);
DeviceSecretStore::new(identity.device_id)?.clear()
})();
let _ = fs::remove_dir_all(dir);
result
}
}
+362
View File
@@ -0,0 +1,362 @@
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
DeviceIdentity, DeviceRecord, SyncClientError,
device_proof::{is_idempotency_key_shape, push_field},
vault::WrappedAccountKey,
};
const REBIND_CHALLENGE_VERSION: u32 = 1;
const MAX_CHALLENGE_LIFETIME_SECONDS: u64 = 600;
#[derive(Debug, Serialize)]
pub(crate) struct DeviceRebindChallengeRequest<'a> {
pub version: u32,
pub device_id: &'a str,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct DeviceRebindChallengeDocument {
pub version: u32,
pub challenge_id: String,
pub device_id: String,
pub challenge: String,
pub expires_at: u64,
}
impl DeviceRebindChallengeDocument {
pub(crate) fn signed_request(
&self,
identity: &DeviceIdentity,
now_seconds: u64,
) -> Result<DeviceRebindRequest, SyncClientError> {
let _ = self.validated_context(identity, now_seconds)?;
Ok(DeviceRebindRequest {
version: REBIND_CHALLENGE_VERSION,
challenge_id: self.challenge_id.clone(),
device_id: self.device_id.clone(),
signature: identity.sign_message(self.challenge.as_bytes())?,
})
}
fn validated_context<'a>(
&'a self,
identity: &DeviceIdentity,
now_seconds: u64,
) -> Result<(&'a str, &'a str), SyncClientError> {
if self.version != REBIND_CHALLENGE_VERSION || self.device_id != identity.device_id {
return Err(protocol_error("device rebind challenge identity does not match"));
}
if self.expires_at <= now_seconds
|| self.expires_at > now_seconds.saturating_add(MAX_CHALLENGE_LIFETIME_SECONDS)
{
return Err(protocol_error("device rebind challenge expiry is invalid"));
}
let challenge_id = Uuid::parse_str(&self.challenge_id)
.map_err(|_| protocol_error("device rebind challenge identifier is invalid"))?;
if challenge_id.hyphenated().to_string() != self.challenge_id {
return Err(protocol_error("device rebind challenge identifier is not canonical"));
}
let lines = self.challenge.split('\n').collect::<Vec<_>>();
if lines.len() != 7 || lines[0] != "elydora-device-rebind-v1" {
return Err(protocol_error("device rebind challenge format is invalid"));
}
assert_challenge_field(lines[1], "challenge_id", &self.challenge_id)?;
let user_id = challenge_value(lines[2], "user_id")?;
let session_id = challenge_value(lines[3], "session_id")?;
assert_challenge_field(lines[4], "device_id", &self.device_id)?;
assert_challenge_field(lines[5], "expires_at", &self.expires_at.to_string())?;
let nonce = challenge_value(lines[6], "nonce")?;
if !is_lower_hex(nonce, 64) || !is_subject_id(user_id) || !is_subject_id(session_id) {
return Err(protocol_error("device rebind challenge value is invalid"));
}
Ok((user_id, session_id))
}
}
#[derive(Debug, Serialize)]
pub(crate) struct DeviceRebindRequest {
pub version: u32,
pub challenge_id: String,
pub device_id: String,
pub signature: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeviceRebindDocument {
pub version: u32,
pub user_id: String,
pub session_id: String,
pub device_id: String,
pub bound_at: u64,
}
impl DeviceRebindDocument {
pub(crate) fn validate(
&self,
identity: &DeviceIdentity,
challenge: &DeviceRebindChallengeDocument,
now_seconds: u64,
) -> Result<(), SyncClientError> {
let (user_id, session_id) = challenge.validated_context(identity, now_seconds)?;
if self.version != REBIND_CHALLENGE_VERSION
|| self.device_id != identity.device_id
|| self.user_id != user_id
|| self.session_id != session_id
|| self.bound_at > challenge.expires_at
{
return Err(protocol_error("device rebind response does not match challenge"));
}
Ok(())
}
}
#[derive(Debug, Serialize)]
pub struct DeviceApprovalRequest<'a> {
pub version: u32,
pub device_id: &'a str,
pub key_id: &'a str,
pub generation: u64,
pub envelope: &'a WrappedAccountKey,
pub idempotency_key: &'a str,
pub proof_created_at: u64,
pub approval_proof: String,
}
impl<'a> DeviceApprovalRequest<'a> {
pub fn new(
user_id: &str,
approver: &DeviceIdentity,
device_id: &'a str,
key_id: &'a str,
generation: u64,
envelope: &'a WrappedAccountKey,
idempotency_key: &'a str,
) -> Result<Self, SyncClientError> {
let proof_created_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| protocol_error("system clock is invalid"))?
.as_secs();
let message = approval_proof_message(&ApprovalProofFields {
user_id,
approver_device_id: &approver.device_id,
device_id,
key_id,
generation,
envelope,
idempotency_key,
proof_created_at,
})?;
Ok(Self {
version: 2,
device_id,
key_id,
generation,
envelope,
idempotency_key,
proof_created_at,
approval_proof: approver.sign_message(&message)?,
})
}
}
struct ApprovalProofFields<'a> {
user_id: &'a str,
approver_device_id: &'a str,
device_id: &'a str,
key_id: &'a str,
generation: u64,
envelope: &'a WrappedAccountKey,
idempotency_key: &'a str,
proof_created_at: u64,
}
fn approval_proof_message(fields: &ApprovalProofFields<'_>) -> Result<Vec<u8>, SyncClientError> {
if fields.user_id.is_empty()
|| fields.generation == 0
|| !is_idempotency_key_shape(fields.idempotency_key)
|| fields.key_id.len() != 64
{
return Err(protocol_error("device approval proof fields are invalid"));
}
fields.envelope.validate_wire()?;
let generation = fields.generation.to_string();
let envelope_version = fields.envelope.version.to_string();
let proof_created_at = fields.proof_created_at.to_string();
let mut message = Vec::with_capacity(512);
for field in [
"elydora-device-approval-v2",
fields.user_id,
fields.approver_device_id,
fields.device_id,
fields.key_id,
&generation,
&envelope_version,
&fields.envelope.suite,
&fields.envelope.encapped_key,
&fields.envelope.ciphertext,
fields.idempotency_key,
&proof_created_at,
] {
push_field(&mut message, field);
}
Ok(message)
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeviceApprovalDocument {
pub version: u32,
pub user_id: String,
pub approved_by_device_id: String,
pub approved_at: u64,
pub device: DeviceRecord,
}
fn assert_challenge_field(
line: &str,
name: &'static str,
expected: &str,
) -> Result<(), SyncClientError> {
if challenge_value(line, name)? != expected {
return Err(protocol_error("device rebind challenge binding does not match"));
}
Ok(())
}
fn challenge_value<'a>(line: &'a str, name: &'static str) -> Result<&'a str, SyncClientError> {
let value = line
.strip_prefix(name)
.and_then(|suffix| suffix.strip_prefix('='))
.ok_or_else(|| protocol_error("device rebind challenge field is invalid"))?;
if value.is_empty() {
return Err(protocol_error("device rebind challenge field is empty"));
}
Ok(value)
}
fn is_subject_id(value: &str) -> bool {
(3..=128).contains(&value.len())
&& value.bytes().all(|byte| byte.is_ascii_alphanumeric() || b"._:-".contains(&byte))
}
fn is_lower_hex(value: &str, length: usize) -> bool {
value.len() == length
&& value.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
fn protocol_error(reason: &'static str) -> SyncClientError {
SyncClientError::DeviceTrust { reason }
}
#[cfg(test)]
mod tests {
use ed25519_dalek::{Signer, SigningKey};
use super::*;
fn identity() -> DeviceIdentity {
DeviceIdentity {
device_id: "ely-018f0f4fbbcc7f36a241d1a2a1f01111".to_string(),
public_key: "01".repeat(32),
wrapping_public_key: "02".repeat(32),
device_name: "Test".to_string(),
platform: "macos".to_string(),
}
}
fn challenge() -> DeviceRebindChallengeDocument {
let challenge_id = "018f0f4f-bbcc-7f36-a241-d1a2a1f01111";
let device_id = identity().device_id;
let expires_at = 1_000;
DeviceRebindChallengeDocument {
version: 1,
challenge_id: challenge_id.to_string(),
device_id: device_id.clone(),
challenge: format!(
"elydora-device-rebind-v1\nchallenge_id={challenge_id}\nuser_id=user-01\nsession_id=session-01\ndevice_id={device_id}\nexpires_at={expires_at}\nnonce={}",
"ab".repeat(32)
),
expires_at,
}
}
#[test]
fn canonical_rebind_challenge_binds_device_and_session() -> Result<(), SyncClientError> {
let challenge = challenge();
let identity = identity();
assert_eq!(challenge.validated_context(&identity, 700)?, ("user-01", "session-01"));
Ok(())
}
#[test]
fn rebind_challenge_rejects_metadata_changes() {
let identity = identity();
for changed in [
DeviceRebindChallengeDocument { device_id: "device-02".to_string(), ..challenge() },
DeviceRebindChallengeDocument { expires_at: 701, ..challenge() },
DeviceRebindChallengeDocument {
challenge: challenge().challenge.replace("nonce=ab", "nonce=Ab"),
..challenge()
},
] {
assert!(changed.validated_context(&identity, 700).is_err());
}
}
#[test]
fn approval_proof_uses_the_frozen_worker_field_order() -> Result<(), SyncClientError> {
let envelope = WrappedAccountKey {
version: 1,
suite: crate::ACCOUNT_KEY_WRAP_SUITE.to_string(),
encapped_key: "A".repeat(43),
ciphertext: "B".repeat(64),
};
let key_id = "a".repeat(64);
let message = approval_proof_message(&ApprovalProofFields {
user_id: "user-01",
approver_device_id: "device-01",
device_id: "device-02",
key_id: &key_id,
generation: 1,
envelope: &envelope,
idempotency_key: "device-approval-0001",
proof_created_at: 1_780_000_300,
})?;
let fields = [
"elydora-device-approval-v2".to_string(),
"user-01".to_string(),
"device-01".to_string(),
"device-02".to_string(),
"a".repeat(64),
"1".to_string(),
"1".to_string(),
crate::ACCOUNT_KEY_WRAP_SUITE.to_string(),
"A".repeat(43),
"B".repeat(64),
"device-approval-0001".to_string(),
"1780000300".to_string(),
];
let expected =
fields.iter().map(|field| format!("{}:{field}", field.len())).collect::<String>();
assert_eq!(message, expected.as_bytes());
let private_key = crate::device::decode_hex_32(
"9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
"test private key is invalid",
)?;
let signature = SigningKey::from_bytes(&private_key).sign(&message);
let signature_hex =
signature.to_bytes().iter().map(|byte| format!("{byte:02x}")).collect::<String>();
assert_eq!(
signature_hex,
"f12fb7a5f7f20551bd22d0fcf8f5787d49f6202f89e42c332c248772fd9a59c82a9d8b6ac47ea84340170fc1555fc74d70a0d6ba3541df257882d46d6d79d901"
);
Ok(())
}
}
+169
View File
@@ -0,0 +1,169 @@
use sha2::{Digest, Sha256};
use crate::{DeviceIdentity, DeviceRecord, SyncClientError, device::decode_hex_32};
const REGISTRATION_PROOF_DOMAIN: &str = "elydora-device-registration-v2";
const VERIFICATION_CODE_DOMAIN: &str = "elydora-device-verification-v1";
impl DeviceIdentity {
pub fn registration_proof(&self, idempotency_key: &str) -> Result<String, SyncClientError> {
let message = self.registration_proof_message(idempotency_key)?;
self.sign_message(&message)
}
pub fn verification_code(&self) -> Result<String, SyncClientError> {
self.validate()?;
verification_code(
&self.device_id,
&self.public_key,
&self.wrapping_public_key,
&self.device_name,
&self.platform,
)
}
fn registration_proof_message(
&self,
idempotency_key: &str,
) -> Result<Vec<u8>, SyncClientError> {
self.validate()?;
if !is_idempotency_key_shape(idempotency_key) {
return Err(proof_error("device registration idempotency key is invalid"));
}
let fields = [
REGISTRATION_PROOF_DOMAIN,
&self.device_id,
&self.public_key,
&self.wrapping_public_key,
&self.device_name,
&self.platform,
idempotency_key,
];
let mut message = Vec::with_capacity(512);
for field in fields {
push_field(&mut message, field);
}
Ok(message)
}
}
impl DeviceRecord {
pub fn verification_code(&self) -> Result<String, SyncClientError> {
let wrapping_public_key = self
.wrapping_public_key
.as_deref()
.ok_or_else(|| proof_error("device wrapping public key is unavailable"))?;
verification_code(
&self.device_id,
&self.public_key,
wrapping_public_key,
&self.device_name,
&self.platform,
)
}
}
fn verification_code(
device_id: &str,
public_key: &str,
wrapping_public_key: &str,
device_name: &str,
platform: &str,
) -> Result<String, SyncClientError> {
if !(3..=128).contains(&device_id.len())
|| !device_id.bytes().all(|byte| byte.is_ascii_alphanumeric() || b"._:-".contains(&byte))
{
return Err(proof_error("device identifier is invalid"));
}
decode_hex_32(public_key, "device signing public key encoding is invalid")?;
decode_hex_32(wrapping_public_key, "device wrapping public key encoding is invalid")?;
let fields = [
VERIFICATION_CODE_DOMAIN,
device_id,
public_key,
wrapping_public_key,
device_name,
platform,
];
let mut message = Vec::with_capacity(512);
for field in fields {
push_field(&mut message, field);
}
let digest = Sha256::digest(message);
Ok(digest[..8]
.chunks_exact(2)
.map(|chunk| format!("{:02X}{:02X}", chunk[0], chunk[1]))
.collect::<Vec<_>>()
.join("-"))
}
pub(crate) fn is_idempotency_key_shape(value: &str) -> bool {
(16..=128).contains(&value.len())
&& value
.as_bytes()
.iter()
.all(|byte| matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b':' | b'-'))
}
pub(crate) fn push_field(message: &mut Vec<u8>, value: &str) {
message.extend_from_slice(value.len().to_string().as_bytes());
message.push(b':');
message.extend_from_slice(value.as_bytes());
}
fn proof_error(message: impl Into<String>) -> SyncClientError {
SyncClientError::DeviceKeyStorage(message.into())
}
#[cfg(test)]
mod tests {
use ed25519_dalek::{Signer, SigningKey, Verifier};
use super::*;
use crate::device::generate_key_material;
#[test]
fn registration_proof_matches_worker_canonical_bytes() -> Result<(), SyncClientError> {
let (identity, secrets) = generate_key_material("ELY ñ".to_string(), "macOS".to_string())?;
let idempotency_key = "device-register:01";
let message = identity.registration_proof_message(idempotency_key)?;
let fields = [
REGISTRATION_PROOF_DOMAIN,
&identity.device_id,
&identity.public_key,
&identity.wrapping_public_key,
&identity.device_name,
&identity.platform,
idempotency_key,
];
let expected =
fields.iter().map(|field| format!("{}:{field}", field.len())).collect::<String>();
assert_eq!(message, expected.as_bytes());
let signing_key = SigningKey::from_bytes(secrets.signing_private_key());
let signature = signing_key.sign(&message);
signing_key
.verifying_key()
.verify(&message, &signature)
.map_err(|_| proof_error("device registration proof verification failed"))
}
#[test]
fn verification_code_binds_every_public_identity_field() -> Result<(), SyncClientError> {
let (identity, _) = generate_key_material("ELY ñ".to_string(), "macOS".to_string())?;
let code = identity.verification_code()?;
assert_eq!(code.len(), 19);
let changed = [
DeviceIdentity { device_id: "device-02".to_string(), ..identity.clone() },
DeviceIdentity { public_key: "01".repeat(32), ..identity.clone() },
DeviceIdentity { wrapping_public_key: "02".repeat(32), ..identity.clone() },
DeviceIdentity { device_name: "Other".to_string(), ..identity.clone() },
DeviceIdentity { platform: "linux".to_string(), ..identity.clone() },
];
for candidate in changed {
assert_ne!(candidate.verification_code()?, code);
}
Ok(())
}
}
@@ -0,0 +1,380 @@
use serde::{Deserialize, Serialize};
use crate::{
DeviceIdentity, DeviceRecord, SyncClientError,
device::is_device_id_shape,
device_proof::{is_idempotency_key_shape, push_field},
vault::WrappedAccountKey,
};
const REVOCATION_PROOF_DOMAIN: &str = "elydora-device-revocation-v2";
const PENDING_REVOCATION_PROOF_DOMAIN: &str = "elydora-pending-device-revocation-v2";
const MAX_ROTATION_ENVELOPES: usize = 128;
const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
#[derive(Clone, Debug, Serialize)]
struct DeviceRevocationEnvelope {
recipient_device_id: String,
envelope: WrappedAccountKey,
}
#[derive(Clone, Debug, Serialize)]
pub struct ApprovedDeviceRevocationRequest {
version: u32,
mode: &'static str,
device_id: String,
previous_key_id: String,
previous_generation: u64,
new_key_id: String,
new_generation: u64,
envelopes: Vec<DeviceRevocationEnvelope>,
idempotency_key: String,
rotation_proof: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct PendingDeviceRevocationRequest {
version: u32,
mode: &'static str,
device_id: String,
idempotency_key: String,
pending_revocation_proof: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(untagged)]
pub enum DeviceRevocationRequest {
ApprovedRotate(ApprovedDeviceRevocationRequest),
PendingRevoke(PendingDeviceRevocationRequest),
}
impl DeviceRevocationRequest {
#[allow(clippy::too_many_arguments)]
pub fn approved_rotation(
user_id: &str,
approver: &DeviceIdentity,
target_device_id: &str,
previous_key_id: &str,
previous_generation: u64,
new_key_id: &str,
new_generation: u64,
envelopes: Vec<(String, WrappedAccountKey)>,
idempotency_key: &str,
) -> Result<Self, SyncClientError> {
let envelopes = validate_and_sort_envelopes(envelopes, target_device_id)?;
validate_request_fields(
user_id,
approver,
target_device_id,
previous_key_id,
previous_generation,
new_key_id,
new_generation,
idempotency_key,
)?;
let message = revocation_proof_message(
user_id,
approver,
target_device_id,
previous_key_id,
previous_generation,
new_key_id,
new_generation,
&envelopes,
idempotency_key,
);
Ok(Self::ApprovedRotate(ApprovedDeviceRevocationRequest {
version: 2,
mode: "approved_rotate",
device_id: target_device_id.to_string(),
previous_key_id: previous_key_id.to_string(),
previous_generation,
new_key_id: new_key_id.to_string(),
new_generation,
envelopes,
idempotency_key: idempotency_key.to_string(),
rotation_proof: approver.sign_message(&message)?,
}))
}
pub fn pending(
user_id: &str,
approver: &DeviceIdentity,
target_device_id: &str,
idempotency_key: &str,
) -> Result<Self, SyncClientError> {
validate_pending_fields(user_id, approver, target_device_id, idempotency_key)?;
let message = pending_revocation_proof_message(
user_id,
&approver.device_id,
target_device_id,
idempotency_key,
);
Ok(Self::PendingRevoke(PendingDeviceRevocationRequest {
version: 2,
mode: "pending_revoke",
device_id: target_device_id.to_string(),
idempotency_key: idempotency_key.to_string(),
pending_revocation_proof: approver.sign_message(&message)?,
}))
}
}
fn pending_revocation_proof_message(
user_id: &str,
approver_device_id: &str,
target_device_id: &str,
idempotency_key: &str,
) -> Vec<u8> {
let mut message = Vec::with_capacity(256);
for field in [
PENDING_REVOCATION_PROOF_DOMAIN,
user_id,
approver_device_id,
target_device_id,
idempotency_key,
] {
push_field(&mut message, field);
}
message
}
#[derive(Clone, Debug, Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
pub enum DeviceRevocationDocument {
ApprovedRotate {
version: u32,
user_id: String,
revoked_by_device_id: String,
revoked_at: u64,
key_id: String,
generation: u64,
device: DeviceRecord,
},
PendingRevoke {
version: u32,
user_id: String,
revoked_by_device_id: String,
revoked_at: u64,
device: DeviceRecord,
},
}
#[allow(clippy::too_many_arguments)]
fn validate_request_fields(
user_id: &str,
approver: &DeviceIdentity,
target_device_id: &str,
previous_key_id: &str,
previous_generation: u64,
new_key_id: &str,
new_generation: u64,
idempotency_key: &str,
) -> Result<(), SyncClientError> {
validate_pending_fields(user_id, approver, target_device_id, idempotency_key)?;
if !is_key_id(previous_key_id) || !is_key_id(new_key_id) || previous_key_id == new_key_id {
return Err(protocol_error("device revocation key identifier is invalid"));
}
if previous_generation == 0
|| previous_generation >= MAX_SAFE_INTEGER
|| new_generation != previous_generation + 1
{
return Err(protocol_error("device revocation generation is invalid"));
}
Ok(())
}
fn validate_pending_fields(
user_id: &str,
approver: &DeviceIdentity,
target_device_id: &str,
idempotency_key: &str,
) -> Result<(), SyncClientError> {
if user_id.trim().is_empty() || user_id.len() > 4096 {
return Err(protocol_error("device revocation user identifier is invalid"));
}
approver.validate()?;
if !is_device_id_shape(target_device_id) || target_device_id == approver.device_id {
return Err(protocol_error("device revocation target is invalid"));
}
if !is_idempotency_key_shape(idempotency_key) {
return Err(protocol_error("device revocation idempotency key is invalid"));
}
Ok(())
}
fn validate_and_sort_envelopes(
envelopes: Vec<(String, WrappedAccountKey)>,
target_device_id: &str,
) -> Result<Vec<DeviceRevocationEnvelope>, SyncClientError> {
if envelopes.is_empty() || envelopes.len() > MAX_ROTATION_ENVELOPES {
return Err(protocol_error("device revocation envelope count is invalid"));
}
let mut envelopes = envelopes
.into_iter()
.map(|(recipient_device_id, envelope)| {
if !is_device_id_shape(&recipient_device_id) || recipient_device_id == target_device_id
{
return Err(protocol_error("device revocation envelope recipient is invalid"));
}
envelope.validate_wire()?;
Ok(DeviceRevocationEnvelope { recipient_device_id, envelope })
})
.collect::<Result<Vec<_>, SyncClientError>>()?;
envelopes.sort_by(|left, right| left.recipient_device_id.cmp(&right.recipient_device_id));
if envelopes.windows(2).any(|pair| pair[0].recipient_device_id == pair[1].recipient_device_id) {
return Err(protocol_error("device revocation envelope recipient is duplicated"));
}
Ok(envelopes)
}
#[allow(clippy::too_many_arguments)]
fn revocation_proof_message(
user_id: &str,
approver: &DeviceIdentity,
target_device_id: &str,
previous_key_id: &str,
previous_generation: u64,
new_key_id: &str,
new_generation: u64,
envelopes: &[DeviceRevocationEnvelope],
idempotency_key: &str,
) -> Vec<u8> {
let previous_generation = previous_generation.to_string();
let new_generation = new_generation.to_string();
let envelope_count = envelopes.len().to_string();
let fields = [
REVOCATION_PROOF_DOMAIN,
user_id,
&approver.device_id,
target_device_id,
previous_key_id,
&previous_generation,
new_key_id,
&new_generation,
idempotency_key,
&envelope_count,
];
let mut message = Vec::with_capacity(1024);
for field in fields {
push_field(&mut message, field);
}
for item in envelopes {
let envelope_version = item.envelope.version.to_string();
for field in [
item.recipient_device_id.as_str(),
&envelope_version,
&item.envelope.suite,
&item.envelope.encapped_key,
&item.envelope.ciphertext,
] {
push_field(&mut message, field);
}
}
message
}
fn is_key_id(value: &str) -> bool {
value.len() == 64
&& value.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
fn protocol_error(reason: &'static str) -> SyncClientError {
SyncClientError::DeviceTrust { reason }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
AccountKey, VaultContext, device::generate_key_material, vault::WrappedAccountKey,
};
#[test]
fn pending_proof_matches_worker_vector() {
let message = pending_revocation_proof_message(
"user-01",
"device-01",
"device-02",
"device-revocation-0001",
);
assert_eq!(
message,
b"36:elydora-pending-device-revocation-v27:user-019:device-019:device-0222:device-revocation-0001"
);
}
#[test]
fn proof_matches_worker_order_and_canonical_bytes() -> Result<(), SyncClientError> {
let (approver, _) = generate_key_material("Approver".to_string(), "macos".to_string())?;
let (recipient_b, _) = generate_key_material("B".to_string(), "macos".to_string())?;
let (recipient_a, _) = generate_key_material("A".to_string(), "macos".to_string())?;
let previous_key = AccountKey::from_bytes([41; 32]);
let new_key = AccountKey::from_bytes([43; 32]);
let new_key_id = new_key.key_id();
let envelopes = validate_and_sort_envelopes(
vec![
wrapped(&new_key, &new_key_id, &approver, &recipient_b)?,
wrapped(&new_key, &new_key_id, &approver, &recipient_a)?,
],
"device-target",
)?;
let message = revocation_proof_message(
"user-01",
&approver,
"device-target",
&previous_key.key_id(),
1,
&new_key_id,
2,
&envelopes,
"device-revocation:01",
);
assert!(envelopes[0].recipient_device_id < envelopes[1].recipient_device_id);
let mut fields = vec![
REVOCATION_PROOF_DOMAIN.to_string(),
"user-01".to_string(),
approver.device_id,
"device-target".to_string(),
previous_key.key_id(),
"1".to_string(),
new_key_id,
"2".to_string(),
"device-revocation:01".to_string(),
envelopes.len().to_string(),
];
for item in envelopes {
fields.extend([
item.recipient_device_id,
item.envelope.version.to_string(),
item.envelope.suite,
item.envelope.encapped_key,
item.envelope.ciphertext,
]);
}
let expected =
fields.iter().map(|field| format!("{}:{field}", field.len())).collect::<String>();
assert_eq!(message, expected.as_bytes());
Ok(())
}
fn wrapped(
key: &AccountKey,
key_id: &str,
approver: &DeviceIdentity,
recipient: &DeviceIdentity,
) -> Result<(String, WrappedAccountKey), SyncClientError> {
let envelope = WrappedAccountKey::wrap(
key,
&VaultContext {
user_id: "user-01",
recipient_device_id: &recipient.device_id,
recipient_wrapping_public_key: &recipient.wrapping_public_key,
approver_device_id: &approver.device_id,
generation: 2,
key_id,
},
)?;
Ok((recipient.device_id.clone(), envelope))
}
}
@@ -0,0 +1,121 @@
use zeroize::{Zeroize, Zeroizing};
use crate::{
SyncClientError,
credential_store::{clear_secret, load_secret, save_secret},
device::is_device_id_shape,
};
const KEYCHAIN_SERVICE: &str = "com.elydora.ely-browser.sync.device-secrets.v2";
const RECORD_VERSION: u8 = 2;
const SECRET_BYTES: usize = 32;
const RECORD_BYTES: usize = 1 + 2 * SECRET_BYTES;
#[derive(Clone, Debug)]
pub struct DeviceSecretStore {
device_id: String,
}
impl DeviceSecretStore {
pub fn new(device_id: impl Into<String>) -> Result<Self, SyncClientError> {
let device_id = device_id.into();
if !is_device_id_shape(&device_id) {
return Err(storage_error("device identifier is invalid"));
}
Ok(Self { device_id })
}
pub(crate) fn load(&self) -> Result<Option<DeviceSecrets>, SyncClientError> {
match load_secret(KEYCHAIN_SERVICE, &self.device_id).map_err(storage_error)? {
Some(record) => decode_secret_record(record).map(Some),
None => Ok(None),
}
}
pub(crate) fn load_required(&self) -> Result<DeviceSecrets, SyncClientError> {
self.load()?.ok_or_else(|| SyncClientError::DeviceKeyUnavailable {
device_id: self.device_id.clone(),
})
}
pub(crate) fn save(&self, secrets: &DeviceSecrets) -> Result<(), SyncClientError> {
let record = encode_secret_record(secrets);
save_secret(KEYCHAIN_SERVICE, &self.device_id, record.as_slice()).map_err(storage_error)
}
pub fn clear(&self) -> Result<(), SyncClientError> {
clear_secret(KEYCHAIN_SERVICE, &self.device_id).map_err(storage_error)
}
}
pub(crate) struct DeviceSecrets {
signing_private_key: Zeroizing<[u8; SECRET_BYTES]>,
wrapping_private_key: Zeroizing<[u8; SECRET_BYTES]>,
}
impl DeviceSecrets {
pub(crate) fn new(
signing_private_key: [u8; SECRET_BYTES],
wrapping_private_key: [u8; SECRET_BYTES],
) -> Self {
Self {
signing_private_key: Zeroizing::new(signing_private_key),
wrapping_private_key: Zeroizing::new(wrapping_private_key),
}
}
pub(crate) fn signing_private_key(&self) -> &[u8; SECRET_BYTES] {
&self.signing_private_key
}
pub(crate) fn wrapping_private_key(&self) -> &[u8; SECRET_BYTES] {
&self.wrapping_private_key
}
}
fn encode_secret_record(secrets: &DeviceSecrets) -> Zeroizing<[u8; RECORD_BYTES]> {
let mut record = Zeroizing::new([0_u8; RECORD_BYTES]);
record[0] = RECORD_VERSION;
record[1..1 + SECRET_BYTES].copy_from_slice(secrets.signing_private_key());
record[1 + SECRET_BYTES..].copy_from_slice(secrets.wrapping_private_key());
record
}
fn decode_secret_record(mut record: Zeroizing<Vec<u8>>) -> Result<DeviceSecrets, SyncClientError> {
if record.len() != RECORD_BYTES || record[0] != RECORD_VERSION {
return Err(storage_error("device secret record is invalid"));
}
let mut signing_private_key = [0_u8; SECRET_BYTES];
let mut wrapping_private_key = [0_u8; SECRET_BYTES];
signing_private_key.copy_from_slice(&record[1..1 + SECRET_BYTES]);
wrapping_private_key.copy_from_slice(&record[1 + SECRET_BYTES..]);
record.zeroize();
Ok(DeviceSecrets::new(signing_private_key, wrapping_private_key))
}
fn storage_error(message: impl Into<String>) -> SyncClientError {
SyncClientError::DeviceKeyStorage(message.into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secret_record_round_trips_both_private_keys() -> Result<(), SyncClientError> {
let secrets = DeviceSecrets::new([7; SECRET_BYTES], [19; SECRET_BYTES]);
let record = encode_secret_record(&secrets);
let decoded = decode_secret_record(Zeroizing::new(record.to_vec()))?;
assert_eq!(decoded.signing_private_key(), &[7; SECRET_BYTES]);
assert_eq!(decoded.wrapping_private_key(), &[19; SECRET_BYTES]);
Ok(())
}
#[test]
fn secret_record_rejects_unknown_versions() {
let mut record = vec![0_u8; RECORD_BYTES];
record[0] = RECORD_VERSION + 1;
assert!(decode_secret_record(Zeroizing::new(record)).is_err());
}
}
+371
View File
@@ -0,0 +1,371 @@
use std::fmt;
use chacha20poly1305::{
XChaCha20Poly1305, XNonce,
aead::{Aead, KeyInit, Payload},
};
use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;
use crate::{error::SyncClientError, snapshot::MAX_SNAPSHOT_BYTES};
pub const SNAPSHOT_ENCRYPTION_VERSION: u32 = 2;
const ENVELOPE_MAGIC: &[u8; 8] = b"ELYSYNC\0";
const ENVELOPE_VERSION: u8 = 1;
const ALGORITHM_XCHACHA20_POLY1305: u8 = 1;
const KEY_BYTES: usize = 32;
const HASH_BYTES: usize = 32;
const NONCE_BYTES: usize = 24;
const TAG_BYTES: usize = 16;
const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
const HEADER_BYTES: usize = ENVELOPE_MAGIC.len() + 2 + KEY_BYTES + HASH_BYTES + NONCE_BYTES;
const MAX_PLAINTEXT_BYTES: usize = MAX_SNAPSHOT_BYTES - HEADER_BYTES - TAG_BYTES;
const HKDF_SALT: &[u8] = b"ely-sync-account-key-v1";
const ENCRYPTION_KEY_INFO: &[u8] = b"snapshot-encryption-key";
const CONTENT_KEY_INFO: &[u8] = b"snapshot-content-authentication-key";
const KEY_ID_DOMAIN: &[u8] = b"ely-sync-key-id-v1\0";
const AAD_DOMAIN_V1: &[u8] = b"ely-sync-snapshot-aad-v1\0";
const AAD_DOMAIN_V2: &[u8] = b"ely-sync-snapshot-aad-v2\0";
type HmacSha256 = Hmac<Sha256>;
#[derive(Clone)]
pub struct AccountKey(Zeroizing<[u8; KEY_BYTES]>);
impl AccountKey {
pub fn generate() -> Result<Self, SyncClientError> {
let mut bytes = Zeroizing::new([0_u8; KEY_BYTES]);
getrandom::fill(bytes.as_mut())
.map_err(|_| encryption_error("secure randomness unavailable"))?;
Ok(Self::from_secret(bytes))
}
pub fn from_bytes(bytes: [u8; KEY_BYTES]) -> Self {
Self::from_secret(Zeroizing::new(bytes))
}
pub fn key_id(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(KEY_ID_DOMAIN);
hasher.update(self.0.as_slice());
hex_string(&hasher.finalize())
}
pub fn content_hash(&self, plaintext: &[u8]) -> Result<String, SyncClientError> {
let key = self.derived_key(CONTENT_KEY_INFO)?;
let mut mac = <HmacSha256 as Mac>::new_from_slice(key.as_slice())
.map_err(|_| encryption_error("content authentication key is invalid"))?;
mac.update(plaintext);
Ok(hex_string(&mac.finalize().into_bytes()))
}
pub fn encrypt(
&self,
context: &SnapshotCryptoContext<'_>,
plaintext: &[u8],
) -> Result<EncryptedSnapshot, SyncClientError> {
self.encrypt_with_version(context, plaintext, SNAPSHOT_ENCRYPTION_VERSION)
}
fn encrypt_with_version(
&self,
context: &SnapshotCryptoContext<'_>,
plaintext: &[u8],
encryption_version: u32,
) -> Result<EncryptedSnapshot, SyncClientError> {
if !matches!(encryption_version, 1 | SNAPSHOT_ENCRYPTION_VERSION) {
return Err(encryption_error("snapshot encryption version is unsupported"));
}
if plaintext.is_empty() || plaintext.len() > MAX_PLAINTEXT_BYTES {
return Err(SyncClientError::SnapshotTooLarge {
bytes: plaintext.len(),
limit: MAX_PLAINTEXT_BYTES,
});
}
let key_id = self.key_id();
let content_hash = self.content_hash(plaintext)?;
let key_id_bytes = decode_hex_32(&key_id)?;
let content_hash_bytes = decode_hex_32(&content_hash)?;
let aad = snapshot_aad(context, encryption_version, &key_id_bytes, &content_hash_bytes)?;
let encryption_key = self.derived_key(ENCRYPTION_KEY_INFO)?;
let cipher = XChaCha20Poly1305::new_from_slice(encryption_key.as_slice())
.map_err(|_| encryption_error("snapshot encryption key is invalid"))?;
let mut nonce = [0_u8; NONCE_BYTES];
getrandom::fill(&mut nonce)
.map_err(|_| encryption_error("secure randomness unavailable"))?;
let nonce = nonce_ref(&nonce)?;
let ciphertext = cipher
.encrypt(nonce, Payload { msg: plaintext, aad: &aad })
.map_err(|_| encryption_error("snapshot encryption failed"))?;
let mut bytes = Vec::with_capacity(HEADER_BYTES + ciphertext.len());
bytes.extend_from_slice(ENVELOPE_MAGIC);
bytes.push(ENVELOPE_VERSION);
bytes.push(ALGORITHM_XCHACHA20_POLY1305);
bytes.extend_from_slice(&key_id_bytes);
bytes.extend_from_slice(&content_hash_bytes);
bytes.extend_from_slice(nonce);
bytes.extend_from_slice(&ciphertext);
Ok(EncryptedSnapshot { bytes, key_id, content_hash })
}
pub fn decrypt(
&self,
context: &SnapshotCryptoContext<'_>,
encryption_version: u32,
expected_key_id: &str,
expected_content_hash: &str,
envelope: &[u8],
) -> Result<Vec<u8>, SyncClientError> {
if !matches!(encryption_version, 1 | SNAPSHOT_ENCRYPTION_VERSION) {
return Err(encryption_error("snapshot encryption version is unsupported"));
}
if envelope.len() < HEADER_BYTES + TAG_BYTES {
return Err(encryption_error("snapshot envelope is truncated"));
}
if &envelope[..ENVELOPE_MAGIC.len()] != ENVELOPE_MAGIC {
return Err(encryption_error("snapshot envelope magic is invalid"));
}
if envelope[ENVELOPE_MAGIC.len()] != ENVELOPE_VERSION
|| envelope[ENVELOPE_MAGIC.len() + 1] != ALGORITHM_XCHACHA20_POLY1305
{
return Err(encryption_error("snapshot envelope algorithm is unsupported"));
}
let mut offset = ENVELOPE_MAGIC.len() + 2;
let key_id_bytes = array_at::<KEY_BYTES>(envelope, offset)?;
offset += KEY_BYTES;
let content_hash_bytes = array_at::<HASH_BYTES>(envelope, offset)?;
offset += HASH_BYTES;
let nonce = array_at::<NONCE_BYTES>(envelope, offset)?;
offset += NONCE_BYTES;
let key_id = hex_string(&key_id_bytes);
let content_hash = hex_string(&content_hash_bytes);
if key_id != expected_key_id || key_id != self.key_id() {
return Err(encryption_error("snapshot key identifier does not match"));
}
if content_hash != expected_content_hash {
return Err(encryption_error("snapshot content hash does not match"));
}
let aad = snapshot_aad(context, encryption_version, &key_id_bytes, &content_hash_bytes)?;
let encryption_key = self.derived_key(ENCRYPTION_KEY_INFO)?;
let cipher = XChaCha20Poly1305::new_from_slice(encryption_key.as_slice())
.map_err(|_| encryption_error("snapshot encryption key is invalid"))?;
let nonce = nonce_ref(&nonce)?;
let plaintext = cipher
.decrypt(nonce, Payload { msg: &envelope[offset..], aad: &aad })
.map_err(|_| encryption_error("snapshot authentication failed"))?;
if self.content_hash(&plaintext)? != content_hash {
return Err(encryption_error("snapshot plaintext authentication failed"));
}
Ok(plaintext)
}
pub(crate) fn bytes(&self) -> &[u8; KEY_BYTES] {
&self.0
}
pub(crate) fn from_secret(bytes: Zeroizing<[u8; KEY_BYTES]>) -> Self {
Self(bytes)
}
fn derived_key(&self, info: &[u8]) -> Result<Zeroizing<[u8; KEY_BYTES]>, SyncClientError> {
let hkdf = Hkdf::<Sha256>::new(Some(HKDF_SALT), self.0.as_slice());
let mut output = Zeroizing::new([0_u8; KEY_BYTES]);
hkdf.expand(info, output.as_mut())
.map_err(|_| encryption_error("account key derivation failed"))?;
Ok(output)
}
}
impl fmt::Debug for AccountKey {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.debug_tuple("AccountKey").field(&"[REDACTED]").finish()
}
}
#[derive(Clone, Copy, Debug)]
pub struct SnapshotCryptoContext<'a> {
pub user_id: &'a str,
pub vault_generation: u64,
pub snapshot_id: &'a str,
pub schema_rev: u32,
pub logical_clock: u64,
pub device_id: &'a str,
pub head_revision: u64,
pub base_head: Option<&'a SnapshotHeadRef>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SnapshotHeadRef {
pub(crate) revision: u64,
pub(crate) snapshot_id: String,
pub(crate) payload_hash: String,
}
impl SnapshotHeadRef {
pub(crate) fn new(
revision: u64,
snapshot_id: impl Into<String>,
payload_hash: impl Into<String>,
) -> Result<Self, SyncClientError> {
let head =
Self { revision, snapshot_id: snapshot_id.into(), payload_hash: payload_hash.into() };
head.validate()?;
Ok(head)
}
pub fn revision(&self) -> u64 {
self.revision
}
pub fn snapshot_id(&self) -> &str {
&self.snapshot_id
}
pub fn payload_hash(&self) -> &str {
&self.payload_hash
}
fn validate(&self) -> Result<(), SyncClientError> {
if self.revision == 0
|| self.revision > MAX_SAFE_INTEGER
|| self.snapshot_id.is_empty()
|| self.snapshot_id.len() > 128
|| !self.snapshot_id.as_bytes()[0].is_ascii_lowercase()
&& !self.snapshot_id.as_bytes()[0].is_ascii_digit()
|| !self.snapshot_id.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._-".contains(&byte)
})
|| decode_hex_32(&self.payload_hash).is_err()
{
return Err(encryption_error("snapshot head reference is invalid"));
}
Ok(())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EncryptedSnapshot {
bytes: Vec<u8>,
key_id: String,
content_hash: String,
}
impl EncryptedSnapshot {
pub fn bytes(&self) -> &[u8] {
&self.bytes
}
pub fn key_id(&self) -> &str {
&self.key_id
}
pub fn content_hash(&self) -> &str {
&self.content_hash
}
}
fn snapshot_aad(
context: &SnapshotCryptoContext<'_>,
encryption_version: u32,
key_id: &[u8; KEY_BYTES],
content_hash: &[u8; HASH_BYTES],
) -> Result<Vec<u8>, SyncClientError> {
let domain = if encryption_version == 1 { AAD_DOMAIN_V1 } else { AAD_DOMAIN_V2 };
let mut aad = Vec::with_capacity(domain.len() + 3 * 130 + 128);
aad.extend_from_slice(domain);
push_text(&mut aad, context.user_id)?;
aad.extend_from_slice(&context.vault_generation.to_be_bytes());
push_text(&mut aad, context.snapshot_id)?;
aad.extend_from_slice(&context.schema_rev.to_be_bytes());
aad.extend_from_slice(&context.logical_clock.to_be_bytes());
push_text(&mut aad, context.device_id)?;
aad.extend_from_slice(key_id);
aad.extend_from_slice(content_hash);
if encryption_version == SNAPSHOT_ENCRYPTION_VERSION {
push_head_lineage(&mut aad, context)?;
}
Ok(aad)
}
fn push_head_lineage(
aad: &mut Vec<u8>,
context: &SnapshotCryptoContext<'_>,
) -> Result<(), SyncClientError> {
if context.head_revision == 0 {
return Err(encryption_error("snapshot head revision is invalid"));
}
aad.extend_from_slice(&context.head_revision.to_be_bytes());
match context.base_head {
None if context.head_revision == 1 => aad.push(0),
Some(base) if base.revision.checked_add(1) == Some(context.head_revision) => {
base.validate()?;
aad.push(1);
aad.extend_from_slice(&base.revision.to_be_bytes());
push_text(aad, &base.snapshot_id)?;
aad.extend_from_slice(&decode_hex_32(&base.payload_hash)?);
}
_ => return Err(encryption_error("snapshot head lineage is invalid")),
}
Ok(())
}
fn push_text(output: &mut Vec<u8>, value: &str) -> Result<(), SyncClientError> {
let length = u16::try_from(value.len())
.map_err(|_| encryption_error("snapshot authenticated metadata is too long"))?;
output.extend_from_slice(&length.to_be_bytes());
output.extend_from_slice(value.as_bytes());
Ok(())
}
fn array_at<const N: usize>(bytes: &[u8], offset: usize) -> Result<[u8; N], SyncClientError> {
bytes
.get(offset..offset + N)
.and_then(|slice| slice.try_into().ok())
.ok_or_else(|| encryption_error("snapshot envelope is truncated"))
}
fn nonce_ref(bytes: &[u8; NONCE_BYTES]) -> Result<&XNonce, SyncClientError> {
bytes.as_slice().try_into().map_err(|_| encryption_error("snapshot nonce is invalid"))
}
fn decode_hex_32(value: &str) -> Result<[u8; 32], SyncClientError> {
if value.len() != 64 {
return Err(encryption_error("snapshot hash encoding is invalid"));
}
let mut bytes = [0_u8; 32];
for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
bytes[index] = (hex_nibble(pair[0])? << 4) | hex_nibble(pair[1])?;
}
Ok(bytes)
}
fn hex_nibble(byte: u8) -> Result<u8, SyncClientError> {
match byte {
b'0'..=b'9' => Ok(byte - b'0'),
b'a'..=b'f' => Ok(byte - b'a' + 10),
_ => Err(encryption_error("snapshot hash encoding is invalid")),
}
}
fn hex_string(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn encryption_error(reason: &'static str) -> SyncClientError {
SyncClientError::SnapshotEncryption { reason }
}
#[cfg(test)]
#[path = "encryption_tests.rs"]
mod tests;
@@ -0,0 +1,202 @@
use super::*;
const CONTEXT: SnapshotCryptoContext<'static> = SnapshotCryptoContext {
user_id: "user-01",
vault_generation: 1,
snapshot_id: "device-01",
schema_rev: 1,
logical_clock: 42,
device_id: "device-01",
head_revision: 1,
base_head: None,
};
#[test]
fn snapshot_encryption_round_trips_and_hides_plaintext() -> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([7; 32]);
let plaintext = br#"{"tabs":[{"url":"https://private.example"}]}"#;
let encrypted = key.encrypt(&CONTEXT, plaintext)?;
assert!(!encrypted.bytes().windows(plaintext.len()).any(|window| window == plaintext));
assert_eq!(
key.decrypt(
&CONTEXT,
SNAPSHOT_ENCRYPTION_VERSION,
encrypted.key_id(),
encrypted.content_hash(),
encrypted.bytes(),
)?,
plaintext
);
Ok(())
}
#[test]
fn snapshot_encryption_uses_fresh_nonces_and_stable_keyed_content_hashes()
-> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([9; 32]);
let first = key.encrypt(&CONTEXT, b"same payload")?;
let second = key.encrypt(&CONTEXT, b"same payload")?;
assert_ne!(first.bytes(), second.bytes());
assert_eq!(first.content_hash(), second.content_hash());
assert_ne!(
first.content_hash(),
AccountKey::from_bytes([10; 32]).content_hash(b"same payload")?
);
Ok(())
}
#[test]
fn snapshot_authentication_rejects_metadata_and_ciphertext_tampering() -> Result<(), SyncClientError>
{
let key = AccountKey::from_bytes([11; 32]);
let encrypted = key.encrypt(&CONTEXT, b"authenticated payload")?;
let changed_context = SnapshotCryptoContext { logical_clock: 43, ..CONTEXT };
assert!(
key.decrypt(
&changed_context,
SNAPSHOT_ENCRYPTION_VERSION,
encrypted.key_id(),
encrypted.content_hash(),
encrypted.bytes(),
)
.is_err()
);
let mut tampered = encrypted.bytes().to_vec();
let last = tampered.len() - 1;
tampered[last] ^= 1;
assert!(
key.decrypt(
&CONTEXT,
SNAPSHOT_ENCRYPTION_VERSION,
encrypted.key_id(),
encrypted.content_hash(),
&tampered,
)
.is_err()
);
Ok(())
}
#[test]
fn snapshot_authentication_binds_every_routing_field() -> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([13; 32]);
let encrypted = key.encrypt(&CONTEXT, b"routing metadata")?;
let changed = [
SnapshotCryptoContext { user_id: "user-02", ..CONTEXT },
SnapshotCryptoContext { vault_generation: 2, ..CONTEXT },
SnapshotCryptoContext { snapshot_id: "device-02", ..CONTEXT },
SnapshotCryptoContext { schema_rev: 2, ..CONTEXT },
SnapshotCryptoContext { logical_clock: 41, ..CONTEXT },
SnapshotCryptoContext { device_id: "device-02", ..CONTEXT },
];
for context in changed {
assert!(
key.decrypt(
&context,
SNAPSHOT_ENCRYPTION_VERSION,
encrypted.key_id(),
encrypted.content_hash(),
encrypted.bytes(),
)
.is_err()
);
}
Ok(())
}
#[test]
fn snapshot_authentication_binds_head_lineage() -> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([14; 32]);
let base = SnapshotHeadRef::new(7, "device-02", "31".repeat(32))?;
let context = SnapshotCryptoContext { head_revision: 8, base_head: Some(&base), ..CONTEXT };
let encrypted = key.encrypt(&context, b"head lineage")?;
for changed_base in [
SnapshotHeadRef::new(6, "device-02", "31".repeat(32))?,
SnapshotHeadRef::new(7, "device-03", "31".repeat(32))?,
SnapshotHeadRef::new(7, "device-02", "32".repeat(32))?,
] {
let changed = SnapshotCryptoContext {
head_revision: changed_base.revision + 1,
base_head: Some(&changed_base),
..CONTEXT
};
assert!(
key.decrypt(
&changed,
SNAPSHOT_ENCRYPTION_VERSION,
encrypted.key_id(),
encrypted.content_hash(),
encrypted.bytes(),
)
.is_err()
);
}
let changed_revision = SnapshotCryptoContext { head_revision: 9, ..context };
assert!(
key.decrypt(
&changed_revision,
SNAPSHOT_ENCRYPTION_VERSION,
encrypted.key_id(),
encrypted.content_hash(),
encrypted.bytes(),
)
.is_err()
);
Ok(())
}
#[test]
fn legacy_v1_aad_remains_decryptable() -> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([16; 32]);
let encrypted = key.encrypt_with_version(&CONTEXT, b"legacy snapshot", 1)?;
assert_eq!(
key.decrypt(&CONTEXT, 1, encrypted.key_id(), encrypted.content_hash(), encrypted.bytes(),)?,
b"legacy snapshot"
);
Ok(())
}
#[test]
fn snapshot_envelope_rejects_unknown_versions_and_wrong_keys() -> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([15; 32]);
let encrypted = key.encrypt(&CONTEXT, b"versioned payload")?;
assert!(
key.decrypt(
&CONTEXT,
SNAPSHOT_ENCRYPTION_VERSION + 1,
encrypted.key_id(),
encrypted.content_hash(),
encrypted.bytes(),
)
.is_err()
);
assert!(
AccountKey::from_bytes([16; 32])
.decrypt(
&CONTEXT,
SNAPSHOT_ENCRYPTION_VERSION,
encrypted.key_id(),
encrypted.content_hash(),
encrypted.bytes(),
)
.is_err()
);
Ok(())
}
#[test]
fn snapshot_envelope_honors_the_transport_size_limit() -> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([17; 32]);
let maximum = vec![0_u8; MAX_PLAINTEXT_BYTES];
let encrypted = key.encrypt(&CONTEXT, &maximum)?;
assert_eq!(encrypted.bytes().len(), MAX_SNAPSHOT_BYTES);
assert!(key.encrypt(&CONTEXT, &vec![0_u8; MAX_PLAINTEXT_BYTES + 1]).is_err());
Ok(())
}
+24
View File
@@ -34,6 +34,30 @@ pub enum SyncClientError {
#[error("Snapshot schema is invalid: {0}")]
SnapshotSchema(String),
#[error("Snapshot encryption failed: {reason}")]
SnapshotEncryption { reason: &'static str },
#[error("Cloud Sync snapshot head is changing; retry shortly")]
SnapshotBusy,
#[error("Sync account key storage is unavailable: {0}")]
AccountKeyStorage(String),
#[error("Sync account key is unavailable for encrypted cloud data")]
AccountKeyUnavailable,
#[error("Device private key storage is unavailable: {0}")]
DeviceKeyStorage(String),
#[error("Device private keys are unavailable for {device_id}")]
DeviceKeyUnavailable { device_id: String },
#[error("Device trust protocol failed: {reason}")]
DeviceTrust { reason: &'static str },
#[error("Sync account key vault operation failed: {reason}")]
VaultCrypto { reason: &'static str },
#[error("Sync policy blocks this operation: {reason}")]
SyncPolicy { reason: String },
+310
View File
@@ -0,0 +1,310 @@
use std::{
collections::BTreeMap,
fs::{File, OpenOptions},
path::{Path, PathBuf},
};
use fs2::FileExt;
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;
use crate::{
AccountKey, SyncClientError,
credential_store::{clear_secret, load_secret, save_secret},
};
const KEYCHAIN_SERVICE: &str = "com.elydora.ely-browser.sync.account-key.v3";
const KEY_RECORD_VERSION: u8 = 3;
const KEY_RECORD_HEADER_BYTES: usize = 11;
const KEY_RECORD_ENTRY_BYTES: usize = 40;
const MAX_STORED_KEYS: usize = 1024;
#[derive(Clone, Debug)]
pub struct StoredAccountKeys {
current_generation: u64,
keys: BTreeMap<u64, AccountKey>,
}
impl StoredAccountKeys {
pub fn current_generation(&self) -> u64 {
self.current_generation
}
pub fn current_key(&self) -> Option<&AccountKey> {
self.keys.get(&self.current_generation)
}
pub fn key(&self, generation: u64) -> Option<&AccountKey> {
self.keys.get(&generation)
}
fn from_current(key: &AccountKey, generation: u64) -> Result<Self, SyncClientError> {
assert_generation(generation)?;
let mut keys = BTreeMap::new();
keys.insert(generation, key.clone());
Ok(Self { current_generation: generation, keys })
}
fn set_current(&mut self, key: &AccountKey, generation: u64) -> Result<bool, SyncClientError> {
assert_generation(generation)?;
if generation < self.current_generation {
return Err(storage_error("sync account key generation would roll back"));
}
let changed = self.insert_key(key, generation)?;
if generation == self.current_generation {
return Ok(changed);
}
self.current_generation = generation;
Ok(true)
}
fn insert_historical(
&mut self,
key: &AccountKey,
generation: u64,
) -> Result<bool, SyncClientError> {
assert_generation(generation)?;
if generation > self.current_generation {
return Err(storage_error("historical sync key exceeds current generation"));
}
self.insert_key(key, generation)
}
fn insert_key(&mut self, key: &AccountKey, generation: u64) -> Result<bool, SyncClientError> {
if let Some(stored) = self.keys.get(&generation) {
if stored.key_id() != key.key_id() {
return Err(storage_error("sync account key changed within one generation"));
}
return Ok(false);
}
if self.keys.len() >= MAX_STORED_KEYS {
return Err(storage_error("sync account key history is full"));
}
self.keys.insert(generation, key.clone());
Ok(true)
}
}
#[derive(Clone, Debug)]
pub struct AccountKeyStore {
user_id: String,
lock_path: PathBuf,
}
impl AccountKeyStore {
pub fn new(
user_id: impl Into<String>,
lock_directory: impl Into<PathBuf>,
) -> Result<Self, SyncClientError> {
let user_id = user_id.into();
if user_id.trim().is_empty() {
return Err(storage_error("sync user identifier is empty"));
}
let lock_name = format!("{}.lock", hex_string(&Sha256::digest(user_id.as_bytes())));
Ok(Self { user_id, lock_path: lock_directory.into().join(lock_name) })
}
pub fn load(&self) -> Result<Option<StoredAccountKeys>, SyncClientError> {
match load_secret(KEYCHAIN_SERVICE, &self.user_id).map_err(storage_error)? {
Some(record) => decode_key_record(record).map(Some),
None => Ok(None),
}
}
pub fn save_current(&self, key: &AccountKey, generation: u64) -> Result<(), SyncClientError> {
self.with_lock(|| {
let Some(mut stored) = self.load()? else {
return self.write(&StoredAccountKeys::from_current(key, generation)?);
};
if !stored.set_current(key, generation)? {
return Ok(());
}
self.write(&stored)
})
}
pub fn save_historical(
&self,
key: &AccountKey,
generation: u64,
) -> Result<(), SyncClientError> {
self.with_lock(|| {
let mut stored = self
.load()?
.ok_or_else(|| storage_error("current sync account key is unavailable"))?;
if !stored.insert_historical(key, generation)? {
return Ok(());
}
self.write(&stored)
})
}
pub fn clear(&self) -> Result<(), SyncClientError> {
self.with_lock(|| clear_secret(KEYCHAIN_SERVICE, &self.user_id).map_err(storage_error))
}
fn write(&self, stored: &StoredAccountKeys) -> Result<(), SyncClientError> {
let record = encode_key_record(stored)?;
save_secret(KEYCHAIN_SERVICE, &self.user_id, record.as_slice()).map_err(storage_error)
}
fn with_lock<T>(
&self,
operation: impl FnOnce() -> Result<T, SyncClientError>,
) -> Result<T, SyncClientError> {
let lock = open_lock_file(&self.lock_path)?;
lock.lock_exclusive().map_err(|error| storage_error(error.to_string()))?;
let result = operation();
let unlock_result =
FileExt::unlock(&lock).map_err(|error| storage_error(error.to_string()));
match (result, unlock_result) {
(Err(error), _) => Err(error),
(Ok(_), Err(error)) => Err(error),
(Ok(value), Ok(())) => Ok(value),
}
}
}
fn open_lock_file(path: &Path) -> Result<File, SyncClientError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|error| storage_error(error.to_string()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
.map_err(|error| storage_error(error.to_string()))?;
}
}
let mut options = OpenOptions::new();
options.create(true).read(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let file = options.open(path).map_err(|error| storage_error(error.to_string()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o600))
.map_err(|error| storage_error(error.to_string()))?;
}
Ok(file)
}
fn encode_key_record(stored: &StoredAccountKeys) -> Result<Zeroizing<Vec<u8>>, SyncClientError> {
if stored.keys.is_empty()
|| stored.keys.len() > MAX_STORED_KEYS
|| !stored.keys.contains_key(&stored.current_generation)
{
return Err(storage_error("sync account key history is invalid"));
}
let count = u16::try_from(stored.keys.len())
.map_err(|_| storage_error("sync account key history is too large"))?;
let mut record = Zeroizing::new(Vec::with_capacity(
KEY_RECORD_HEADER_BYTES + stored.keys.len() * KEY_RECORD_ENTRY_BYTES,
));
record.push(KEY_RECORD_VERSION);
record.extend_from_slice(&stored.current_generation.to_be_bytes());
record.extend_from_slice(&count.to_be_bytes());
for (generation, key) in &stored.keys {
record.extend_from_slice(&generation.to_be_bytes());
record.extend_from_slice(key.bytes());
}
Ok(record)
}
fn decode_key_record(record: Zeroizing<Vec<u8>>) -> Result<StoredAccountKeys, SyncClientError> {
if record.len() < KEY_RECORD_HEADER_BYTES || record[0] != KEY_RECORD_VERSION {
return Err(storage_error("sync account key record is invalid"));
}
let current_generation = u64::from_be_bytes(
record[1..9]
.try_into()
.map_err(|_| storage_error("sync account key generation is invalid"))?,
);
assert_generation(current_generation)?;
let count = usize::from(u16::from_be_bytes(
record[9..11].try_into().map_err(|_| storage_error("sync account key count is invalid"))?,
));
if count == 0
|| count > MAX_STORED_KEYS
|| record.len() != KEY_RECORD_HEADER_BYTES + count * KEY_RECORD_ENTRY_BYTES
{
return Err(storage_error("sync account key record size is invalid"));
}
let mut keys = BTreeMap::new();
for entry in record[KEY_RECORD_HEADER_BYTES..].chunks_exact(KEY_RECORD_ENTRY_BYTES) {
let generation = u64::from_be_bytes(
entry[..8]
.try_into()
.map_err(|_| storage_error("sync account key generation is invalid"))?,
);
assert_generation(generation)?;
let mut bytes = Zeroizing::new([0_u8; 32]);
bytes.copy_from_slice(&entry[8..]);
if keys.insert(generation, AccountKey::from_secret(bytes)).is_some() {
return Err(storage_error("sync account key generation is duplicated"));
}
}
if !keys.contains_key(&current_generation) {
return Err(storage_error("current sync account key is missing"));
}
Ok(StoredAccountKeys { current_generation, keys })
}
fn assert_generation(generation: u64) -> Result<(), SyncClientError> {
if generation == 0 {
return Err(storage_error("sync account key generation is invalid"));
}
Ok(())
}
fn storage_error(message: impl Into<String>) -> SyncClientError {
SyncClientError::AccountKeyStorage(message.into())
}
fn hex_string(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_record_round_trips_current_and_historical_keys() -> Result<(), SyncClientError> {
let current = AccountKey::from_bytes([19; 32]);
let historical = AccountKey::from_bytes([17; 32]);
let mut stored = StoredAccountKeys::from_current(&current, 7)?;
stored.insert_historical(&historical, 3)?;
let record = encode_key_record(&stored)?;
let decoded = decode_key_record(Zeroizing::new(record.to_vec()))?;
assert_eq!(decoded.current_key().map(AccountKey::key_id), Some(current.key_id()));
assert_eq!(decoded.key(3).map(AccountKey::key_id), Some(historical.key_id()));
assert_eq!(decoded.current_generation(), 7);
Ok(())
}
#[test]
fn key_record_rejects_unknown_versions() {
let record = vec![KEY_RECORD_VERSION + 1; KEY_RECORD_HEADER_BYTES];
assert!(decode_key_record(Zeroizing::new(record)).is_err());
}
#[test]
fn key_record_rejects_zero_generation() {
assert!(StoredAccountKeys::from_current(&AccountKey::from_bytes([21; 32]), 0).is_err());
}
#[test]
fn current_generation_cannot_roll_back() -> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([23; 32]);
let mut stored = StoredAccountKeys::from_current(&key, 4)?;
assert!(stored.set_current(&AccountKey::from_bytes([25; 32]), 3).is_err());
assert!(stored.insert_historical(&AccountKey::from_bytes([27; 32]), 3)?);
assert_eq!(stored.current_generation(), 4);
Ok(())
}
}
+30 -2
View File
@@ -18,14 +18,42 @@
pub mod auth;
pub mod client;
mod credential_store;
pub mod device;
mod device_api;
mod device_proof;
mod device_revocation;
pub mod device_secret_store;
pub mod email_otp;
pub mod encryption;
pub mod error;
pub mod key_store;
pub mod snapshot;
pub mod vault;
mod vault_bootstrap;
pub use auth::{BearerToken, BearerTokenStore};
pub use client::{ApiClientConfig, SyncApiClient, SyncLatestSnapshotDocument, SyncStatusDocument};
pub use client::{
ApiClientConfig, SnapshotDownloadResult, SnapshotUploadResult, SyncApiClient,
SyncLatestSnapshotDocument, SyncSnapshotHeadConflictDocument, SyncStatusDocument,
};
pub use device::{DeviceIdentity, DeviceListResponse, DeviceRecord, DeviceRegistration};
pub use device_api::{DeviceApprovalDocument, DeviceApprovalRequest, DeviceRebindDocument};
pub use device_revocation::{DeviceRevocationDocument, DeviceRevocationRequest};
pub use device_secret_store::DeviceSecretStore;
pub use email_otp::{send_email_otp, verify_email_otp};
pub use encryption::{
AccountKey, EncryptedSnapshot, SNAPSHOT_ENCRYPTION_VERSION, SnapshotCryptoContext,
SnapshotHeadRef,
};
pub use error::SyncClientError;
pub use snapshot::{SnapshotDownload, SnapshotPayload, SnapshotUploadRequest};
pub use key_store::{AccountKeyStore, StoredAccountKeys};
pub use snapshot::{
AuthenticatedSnapshot, AuthenticatedSnapshotHead, SnapshotDownload, SnapshotPayload,
SnapshotUploadRequest,
};
pub use vault::{
ACCOUNT_KEY_WRAP_SUITE, ACCOUNT_KEY_WRAP_VERSION, SyncVaultDocument, VaultContext,
WrappedAccountKey,
};
pub use vault_bootstrap::SyncVaultBootstrapRequest;
+268 -11
View File
@@ -1,7 +1,13 @@
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::error::SyncClientError;
use crate::{
encryption::{
AccountKey, EncryptedSnapshot, SNAPSHOT_ENCRYPTION_VERSION, SnapshotCryptoContext,
SnapshotHeadRef,
},
error::SyncClientError,
};
/// Hard cap from `cloudflare/src/sync_snapshot.ts`: a single snapshot
/// upload may not exceed 10 MiB. We enforce the same limit client-side
@@ -56,28 +62,51 @@ pub struct SnapshotUploadRequest<'a> {
pub snapshot_id: &'a str,
pub region: &'a str,
pub payload_hash: &'a str,
pub encryption_version: u32,
pub vault_generation: u64,
pub key_id: &'a str,
pub content_hash: &'a str,
pub schema_rev: u32,
pub logical_clock: u64,
pub head_revision: u64,
pub base_head: Option<&'a SnapshotHeadRef>,
pub data_base64: String,
}
impl<'a> SnapshotUploadRequest<'a> {
pub fn new(
snapshot_id: &'a str,
region: &'a str,
schema_rev: u32,
logical_clock: u64,
context: &SnapshotCryptoContext<'a>,
base_head: Option<&'a AuthenticatedSnapshotHead>,
encrypted: &'a EncryptedSnapshot,
payload: &'a SnapshotPayload,
) -> Self {
Self {
version: 1,
snapshot_id,
) -> Result<Self, SyncClientError> {
let head_revision = match base_head {
Some(base) => base.next_revision()?,
None => 1,
};
if context.head_revision != head_revision
|| context.base_head != base_head.map(AuthenticatedSnapshotHead::head_ref)
{
return Err(SyncClientError::SnapshotEncryption {
reason: "snapshot upload context does not match authenticated base",
});
}
Ok(Self {
version: 3,
snapshot_id: context.snapshot_id,
region,
payload_hash: payload.payload_hash(),
schema_rev,
logical_clock,
encryption_version: SNAPSHOT_ENCRYPTION_VERSION,
vault_generation: context.vault_generation,
key_id: encrypted.key_id(),
content_hash: encrypted.content_hash(),
schema_rev: context.schema_rev,
logical_clock: context.logical_clock,
head_revision,
base_head: base_head.map(AuthenticatedSnapshotHead::head_ref),
data_base64: encode_base64(payload.bytes()),
}
})
}
}
@@ -96,6 +125,12 @@ impl SnapshotDownload {
/// worker enforces on upload — we re-check on download so a
/// tampered storage layer doesn't silently desync the user.
pub fn payload(&self) -> Result<SnapshotPayload, SyncClientError> {
if self.data_base64.len() > MAX_SNAPSHOT_BYTES.div_ceil(3) * 4 {
return Err(SyncClientError::SnapshotTooLarge {
bytes: self.data_base64.len(),
limit: MAX_SNAPSHOT_BYTES.div_ceil(3) * 4,
});
}
let bytes = decode_base64(&self.data_base64)
.map_err(|error| SyncClientError::SnapshotBase64(error.to_string()))?;
let payload = SnapshotPayload::new(bytes)?;
@@ -106,6 +141,54 @@ impl SnapshotDownload {
}
Ok(payload)
}
pub fn authenticate(
&self,
expected_head: &SnapshotHeadRef,
key: &AccountKey,
) -> Result<AuthenticatedSnapshot, SyncClientError> {
if self.version != 3 {
return Err(SyncClientError::SnapshotEncryption {
reason: "snapshot response version is unsupported",
});
}
let actual_head = self.snapshot.head_ref()?;
if &actual_head != expected_head {
return Err(SyncClientError::SnapshotEncryption {
reason: "snapshot response head does not match request",
});
}
let payload = self.payload()?;
let context = SnapshotCryptoContext {
user_id: &self.user_id,
vault_generation: self.snapshot.vault_generation,
snapshot_id: &self.snapshot.snapshot_id,
schema_rev: self.snapshot.schema_rev,
logical_clock: self.snapshot.logical_clock,
device_id: &self.snapshot.device_id,
head_revision: self.snapshot.head_revision,
base_head: self.snapshot.base_head.as_ref(),
};
let plaintext = key.decrypt(
&context,
self.snapshot.encryption_version,
&self.snapshot.key_id,
&self.snapshot.content_hash,
payload.bytes(),
)?;
Ok(AuthenticatedSnapshot {
plaintext,
head: AuthenticatedSnapshotHead {
head: actual_head,
logical_clock: self.snapshot.logical_clock,
content_hash: self.snapshot.content_hash.clone(),
vault_generation: self.snapshot.vault_generation,
key_id: self.snapshot.key_id.clone(),
device_id: self.snapshot.device_id.clone(),
size_bytes: self.snapshot.size_bytes,
},
})
}
}
#[derive(Clone, Debug, Deserialize)]
@@ -113,13 +196,101 @@ pub struct SnapshotDocument {
pub snapshot_id: String,
pub r2_key: String,
pub payload_hash: String,
pub encryption_version: u32,
pub vault_generation: u64,
pub key_id: String,
pub content_hash: String,
pub schema_rev: u32,
pub logical_clock: u64,
pub head_revision: u64,
pub base_head: Option<SnapshotHeadRef>,
pub device_id: String,
pub size_bytes: u64,
pub created_at: u64,
}
impl SnapshotDocument {
fn head_ref(&self) -> Result<SnapshotHeadRef, SyncClientError> {
SnapshotHeadRef::new(
self.head_revision,
self.snapshot_id.clone(),
self.payload_hash.clone(),
)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AuthenticatedSnapshotHead {
head: SnapshotHeadRef,
logical_clock: u64,
content_hash: String,
vault_generation: u64,
key_id: String,
device_id: String,
size_bytes: u64,
}
impl AuthenticatedSnapshotHead {
pub fn revision(&self) -> u64 {
self.head.revision()
}
pub fn logical_clock(&self) -> u64 {
self.logical_clock
}
pub fn content_hash(&self) -> &str {
&self.content_hash
}
pub fn vault_generation(&self) -> u64 {
self.vault_generation
}
pub fn key_id(&self) -> &str {
&self.key_id
}
pub fn snapshot_id(&self) -> &str {
self.head.snapshot_id()
}
pub fn device_id(&self) -> &str {
&self.device_id
}
pub fn size_bytes(&self) -> u64 {
self.size_bytes
}
pub fn next_revision(&self) -> Result<u64, SyncClientError> {
if self.head.revision >= 9_007_199_254_740_991 {
return Err(SyncClientError::SnapshotEncryption {
reason: "snapshot head revision exceeds the wire limit",
});
}
self.head.revision.checked_add(1).ok_or(SyncClientError::SnapshotEncryption {
reason: "snapshot head revision overflowed",
})
}
pub fn head_ref(&self) -> &SnapshotHeadRef {
&self.head
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AuthenticatedSnapshot {
plaintext: Vec<u8>,
head: AuthenticatedSnapshotHead,
}
impl AuthenticatedSnapshot {
pub fn into_parts(self) -> (Vec<u8>, AuthenticatedSnapshotHead) {
(self.plaintext, self.head)
}
}
const BASE64_CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
fn encode_base64(bytes: &[u8]) -> String {
@@ -228,4 +399,90 @@ mod tests {
}
Ok(())
}
#[test]
fn upload_request_serializes_encrypted_wire_v3() -> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([23; 32]);
let context = SnapshotCryptoContext {
user_id: "user-01",
vault_generation: 1,
snapshot_id: "device-01",
schema_rev: 1,
logical_clock: 42,
device_id: "device-01",
head_revision: 1,
base_head: None,
};
let encrypted = key.encrypt(&context, br#"{"secret":"value"}"#)?;
let payload = SnapshotPayload::new(encrypted.bytes().to_vec())?;
let request = SnapshotUploadRequest::new("auto", &context, None, &encrypted, &payload)?;
let value = serde_json::to_value(request)
.map_err(|source| SyncClientError::Json { endpoint: "test".to_string(), source })?;
assert_eq!(value["version"], 3);
assert_eq!(value["encryption_version"], SNAPSHOT_ENCRYPTION_VERSION);
assert_eq!(value["head_revision"], 1);
assert!(value["base_head"].is_null());
assert_eq!(value["key_id"], encrypted.key_id());
assert_eq!(value["content_hash"], encrypted.content_hash());
assert!(!value["data_base64"].as_str().unwrap_or_default().contains("secret"));
Ok(())
}
#[test]
fn downloaded_head_becomes_a_merge_base_after_authentication() -> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([25; 32]);
let expected_head = SnapshotHeadRef::new(1, "device-01", "00".repeat(32))?;
let context = SnapshotCryptoContext {
user_id: "user-01",
vault_generation: 1,
snapshot_id: expected_head.snapshot_id(),
schema_rev: 1,
logical_clock: 42,
device_id: "device-01",
head_revision: 1,
base_head: None,
};
let encrypted = key.encrypt(&context, b"authenticated head")?;
let payload = SnapshotPayload::new(encrypted.bytes().to_vec())?;
let expected_head =
SnapshotHeadRef::new(1, context.snapshot_id, payload.payload_hash().to_string())?;
let download = SnapshotDownload {
version: 3,
user_id: context.user_id.to_string(),
device_id: context.device_id.to_string(),
snapshot: SnapshotDocument {
snapshot_id: context.snapshot_id.to_string(),
r2_key: "sync-snapshots/test".to_string(),
payload_hash: payload.payload_hash().to_string(),
encryption_version: SNAPSHOT_ENCRYPTION_VERSION,
vault_generation: context.vault_generation,
key_id: encrypted.key_id().to_string(),
content_hash: encrypted.content_hash().to_string(),
schema_rev: context.schema_rev,
logical_clock: context.logical_clock,
head_revision: context.head_revision,
base_head: None,
device_id: context.device_id.to_string(),
size_bytes: u64::try_from(payload.bytes().len()).unwrap_or_default(),
created_at: 1,
},
data_base64: encode_base64(payload.bytes()),
};
let (plaintext, authenticated_head) =
download.authenticate(&expected_head, &key)?.into_parts();
assert_eq!(plaintext, b"authenticated head");
assert_eq!(authenticated_head.revision(), 1);
assert_eq!(authenticated_head.content_hash(), encrypted.content_hash());
assert!(
download
.authenticate(
&SnapshotHeadRef::new(1, "device-02", payload.payload_hash().to_string())?,
&key,
)
.is_err()
);
Ok(())
}
}
+396
View File
@@ -0,0 +1,396 @@
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use hpke::{
Deserializable, Kem, OpModeR, OpModeS, Serializable, aead::ChaCha20Poly1305, kdf::HkdfSha256,
kem::X25519HkdfSha256, single_shot_open, single_shot_seal,
};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::{
AccountKey, DeviceIdentity, DeviceSecretStore, SyncClientError,
device::{decode_hex_32, is_device_id_shape},
};
pub const ACCOUNT_KEY_WRAP_VERSION: u32 = 1;
pub const ACCOUNT_KEY_WRAP_SUITE: &str = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305";
const INFO_DOMAIN: &[u8] = b"ely-sync-account-key-hpke-info-v1\0";
const AAD_DOMAIN: &[u8] = b"ely-sync-account-key-hpke-aad-v1\0";
const ACCOUNT_KEY_BYTES: usize = 32;
const ENCAPSULATED_KEY_BYTES: usize = 32;
const CIPHERTEXT_BYTES: usize = ACCOUNT_KEY_BYTES + 16;
const MAX_CONTEXT_TEXT_BYTES: usize = 4096;
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SyncVaultDocument {
pub version: u32,
pub user_id: String,
pub key_id: String,
pub generation: u64,
pub recipient_device_id: String,
pub approver_device_id: String,
pub envelope: WrappedAccountKey,
pub created_at: u64,
}
impl SyncVaultDocument {
pub fn unwrap_for(
&self,
expected_user_id: &str,
identity: &DeviceIdentity,
) -> Result<AccountKey, SyncClientError> {
if self.version != 1
|| self.user_id != expected_user_id
|| self.recipient_device_id != identity.device_id
{
return Err(vault_error("vault document identity does not match"));
}
self.envelope.unwrap(
&VaultContext {
user_id: &self.user_id,
recipient_device_id: &self.recipient_device_id,
recipient_wrapping_public_key: &identity.wrapping_public_key,
approver_device_id: &self.approver_device_id,
generation: self.generation,
key_id: &self.key_id,
},
identity,
)
}
}
#[derive(Clone, Copy, Debug)]
pub struct VaultContext<'a> {
pub user_id: &'a str,
pub recipient_device_id: &'a str,
pub recipient_wrapping_public_key: &'a str,
pub approver_device_id: &'a str,
pub generation: u64,
pub key_id: &'a str,
}
impl VaultContext<'_> {
fn validate(&self) -> Result<(), SyncClientError> {
validate_text(self.user_id, "vault user identifier is invalid")?;
if !is_device_id_shape(self.recipient_device_id) {
return Err(vault_error("vault recipient device identifier is invalid"));
}
if !is_device_id_shape(self.approver_device_id) {
return Err(vault_error("vault approver device identifier is invalid"));
}
decode_vault_hex_32(
self.recipient_wrapping_public_key,
"vault recipient wrapping public key is invalid",
)?;
decode_vault_hex_32(self.key_id, "vault account key identifier is invalid")?;
if self.generation == 0 {
return Err(vault_error("vault generation is invalid"));
}
Ok(())
}
}
/// Strict JSON envelope for an AccountKey wrapped to one approved device.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WrappedAccountKey {
pub version: u32,
pub suite: String,
pub encapped_key: String,
pub ciphertext: String,
}
impl WrappedAccountKey {
pub fn wrap(
account_key: &AccountKey,
context: &VaultContext<'_>,
) -> Result<Self, SyncClientError> {
context.validate()?;
if account_key.key_id() != context.key_id {
return Err(vault_error("vault account key identifier does not match"));
}
let public_key_bytes = decode_vault_hex_32(
context.recipient_wrapping_public_key,
"vault recipient wrapping public key is invalid",
)?;
let public_key = <X25519HkdfSha256 as Kem>::PublicKey::from_bytes(&public_key_bytes)
.map_err(|_| vault_error("vault recipient wrapping public key is invalid"))?;
let info = context_bytes(INFO_DOMAIN, context)?;
let aad = context_bytes(AAD_DOMAIN, context)?;
let (encapped_key, ciphertext) =
single_shot_seal::<ChaCha20Poly1305, HkdfSha256, X25519HkdfSha256>(
&OpModeS::Base,
&public_key,
&info,
account_key.bytes(),
&aad,
)
.map_err(|_| vault_error("account key wrapping failed"))?;
Ok(Self {
version: ACCOUNT_KEY_WRAP_VERSION,
suite: ACCOUNT_KEY_WRAP_SUITE.to_string(),
encapped_key: URL_SAFE_NO_PAD.encode(encapped_key.to_bytes()),
ciphertext: URL_SAFE_NO_PAD.encode(ciphertext),
})
}
pub fn unwrap(
&self,
context: &VaultContext<'_>,
recipient: &DeviceIdentity,
) -> Result<AccountKey, SyncClientError> {
context.validate()?;
recipient.validate()?;
if recipient.device_id != context.recipient_device_id
|| recipient.wrapping_public_key != context.recipient_wrapping_public_key
{
return Err(vault_error("vault recipient identity does not match context"));
}
let store = DeviceSecretStore::new(recipient.device_id.clone())?;
let secrets = store.load_required()?;
recipient.validate_secrets(&secrets)?;
self.unwrap_with_private_key(context, secrets.wrapping_private_key())
}
pub fn self_wrap(
account_key: &AccountKey,
user_id: &str,
identity: &DeviceIdentity,
generation: u64,
) -> Result<Self, SyncClientError> {
identity.validate()?;
Self::wrap(
account_key,
&VaultContext {
user_id,
recipient_device_id: &identity.device_id,
recipient_wrapping_public_key: &identity.wrapping_public_key,
approver_device_id: &identity.device_id,
generation,
key_id: &account_key.key_id(),
},
)
}
pub fn self_unwrap(
&self,
user_id: &str,
identity: &DeviceIdentity,
generation: u64,
key_id: &str,
) -> Result<AccountKey, SyncClientError> {
self.unwrap(
&VaultContext {
user_id,
recipient_device_id: &identity.device_id,
recipient_wrapping_public_key: &identity.wrapping_public_key,
approver_device_id: &identity.device_id,
generation,
key_id,
},
identity,
)
}
fn unwrap_with_private_key(
&self,
context: &VaultContext<'_>,
private_key_bytes: &[u8; ACCOUNT_KEY_BYTES],
) -> Result<AccountKey, SyncClientError> {
self.validate_wire()?;
let private_key = <X25519HkdfSha256 as Kem>::PrivateKey::from_bytes(private_key_bytes)
.map_err(|_| vault_error("vault recipient private key is invalid"))?;
let encapped_key_bytes = decode_base64url_exact::<ENCAPSULATED_KEY_BYTES>(
&self.encapped_key,
"vault encapsulated key encoding is invalid",
)?;
let encapped_key = <X25519HkdfSha256 as Kem>::EncappedKey::from_bytes(&encapped_key_bytes)
.map_err(|_| vault_error("vault encapsulated key is invalid"))?;
let ciphertext = decode_base64url_exact::<CIPHERTEXT_BYTES>(
&self.ciphertext,
"vault ciphertext encoding is invalid",
)?;
let info = context_bytes(INFO_DOMAIN, context)?;
let aad = context_bytes(AAD_DOMAIN, context)?;
let plaintext = Zeroizing::new(
single_shot_open::<ChaCha20Poly1305, HkdfSha256, X25519HkdfSha256>(
&OpModeR::Base,
&private_key,
&encapped_key,
&info,
&ciphertext,
&aad,
)
.map_err(|_| vault_error("account key unwrap authentication failed"))?,
);
if plaintext.len() != ACCOUNT_KEY_BYTES {
return Err(vault_error("unwrapped account key has invalid length"));
}
let mut bytes = Zeroizing::new([0_u8; ACCOUNT_KEY_BYTES]);
bytes.copy_from_slice(&plaintext);
let account_key = AccountKey::from_secret(bytes);
if account_key.key_id() != context.key_id {
return Err(vault_error("unwrapped account key identifier does not match"));
}
Ok(account_key)
}
pub(crate) fn validate_wire(&self) -> Result<(), SyncClientError> {
if self.version != ACCOUNT_KEY_WRAP_VERSION {
return Err(vault_error("vault envelope version is unsupported"));
}
if self.suite != ACCOUNT_KEY_WRAP_SUITE {
return Err(vault_error("vault envelope suite is unsupported"));
}
decode_base64url_exact::<ENCAPSULATED_KEY_BYTES>(
&self.encapped_key,
"vault encapsulated key encoding is invalid",
)?;
decode_base64url_exact::<CIPHERTEXT_BYTES>(
&self.ciphertext,
"vault ciphertext encoding is invalid",
)?;
Ok(())
}
}
fn context_bytes(domain: &[u8], context: &VaultContext<'_>) -> Result<Vec<u8>, SyncClientError> {
context.validate()?;
let mut bytes = Vec::with_capacity(domain.len() + 512);
bytes.extend_from_slice(domain);
bytes.extend_from_slice(&ACCOUNT_KEY_WRAP_VERSION.to_be_bytes());
push_text(&mut bytes, ACCOUNT_KEY_WRAP_SUITE)?;
push_text(&mut bytes, context.user_id)?;
push_text(&mut bytes, context.recipient_device_id)?;
push_text(&mut bytes, context.recipient_wrapping_public_key)?;
push_text(&mut bytes, context.approver_device_id)?;
bytes.extend_from_slice(&context.generation.to_be_bytes());
push_text(&mut bytes, context.key_id)?;
Ok(bytes)
}
fn validate_text(value: &str, reason: &'static str) -> Result<(), SyncClientError> {
if value.trim().is_empty() || value.len() > MAX_CONTEXT_TEXT_BYTES {
return Err(vault_error(reason));
}
Ok(())
}
fn push_text(output: &mut Vec<u8>, value: &str) -> Result<(), SyncClientError> {
validate_text(value, "vault authenticated metadata is invalid")?;
let length = u16::try_from(value.len())
.map_err(|_| vault_error("vault authenticated metadata is too long"))?;
output.extend_from_slice(&length.to_be_bytes());
output.extend_from_slice(value.as_bytes());
Ok(())
}
fn decode_base64url_exact<const N: usize>(
value: &str,
reason: &'static str,
) -> Result<[u8; N], SyncClientError> {
let decoded = URL_SAFE_NO_PAD.decode(value).map_err(|_| vault_error(reason))?;
if URL_SAFE_NO_PAD.encode(&decoded) != value {
return Err(vault_error(reason));
}
decoded.try_into().map_err(|_| vault_error(reason))
}
fn decode_vault_hex_32(value: &str, reason: &'static str) -> Result<[u8; 32], SyncClientError> {
decode_hex_32(value, reason).map_err(|_| vault_error(reason))
}
fn vault_error(reason: &'static str) -> SyncClientError {
SyncClientError::VaultCrypto { reason }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::device::generate_key_material;
fn context<'a>(identity: &'a DeviceIdentity, key_id: &'a str) -> VaultContext<'a> {
VaultContext {
user_id: "user-01",
recipient_device_id: &identity.device_id,
recipient_wrapping_public_key: &identity.wrapping_public_key,
approver_device_id: &identity.device_id,
generation: 1,
key_id,
}
}
#[test]
fn account_key_round_trips_through_hpke() -> Result<(), SyncClientError> {
let (identity, secrets) = generate_key_material("Test".to_string(), "macos".to_string())?;
let account_key = AccountKey::from_bytes([41; ACCOUNT_KEY_BYTES]);
let key_id = account_key.key_id();
let context = context(&identity, &key_id);
let wrapped = WrappedAccountKey::self_wrap(&account_key, "user-01", &identity, 1)?;
let unwrapped =
wrapped.unwrap_with_private_key(&context, secrets.wrapping_private_key())?;
assert_eq!(unwrapped.key_id(), account_key.key_id());
assert_eq!(wrapped.encapped_key.len(), 43);
assert_eq!(wrapped.ciphertext.len(), 64);
Ok(())
}
#[test]
fn authenticated_context_rejects_metadata_changes() -> Result<(), SyncClientError> {
let (identity, secrets) = generate_key_material("Test".to_string(), "macos".to_string())?;
let account_key = AccountKey::from_bytes([43; ACCOUNT_KEY_BYTES]);
let key_id = account_key.key_id();
let context = context(&identity, &key_id);
let wrapped = WrappedAccountKey::wrap(&account_key, &context)?;
let other_key_id = AccountKey::from_bytes([44; ACCOUNT_KEY_BYTES]).key_id();
let other_wrapping_public_key = "01".repeat(ACCOUNT_KEY_BYTES);
let changed = [
VaultContext { user_id: "user-02", ..context },
VaultContext { recipient_device_id: "device-02", ..context },
VaultContext { recipient_wrapping_public_key: &other_wrapping_public_key, ..context },
VaultContext { approver_device_id: "device-02", ..context },
VaultContext { generation: 2, ..context },
VaultContext { key_id: &other_key_id, ..context },
];
for changed_context in changed {
assert!(
wrapped
.unwrap_with_private_key(&changed_context, secrets.wrapping_private_key())
.is_err()
);
}
Ok(())
}
#[test]
fn wire_schema_rejects_unknown_fields() -> Result<(), SyncClientError> {
let json = format!(
r#"{{"version":1,"suite":"{ACCOUNT_KEY_WRAP_SUITE}","encapped_key":"{}","ciphertext":"{}","unknown":true}}"#,
"A".repeat(43),
"A".repeat(64)
);
assert!(serde_json::from_str::<WrappedAccountKey>(&json).is_err());
Ok(())
}
#[cfg(target_os = "macos")]
#[test]
fn self_wrap_uses_native_credential_store() -> Result<(), SyncClientError> {
let identity = DeviceIdentity::generate("Test", "macos")?;
let store = DeviceSecretStore::new(identity.device_id.clone())?;
let result = (|| {
let account_key = AccountKey::from_bytes([47; ACCOUNT_KEY_BYTES]);
let key_id = account_key.key_id();
let wrapped = WrappedAccountKey::self_wrap(&account_key, "user-01", &identity, 1)?;
let unwrapped = wrapped.self_unwrap("user-01", &identity, 1, &key_id)?;
assert_eq!(unwrapped.key_id(), key_id);
Ok(())
})();
store.clear()?;
result
}
}
@@ -0,0 +1,130 @@
use serde::Serialize;
use crate::{
DeviceIdentity, SyncClientError,
device::decode_hex_32,
device_proof::{is_idempotency_key_shape, push_field},
vault::WrappedAccountKey,
};
const BOOTSTRAP_PROOF_DOMAIN: &str = "elydora-sync-vault-bootstrap-v2";
const MAX_USER_ID_BYTES: usize = 4096;
#[derive(Clone, Debug, Serialize)]
pub struct SyncVaultBootstrapRequest<'a> {
pub version: u32,
pub key_id: &'a str,
pub generation: u64,
pub envelope: &'a WrappedAccountKey,
pub idempotency_key: &'a str,
pub bootstrap_proof: String,
}
impl<'a> SyncVaultBootstrapRequest<'a> {
pub fn signed(
user_id: &str,
identity: &DeviceIdentity,
key_id: &'a str,
envelope: &'a WrappedAccountKey,
idempotency_key: &'a str,
) -> Result<Self, SyncClientError> {
let generation = 1;
let message = bootstrap_proof_message(
user_id,
identity,
key_id,
generation,
envelope,
idempotency_key,
)?;
Ok(Self {
version: 2,
key_id,
generation,
envelope,
idempotency_key,
bootstrap_proof: identity.sign_message(&message)?,
})
}
}
fn bootstrap_proof_message(
user_id: &str,
identity: &DeviceIdentity,
key_id: &str,
generation: u64,
envelope: &WrappedAccountKey,
idempotency_key: &str,
) -> Result<Vec<u8>, SyncClientError> {
if user_id.trim().is_empty() || user_id.len() > MAX_USER_ID_BYTES {
return Err(bootstrap_error("vault user identifier is invalid"));
}
identity.validate()?;
decode_hex_32(key_id, "vault account key identifier is invalid")?;
if generation != 1 {
return Err(bootstrap_error("vault bootstrap generation is invalid"));
}
envelope.validate_wire()?;
if !is_idempotency_key_shape(idempotency_key) {
return Err(bootstrap_error("vault bootstrap idempotency key is invalid"));
}
let generation = generation.to_string();
let envelope_version = envelope.version.to_string();
let fields = [
BOOTSTRAP_PROOF_DOMAIN,
user_id,
&identity.device_id,
key_id,
&generation,
&envelope_version,
&envelope.suite,
&envelope.encapped_key,
&envelope.ciphertext,
idempotency_key,
];
let mut message = Vec::with_capacity(512);
for field in fields {
push_field(&mut message, field);
}
Ok(message)
}
fn bootstrap_error(reason: &'static str) -> SyncClientError {
SyncClientError::VaultCrypto { reason }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AccountKey, device::generate_key_material, vault::WrappedAccountKey};
#[test]
fn proof_matches_worker_canonical_bytes() -> Result<(), SyncClientError> {
let (identity, _) = generate_key_material("Test".to_string(), "macos".to_string())?;
let account_key = AccountKey::from_bytes([37; 32]);
let key_id = account_key.key_id();
let user_id = "usér-01";
let envelope = WrappedAccountKey::self_wrap(&account_key, user_id, &identity, 1)?;
let idempotency_key = "vault-bootstrap:01";
let message =
bootstrap_proof_message(user_id, &identity, &key_id, 1, &envelope, idempotency_key)?;
let fields = [
BOOTSTRAP_PROOF_DOMAIN.to_string(),
user_id.to_string(),
identity.device_id,
key_id,
"1".to_string(),
envelope.version.to_string(),
envelope.suite,
envelope.encapped_key,
envelope.ciphertext,
idempotency_key.to_string(),
];
let expected =
fields.iter().map(|field| format!("{}:{field}", field.len())).collect::<String>();
assert_eq!(message, expected.as_bytes());
Ok(())
}
}