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(_) if self.decryption_error_mode == FieldDecryptionErrorMode::KeepCiphertext => {
643 Ok(value)
644 }
645 Err(error) => Err(SyncularError::protocol_message(format!(
646 "Failed to decrypt {}.{}.{} row={}: {}",
647 target.scope,
648 target.table,
649 field,
650 target.row_id,
651 error.message_text()
652 ))),
653 }
654 }
655}
656
657#[derive(Debug, Clone)]
658struct RuleConfig {
659 fields: BTreeSet<String>,
660 row_id_field: String,
661}
662
663#[derive(Clone)]
664struct RuleIndex {
665 by_scope_table: HashMap<(String, String), RuleConfig>,
666 tables_by_scope: HashMap<String, BTreeSet<String>>,
667 scopes_by_table: HashMap<String, BTreeSet<String>>,
668}
669
670impl RuleIndex {
671 fn build(rules: &[FieldEncryptionRule]) -> Result<Self> {
672 let mut by_scope_table: HashMap<(String, String), RuleConfig> = HashMap::new();
673 let mut tables_by_scope: HashMap<String, BTreeSet<String>> = HashMap::new();
674 let mut scopes_by_table: HashMap<String, BTreeSet<String>> = HashMap::new();
675
676 for rule in rules {
677 if rule.scope.trim().is_empty() {
678 return Err(SyncularError::config(
679 "field encryption rule scope cannot be empty",
680 ));
681 }
682 let table = rule.table.clone().unwrap_or_else(|| "*".to_string());
683 if table.trim().is_empty() {
684 return Err(SyncularError::config(
685 "field encryption rule table cannot be empty",
686 ));
687 }
688 if rule.fields.is_empty() {
689 return Err(SyncularError::config(format!(
690 "field encryption rule {}/{} has no fields",
691 rule.scope, table
692 )));
693 }
694 for field in &rule.fields {
695 if field.trim().is_empty() {
696 return Err(SyncularError::config(format!(
697 "field encryption rule {}/{} has an empty field",
698 rule.scope, table
699 )));
700 }
701 }
702
703 let row_id_field = rule
704 .row_id_field
705 .clone()
706 .unwrap_or_else(|| "id".to_string());
707 let key = (rule.scope.clone(), table.clone());
708 let entry = by_scope_table.entry(key).or_insert_with(|| RuleConfig {
709 fields: BTreeSet::new(),
710 row_id_field: row_id_field.clone(),
711 });
712 if entry.row_id_field != row_id_field {
713 return Err(SyncularError::config(format!(
714 "conflicting rowIdField for field encryption rule {}/{}",
715 rule.scope, table
716 )));
717 }
718 for field in &rule.fields {
719 entry.fields.insert(field.clone());
720 }
721
722 if table != "*" {
723 tables_by_scope
724 .entry(rule.scope.clone())
725 .or_default()
726 .insert(table.clone());
727 scopes_by_table
728 .entry(table)
729 .or_default()
730 .insert(rule.scope.clone());
731 }
732 }
733
734 Ok(Self {
735 by_scope_table,
736 tables_by_scope,
737 scopes_by_table,
738 })
739 }
740
741 fn config_for(&self, scope: &str, table: &str) -> Option<&RuleConfig> {
742 self.by_scope_table
743 .get(&(scope.to_string(), table.to_string()))
744 .or_else(|| {
745 self.by_scope_table
746 .get(&(scope.to_string(), "*".to_string()))
747 })
748 }
749
750 fn only_table_for_scope(&self, scope: &str) -> Option<&str> {
751 let tables = self.tables_by_scope.get(scope)?;
752 if tables.len() == 1 {
753 tables.iter().next().map(String::as_str)
754 } else {
755 None
756 }
757 }
758
759 fn resolve_scope_and_table(&self, identifier: &str) -> ResolvedScopeTable {
760 if self.config_for(identifier, identifier).is_some() {
761 return ResolvedScopeTable {
762 scope: identifier.to_string(),
763 table: identifier.to_string(),
764 };
765 }
766
767 if let Some(table) = self.only_table_for_scope(identifier) {
768 return ResolvedScopeTable {
769 scope: identifier.to_string(),
770 table: table.to_string(),
771 };
772 }
773
774 if let Some(scopes) = self.scopes_by_table.get(identifier) {
775 if scopes.len() == 1 {
776 return ResolvedScopeTable {
777 scope: scopes.iter().next().expect("one scope").clone(),
778 table: identifier.to_string(),
779 };
780 }
781 }
782
783 ResolvedScopeTable {
784 scope: identifier.to_string(),
785 table: identifier.to_string(),
786 }
787 }
788}
789
790struct ResolvedScopeTable {
791 scope: String,
792 table: String,
793}
794
795#[derive(Debug, Clone, Copy)]
796enum TransformMode {
797 Encrypt,
798 Decrypt,
799}
800
801#[derive(Debug, Clone, Copy)]
802struct FieldRecordTarget<'a> {
803 scope: &'a str,
804 table: &'a str,
805 row_id: &'a str,
806}
807
808#[derive(Debug, Clone)]
809struct DecodedEnvelope {
810 kid: String,
811 nonce: Vec<u8>,
812 ciphertext: Vec<u8>,
813}
814
815#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
816#[serde(rename_all = "camelCase")]
817pub struct X25519KeyPair {
818 pub public_key: String,
819 pub private_key: String,
820}
821
822#[derive(Debug, Clone, PartialEq, Eq)]
823pub struct WrappedKey {
824 pub ephemeral_public: Vec<u8>,
825 pub ciphertext: Vec<u8>,
826}
827
828#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
829#[serde(rename_all = "camelCase")]
830pub struct WrappedKeyJson {
831 pub ephemeral_public: String,
832 pub ciphertext: String,
833}
834
835#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
836#[serde(rename_all = "camelCase")]
837#[serde(tag = "type")]
838pub enum ParsedKeyShare {
839 #[serde(rename = "symmetric")]
840 Symmetric { key: String, kid: Option<String> },
841 #[serde(rename = "publicKey")]
842 PublicKey { public_key: String },
843}
844
845#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
846#[serde(rename_all = "camelCase")]
847pub struct Argon2idKeyDerivationParams {
848 pub memory_kib: u32,
849 pub iterations: u32,
850 pub parallelism: u32,
851}
852
853impl Default for Argon2idKeyDerivationParams {
854 fn default() -> Self {
855 Self {
856 memory_kib: 19 * 1024,
857 iterations: 2,
858 parallelism: 1,
859 }
860 }
861}
862
863pub fn generate_symmetric_key() -> Result<Vec<u8>> {
864 random_bytes(32)
865}
866
867pub fn key_to_base64url(key: &[u8]) -> Result<String> {
868 validate_32_bytes("key", key)?;
869 Ok(URL_SAFE_NO_PAD.encode(key))
870}
871
872pub fn base64url_to_key(encoded: &str) -> Result<Vec<u8>> {
873 let key = URL_SAFE_NO_PAD
874 .decode(encoded)
875 .map_err(|err| SyncularError::config(format!("invalid base64url key: {err}")))?;
876 validate_32_bytes("key", &key)?;
877 Ok(key)
878}
879
880pub fn key_to_mnemonic(key: &[u8]) -> Result<String> {
881 validate_32_bytes("key", key)?;
882 let mnemonic = Mnemonic::from_entropy_in(Language::English, key)
883 .map_err(|err| SyncularError::config(format!("encode mnemonic: {err}")))?;
884 Ok(mnemonic.to_string())
885}
886
887pub fn mnemonic_to_key(phrase: &str) -> Result<Vec<u8>> {
888 let normalized = normalize_mnemonic_input(phrase);
889 let mnemonic = Mnemonic::parse_in_normalized(Language::English, &normalized)
890 .map_err(|err| SyncularError::config(format!("decode mnemonic: {err}")))?;
891 let key = mnemonic.to_entropy();
892 validate_32_bytes("mnemonic entropy", &key)?;
893 Ok(key)
894}
895
896pub fn generate_x25519_keypair() -> Result<X25519KeyPair> {
897 let private_key = random_array_32()?;
898 let public_key = PublicKey::from(&StaticSecret::from(private_key));
899 Ok(X25519KeyPair {
900 public_key: URL_SAFE_NO_PAD.encode(public_key.as_bytes()),
901 private_key: URL_SAFE_NO_PAD.encode(private_key),
902 })
903}
904
905pub fn public_key_to_mnemonic(public_key: &[u8]) -> Result<String> {
906 key_to_mnemonic(public_key)
907}
908
909pub fn mnemonic_to_public_key(phrase: &str) -> Result<Vec<u8>> {
910 mnemonic_to_key(phrase)
911}
912
913pub fn wrap_key_for_recipient(
914 recipient_public_key: &[u8],
915 symmetric_key: &[u8],
916) -> Result<WrappedKey> {
917 let recipient_public_key =
918 PublicKey::from(expect_32("recipient public key", recipient_public_key)?);
919 validate_32_bytes("symmetric key", symmetric_key)?;
920
921 let mut ephemeral_private = random_array_32()?;
922 let ephemeral_secret = StaticSecret::from(ephemeral_private);
923 let ephemeral_public = PublicKey::from(&ephemeral_secret);
924 let shared_secret = ephemeral_secret.diffie_hellman(&recipient_public_key);
925 validate_shared_secret(shared_secret.as_bytes())?;
926
927 let mut wrapping_key = [0u8; 32];
928 Hkdf::<Sha256>::new(Some(ephemeral_public.as_bytes()), shared_secret.as_bytes())
929 .expand(KEY_WRAP_HKDF_INFO, &mut wrapping_key)
930 .map_err(|err| SyncularError::protocol_message(format!("derive wrapping key: {err}")))?;
931 let nonce = random_bytes(24)?;
932 let encrypted = xchacha_encrypt(&wrapping_key, &nonce, &[], symmetric_key)?;
933 wrapping_key.zeroize();
934 ephemeral_private.zeroize();
935
936 let mut ciphertext = Vec::with_capacity(24 + encrypted.len());
937 ciphertext.extend_from_slice(&nonce);
938 ciphertext.extend_from_slice(&encrypted);
939 Ok(WrappedKey {
940 ephemeral_public: ephemeral_public.as_bytes().to_vec(),
941 ciphertext,
942 })
943}
944
945pub fn unwrap_key(my_private_key: &[u8], wrapped: &WrappedKey) -> Result<Vec<u8>> {
946 let my_private_key = StaticSecret::from(expect_32("private key", my_private_key)?);
947 let ephemeral_public = PublicKey::from(expect_32(
948 "ephemeral public key",
949 &wrapped.ephemeral_public,
950 )?);
951 if wrapped.ciphertext.len() != 72 {
952 return Err(SyncularError::protocol_message(format!(
953 "wrapped key ciphertext must be 72 bytes, got {}",
954 wrapped.ciphertext.len()
955 )));
956 }
957 let shared_secret = my_private_key.diffie_hellman(&ephemeral_public);
958 validate_shared_secret(shared_secret.as_bytes())?;
959
960 let mut wrapping_key = [0u8; 32];
961 Hkdf::<Sha256>::new(Some(&wrapped.ephemeral_public), shared_secret.as_bytes())
962 .expand(KEY_WRAP_HKDF_INFO, &mut wrapping_key)
963 .map_err(|err| SyncularError::protocol_message(format!("derive wrapping key: {err}")))?;
964 let nonce = &wrapped.ciphertext[..24];
965 let encrypted = &wrapped.ciphertext[24..];
966 let key = xchacha_decrypt(&wrapping_key, nonce, &[], encrypted)?;
967 wrapping_key.zeroize();
968 validate_32_bytes("unwrapped key", &key)?;
969 Ok(key)
970}
971
972pub fn encode_wrapped_key(wrapped: &WrappedKey) -> String {
973 let mut combined =
974 Vec::with_capacity(wrapped.ephemeral_public.len() + wrapped.ciphertext.len());
975 combined.extend_from_slice(&wrapped.ephemeral_public);
976 combined.extend_from_slice(&wrapped.ciphertext);
977 URL_SAFE_NO_PAD.encode(combined)
978}
979
980pub fn decode_wrapped_key(encoded: &str) -> Result<WrappedKey> {
981 let combined = URL_SAFE_NO_PAD
982 .decode(encoded)
983 .map_err(|err| SyncularError::protocol_message(format!("decode wrapped key: {err}")))?;
984 if combined.len() != 104 {
985 return Err(SyncularError::protocol_message(format!(
986 "wrapped key must be 104 bytes, got {}",
987 combined.len()
988 )));
989 }
990 Ok(WrappedKey {
991 ephemeral_public: combined[..32].to_vec(),
992 ciphertext: combined[32..].to_vec(),
993 })
994}
995
996pub fn wrapped_key_to_json(wrapped: &WrappedKey) -> WrappedKeyJson {
997 WrappedKeyJson {
998 ephemeral_public: URL_SAFE_NO_PAD.encode(&wrapped.ephemeral_public),
999 ciphertext: URL_SAFE_NO_PAD.encode(&wrapped.ciphertext),
1000 }
1001}
1002
1003pub fn wrapped_key_from_json(json: &WrappedKeyJson) -> Result<WrappedKey> {
1004 let ephemeral_public = base64url_to_key(&json.ephemeral_public)?;
1005 let ciphertext = URL_SAFE_NO_PAD.decode(&json.ciphertext).map_err(|err| {
1006 SyncularError::protocol_message(format!("decode wrapped key ciphertext: {err}"))
1007 })?;
1008 Ok(WrappedKey {
1009 ephemeral_public,
1010 ciphertext,
1011 })
1012}
1013
1014pub fn key_to_share_url(key: &[u8], kid: Option<&str>) -> Result<String> {
1015 validate_32_bytes("key", key)?;
1016 if let Some(kid) = kid {
1017 validate_kid(kid)?;
1018 }
1019 let kid_part = kid
1020 .map(|kid| format!("/{}", percent_encode(kid)))
1021 .unwrap_or_default();
1022 Ok(format!(
1023 "sync://k/1/{}{}",
1024 URL_SAFE_NO_PAD.encode(key),
1025 kid_part
1026 ))
1027}
1028
1029pub fn public_key_to_share_url(public_key: &[u8]) -> Result<String> {
1030 validate_32_bytes("public key", public_key)?;
1031 Ok(format!(
1032 "sync://pk/1/{}",
1033 URL_SAFE_NO_PAD.encode(public_key)
1034 ))
1035}
1036
1037pub fn parse_share_url(url: &str) -> Result<ParsedKeyShare> {
1038 let Some(rest) = url.strip_prefix("sync://") else {
1039 return Err(SyncularError::config("share URL must start with sync://"));
1040 };
1041 let mut parts = rest.split('/');
1042 let share_type = parts
1043 .next()
1044 .ok_or_else(|| SyncularError::config("missing share URL type"))?;
1045 let version = parts
1046 .next()
1047 .ok_or_else(|| SyncularError::config("missing share URL version"))?;
1048 let encoded = parts
1049 .next()
1050 .ok_or_else(|| SyncularError::config("missing share URL key data"))?;
1051 if version != "1" {
1052 return Err(SyncularError::config(format!(
1053 "unsupported share URL version: {version}"
1054 )));
1055 }
1056 match share_type {
1057 "k" => {
1058 let key = base64url_to_key(encoded)?;
1059 let kid = parts.next().map(percent_decode).transpose()?;
1060 if let Some(kid) = kid.as_deref() {
1061 validate_kid(kid)?;
1062 }
1063 Ok(ParsedKeyShare::Symmetric {
1064 key: URL_SAFE_NO_PAD.encode(key),
1065 kid,
1066 })
1067 }
1068 "pk" => {
1069 let public_key = base64url_to_key(encoded)?;
1070 Ok(ParsedKeyShare::PublicKey {
1071 public_key: URL_SAFE_NO_PAD.encode(public_key),
1072 })
1073 }
1074 _ => Err(SyncularError::config(format!(
1075 "unknown share URL type: {share_type}"
1076 ))),
1077 }
1078}
1079
1080pub fn derive_scoped_passphrase_key_pbkdf2(
1081 passphrase: &str,
1082 scope: &str,
1083 iterations: u32,
1084) -> Result<Vec<u8>> {
1085 if iterations == 0 {
1086 return Err(SyncularError::config(
1087 "PBKDF2 iteration count must be greater than zero",
1088 ));
1089 }
1090 let mut key = [0u8; 32];
1091 let salt = format!("sync-e2ee:{scope}");
1092 pbkdf2_hmac::<Sha256>(passphrase.as_bytes(), salt.as_bytes(), iterations, &mut key);
1093 Ok(key.to_vec())
1094}
1095
1096pub fn derive_passphrase_key_argon2id(
1097 passphrase: &str,
1098 salt: &[u8],
1099 params: Argon2idKeyDerivationParams,
1100) -> Result<Vec<u8>> {
1101 if salt.len() < 8 {
1102 return Err(SyncularError::config(
1103 "Argon2id salt must be at least 8 bytes",
1104 ));
1105 }
1106 let params = Params::new(
1107 params.memory_kib,
1108 params.iterations,
1109 params.parallelism,
1110 Some(32),
1111 )
1112 .map_err(|err| SyncularError::config(format!("invalid Argon2id params: {err}")))?;
1113 let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
1114 let mut key = [0u8; 32];
1115 argon
1116 .hash_password_into(passphrase.as_bytes(), salt, &mut key)
1117 .map_err(|err| SyncularError::config(format!("derive Argon2id key: {err}")))?;
1118 Ok(key.to_vec())
1119}
1120
1121pub fn encryption_helpers_json(method: &str, args_json: &str) -> Result<String> {
1122 match method {
1123 "generateSymmetricKey" => Ok(serde_json::to_string(&key_to_base64url(
1124 &generate_symmetric_key()?,
1125 )?)?),
1126 "keyToMnemonic" => {
1127 let args: KeyMaterialArgs = serde_json::from_str(args_json)?;
1128 Ok(serde_json::to_string(&key_to_mnemonic(
1129 &decode_key_material(&args.key)?,
1130 )?)?)
1131 }
1132 "mnemonicToKey" => {
1133 let args: MnemonicArgs = serde_json::from_str(args_json)?;
1134 Ok(serde_json::to_string(&key_to_base64url(
1135 &mnemonic_to_key(&args.phrase)?,
1136 )?)?)
1137 }
1138 "generateKeypair" => Ok(serde_json::to_string(&generate_x25519_keypair()?)?),
1139 "wrapKeyForRecipient" => {
1140 let args: WrapKeyArgs = serde_json::from_str(args_json)?;
1141 let wrapped = wrap_key_for_recipient(
1142 &decode_key_material(&args.recipient_public_key)?,
1143 &decode_key_material(&args.symmetric_key)?,
1144 )?;
1145 Ok(serde_json::to_string(&encode_wrapped_key(&wrapped))?)
1146 }
1147 "unwrapKey" => {
1148 let args: UnwrapKeyArgs = serde_json::from_str(args_json)?;
1149 let wrapped = decode_wrapped_key(&args.wrapped_key)?;
1150 Ok(serde_json::to_string(&key_to_base64url(&unwrap_key(
1151 &decode_key_material(&args.private_key)?,
1152 &wrapped,
1153 )?)?)?)
1154 }
1155 "keyToShareUrl" => {
1156 let args: KeyShareUrlArgs = serde_json::from_str(args_json)?;
1157 Ok(serde_json::to_string(&key_to_share_url(
1158 &decode_key_material(&args.key)?,
1159 args.kid.as_deref(),
1160 )?)?)
1161 }
1162 "publicKeyToShareUrl" => {
1163 let args: PublicKeyArgs = serde_json::from_str(args_json)?;
1164 Ok(serde_json::to_string(&public_key_to_share_url(
1165 &decode_key_material(&args.public_key)?,
1166 )?)?)
1167 }
1168 "parseShareUrl" => {
1169 let args: ShareUrlArgs = serde_json::from_str(args_json)?;
1170 Ok(serde_json::to_string(&parse_share_url(&args.url)?)?)
1171 }
1172 "deriveScopedPassphraseKeyPbkdf2" => {
1173 let args: Pbkdf2Args = serde_json::from_str(args_json)?;
1174 Ok(serde_json::to_string(&key_to_base64url(
1175 &derive_scoped_passphrase_key_pbkdf2(
1176 &args.passphrase,
1177 &args.scope,
1178 args.iterations.unwrap_or(100_000),
1179 )?,
1180 )?)?)
1181 }
1182 "derivePassphraseKeyArgon2id" => {
1183 let args: Argon2idArgs = serde_json::from_str(args_json)?;
1184 Ok(serde_json::to_string(&key_to_base64url(
1185 &derive_passphrase_key_argon2id(
1186 &args.passphrase,
1187 &decode_key_material(&args.salt)?,
1188 args.params.unwrap_or_default(),
1189 )?,
1190 )?)?)
1191 }
1192 _ => Err(SyncularError::config(format!(
1193 "unknown encryption helper method: {method}"
1194 ))),
1195 }
1196}
1197
1198fn encode_envelope(prefix: &str, kid: &str, nonce: &[u8], ciphertext: &[u8]) -> String {
1199 format!(
1200 "{prefix}{kid}:{}:{}",
1201 URL_SAFE_NO_PAD.encode(nonce),
1202 URL_SAFE_NO_PAD.encode(ciphertext)
1203 )
1204}
1205
1206fn decode_envelope(prefix: &str, value: &str) -> Result<Option<DecodedEnvelope>> {
1207 let Some(rest) = value.strip_prefix(prefix) else {
1208 return Ok(None);
1209 };
1210 let mut parts = rest.split(':');
1211 let kid = parts.next().unwrap_or_default();
1212 let nonce = parts.next().unwrap_or_default();
1213 let ciphertext = parts.next().unwrap_or_default();
1214 if parts.next().is_some() || kid.is_empty() || nonce.is_empty() || ciphertext.is_empty() {
1215 return Ok(None);
1216 }
1217 let nonce = URL_SAFE_NO_PAD.decode(nonce).map_err(|err| {
1218 SyncularError::protocol_message(format!("decode encryption nonce: {err}"))
1219 })?;
1220 let ciphertext = URL_SAFE_NO_PAD
1221 .decode(ciphertext)
1222 .map_err(|err| SyncularError::protocol_message(format!("decode ciphertext: {err}")))?;
1223 Ok(Some(DecodedEnvelope {
1224 kid: kid.to_string(),
1225 nonce,
1226 ciphertext,
1227 }))
1228}
1229
1230pub(crate) fn xchacha_encrypt(
1231 key: &[u8],
1232 nonce: &[u8],
1233 aad: &[u8],
1234 plaintext: &[u8],
1235) -> Result<Vec<u8>> {
1236 validate_32_bytes("XChaCha20-Poly1305 key", key)?;
1237 if nonce.len() != 24 {
1238 return Err(SyncularError::protocol_message(format!(
1239 "XChaCha20-Poly1305 nonce must be 24 bytes, got {}",
1240 nonce.len()
1241 )));
1242 }
1243 let cipher = XChaCha20Poly1305::new_from_slice(key)
1244 .map_err(|_| SyncularError::protocol_message("invalid XChaCha20-Poly1305 key"))?;
1245 cipher
1246 .encrypt(
1247 XNonce::from_slice(nonce),
1248 Payload {
1249 msg: plaintext,
1250 aad,
1251 },
1252 )
1253 .map_err(|_| SyncularError::protocol_message("XChaCha20-Poly1305 encryption failed"))
1254}
1255
1256pub(crate) fn xchacha_decrypt(
1257 key: &[u8],
1258 nonce: &[u8],
1259 aad: &[u8],
1260 ciphertext: &[u8],
1261) -> Result<Vec<u8>> {
1262 validate_32_bytes("XChaCha20-Poly1305 key", key)?;
1263 if nonce.len() != 24 {
1264 return Err(SyncularError::protocol_message(format!(
1265 "XChaCha20-Poly1305 nonce must be 24 bytes, got {}",
1266 nonce.len()
1267 )));
1268 }
1269 let cipher = XChaCha20Poly1305::new_from_slice(key)
1270 .map_err(|_| SyncularError::protocol_message("invalid XChaCha20-Poly1305 key"))?;
1271 cipher
1272 .decrypt(
1273 XNonce::from_slice(nonce),
1274 Payload {
1275 msg: ciphertext,
1276 aad,
1277 },
1278 )
1279 .map_err(|_| SyncularError::protocol_message("XChaCha20-Poly1305 decryption failed"))
1280}
1281
1282fn make_aad(scope: &str, table: &str, row_id: &str, field: &str) -> Vec<u8> {
1283 format!("{scope}\u{1f}{table}\u{1f}{row_id}\u{1f}{field}").into_bytes()
1284}
1285
1286fn make_blob_aad(kid: &str, mime_type: &str) -> Vec<u8> {
1287 format!(
1288 "syncular:blob:v1\u{1f}{kid}\u{1f}{}",
1289 normalize_blob_mime_type(mime_type)
1290 )
1291 .into_bytes()
1292}
1293
1294fn snapshot_row_id(
1295 row: &Map<String, Value>,
1296 row_id_field: &str,
1297 scope: &str,
1298 table: &str,
1299) -> Result<String> {
1300 let row_id = row
1301 .get(row_id_field)
1302 .and_then(|value| match value {
1303 Value::String(value) => Some(value.clone()),
1304 Value::Number(value) => Some(value.to_string()),
1305 _ => None,
1306 })
1307 .unwrap_or_default();
1308 if row_id.is_empty() {
1309 return Err(SyncularError::protocol_message(format!(
1310 "snapshot row for {scope}/{table} is missing row id field \"{row_id_field}\""
1311 )));
1312 }
1313 Ok(row_id)
1314}
1315
1316pub(crate) fn random_bytes(length: usize) -> Result<Vec<u8>> {
1317 let mut bytes = vec![0u8; length];
1318 getrandom::getrandom(&mut bytes)
1319 .map_err(|err| SyncularError::config(format!("secure random generator failed: {err}")))?;
1320 Ok(bytes)
1321}
1322
1323fn random_array_32() -> Result<[u8; 32]> {
1324 let mut bytes = [0u8; 32];
1325 getrandom::getrandom(&mut bytes)
1326 .map_err(|err| SyncularError::config(format!("secure random generator failed: {err}")))?;
1327 Ok(bytes)
1328}
1329
1330pub(crate) fn validate_32_bytes(label: &str, bytes: &[u8]) -> Result<()> {
1331 if bytes.len() != 32 {
1332 return Err(SyncularError::config(format!(
1333 "{label} must be 32 bytes, got {}",
1334 bytes.len()
1335 )));
1336 }
1337 Ok(())
1338}
1339
1340fn expect_32(label: &str, bytes: &[u8]) -> Result<[u8; 32]> {
1341 validate_32_bytes(label, bytes)?;
1342 let mut out = [0u8; 32];
1343 out.copy_from_slice(bytes);
1344 Ok(out)
1345}
1346
1347fn validate_kid(kid: &str) -> Result<()> {
1348 if kid.is_empty() {
1349 return Err(SyncularError::config("encryption key id cannot be empty"));
1350 }
1351 if kid.contains(':') {
1352 return Err(SyncularError::config(
1353 "encryption key id must not contain ':'",
1354 ));
1355 }
1356 Ok(())
1357}
1358
1359fn validate_shared_secret(shared_secret: &[u8; 32]) -> Result<()> {
1360 if shared_secret.iter().all(|byte| *byte == 0) {
1361 return Err(SyncularError::protocol_message(
1362 "X25519 shared secret is all zeros",
1363 ));
1364 }
1365 Ok(())
1366}
1367
1368fn decode_key_material(material: &str) -> Result<Vec<u8>> {
1369 let trimmed = material.trim();
1370 let decoded = if let Some(rest) = trimmed.strip_prefix("hex:") {
1371 hex::decode(rest).map_err(|err| SyncularError::config(format!("invalid hex key: {err}")))?
1372 } else if let Some(rest) = trimmed.strip_prefix("base64:") {
1373 STANDARD
1374 .decode(rest)
1375 .map_err(|err| SyncularError::config(format!("invalid base64 key: {err}")))?
1376 } else if let Some(rest) = trimmed.strip_prefix("base64url:") {
1377 URL_SAFE_NO_PAD
1378 .decode(rest)
1379 .map_err(|err| SyncularError::config(format!("invalid base64url key: {err}")))?
1380 } else if trimmed.len() == 64 && trimmed.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1381 hex::decode(trimmed)
1382 .map_err(|err| SyncularError::config(format!("invalid hex key: {err}")))?
1383 } else {
1384 URL_SAFE_NO_PAD
1385 .decode(trimmed)
1386 .map_err(|err| SyncularError::config(format!("invalid base64url key: {err}")))?
1387 };
1388 validate_32_bytes("key material", &decoded)?;
1389 Ok(decoded)
1390}
1391
1392fn normalize_mnemonic_input(phrase: &str) -> String {
1393 phrase
1394 .trim()
1395 .to_lowercase()
1396 .split_whitespace()
1397 .collect::<Vec<_>>()
1398 .join(" ")
1399}
1400
1401fn percent_encode(value: &str) -> String {
1402 let mut out = String::new();
1403 for byte in value.bytes() {
1404 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
1405 out.push(byte as char);
1406 } else {
1407 out.push_str(&format!("%{byte:02X}"));
1408 }
1409 }
1410 out
1411}
1412
1413fn percent_decode(value: &str) -> Result<String> {
1414 let bytes = value.as_bytes();
1415 let mut out = Vec::with_capacity(bytes.len());
1416 let mut index = 0usize;
1417 while index < bytes.len() {
1418 if bytes[index] != b'%' {
1419 out.push(bytes[index]);
1420 index += 1;
1421 continue;
1422 }
1423 if index + 2 >= bytes.len() {
1424 return Err(SyncularError::config("invalid percent-encoded key id"));
1425 }
1426 let hex = std::str::from_utf8(&bytes[index + 1..index + 3])
1427 .map_err(|_| SyncularError::config("invalid percent-encoded key id"))?;
1428 let byte = u8::from_str_radix(hex, 16)
1429 .map_err(|_| SyncularError::config("invalid percent-encoded key id"))?;
1430 out.push(byte);
1431 index += 3;
1432 }
1433 String::from_utf8(out).map_err(|_| SyncularError::config("invalid UTF-8 key id"))
1434}
1435
1436#[derive(Debug, Deserialize)]
1437#[serde(rename_all = "camelCase")]
1438struct KeyMaterialArgs {
1439 key: String,
1440}
1441
1442#[derive(Debug, Deserialize)]
1443#[serde(rename_all = "camelCase")]
1444struct MnemonicArgs {
1445 phrase: String,
1446}
1447
1448#[derive(Debug, Deserialize)]
1449#[serde(rename_all = "camelCase")]
1450struct WrapKeyArgs {
1451 recipient_public_key: String,
1452 symmetric_key: String,
1453}
1454
1455#[derive(Debug, Deserialize)]
1456#[serde(rename_all = "camelCase")]
1457struct UnwrapKeyArgs {
1458 private_key: String,
1459 wrapped_key: String,
1460}
1461
1462#[derive(Debug, Deserialize)]
1463#[serde(rename_all = "camelCase")]
1464struct KeyShareUrlArgs {
1465 key: String,
1466 kid: Option<String>,
1467}
1468
1469#[derive(Debug, Deserialize)]
1470#[serde(rename_all = "camelCase")]
1471struct PublicKeyArgs {
1472 public_key: String,
1473}
1474
1475#[derive(Debug, Deserialize)]
1476#[serde(rename_all = "camelCase")]
1477struct ShareUrlArgs {
1478 url: String,
1479}
1480
1481#[derive(Debug, Deserialize)]
1482#[serde(rename_all = "camelCase")]
1483struct Pbkdf2Args {
1484 passphrase: String,
1485 scope: String,
1486 iterations: Option<u32>,
1487}
1488
1489#[derive(Debug, Deserialize)]
1490#[serde(rename_all = "camelCase")]
1491struct Argon2idArgs {
1492 passphrase: String,
1493 salt: String,
1494 params: Option<Argon2idKeyDerivationParams>,
1495}
1496
1497#[cfg(test)]
1498mod tests {
1499 use super::*;
1500 use crate::protocol::{PullResponse, SubscriptionResponse, SyncCommit, SyncSnapshot};
1501 use serde_json::json;
1502
1503 fn encryption() -> FieldEncryption {
1504 let mut keys = BTreeMap::new();
1505 keys.insert("default".to_string(), URL_SAFE_NO_PAD.encode([7u8; 32]));
1506 FieldEncryption::from_static_config(StaticFieldEncryptionConfig {
1507 rules: vec![FieldEncryptionRule {
1508 scope: "tasks".to_string(),
1509 table: Some("tasks".to_string()),
1510 fields: vec!["title".to_string()],
1511 row_id_field: None,
1512 }],
1513 keys,
1514 encryption_kid: None,
1515 decryption_error_mode: None,
1516 envelope_prefix: None,
1517 })
1518 .expect("encryption")
1519 }
1520
1521 fn ctx() -> FieldEncryptionContext {
1522 FieldEncryptionContext {
1523 actor_id: "user-rust".to_string(),
1524 client_id: "client-rust".to_string(),
1525 }
1526 }
1527
1528 fn blob_encryption() -> BlobEncryption {
1529 let mut keys = BTreeMap::new();
1530 keys.insert("default".to_string(), URL_SAFE_NO_PAD.encode([9u8; 32]));
1531 BlobEncryption::from_static_config(StaticBlobEncryptionConfig {
1532 keys,
1533 encryption_kid: None,
1534 })
1535 .expect("blob encryption")
1536 }
1537
1538 #[test]
1539 fn encrypts_push_and_decrypts_pull_rows() -> Result<()> {
1540 let encryption = encryption();
1541 let op = SyncOperation {
1542 table: "tasks".to_string(),
1543 row_id: "t1".to_string(),
1544 op: "upsert".to_string(),
1545 payload: Some(json!({ "title": "Secret", "completed": 0 })),
1546 base_version: None,
1547 };
1548 let encrypted = encryption.transform_operations_for_push(&ctx(), vec![op.clone()])?;
1549 let payload = encrypted[0].payload.as_ref().expect("payload");
1550 let title = payload["title"].as_str().expect("encrypted title");
1551 assert!(title.starts_with(DEFAULT_FIELD_ENCRYPTION_PREFIX));
1552 assert_ne!(title, "Secret");
1553
1554 let pull = PullResponse {
1555 ok: true,
1556 subscriptions: vec![SubscriptionResponse {
1557 id: "sub-tasks".to_string(),
1558 status: "active".to_string(),
1559 scopes: Map::new(),
1560 bootstrap: true,
1561 bootstrap_state: None,
1562 next_cursor: 1,
1563 integrity: None,
1564 commits: Vec::new(),
1565 snapshots: Some(vec![SyncSnapshot {
1566 table: "tasks".to_string(),
1567 rows: vec![json!({ "id": "t1", "title": title, "completed": 0 })],
1568 chunks: None,
1569 artifacts: None,
1570 manifest: None,
1571 is_first_page: true,
1572 is_last_page: true,
1573 bootstrap_state_after: None,
1574 }]),
1575 }],
1576 };
1577 let decrypted = encryption.transform_pull_response(&ctx(), pull)?;
1578 let row = &decrypted.subscriptions[0].snapshots.as_ref().unwrap()[0].rows[0];
1579 assert_eq!(row["title"], "Secret");
1580 Ok(())
1581 }
1582
1583 #[test]
1584 fn encrypted_blob_body_roundtrips_with_ciphertext_ref() -> Result<()> {
1585 let encryption = blob_encryption();
1586 let plaintext = b"top secret blob payload";
1587 let encrypted = encryption.encrypt_blob(plaintext, "text/plain")?;
1588 assert!(encrypted.blob.encrypted);
1589 assert_eq!(encrypted.blob.key_id.as_deref(), Some("default"));
1590 assert_eq!(encrypted.blob.hash, blob_hash(&encrypted.body));
1591 assert_ne!(encrypted.blob.hash, blob_hash(plaintext));
1592 assert_ne!(encrypted.body, plaintext);
1593
1594 let decrypted = encryption.decrypt_blob(&encrypted.blob, &encrypted.body)?;
1595 assert_eq!(decrypted, plaintext);
1596 Ok(())
1597 }
1598
1599 #[test]
1600 fn encrypted_blob_decryption_authenticates_metadata() -> Result<()> {
1601 let encryption = blob_encryption();
1602 let encrypted = encryption.encrypt_blob(b"payload", "text/plain")?;
1603
1604 let mut tampered_mime = encrypted.blob.clone();
1605 tampered_mime.mime_type = "application/json".to_string();
1606 let error = encryption
1607 .decrypt_blob(&tampered_mime, &encrypted.body)
1608 .unwrap_err();
1609 assert!(error.to_string().contains("decryption failed"));
1610
1611 let mut tampered_key = encrypted.blob.clone();
1612 tampered_key.key_id = Some("missing".to_string());
1613 let error = encryption
1614 .decrypt_blob(&tampered_key, &encrypted.body)
1615 .unwrap_err();
1616 assert!(error.to_string().contains("Missing blob encryption key"));
1617 Ok(())
1618 }
1619
1620 #[test]
1621 fn decrypts_incremental_changes() -> Result<()> {
1622 let encryption = encryption();
1623 let encrypted = encryption.transform_operations_for_push(
1624 &ctx(),
1625 vec![SyncOperation {
1626 table: "tasks".to_string(),
1627 row_id: "t2".to_string(),
1628 op: "upsert".to_string(),
1629 payload: Some(json!({ "title": "Incremental" })),
1630 base_version: None,
1631 }],
1632 )?;
1633 let title = encrypted[0].payload.as_ref().unwrap()["title"]
1634 .as_str()
1635 .unwrap()
1636 .to_string();
1637 let pull = PullResponse {
1638 ok: true,
1639 subscriptions: vec![SubscriptionResponse {
1640 id: "sub-tasks".to_string(),
1641 status: "active".to_string(),
1642 scopes: Map::new(),
1643 bootstrap: false,
1644 bootstrap_state: None,
1645 next_cursor: 2,
1646 integrity: None,
1647 snapshots: None,
1648 commits: vec![SyncCommit {
1649 commit_seq: 2,
1650 created_at: "2026-05-10T00:00:00.000Z".to_string(),
1651 actor_id: "other".to_string(),
1652 changes: vec![SyncChange {
1653 table: "tasks".to_string(),
1654 row_id: "t2".to_string(),
1655 op: "upsert".to_string(),
1656 row_json: Some(json!({ "id": "t2", "title": title })),
1657 row_version: Some(1),
1658 scopes: Map::new(),
1659 }],
1660 }],
1661 }],
1662 };
1663 let decrypted = encryption.transform_pull_response(&ctx(), pull)?;
1664 let change = &decrypted.subscriptions[0].commits[0].changes[0];
1665 assert_eq!(change.row_json.as_ref().unwrap()["title"], "Incremental");
1666 Ok(())
1667 }
1668
1669 #[test]
1670 fn key_wrapping_roundtrips() -> Result<()> {
1671 let alice = generate_x25519_keypair()?;
1672 let key = generate_symmetric_key()?;
1673 let wrapped = wrap_key_for_recipient(&decode_key_material(&alice.public_key)?, &key)?;
1674 let unwrapped = unwrap_key(&decode_key_material(&alice.private_key)?, &wrapped)?;
1675 assert_eq!(unwrapped, key);
1676 Ok(())
1677 }
1678
1679 #[test]
1680 fn static_reader_keys_do_not_require_default_encryption_kid() -> Result<()> {
1681 let mut keys = BTreeMap::new();
1682 keys.insert("k1".to_string(), URL_SAFE_NO_PAD.encode([1u8; 32]));
1683 let provider = StaticFieldEncryptionKeys::from_key_material(keys, None)?;
1684 assert!(provider.get_key("k1").is_ok());
1685 assert_eq!(
1686 provider.encryption_kid(
1687 &ctx(),
1688 &FieldEncryptionTarget {
1689 scope: "tasks".to_string(),
1690 table: "tasks".to_string(),
1691 row_id: "t1".to_string(),
1692 field: "title".to_string(),
1693 }
1694 )?,
1695 "default"
1696 );
1697 Ok(())
1698 }
1699
1700 #[test]
1701 fn key_share_url_roundtrips() -> Result<()> {
1702 let key = [9u8; 32];
1703 let url = key_to_share_url(&key, Some("scope~patient%3Ap1"))?;
1704 let parsed = parse_share_url(&url)?;
1705 assert_eq!(
1706 parsed,
1707 ParsedKeyShare::Symmetric {
1708 key: URL_SAFE_NO_PAD.encode(key),
1709 kid: Some("scope~patient%3Ap1".to_string())
1710 }
1711 );
1712 Ok(())
1713 }
1714
1715 #[test]
1716 fn mnemonic_roundtrips_32_byte_keys() -> Result<()> {
1717 let key = [3u8; 32];
1718 let phrase = key_to_mnemonic(&key)?;
1719 let decoded = mnemonic_to_key(&phrase)?;
1720 assert_eq!(decoded, key);
1721 Ok(())
1722 }
1723}