Skip to main content

syncular_runtime/core/
encryption.rs

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}