fix(sync): secure encrypted snapshot lifecycle
This commit is contained in:
@@ -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: ®istration_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;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
|
||||
@@ -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(¤t_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(¤t, 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(())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user