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