1use crate::error::{Result, SyncularError};
2use crate::protocol::{
3 blob_hash, normalize_blob_mime_type, validate_blob_bytes, validate_blob_size_bytes, BlobRef,
4 OperationResult, PullResponse, PushBatchRequest, PushCommitRequest, PushCommitResponse,
5 SyncChange, SyncOperation,
6};
7use argon2::{Algorithm, Argon2, Params, Version};
8use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
9use base64::Engine as _;
10use bip39::{Language, Mnemonic};
11use chacha20poly1305::aead::{Aead, KeyInit, Payload};
12use chacha20poly1305::{XChaCha20Poly1305, XNonce};
13use hkdf::Hkdf;
14use pbkdf2::pbkdf2_hmac;
15use serde::{Deserialize, Serialize};
16use serde_json::{Map, Value};
17use sha2::Sha256;
18use std::collections::{BTreeMap, BTreeSet, HashMap};
19use std::sync::Arc;
20use x25519_dalek::{PublicKey, StaticSecret};
21use zeroize::Zeroize;
22
23pub const DEFAULT_FIELD_ENCRYPTION_PREFIX: &str = "dgsync:e2ee:1:";
24const KEY_WRAP_HKDF_INFO: &[u8] = b"syncular-key-wrap-v1";
25const BLOB_CIPHERTEXT_VERSION: u8 = 1;
26const BLOB_NONCE_LEN: usize = 24;
27const BLOB_CIPHERTEXT_HEADER_LEN: usize = 1 + BLOB_NONCE_LEN;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub enum FieldDecryptionErrorMode {
32 Throw,
33 KeepCiphertext,
34}
35
36impl Default for FieldDecryptionErrorMode {
37 fn default() -> Self {
38 Self::Throw
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct FieldEncryptionRule {
45 pub scope: String,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub table: Option<String>,
48 pub fields: Vec<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub row_id_field: Option<String>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct FieldEncryptionContext {
55 pub actor_id: String,
56 pub client_id: String,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct FieldEncryptionTarget {
61 pub scope: String,
62 pub table: String,
63 pub row_id: String,
64 pub field: String,
65}
66
67pub trait FieldEncryptionKeyProvider: Send + Sync {
68 fn get_key(&self, kid: &str) -> Result<Vec<u8>>;
69
70 fn encryption_kid(
71 &self,
72 _ctx: &FieldEncryptionContext,
73 _target: &FieldEncryptionTarget,
74 ) -> Result<String> {
75 Ok("default".to_string())
76 }
77}
78
79#[derive(Debug, Clone)]
80pub struct StaticFieldEncryptionKeys {
81 keys: BTreeMap<String, Vec<u8>>,
82 encryption_kid: String,
83}
84
85impl StaticFieldEncryptionKeys {
86 pub fn new(
87 keys: impl IntoIterator<Item = (impl Into<String>, impl Into<Vec<u8>>)>,
88 encryption_kid: Option<String>,
89 ) -> Result<Self> {
90 let mut decoded = BTreeMap::new();
91 for (kid, key) in keys {
92 let kid = kid.into();
93 validate_kid(&kid)?;
94 let key = key.into();
95 validate_32_bytes("encryption key", &key)?;
96 decoded.insert(kid, key);
97 }
98
99 let encryption_kid = encryption_kid.unwrap_or_else(|| "default".to_string());
100 validate_kid(&encryption_kid)?;
101
102 Ok(Self {
103 keys: decoded,
104 encryption_kid,
105 })
106 }
107
108 pub fn from_key_material(
109 keys: BTreeMap<String, String>,
110 encryption_kid: Option<String>,
111 ) -> Result<Self> {
112 let mut decoded = BTreeMap::new();
113 for (kid, material) in keys {
114 decoded.insert(kid, decode_key_material(&material)?);
115 }
116 Self::new(decoded, encryption_kid)
117 }
118}
119
120impl FieldEncryptionKeyProvider for StaticFieldEncryptionKeys {
121 fn get_key(&self, kid: &str) -> Result<Vec<u8>> {
122 self.keys.get(kid).cloned().ok_or_else(|| {
123 SyncularError::config(format!("Missing encryption key for kid \"{kid}\""))
124 })
125 }
126
127 fn encryption_kid(
128 &self,
129 _ctx: &FieldEncryptionContext,
130 _target: &FieldEncryptionTarget,
131 ) -> Result<String> {
132 Ok(self.encryption_kid.clone())
133 }
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct StaticFieldEncryptionConfig {
139 pub rules: Vec<FieldEncryptionRule>,
140 pub keys: BTreeMap<String, String>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub encryption_kid: Option<String>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub decryption_error_mode: Option<FieldDecryptionErrorMode>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub envelope_prefix: Option<String>,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct StaticBlobEncryptionConfig {
152 pub keys: BTreeMap<String, String>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub encryption_kid: Option<String>,
155}
156
157#[derive(Debug, Clone)]
158pub struct EncryptedBlobBody {
159 pub blob: BlobRef,
160 pub body: Vec<u8>,
161}
162
163#[derive(Debug, Clone)]
164pub struct BlobEncryption {
165 keys: BTreeMap<String, Vec<u8>>,
166 encryption_kid: String,
167}
168
169impl BlobEncryption {
170 pub fn from_static_config(config: StaticBlobEncryptionConfig) -> Result<Self> {
171 let mut keys = BTreeMap::new();
172 for (kid, material) in config.keys {
173 validate_kid(&kid)?;
174 keys.insert(kid, decode_key_material(&material)?);
175 }
176
177 let encryption_kid = config
178 .encryption_kid
179 .unwrap_or_else(|| "default".to_string());
180 validate_kid(&encryption_kid)?;
181 if keys.is_empty() {
182 return Err(SyncularError::config(
183 "blob encryption requires at least one key",
184 ));
185 }
186 if !keys.contains_key(&encryption_kid) {
187 return Err(SyncularError::config(format!(
188 "blob encryption key \"{encryption_kid}\" is missing"
189 )));
190 }
191
192 Ok(Self {
193 keys,
194 encryption_kid,
195 })
196 }
197
198 pub fn from_static_config_json(config_json: &str) -> Result<Option<Self>> {
199 let trimmed = config_json.trim();
200 if trimmed.is_empty() || trimmed == "null" {
201 return Ok(None);
202 }
203 let config: StaticBlobEncryptionConfig = serde_json::from_str(trimmed)?;
204 Ok(Some(Self::from_static_config(config)?))
205 }
206
207 pub fn encrypt_blob(&self, plaintext: &[u8], mime_type: &str) -> Result<EncryptedBlobBody> {
208 let mime_type = normalize_blob_mime_type(mime_type);
209 let kid = self.encryption_kid.clone();
210 let key = self.key(&kid)?;
211 let nonce = random_bytes(BLOB_NONCE_LEN)?;
212 let aad = make_blob_aad(&kid, &mime_type);
213 let encrypted = xchacha_encrypt(key, &nonce, &aad, plaintext)?;
214 let mut body = Vec::with_capacity(BLOB_CIPHERTEXT_HEADER_LEN + encrypted.len());
215 body.push(BLOB_CIPHERTEXT_VERSION);
216 body.extend_from_slice(&nonce);
217 body.extend_from_slice(&encrypted);
218
219 let size = i64::try_from(body.len()).map_err(|_| {
220 SyncularError::protocol_message("encrypted blob is too large for SQLite size metadata")
221 })?;
222 validate_blob_size_bytes(size)?;
223 let blob = BlobRef {
224 hash: blob_hash(&body),
225 size,
226 mime_type,
227 encrypted: true,
228 key_id: Some(kid),
229 };
230 Ok(EncryptedBlobBody { blob, body })
231 }
232
233 pub fn decrypt_blob(&self, blob: &BlobRef, body: &[u8]) -> Result<Vec<u8>> {
234 validate_blob_bytes(blob, body)?;
235 if !blob.encrypted {
236 return Ok(body.to_vec());
237 }
238 let kid = blob.key_id.as_deref().ok_or_else(|| {
239 SyncularError::protocol_message("encrypted blob ref is missing keyId")
240 })?;
241 validate_kid(kid)?;
242 let key = self.key(kid)?;
243 if body.len() < BLOB_CIPHERTEXT_HEADER_LEN {
244 return Err(SyncularError::protocol_message(
245 "encrypted blob body is too short",
246 ));
247 }
248 if body[0] != BLOB_CIPHERTEXT_VERSION {
249 return Err(SyncularError::protocol_message(format!(
250 "unsupported encrypted blob version {}",
251 body[0]
252 )));
253 }
254 let nonce = &body[1..BLOB_CIPHERTEXT_HEADER_LEN];
255 let ciphertext = &body[BLOB_CIPHERTEXT_HEADER_LEN..];
256 xchacha_decrypt(key, nonce, &make_blob_aad(kid, &blob.mime_type), ciphertext)
257 }
258
259 pub fn ensure_can_decrypt(&self, blob: &BlobRef) -> Result<()> {
260 if !blob.encrypted {
261 return Ok(());
262 }
263 let kid = blob.key_id.as_deref().ok_or_else(|| {
264 SyncularError::protocol_message("encrypted blob ref is missing keyId")
265 })?;
266 validate_kid(kid)?;
267 let _ = self.key(kid)?;
268 Ok(())
269 }
270
271 fn key(&self, kid: &str) -> Result<&[u8]> {
272 self.keys.get(kid).map(Vec::as_slice).ok_or_else(|| {
273 SyncularError::config(format!("Missing blob encryption key for kid \"{kid}\""))
274 })
275 }
276}
277
278#[derive(Clone)]
279pub struct FieldEncryption {
280 rules: Vec<FieldEncryptionRule>,
281 index: RuleIndex,
282 keys: Arc<dyn FieldEncryptionKeyProvider>,
283 prefix: String,
284 decryption_error_mode: FieldDecryptionErrorMode,
285}
286
287impl FieldEncryption {
288 pub fn new(
289 rules: Vec<FieldEncryptionRule>,
290 keys: Arc<dyn FieldEncryptionKeyProvider>,
291 ) -> Result<Self> {
292 Self::with_options(rules, keys, None, FieldDecryptionErrorMode::Throw)
293 }
294
295 pub fn with_options(
296 rules: Vec<FieldEncryptionRule>,
297 keys: Arc<dyn FieldEncryptionKeyProvider>,
298 envelope_prefix: Option<String>,
299 decryption_error_mode: FieldDecryptionErrorMode,
300 ) -> Result<Self> {
301 let prefix = envelope_prefix.unwrap_or_else(|| DEFAULT_FIELD_ENCRYPTION_PREFIX.to_string());
302 if !prefix.ends_with(':') {
303 return Err(SyncularError::config(
304 "field encryption envelope prefix must end with ':'",
305 ));
306 }
307 let index = RuleIndex::build(&rules)?;
308 Ok(Self {
309 rules,
310 index,
311 keys,
312 prefix,
313 decryption_error_mode,
314 })
315 }
316
317 pub fn from_static_config(config: StaticFieldEncryptionConfig) -> Result<Self> {
318 let keys =
319 StaticFieldEncryptionKeys::from_key_material(config.keys, config.encryption_kid)?;
320 Self::with_options(
321 config.rules,
322 Arc::new(keys),
323 config.envelope_prefix,
324 config.decryption_error_mode.unwrap_or_default(),
325 )
326 }
327
328 pub fn from_static_config_json(config_json: &str) -> Result<Option<Self>> {
329 let trimmed = config_json.trim();
330 if trimmed.is_empty() || trimmed == "null" {
331 return Ok(None);
332 }
333 let config: StaticFieldEncryptionConfig = serde_json::from_str(trimmed)?;
334 Ok(Some(Self::from_static_config(config)?))
335 }
336
337 pub fn rules(&self) -> &[FieldEncryptionRule] {
338 &self.rules
339 }
340
341 pub fn transform_push_batch_request(
342 &self,
343 ctx: &FieldEncryptionContext,
344 mut request: PushBatchRequest,
345 ) -> Result<PushBatchRequest> {
346 for commit in &mut request.commits {
347 self.transform_push_commit_request_in_place(ctx, commit)?;
348 }
349 Ok(request)
350 }
351
352 pub fn transform_push_commit_request(
353 &self,
354 ctx: &FieldEncryptionContext,
355 mut request: PushCommitRequest,
356 ) -> Result<PushCommitRequest> {
357 self.transform_push_commit_request_in_place(ctx, &mut request)?;
358 Ok(request)
359 }
360
361 pub fn transform_operations_for_push(
362 &self,
363 ctx: &FieldEncryptionContext,
364 operations: Vec<SyncOperation>,
365 ) -> Result<Vec<SyncOperation>> {
366 operations
367 .into_iter()
368 .map(|operation| self.transform_operation_for_push(ctx, operation))
369 .collect()
370 }
371
372 pub fn transform_push_response(
373 &self,
374 ctx: &FieldEncryptionContext,
375 outbox_operations: &[SyncOperation],
376 mut response: PushCommitResponse,
377 ) -> Result<PushCommitResponse> {
378 for result in &mut response.results {
379 self.transform_operation_result(ctx, outbox_operations, result)?;
380 }
381 Ok(response)
382 }
383
384 pub fn transform_pull_response(
385 &self,
386 ctx: &FieldEncryptionContext,
387 mut response: PullResponse,
388 ) -> Result<PullResponse> {
389 for sub in &mut response.subscriptions {
390 if let Some(snapshots) = &mut sub.snapshots {
391 for snapshot in snapshots {
392 for row in &mut snapshot.rows {
393 *row = self.transform_snapshot_row(ctx, &snapshot.table, row.clone())?;
394 }
395 }
396 }
397 for commit in &mut sub.commits {
398 for change in &mut commit.changes {
399 self.transform_change_in_place(ctx, change)?;
400 }
401 }
402 }
403 Ok(response)
404 }
405
406 pub fn transform_snapshot_row(
407 &self,
408 ctx: &FieldEncryptionContext,
409 snapshot_table: &str,
410 row: Value,
411 ) -> Result<Value> {
412 let Value::Object(record) = row else {
413 return Ok(row);
414 };
415 let scope = snapshot_table.to_string();
416 let table = self.infer_snapshot_table(&scope, &record)?;
417 let Some(config) = self.index.config_for(&scope, &table) else {
418 return Ok(Value::Object(record));
419 };
420 let row_id = snapshot_row_id(&record, &config.row_id_field, &scope, &table)?;
421 let transformed = self.transform_record_fields(
422 ctx,
423 TransformMode::Decrypt,
424 FieldRecordTarget {
425 scope: &scope,
426 table: &table,
427 row_id: &row_id,
428 },
429 record,
430 )?;
431 Ok(Value::Object(transformed))
432 }
433
434 pub fn transform_change(
435 &self,
436 ctx: &FieldEncryptionContext,
437 mut change: SyncChange,
438 ) -> Result<SyncChange> {
439 self.transform_change_in_place(ctx, &mut change)?;
440 Ok(change)
441 }
442
443 fn transform_push_commit_request_in_place(
444 &self,
445 ctx: &FieldEncryptionContext,
446 request: &mut PushCommitRequest,
447 ) -> Result<()> {
448 for operation in &mut request.operations {
449 *operation = self.transform_operation_for_push(ctx, operation.clone())?;
450 }
451 Ok(())
452 }
453
454 fn transform_operation_for_push(
455 &self,
456 ctx: &FieldEncryptionContext,
457 mut operation: SyncOperation,
458 ) -> Result<SyncOperation> {
459 if operation.op != "upsert" {
460 return Ok(operation);
461 }
462 let Some(Value::Object(record)) = operation.payload.take() else {
463 return Ok(operation);
464 };
465 let target = self.index.resolve_scope_and_table(&operation.table);
466 let record = self.transform_record_fields(
467 ctx,
468 TransformMode::Encrypt,
469 FieldRecordTarget {
470 scope: &target.scope,
471 table: &target.table,
472 row_id: &operation.row_id,
473 },
474 record,
475 )?;
476 operation.payload = Some(Value::Object(record));
477 Ok(operation)
478 }
479
480 fn transform_operation_result(
481 &self,
482 ctx: &FieldEncryptionContext,
483 outbox_operations: &[SyncOperation],
484 result: &mut OperationResult,
485 ) -> Result<()> {
486 if result.status != "conflict" && result.status != "error" {
487 return Ok(());
488 }
489 let Some(Value::Object(record)) = result.server_row.take() else {
490 return Ok(());
491 };
492 let Some(operation) = outbox_operations.get(result.op_index as usize) else {
493 result.server_row = Some(Value::Object(record));
494 return Ok(());
495 };
496 let target = self.index.resolve_scope_and_table(&operation.table);
497 let record = self.transform_record_fields(
498 ctx,
499 TransformMode::Decrypt,
500 FieldRecordTarget {
501 scope: &target.scope,
502 table: &target.table,
503 row_id: &operation.row_id,
504 },
505 record,
506 )?;
507 result.server_row = Some(Value::Object(record));
508 Ok(())
509 }
510
511 fn transform_change_in_place(
512 &self,
513 ctx: &FieldEncryptionContext,
514 change: &mut SyncChange,
515 ) -> Result<()> {
516 if change.op != "upsert" {
517 return Ok(());
518 }
519 let Some(Value::Object(record)) = change.row_json.take() else {
520 return Ok(());
521 };
522 let target = self.index.resolve_scope_and_table(&change.table);
523 let record = self.transform_record_fields(
524 ctx,
525 TransformMode::Decrypt,
526 FieldRecordTarget {
527 scope: &target.scope,
528 table: &target.table,
529 row_id: &change.row_id,
530 },
531 record,
532 )?;
533 change.row_json = Some(Value::Object(record));
534 Ok(())
535 }
536
537 fn infer_snapshot_table(&self, scope: &str, row: &Map<String, Value>) -> Result<String> {
538 if let Some(table) = row.get("table_name").and_then(Value::as_str) {
539 if !table.is_empty() {
540 return Ok(table.to_string());
541 }
542 }
543 if let Some(table) = row.get("__table").and_then(Value::as_str) {
544 if !table.is_empty() {
545 return Ok(table.to_string());
546 }
547 }
548 if let Some(table) = self.index.only_table_for_scope(scope) {
549 return Ok(table.to_string());
550 }
551 Ok(scope.to_string())
552 }
553
554 fn transform_record_fields(
555 &self,
556 ctx: &FieldEncryptionContext,
557 mode: TransformMode,
558 target: FieldRecordTarget<'_>,
559 mut record: Map<String, Value>,
560 ) -> Result<Map<String, Value>> {
561 let Some(config) = self.index.config_for(target.scope, target.table) else {
562 return Ok(record);
563 };
564
565 for field in &config.fields {
566 let Some(value) = record.remove(field) else {
567 continue;
568 };
569 let transformed = match mode {
570 TransformMode::Encrypt => self.encrypt_value(ctx, &target, field, value)?,
571 TransformMode::Decrypt => self.decrypt_value(&target, field, value)?,
572 };
573 record.insert(field.clone(), transformed);
574 }
575
576 Ok(record)
577 }
578
579 fn encrypt_value(
580 &self,
581 ctx: &FieldEncryptionContext,
582 target: &FieldRecordTarget<'_>,
583 field: &str,
584 value: Value,
585 ) -> Result<Value> {
586 if value.is_null() {
587 return Ok(value);
588 }
589 if value
590 .as_str()
591 .and_then(|value| decode_envelope(&self.prefix, value).ok().flatten())
592 .is_some()
593 {
594 return Ok(value);
595 }
596
597 let field_target = FieldEncryptionTarget {
598 scope: target.scope.to_string(),
599 table: target.table.to_string(),
600 row_id: target.row_id.to_string(),
601 field: field.to_string(),
602 };
603 let kid = self.keys.encryption_kid(ctx, &field_target)?;
604 validate_kid(&kid)?;
605 let key = self.keys.get_key(&kid)?;
606 validate_32_bytes("encryption key", &key)?;
607 let nonce = random_bytes(24)?;
608 let aad = make_aad(target.scope, target.table, target.row_id, field);
609 let plaintext = serde_json::to_vec(&value)?;
610 let ciphertext = xchacha_encrypt(&key, &nonce, &aad, &plaintext)?;
611 Ok(Value::String(encode_envelope(
612 &self.prefix,
613 &kid,
614 &nonce,
615 &ciphertext,
616 )))
617 }
618
619 fn decrypt_value(
620 &self,
621 target: &FieldRecordTarget<'_>,
622 field: &str,
623 value: Value,
624 ) -> Result<Value> {
625 let Some(raw) = value.as_str() else {
626 return Ok(value);
627 };
628 let Some(envelope) = decode_envelope(&self.prefix, raw)? else {
629 return Ok(value);
630 };
631
632 let decrypt = || -> Result<Value> {
633 let key = self.keys.get_key(&envelope.kid)?;
634 validate_32_bytes("encryption key", &key)?;
635 let aad = make_aad(target.scope, target.table, target.row_id, field);
636 let plaintext = xchacha_decrypt(&key, &envelope.nonce, &aad, &envelope.ciphertext)?;
637 Ok(serde_json::from_slice(&plaintext)?)
638 };
639
640 match decrypt() {
641 Ok(value) => Ok(value),
642 Err(error)
643 if self.decryption_error_mode == FieldDecryptionErrorMode::KeepCiphertext =>
644 {
645 Ok(value)
646 }
647 Err(error) => Err(SyncularError::protocol_message(format!(
648 "Failed to decrypt {}.{}.{} row={}: {}",
649 target.scope,
650 target.table,
651 field,
652 target.row_id,
653 error.message_text()
654 ))),
655 }
656 }
657}
658
659#[derive(Debug, Clone)]
660struct RuleConfig {
661 fields: BTreeSet<String>,
662 row_id_field: String,
663}
664
665#[derive(Clone)]
666struct RuleIndex {
667 by_scope_table: HashMap<(String, String), RuleConfig>,
668 tables_by_scope: HashMap<String, BTreeSet<String>>,
669 scopes_by_table: HashMap<String, BTreeSet<String>>,
670}
671
672impl RuleIndex {
673 fn build(rules: &[FieldEncryptionRule]) -> Result<Self> {
674 let mut by_scope_table: HashMap<(String, String), RuleConfig> = HashMap::new();
675 let mut tables_by_scope: HashMap<String, BTreeSet<String>> = HashMap::new();
676 let mut scopes_by_table: HashMap<String, BTreeSet<String>> = HashMap::new();
677
678 for rule in rules {
679 if rule.scope.trim().is_empty() {
680 return Err(SyncularError::config(
681 "field encryption rule scope cannot be empty",
682 ));
683 }
684 let table = rule.table.clone().unwrap_or_else(|| "*".to_string());
685 if table.trim().is_empty() {
686 return Err(SyncularError::config(
687 "field encryption rule table cannot be empty",
688 ));
689 }
690 if rule.fields.is_empty() {
691 return Err(SyncularError::config(format!(
692 "field encryption rule {}/{} has no fields",
693 rule.scope, table
694 )));
695 }
696 for field in &rule.fields {
697 if field.trim().is_empty() {
698 return Err(SyncularError::config(format!(
699 "field encryption rule {}/{} has an empty field",
700 rule.scope, table
701 )));
702 }
703 }
704
705 let row_id_field = rule
706 .row_id_field
707 .clone()
708 .unwrap_or_else(|| "id".to_string());
709 let key = (rule.scope.clone(), table.clone());
710 let entry = by_scope_table.entry(key).or_insert_with(|| RuleConfig {
711 fields: BTreeSet::new(),
712 row_id_field: row_id_field.clone(),
713 });
714 if entry.row_id_field != row_id_field {
715 return Err(SyncularError::config(format!(
716 "conflicting rowIdField for field encryption rule {}/{}",
717 rule.scope, table
718 )));
719 }
720 for field in &rule.fields {
721 entry.fields.insert(field.clone());
722 }
723
724 if table != "*" {
725 tables_by_scope
726 .entry(rule.scope.clone())
727 .or_default()
728 .insert(table.clone());
729 scopes_by_table
730 .entry(table)
731 .or_default()
732 .insert(rule.scope.clone());
733 }
734 }
735
736 Ok(Self {
737 by_scope_table,
738 tables_by_scope,
739 scopes_by_table,
740 })
741 }
742
743 fn config_for(&self, scope: &str, table: &str) -> Option<&RuleConfig> {
744 self.by_scope_table
745 .get(&(scope.to_string(), table.to_string()))
746 .or_else(|| {
747 self.by_scope_table
748 .get(&(scope.to_string(), "*".to_string()))
749 })
750 }
751
752 fn only_table_for_scope(&self, scope: &str) -> Option<&str> {
753 let tables = self.tables_by_scope.get(scope)?;
754 if tables.len() == 1 {
755 tables.iter().next().map(String::as_str)
756 } else {
757 None
758 }
759 }
760
761 fn resolve_scope_and_table(&self, identifier: &str) -> ResolvedScopeTable {
762 if self.config_for(identifier, identifier).is_some() {
763 return ResolvedScopeTable {
764 scope: identifier.to_string(),
765 table: identifier.to_string(),
766 };
767 }
768
769 if let Some(table) = self.only_table_for_scope(identifier) {
770 return ResolvedScopeTable {
771 scope: identifier.to_string(),
772 table: table.to_string(),
773 };
774 }
775
776 if let Some(scopes) = self.scopes_by_table.get(identifier) {
777 if scopes.len() == 1 {
778 return ResolvedScopeTable {
779 scope: scopes.iter().next().expect("one scope").clone(),
780 table: identifier.to_string(),
781 };
782 }
783 }
784
785 ResolvedScopeTable {
786 scope: identifier.to_string(),
787 table: identifier.to_string(),
788 }
789 }
790}
791
792struct ResolvedScopeTable {
793 scope: String,
794 table: String,
795}
796
797#[derive(Debug, Clone, Copy)]
798enum TransformMode {
799 Encrypt,
800 Decrypt,
801}
802
803#[derive(Debug, Clone, Copy)]
804struct FieldRecordTarget<'a> {
805 scope: &'a str,
806 table: &'a str,
807 row_id: &'a str,
808}
809
810#[derive(Debug, Clone)]
811struct DecodedEnvelope {
812 kid: String,
813 nonce: Vec<u8>,
814 ciphertext: Vec<u8>,
815}
816
817#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
818#[serde(rename_all = "camelCase")]
819pub struct X25519KeyPair {
820 pub public_key: String,
821 pub private_key: String,
822}
823
824#[derive(Debug, Clone, PartialEq, Eq)]
825pub struct WrappedKey {
826 pub ephemeral_public: Vec<u8>,
827 pub ciphertext: Vec<u8>,
828}
829
830#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
831#[serde(rename_all = "camelCase")]
832pub struct WrappedKeyJson {
833 pub ephemeral_public: String,
834 pub ciphertext: String,
835}
836
837#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
838#[serde(rename_all = "camelCase")]
839#[serde(tag = "type")]
840pub enum ParsedKeyShare {
841 #[serde(rename = "symmetric")]
842 Symmetric { key: String, kid: Option<String> },
843 #[serde(rename = "publicKey")]
844 PublicKey { public_key: String },
845}
846
847#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
848#[serde(rename_all = "camelCase")]
849pub struct Argon2idKeyDerivationParams {
850 pub memory_kib: u32,
851 pub iterations: u32,
852 pub parallelism: u32,
853}
854
855impl Default for Argon2idKeyDerivationParams {
856 fn default() -> Self {
857 Self {
858 memory_kib: 19 * 1024,
859 iterations: 2,
860 parallelism: 1,
861 }
862 }
863}
864
865pub fn generate_symmetric_key() -> Result<Vec<u8>> {
866 random_bytes(32)
867}
868
869pub fn key_to_base64url(key: &[u8]) -> Result<String> {
870 validate_32_bytes("key", key)?;
871 Ok(URL_SAFE_NO_PAD.encode(key))
872}
873
874pub fn base64url_to_key(encoded: &str) -> Result<Vec<u8>> {
875 let key = URL_SAFE_NO_PAD
876 .decode(encoded)
877 .map_err(|err| SyncularError::config(format!("invalid base64url key: {err}")))?;
878 validate_32_bytes("key", &key)?;
879 Ok(key)
880}
881
882pub fn key_to_mnemonic(key: &[u8]) -> Result<String> {
883 validate_32_bytes("key", key)?;
884 let mnemonic = Mnemonic::from_entropy_in(Language::English, key)
885 .map_err(|err| SyncularError::config(format!("encode mnemonic: {err}")))?;
886 Ok(mnemonic.to_string())
887}
888
889pub fn mnemonic_to_key(phrase: &str) -> Result<Vec<u8>> {
890 let normalized = normalize_mnemonic_input(phrase);
891 let mnemonic = Mnemonic::parse_in_normalized(Language::English, &normalized)
892 .map_err(|err| SyncularError::config(format!("decode mnemonic: {err}")))?;
893 let key = mnemonic.to_entropy();
894 validate_32_bytes("mnemonic entropy", &key)?;
895 Ok(key)
896}
897
898pub fn generate_x25519_keypair() -> Result<X25519KeyPair> {
899 let private_key = random_array_32()?;
900 let public_key = PublicKey::from(&StaticSecret::from(private_key));
901 Ok(X25519KeyPair {
902 public_key: URL_SAFE_NO_PAD.encode(public_key.as_bytes()),
903 private_key: URL_SAFE_NO_PAD.encode(private_key),
904 })
905}
906
907pub fn public_key_to_mnemonic(public_key: &[u8]) -> Result<String> {
908 key_to_mnemonic(public_key)
909}
910
911pub fn mnemonic_to_public_key(phrase: &str) -> Result<Vec<u8>> {
912 mnemonic_to_key(phrase)
913}
914
915pub fn wrap_key_for_recipient(
916 recipient_public_key: &[u8],
917 symmetric_key: &[u8],
918) -> Result<WrappedKey> {
919 let recipient_public_key =
920 PublicKey::from(expect_32("recipient public key", recipient_public_key)?);
921 validate_32_bytes("symmetric key", symmetric_key)?;
922
923 let mut ephemeral_private = random_array_32()?;
924 let ephemeral_secret = StaticSecret::from(ephemeral_private);
925 let ephemeral_public = PublicKey::from(&ephemeral_secret);
926 let shared_secret = ephemeral_secret.diffie_hellman(&recipient_public_key);
927 validate_shared_secret(shared_secret.as_bytes())?;
928
929 let mut wrapping_key = [0u8; 32];
930 Hkdf::<Sha256>::new(Some(ephemeral_public.as_bytes()), shared_secret.as_bytes())
931 .expand(KEY_WRAP_HKDF_INFO, &mut wrapping_key)
932 .map_err(|err| SyncularError::protocol_message(format!("derive wrapping key: {err}")))?;
933 let nonce = random_bytes(24)?;
934 let encrypted = xchacha_encrypt(&wrapping_key, &nonce, &[], symmetric_key)?;
935 wrapping_key.zeroize();
936 ephemeral_private.zeroize();
937
938 let mut ciphertext = Vec::with_capacity(24 + encrypted.len());
939 ciphertext.extend_from_slice(&nonce);
940 ciphertext.extend_from_slice(&encrypted);
941 Ok(WrappedKey {
942 ephemeral_public: ephemeral_public.as_bytes().to_vec(),
943 ciphertext,
944 })
945}
946
947pub fn unwrap_key(my_private_key: &[u8], wrapped: &WrappedKey) -> Result<Vec<u8>> {
948 let my_private_key = StaticSecret::from(expect_32("private key", my_private_key)?);
949 let ephemeral_public = PublicKey::from(expect_32(
950 "ephemeral public key",
951 &wrapped.ephemeral_public,
952 )?);
953 if wrapped.ciphertext.len() != 72 {
954 return Err(SyncularError::protocol_message(format!(
955 "wrapped key ciphertext must be 72 bytes, got {}",
956 wrapped.ciphertext.len()
957 )));
958 }
959 let shared_secret = my_private_key.diffie_hellman(&ephemeral_public);
960 validate_shared_secret(shared_secret.as_bytes())?;
961
962 let mut wrapping_key = [0u8; 32];
963 Hkdf::<Sha256>::new(Some(&wrapped.ephemeral_public), shared_secret.as_bytes())
964 .expand(KEY_WRAP_HKDF_INFO, &mut wrapping_key)
965 .map_err(|err| SyncularError::protocol_message(format!("derive wrapping key: {err}")))?;
966 let nonce = &wrapped.ciphertext[..24];
967 let encrypted = &wrapped.ciphertext[24..];
968 let key = xchacha_decrypt(&wrapping_key, nonce, &[], encrypted)?;
969 wrapping_key.zeroize();
970 validate_32_bytes("unwrapped key", &key)?;
971 Ok(key)
972}
973
974pub fn encode_wrapped_key(wrapped: &WrappedKey) -> String {
975 let mut combined =
976 Vec::with_capacity(wrapped.ephemeral_public.len() + wrapped.ciphertext.len());
977 combined.extend_from_slice(&wrapped.ephemeral_public);
978 combined.extend_from_slice(&wrapped.ciphertext);
979 URL_SAFE_NO_PAD.encode(combined)
980}
981
982pub fn decode_wrapped_key(encoded: &str) -> Result<WrappedKey> {
983 let combined = URL_SAFE_NO_PAD
984 .decode(encoded)
985 .map_err(|err| SyncularError::protocol_message(format!("decode wrapped key: {err}")))?;
986 if combined.len() != 104 {
987 return Err(SyncularError::protocol_message(format!(
988 "wrapped key must be 104 bytes, got {}",
989 combined.len()
990 )));
991 }
992 Ok(WrappedKey {
993 ephemeral_public: combined[..32].to_vec(),
994 ciphertext: combined[32..].to_vec(),
995 })
996}
997
998pub fn wrapped_key_to_json(wrapped: &WrappedKey) -> WrappedKeyJson {
999 WrappedKeyJson {
1000 ephemeral_public: URL_SAFE_NO_PAD.encode(&wrapped.ephemeral_public),
1001 ciphertext: URL_SAFE_NO_PAD.encode(&wrapped.ciphertext),
1002 }
1003}
1004
1005pub fn wrapped_key_from_json(json: &WrappedKeyJson) -> Result<WrappedKey> {
1006 let ephemeral_public = base64url_to_key(&json.ephemeral_public)?;
1007 let ciphertext = URL_SAFE_NO_PAD.decode(&json.ciphertext).map_err(|err| {
1008 SyncularError::protocol_message(format!("decode wrapped key ciphertext: {err}"))
1009 })?;
1010 Ok(WrappedKey {
1011 ephemeral_public,
1012 ciphertext,
1013 })
1014}
1015
1016pub fn key_to_share_url(key: &[u8], kid: Option<&str>) -> Result<String> {
1017 validate_32_bytes("key", key)?;
1018 if let Some(kid) = kid {
1019 validate_kid(kid)?;
1020 }
1021 let kid_part = kid
1022 .map(|kid| format!("/{}", percent_encode(kid)))
1023 .unwrap_or_default();
1024 Ok(format!(
1025 "sync://k/1/{}{}",
1026 URL_SAFE_NO_PAD.encode(key),
1027 kid_part
1028 ))
1029}
1030
1031pub fn public_key_to_share_url(public_key: &[u8]) -> Result<String> {
1032 validate_32_bytes("public key", public_key)?;
1033 Ok(format!(
1034 "sync://pk/1/{}",
1035 URL_SAFE_NO_PAD.encode(public_key)
1036 ))
1037}
1038
1039pub fn parse_share_url(url: &str) -> Result<ParsedKeyShare> {
1040 let Some(rest) = url.strip_prefix("sync://") else {
1041 return Err(SyncularError::config("share URL must start with sync://"));
1042 };
1043 let mut parts = rest.split('/');
1044 let share_type = parts
1045 .next()
1046 .ok_or_else(|| SyncularError::config("missing share URL type"))?;
1047 let version = parts
1048 .next()
1049 .ok_or_else(|| SyncularError::config("missing share URL version"))?;
1050 let encoded = parts
1051 .next()
1052 .ok_or_else(|| SyncularError::config("missing share URL key data"))?;
1053 if version != "1" {
1054 return Err(SyncularError::config(format!(
1055 "unsupported share URL version: {version}"
1056 )));
1057 }
1058 match share_type {
1059 "k" => {
1060 let key = base64url_to_key(encoded)?;
1061 let kid = parts.next().map(percent_decode).transpose()?;
1062 if let Some(kid) = kid.as_deref() {
1063 validate_kid(kid)?;
1064 }
1065 Ok(ParsedKeyShare::Symmetric {
1066 key: URL_SAFE_NO_PAD.encode(key),
1067 kid,
1068 })
1069 }
1070 "pk" => {
1071 let public_key = base64url_to_key(encoded)?;
1072 Ok(ParsedKeyShare::PublicKey {
1073 public_key: URL_SAFE_NO_PAD.encode(public_key),
1074 })
1075 }
1076 _ => Err(SyncularError::config(format!(
1077 "unknown share URL type: {share_type}"
1078 ))),
1079 }
1080}
1081
1082pub fn derive_scoped_passphrase_key_pbkdf2(
1083 passphrase: &str,
1084 scope: &str,
1085 iterations: u32,
1086) -> Result<Vec<u8>> {
1087 if iterations == 0 {
1088 return Err(SyncularError::config(
1089 "PBKDF2 iteration count must be greater than zero",
1090 ));
1091 }
1092 let mut key = [0u8; 32];
1093 let salt = format!("sync-e2ee:{scope}");
1094 pbkdf2_hmac::<Sha256>(passphrase.as_bytes(), salt.as_bytes(), iterations, &mut key);
1095 Ok(key.to_vec())
1096}
1097
1098pub fn derive_passphrase_key_argon2id(
1099 passphrase: &str,
1100 salt: &[u8],
1101 params: Argon2idKeyDerivationParams,
1102) -> Result<Vec<u8>> {
1103 if salt.len() < 8 {
1104 return Err(SyncularError::config(
1105 "Argon2id salt must be at least 8 bytes",
1106 ));
1107 }
1108 let params = Params::new(
1109 params.memory_kib,
1110 params.iterations,
1111 params.parallelism,
1112 Some(32),
1113 )
1114 .map_err(|err| SyncularError::config(format!("invalid Argon2id params: {err}")))?;
1115 let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
1116 let mut key = [0u8; 32];
1117 argon
1118 .hash_password_into(passphrase.as_bytes(), salt, &mut key)
1119 .map_err(|err| SyncularError::config(format!("derive Argon2id key: {err}")))?;
1120 Ok(key.to_vec())
1121}
1122
1123pub fn encryption_helpers_json(method: &str, args_json: &str) -> Result<String> {
1124 match method {
1125 "generateSymmetricKey" => Ok(serde_json::to_string(&key_to_base64url(
1126 &generate_symmetric_key()?,
1127 )?)?),
1128 "keyToMnemonic" => {
1129 let args: KeyMaterialArgs = serde_json::from_str(args_json)?;
1130 Ok(serde_json::to_string(&key_to_mnemonic(
1131 &decode_key_material(&args.key)?,
1132 )?)?)
1133 }
1134 "mnemonicToKey" => {
1135 let args: MnemonicArgs = serde_json::from_str(args_json)?;
1136 Ok(serde_json::to_string(&key_to_base64url(
1137 &mnemonic_to_key(&args.phrase)?,
1138 )?)?)
1139 }
1140 "generateKeypair" => Ok(serde_json::to_string(&generate_x25519_keypair()?)?),
1141 "wrapKeyForRecipient" => {
1142 let args: WrapKeyArgs = serde_json::from_str(args_json)?;
1143 let wrapped = wrap_key_for_recipient(
1144 &decode_key_material(&args.recipient_public_key)?,
1145 &decode_key_material(&args.symmetric_key)?,
1146 )?;
1147 Ok(serde_json::to_string(&encode_wrapped_key(&wrapped))?)
1148 }
1149 "unwrapKey" => {
1150 let args: UnwrapKeyArgs = serde_json::from_str(args_json)?;
1151 let wrapped = decode_wrapped_key(&args.wrapped_key)?;
1152 Ok(serde_json::to_string(&key_to_base64url(&unwrap_key(
1153 &decode_key_material(&args.private_key)?,
1154 &wrapped,
1155 )?)?)?)
1156 }
1157 "keyToShareUrl" => {
1158 let args: KeyShareUrlArgs = serde_json::from_str(args_json)?;
1159 Ok(serde_json::to_string(&key_to_share_url(
1160 &decode_key_material(&args.key)?,
1161 args.kid.as_deref(),
1162 )?)?)
1163 }
1164 "publicKeyToShareUrl" => {
1165 let args: PublicKeyArgs = serde_json::from_str(args_json)?;
1166 Ok(serde_json::to_string(&public_key_to_share_url(
1167 &decode_key_material(&args.public_key)?,
1168 )?)?)
1169 }
1170 "parseShareUrl" => {
1171 let args: ShareUrlArgs = serde_json::from_str(args_json)?;
1172 Ok(serde_json::to_string(&parse_share_url(&args.url)?)?)
1173 }
1174 "deriveScopedPassphraseKeyPbkdf2" => {
1175 let args: Pbkdf2Args = serde_json::from_str(args_json)?;
1176 Ok(serde_json::to_string(&key_to_base64url(
1177 &derive_scoped_passphrase_key_pbkdf2(
1178 &args.passphrase,
1179 &args.scope,
1180 args.iterations.unwrap_or(100_000),
1181 )?,
1182 )?)?)
1183 }
1184 "derivePassphraseKeyArgon2id" => {
1185 let args: Argon2idArgs = serde_json::from_str(args_json)?;
1186 Ok(serde_json::to_string(&key_to_base64url(
1187 &derive_passphrase_key_argon2id(
1188 &args.passphrase,
1189 &decode_key_material(&args.salt)?,
1190 args.params.unwrap_or_default(),
1191 )?,
1192 )?)?)
1193 }
1194 _ => Err(SyncularError::config(format!(
1195 "unknown encryption helper method: {method}"
1196 ))),
1197 }
1198}
1199
1200fn encode_envelope(prefix: &str, kid: &str, nonce: &[u8], ciphertext: &[u8]) -> String {
1201 format!(
1202 "{prefix}{kid}:{}:{}",
1203 URL_SAFE_NO_PAD.encode(nonce),
1204 URL_SAFE_NO_PAD.encode(ciphertext)
1205 )
1206}
1207
1208fn decode_envelope(prefix: &str, value: &str) -> Result<Option<DecodedEnvelope>> {
1209 let Some(rest) = value.strip_prefix(prefix) else {
1210 return Ok(None);
1211 };
1212 let mut parts = rest.split(':');
1213 let kid = parts.next().unwrap_or_default();
1214 let nonce = parts.next().unwrap_or_default();
1215 let ciphertext = parts.next().unwrap_or_default();
1216 if parts.next().is_some() || kid.is_empty() || nonce.is_empty() || ciphertext.is_empty() {
1217 return Ok(None);
1218 }
1219 let nonce = URL_SAFE_NO_PAD.decode(nonce).map_err(|err| {
1220 SyncularError::protocol_message(format!("decode encryption nonce: {err}"))
1221 })?;
1222 let ciphertext = URL_SAFE_NO_PAD
1223 .decode(ciphertext)
1224 .map_err(|err| SyncularError::protocol_message(format!("decode ciphertext: {err}")))?;
1225 Ok(Some(DecodedEnvelope {
1226 kid: kid.to_string(),
1227 nonce,
1228 ciphertext,
1229 }))
1230}
1231
1232pub(crate) fn xchacha_encrypt(
1233 key: &[u8],
1234 nonce: &[u8],
1235 aad: &[u8],
1236 plaintext: &[u8],
1237) -> Result<Vec<u8>> {
1238 validate_32_bytes("XChaCha20-Poly1305 key", key)?;
1239 if nonce.len() != 24 {
1240 return Err(SyncularError::protocol_message(format!(
1241 "XChaCha20-Poly1305 nonce must be 24 bytes, got {}",
1242 nonce.len()
1243 )));
1244 }
1245 let cipher = XChaCha20Poly1305::new_from_slice(key)
1246 .map_err(|_| SyncularError::protocol_message("invalid XChaCha20-Poly1305 key"))?;
1247 cipher
1248 .encrypt(
1249 XNonce::from_slice(nonce),
1250 Payload {
1251 msg: plaintext,
1252 aad,
1253 },
1254 )
1255 .map_err(|_| SyncularError::protocol_message("XChaCha20-Poly1305 encryption failed"))
1256}
1257
1258pub(crate) fn xchacha_decrypt(
1259 key: &[u8],
1260 nonce: &[u8],
1261 aad: &[u8],
1262 ciphertext: &[u8],
1263) -> Result<Vec<u8>> {
1264 validate_32_bytes("XChaCha20-Poly1305 key", key)?;
1265 if nonce.len() != 24 {
1266 return Err(SyncularError::protocol_message(format!(
1267 "XChaCha20-Poly1305 nonce must be 24 bytes, got {}",
1268 nonce.len()
1269 )));
1270 }
1271 let cipher = XChaCha20Poly1305::new_from_slice(key)
1272 .map_err(|_| SyncularError::protocol_message("invalid XChaCha20-Poly1305 key"))?;
1273 cipher
1274 .decrypt(
1275 XNonce::from_slice(nonce),
1276 Payload {
1277 msg: ciphertext,
1278 aad,
1279 },
1280 )
1281 .map_err(|_| SyncularError::protocol_message("XChaCha20-Poly1305 decryption failed"))
1282}
1283
1284fn make_aad(scope: &str, table: &str, row_id: &str, field: &str) -> Vec<u8> {
1285 format!("{scope}\u{1f}{table}\u{1f}{row_id}\u{1f}{field}").into_bytes()
1286}
1287
1288fn make_blob_aad(kid: &str, mime_type: &str) -> Vec<u8> {
1289 format!(
1290 "syncular:blob:v1\u{1f}{kid}\u{1f}{}",
1291 normalize_blob_mime_type(mime_type)
1292 )
1293 .into_bytes()
1294}
1295
1296fn snapshot_row_id(
1297 row: &Map<String, Value>,
1298 row_id_field: &str,
1299 scope: &str,
1300 table: &str,
1301) -> Result<String> {
1302 let row_id = row
1303 .get(row_id_field)
1304 .and_then(|value| match value {
1305 Value::String(value) => Some(value.clone()),
1306 Value::Number(value) => Some(value.to_string()),
1307 _ => None,
1308 })
1309 .unwrap_or_default();
1310 if row_id.is_empty() {
1311 return Err(SyncularError::protocol_message(format!(
1312 "snapshot row for {scope}/{table} is missing row id field \"{row_id_field}\""
1313 )));
1314 }
1315 Ok(row_id)
1316}
1317
1318pub(crate) fn random_bytes(length: usize) -> Result<Vec<u8>> {
1319 let mut bytes = vec![0u8; length];
1320 getrandom::getrandom(&mut bytes)
1321 .map_err(|err| SyncularError::config(format!("secure random generator failed: {err}")))?;
1322 Ok(bytes)
1323}
1324
1325fn random_array_32() -> Result<[u8; 32]> {
1326 let mut bytes = [0u8; 32];
1327 getrandom::getrandom(&mut bytes)
1328 .map_err(|err| SyncularError::config(format!("secure random generator failed: {err}")))?;
1329 Ok(bytes)
1330}
1331
1332pub(crate) fn validate_32_bytes(label: &str, bytes: &[u8]) -> Result<()> {
1333 if bytes.len() != 32 {
1334 return Err(SyncularError::config(format!(
1335 "{label} must be 32 bytes, got {}",
1336 bytes.len()
1337 )));
1338 }
1339 Ok(())
1340}
1341
1342fn expect_32(label: &str, bytes: &[u8]) -> Result<[u8; 32]> {
1343 validate_32_bytes(label, bytes)?;
1344 let mut out = [0u8; 32];
1345 out.copy_from_slice(bytes);
1346 Ok(out)
1347}
1348
1349fn validate_kid(kid: &str) -> Result<()> {
1350 if kid.is_empty() {
1351 return Err(SyncularError::config("encryption key id cannot be empty"));
1352 }
1353 if kid.contains(':') {
1354 return Err(SyncularError::config(
1355 "encryption key id must not contain ':'",
1356 ));
1357 }
1358 Ok(())
1359}
1360
1361fn validate_shared_secret(shared_secret: &[u8; 32]) -> Result<()> {
1362 if shared_secret.iter().all(|byte| *byte == 0) {
1363 return Err(SyncularError::protocol_message(
1364 "X25519 shared secret is all zeros",
1365 ));
1366 }
1367 Ok(())
1368}
1369
1370fn decode_key_material(material: &str) -> Result<Vec<u8>> {
1371 let trimmed = material.trim();
1372 let decoded = if let Some(rest) = trimmed.strip_prefix("hex:") {
1373 hex::decode(rest).map_err(|err| SyncularError::config(format!("invalid hex key: {err}")))?
1374 } else if let Some(rest) = trimmed.strip_prefix("base64:") {
1375 STANDARD
1376 .decode(rest)
1377 .map_err(|err| SyncularError::config(format!("invalid base64 key: {err}")))?
1378 } else if let Some(rest) = trimmed.strip_prefix("base64url:") {
1379 URL_SAFE_NO_PAD
1380 .decode(rest)
1381 .map_err(|err| SyncularError::config(format!("invalid base64url key: {err}")))?
1382 } else if trimmed.len() == 64 && trimmed.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1383 hex::decode(trimmed)
1384 .map_err(|err| SyncularError::config(format!("invalid hex key: {err}")))?
1385 } else {
1386 URL_SAFE_NO_PAD
1387 .decode(trimmed)
1388 .map_err(|err| SyncularError::config(format!("invalid base64url key: {err}")))?
1389 };
1390 validate_32_bytes("key material", &decoded)?;
1391 Ok(decoded)
1392}
1393
1394fn normalize_mnemonic_input(phrase: &str) -> String {
1395 phrase
1396 .trim()
1397 .to_lowercase()
1398 .split_whitespace()
1399 .collect::<Vec<_>>()
1400 .join(" ")
1401}
1402
1403fn percent_encode(value: &str) -> String {
1404 let mut out = String::new();
1405 for byte in value.bytes() {
1406 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
1407 out.push(byte as char);
1408 } else {
1409 out.push_str(&format!("%{byte:02X}"));
1410 }
1411 }
1412 out
1413}
1414
1415fn percent_decode(value: &str) -> Result<String> {
1416 let bytes = value.as_bytes();
1417 let mut out = Vec::with_capacity(bytes.len());
1418 let mut index = 0usize;
1419 while index < bytes.len() {
1420 if bytes[index] != b'%' {
1421 out.push(bytes[index]);
1422 index += 1;
1423 continue;
1424 }
1425 if index + 2 >= bytes.len() {
1426 return Err(SyncularError::config("invalid percent-encoded key id"));
1427 }
1428 let hex = std::str::from_utf8(&bytes[index + 1..index + 3])
1429 .map_err(|_| SyncularError::config("invalid percent-encoded key id"))?;
1430 let byte = u8::from_str_radix(hex, 16)
1431 .map_err(|_| SyncularError::config("invalid percent-encoded key id"))?;
1432 out.push(byte);
1433 index += 3;
1434 }
1435 String::from_utf8(out).map_err(|_| SyncularError::config("invalid UTF-8 key id"))
1436}
1437
1438#[derive(Debug, Deserialize)]
1439#[serde(rename_all = "camelCase")]
1440struct KeyMaterialArgs {
1441 key: String,
1442}
1443
1444#[derive(Debug, Deserialize)]
1445#[serde(rename_all = "camelCase")]
1446struct MnemonicArgs {
1447 phrase: String,
1448}
1449
1450#[derive(Debug, Deserialize)]
1451#[serde(rename_all = "camelCase")]
1452struct WrapKeyArgs {
1453 recipient_public_key: String,
1454 symmetric_key: String,
1455}
1456
1457#[derive(Debug, Deserialize)]
1458#[serde(rename_all = "camelCase")]
1459struct UnwrapKeyArgs {
1460 private_key: String,
1461 wrapped_key: String,
1462}
1463
1464#[derive(Debug, Deserialize)]
1465#[serde(rename_all = "camelCase")]
1466struct KeyShareUrlArgs {
1467 key: String,
1468 kid: Option<String>,
1469}
1470
1471#[derive(Debug, Deserialize)]
1472#[serde(rename_all = "camelCase")]
1473struct PublicKeyArgs {
1474 public_key: String,
1475}
1476
1477#[derive(Debug, Deserialize)]
1478#[serde(rename_all = "camelCase")]
1479struct ShareUrlArgs {
1480 url: String,
1481}
1482
1483#[derive(Debug, Deserialize)]
1484#[serde(rename_all = "camelCase")]
1485struct Pbkdf2Args {
1486 passphrase: String,
1487 scope: String,
1488 iterations: Option<u32>,
1489}
1490
1491#[derive(Debug, Deserialize)]
1492#[serde(rename_all = "camelCase")]
1493struct Argon2idArgs {
1494 passphrase: String,
1495 salt: String,
1496 params: Option<Argon2idKeyDerivationParams>,
1497}
1498
1499#[cfg(test)]
1500mod tests {
1501 use super::*;
1502 use crate::protocol::{PullResponse, SubscriptionResponse, SyncCommit, SyncSnapshot};
1503 use serde_json::json;
1504
1505 fn encryption() -> FieldEncryption {
1506 let mut keys = BTreeMap::new();
1507 keys.insert("default".to_string(), URL_SAFE_NO_PAD.encode([7u8; 32]));
1508 FieldEncryption::from_static_config(StaticFieldEncryptionConfig {
1509 rules: vec![FieldEncryptionRule {
1510 scope: "tasks".to_string(),
1511 table: Some("tasks".to_string()),
1512 fields: vec!["title".to_string()],
1513 row_id_field: None,
1514 }],
1515 keys,
1516 encryption_kid: None,
1517 decryption_error_mode: None,
1518 envelope_prefix: None,
1519 })
1520 .expect("encryption")
1521 }
1522
1523 fn ctx() -> FieldEncryptionContext {
1524 FieldEncryptionContext {
1525 actor_id: "user-rust".to_string(),
1526 client_id: "client-rust".to_string(),
1527 }
1528 }
1529
1530 fn blob_encryption() -> BlobEncryption {
1531 let mut keys = BTreeMap::new();
1532 keys.insert("default".to_string(), URL_SAFE_NO_PAD.encode([9u8; 32]));
1533 BlobEncryption::from_static_config(StaticBlobEncryptionConfig {
1534 keys,
1535 encryption_kid: None,
1536 })
1537 .expect("blob encryption")
1538 }
1539
1540 #[test]
1541 fn encrypts_push_and_decrypts_pull_rows() -> Result<()> {
1542 let encryption = encryption();
1543 let op = SyncOperation {
1544 table: "tasks".to_string(),
1545 row_id: "t1".to_string(),
1546 op: "upsert".to_string(),
1547 payload: Some(json!({ "title": "Secret", "completed": 0 })),
1548 base_version: None,
1549 };
1550 let encrypted = encryption.transform_operations_for_push(&ctx(), vec![op.clone()])?;
1551 let payload = encrypted[0].payload.as_ref().expect("payload");
1552 let title = payload["title"].as_str().expect("encrypted title");
1553 assert!(title.starts_with(DEFAULT_FIELD_ENCRYPTION_PREFIX));
1554 assert_ne!(title, "Secret");
1555
1556 let pull = PullResponse {
1557 ok: true,
1558 subscriptions: vec![SubscriptionResponse {
1559 id: "sub-tasks".to_string(),
1560 status: "active".to_string(),
1561 scopes: Map::new(),
1562 bootstrap: true,
1563 bootstrap_state: None,
1564 next_cursor: 1,
1565 integrity: None,
1566 commits: Vec::new(),
1567 snapshots: Some(vec![SyncSnapshot {
1568 table: "tasks".to_string(),
1569 rows: vec![json!({ "id": "t1", "title": title, "completed": 0 })],
1570 chunks: None,
1571 artifacts: None,
1572 manifest: None,
1573 is_first_page: true,
1574 is_last_page: true,
1575 bootstrap_state_after: None,
1576 }]),
1577 }],
1578 };
1579 let decrypted = encryption.transform_pull_response(&ctx(), pull)?;
1580 let row = &decrypted.subscriptions[0].snapshots.as_ref().unwrap()[0].rows[0];
1581 assert_eq!(row["title"], "Secret");
1582 Ok(())
1583 }
1584
1585 #[test]
1586 fn encrypted_blob_body_roundtrips_with_ciphertext_ref() -> Result<()> {
1587 let encryption = blob_encryption();
1588 let plaintext = b"top secret blob payload";
1589 let encrypted = encryption.encrypt_blob(plaintext, "text/plain")?;
1590 assert!(encrypted.blob.encrypted);
1591 assert_eq!(encrypted.blob.key_id.as_deref(), Some("default"));
1592 assert_eq!(encrypted.blob.hash, blob_hash(&encrypted.body));
1593 assert_ne!(encrypted.blob.hash, blob_hash(plaintext));
1594 assert_ne!(encrypted.body, plaintext);
1595
1596 let decrypted = encryption.decrypt_blob(&encrypted.blob, &encrypted.body)?;
1597 assert_eq!(decrypted, plaintext);
1598 Ok(())
1599 }
1600
1601 #[test]
1602 fn encrypted_blob_decryption_authenticates_metadata() -> Result<()> {
1603 let encryption = blob_encryption();
1604 let encrypted = encryption.encrypt_blob(b"payload", "text/plain")?;
1605
1606 let mut tampered_mime = encrypted.blob.clone();
1607 tampered_mime.mime_type = "application/json".to_string();
1608 let error = encryption
1609 .decrypt_blob(&tampered_mime, &encrypted.body)
1610 .unwrap_err();
1611 assert!(error.to_string().contains("decryption failed"));
1612
1613 let mut tampered_key = encrypted.blob.clone();
1614 tampered_key.key_id = Some("missing".to_string());
1615 let error = encryption
1616 .decrypt_blob(&tampered_key, &encrypted.body)
1617 .unwrap_err();
1618 assert!(error.to_string().contains("Missing blob encryption key"));
1619 Ok(())
1620 }
1621
1622 #[test]
1623 fn decrypts_incremental_changes() -> Result<()> {
1624 let encryption = encryption();
1625 let encrypted = encryption.transform_operations_for_push(
1626 &ctx(),
1627 vec![SyncOperation {
1628 table: "tasks".to_string(),
1629 row_id: "t2".to_string(),
1630 op: "upsert".to_string(),
1631 payload: Some(json!({ "title": "Incremental" })),
1632 base_version: None,
1633 }],
1634 )?;
1635 let title = encrypted[0].payload.as_ref().unwrap()["title"]
1636 .as_str()
1637 .unwrap()
1638 .to_string();
1639 let pull = PullResponse {
1640 ok: true,
1641 subscriptions: vec![SubscriptionResponse {
1642 id: "sub-tasks".to_string(),
1643 status: "active".to_string(),
1644 scopes: Map::new(),
1645 bootstrap: false,
1646 bootstrap_state: None,
1647 next_cursor: 2,
1648 integrity: None,
1649 snapshots: None,
1650 commits: vec![SyncCommit {
1651 commit_seq: 2,
1652 created_at: "2026-05-10T00:00:00.000Z".to_string(),
1653 actor_id: "other".to_string(),
1654 changes: vec![SyncChange {
1655 table: "tasks".to_string(),
1656 row_id: "t2".to_string(),
1657 op: "upsert".to_string(),
1658 row_json: Some(json!({ "id": "t2", "title": title })),
1659 row_version: Some(1),
1660 scopes: Map::new(),
1661 }],
1662 }],
1663 }],
1664 };
1665 let decrypted = encryption.transform_pull_response(&ctx(), pull)?;
1666 let change = &decrypted.subscriptions[0].commits[0].changes[0];
1667 assert_eq!(change.row_json.as_ref().unwrap()["title"], "Incremental");
1668 Ok(())
1669 }
1670
1671 #[test]
1672 fn key_wrapping_roundtrips() -> Result<()> {
1673 let alice = generate_x25519_keypair()?;
1674 let key = generate_symmetric_key()?;
1675 let wrapped = wrap_key_for_recipient(&decode_key_material(&alice.public_key)?, &key)?;
1676 let unwrapped = unwrap_key(&decode_key_material(&alice.private_key)?, &wrapped)?;
1677 assert_eq!(unwrapped, key);
1678 Ok(())
1679 }
1680
1681 #[test]
1682 fn static_reader_keys_do_not_require_default_encryption_kid() -> Result<()> {
1683 let mut keys = BTreeMap::new();
1684 keys.insert("k1".to_string(), URL_SAFE_NO_PAD.encode([1u8; 32]));
1685 let provider = StaticFieldEncryptionKeys::from_key_material(keys, None)?;
1686 assert!(provider.get_key("k1").is_ok());
1687 assert_eq!(
1688 provider.encryption_kid(
1689 &ctx(),
1690 &FieldEncryptionTarget {
1691 scope: "tasks".to_string(),
1692 table: "tasks".to_string(),
1693 row_id: "t1".to_string(),
1694 field: "title".to_string(),
1695 }
1696 )?,
1697 "default"
1698 );
1699 Ok(())
1700 }
1701
1702 #[test]
1703 fn key_share_url_roundtrips() -> Result<()> {
1704 let key = [9u8; 32];
1705 let url = key_to_share_url(&key, Some("scope~patient%3Ap1"))?;
1706 let parsed = parse_share_url(&url)?;
1707 assert_eq!(
1708 parsed,
1709 ParsedKeyShare::Symmetric {
1710 key: URL_SAFE_NO_PAD.encode(key),
1711 kid: Some("scope~patient%3Ap1".to_string())
1712 }
1713 );
1714 Ok(())
1715 }
1716
1717 #[test]
1718 fn mnemonic_roundtrips_32_byte_keys() -> Result<()> {
1719 let key = [3u8; 32];
1720 let phrase = key_to_mnemonic(&key)?;
1721 let decoded = mnemonic_to_key(&phrase)?;
1722 assert_eq!(decoded, key);
1723 Ok(())
1724 }
1725}