|
1 |
+ |
//! Group sync: list the caller's groups, fetch their sealed GCK grant, and
|
|
2 |
+ |
//! push/pull a group's shared changelog under its Group Content Key.
|
|
3 |
+ |
//!
|
|
4 |
+ |
//! The GCK is supplied by the caller — the `SyncStore` resolves it from the grant
|
|
5 |
+ |
//! via the identity key (a later slice); these methods only encrypt/decrypt with
|
|
6 |
+ |
//! it. Group entries bind `(group_id, table, row_id)` as AEAD associated data, so
|
|
7 |
+ |
//! a ciphertext cannot be relocated across groups. Design: wiki
|
|
8 |
+ |
//! synckit-multiscope-design.
|
|
9 |
+ |
|
|
10 |
+ |
use bytes::Bytes;
|
|
11 |
+ |
use tracing::instrument;
|
|
12 |
+ |
use uuid::Uuid;
|
|
13 |
+ |
|
|
14 |
+ |
use crate::{
|
|
15 |
+ |
error::Result,
|
|
16 |
+ |
ids::{DeviceId, GroupId},
|
|
17 |
+ |
types::*,
|
|
18 |
+ |
};
|
|
19 |
+ |
|
|
20 |
+ |
use super::SyncKitClient;
|
|
21 |
+ |
use super::helpers::{Idempotency, check_response};
|
|
22 |
+ |
|
|
23 |
+ |
impl SyncKitClient {
|
|
24 |
+ |
/// List the groups the authenticated user belongs to within this app.
|
|
25 |
+ |
#[instrument(skip(self))]
|
|
26 |
+ |
pub async fn list_groups(&self) -> Result<Vec<SyncGroup>> {
|
|
27 |
+ |
let token = self.require_token()?;
|
|
28 |
+ |
self.retry_request_json(Idempotency::ReadOnly, || {
|
|
29 |
+ |
let req = self.http.get(self.endpoints.groups()).bearer_auth(&token);
|
|
30 |
+ |
async move { check_response(req.send().await?).await }
|
|
31 |
+ |
})
|
|
32 |
+ |
.await
|
|
33 |
+ |
}
|
|
34 |
+ |
|
|
35 |
+ |
/// Fetch the caller's own sealed GCK grant for a group. Open it with the
|
|
36 |
+ |
/// member's identity private key to recover the GCK.
|
|
37 |
+ |
#[instrument(skip(self))]
|
|
38 |
+ |
pub async fn group_grant(&self, group_id: GroupId) -> Result<GroupGrant> {
|
|
39 |
+ |
let token = self.require_token()?;
|
|
40 |
+ |
let url = self.endpoints.group_grant(group_id);
|
|
41 |
+ |
self.retry_request_json(Idempotency::ReadOnly, || {
|
|
42 |
+ |
let req = self.http.get(&url).bearer_auth(&token);
|
|
43 |
+ |
async move { check_response(req.send().await?).await }
|
|
44 |
+ |
})
|
|
45 |
+ |
.await
|
|
46 |
+ |
}
|
|
47 |
+ |
|
|
48 |
+ |
/// Push encrypted changes to a group's shared changelog under its GCK.
|
|
49 |
+ |
/// Returns the server cursor after the push.
|
|
50 |
+ |
#[instrument(skip(self, gck, changes))]
|
|
51 |
+ |
pub async fn group_push(
|
|
52 |
+ |
&self,
|
|
53 |
+ |
group_id: GroupId,
|
|
54 |
+ |
gck: &[u8; 32],
|
|
55 |
+ |
device_id: DeviceId,
|
|
56 |
+ |
changes: Vec<ChangeEntry>,
|
|
57 |
+ |
) -> Result<i64> {
|
|
58 |
+ |
let token = self.require_token()?;
|
|
59 |
+ |
let group_str = group_id.to_string();
|
|
60 |
+ |
let wire_changes = changes
|
|
61 |
+ |
.into_iter()
|
|
62 |
+ |
.map(|c| Self::encrypt_group_change_with_key(&group_str, c, gck))
|
|
63 |
+ |
.collect::<Result<Vec<_>>>()?;
|
|
64 |
+ |
|
|
65 |
+ |
let body = Bytes::from(serde_json::to_vec(&WirePushRequest {
|
|
66 |
+ |
device_id,
|
|
67 |
+ |
batch_id: Uuid::new_v4(),
|
|
68 |
+ |
changes: wire_changes,
|
|
69 |
+ |
})?);
|
|
70 |
+ |
let url = self.endpoints.group_push(group_id);
|
|
71 |
+ |
|
|
72 |
+ |
let push_resp: PushResponse = self
|
|
73 |
+ |
.retry_request_json(Idempotency::Keyed, || {
|
|
74 |
+ |
let req = self
|
|
75 |
+ |
.http
|
|
76 |
+ |
.post(&url)
|
|
77 |
+ |
.bearer_auth(&token)
|
|
78 |
+ |
.header("content-type", "application/json")
|
|
79 |
+ |
.body(body.clone());
|
|
80 |
+ |
async move { check_response(req.send().await?).await }
|
|
81 |
+ |
})
|
|
82 |
+ |
.await?;
|
|
83 |
+ |
Ok(push_resp.cursor)
|
|
84 |
+ |
}
|
|
85 |
+ |
|
|
86 |
+ |
/// Pull a group's changes since `cursor`, decrypting under its GCK. Returns
|
|
87 |
+ |
/// `(changes, new_cursor, has_more)` with per-row device/seq metadata, ready
|
|
88 |
+ |
/// for conflict resolution. Group pull has no master-key-rotation window (a
|
|
89 |
+ |
/// group's key rotates server-side, re-encrypted in place), so the decrypt is
|
|
90 |
+ |
/// a single-key pass.
|
|
91 |
+ |
#[instrument(skip(self, gck))]
|
|
92 |
+ |
pub async fn group_pull_rich(
|
|
93 |
+ |
&self,
|
|
94 |
+ |
group_id: GroupId,
|
|
95 |
+ |
gck: &[u8; 32],
|
|
96 |
+ |
device_id: DeviceId,
|
|
97 |
+ |
cursor: i64,
|
|
98 |
+ |
) -> Result<(Vec<PulledChange>, i64, bool)> {
|
|
99 |
+ |
let token = self.require_token()?;
|
|
100 |
+ |
let body = Bytes::from(serde_json::to_vec(&PullRequest { device_id, cursor })?);
|
|
101 |
+ |
let url = self.endpoints.group_pull(group_id);
|
|
102 |
+ |
|
|
103 |
+ |
let pull_resp: PullResponse = self
|
|
104 |
+ |
.retry_request_json(Idempotency::ReadOnly, || {
|
|
105 |
+ |
let req = self
|
|
106 |
+ |
.http
|
|
107 |
+ |
.post(&url)
|
|
108 |
+ |
.bearer_auth(&token)
|
|
109 |
+ |
.header("content-type", "application/json")
|
|
110 |
+ |
.body(body.clone());
|
|
111 |
+ |
async move { check_response(req.send().await?).await }
|
|
112 |
+ |
})
|
|
113 |
+ |
.await?;
|
|
114 |
+ |
|
|
115 |
+ |
let group_str = group_id.to_string();
|
|
116 |
+ |
let changes = pull_resp
|
|
117 |
+ |
.changes
|
|
118 |
+ |
.into_iter()
|
|
119 |
+ |
.map(|c| Self::decrypt_group_change_to_pulled(&group_str, c, gck))
|
|
120 |
+ |
.collect::<Result<Vec<_>>>()?;
|
|
121 |
+ |
Ok((changes, pull_resp.cursor, pull_resp.has_more))
|
|
122 |
+ |
}
|
|
123 |
+ |
}
|
|
124 |
+ |
|
|
125 |
+ |
#[cfg(test)]
|
|
126 |
+ |
mod tests {
|
|
127 |
+ |
use super::*;
|
|
128 |
+ |
use crate::crypto;
|
|
129 |
+ |
use crate::error::SyncKitError;
|
|
130 |
+ |
use crate::ids::DeviceId;
|
|
131 |
+ |
use chrono::Utc;
|
|
132 |
+ |
|
|
133 |
+ |
fn insert(table: &str, row: &str, data: serde_json::Value) -> ChangeEntry {
|
|
134 |
+ |
ChangeEntry {
|
|
135 |
+ |
table: table.into(),
|
|
136 |
+ |
op: ChangeOp::Insert,
|
|
137 |
+ |
row_id: row.into(),
|
|
138 |
+ |
timestamp: Utc::now(),
|
|
139 |
+ |
hlc: Hlc::zero(DeviceId::nil()),
|
|
140 |
+ |
data: Some(data),
|
|
141 |
+ |
extra: Default::default(),
|
|
142 |
+ |
}
|
|
143 |
+ |
}
|
|
144 |
+ |
|
|
145 |
+ |
fn to_pull(wire: WireChangeEntry, device: DeviceId, seq: i64) -> PullChangeEntry {
|
|
146 |
+ |
PullChangeEntry {
|
|
147 |
+ |
seq,
|
|
148 |
+ |
device_id: device,
|
|
149 |
+ |
table: wire.table,
|
|
150 |
+ |
op: wire.op,
|
|
151 |
+ |
row_id: wire.row_id,
|
|
152 |
+ |
timestamp: wire.timestamp,
|
|
153 |
+ |
data: wire.data,
|
|
154 |
+ |
key_id: None,
|
|
155 |
+ |
}
|
|
156 |
+ |
}
|
|
157 |
+ |
|
|
158 |
+ |
#[test]
|
|
159 |
+ |
fn group_change_encrypt_decrypt_roundtrip_preserves_data_and_hlc() {
|
|
160 |
+ |
let gck = crypto::generate_master_key();
|
|
161 |
+ |
let device = DeviceId::new(uuid::Uuid::new_v4());
|
|
162 |
+ |
let hlc = Hlc {
|
|
163 |
+ |
wall_ms: 7,
|
|
164 |
+ |
counter: 1,
|
|
165 |
+ |
node: device,
|
|
166 |
+ |
};
|
|
167 |
+ |
let mut e = insert("tasks", "r1", serde_json::json!({ "title": "shared" }));
|
|
168 |
+ |
e.hlc = hlc;
|
|
169 |
+ |
|
|
170 |
+ |
let wire = SyncKitClient::encrypt_group_change_with_key("grp-1", e, &gck).unwrap();
|
|
171 |
+ |
let pulled =
|
|
172 |
+ |
SyncKitClient::decrypt_group_change_to_pulled("grp-1", to_pull(wire, device, 3), &gck)
|
|
173 |
+ |
.unwrap();
|
|
174 |
+ |
|
|
175 |
+ |
assert_eq!(pulled.seq, 3);
|
|
176 |
+ |
assert_eq!(pulled.device_id, device);
|
|
177 |
+ |
assert_eq!(
|
|
178 |
+ |
pulled.entry.data.unwrap(),
|
|
179 |
+ |
serde_json::json!({ "title": "shared" })
|
|
180 |
+ |
);
|
|
181 |
+ |
assert_eq!(pulled.entry.hlc, hlc);
|
|
182 |
+ |
}
|
|
183 |
+ |
|
|
184 |
+ |
#[test]
|
|
185 |
+ |
fn group_ciphertext_cannot_be_opened_under_another_group() {
|
|
186 |
+ |
let gck = crypto::generate_master_key();
|
|
187 |
+ |
let wire = SyncKitClient::encrypt_group_change_with_key(
|
|
188 |
+ |
"grp-A",
|
|
189 |
+ |
insert("t", "r", serde_json::json!(1)),
|
|
190 |
+ |
&gck,
|
|
191 |
+ |
)
|
|
192 |
+ |
.unwrap();
|
|
193 |
+ |
// Same GCK, but a different group id in the AAD: the open fails closed.
|
|
194 |
+ |
let err = SyncKitClient::decrypt_group_change_to_pulled(
|
|
195 |
+ |
"grp-B",
|
|
196 |
+ |
to_pull(wire, DeviceId::nil(), 1),
|
|
197 |
+ |
&gck,
|
|
198 |
+ |
)
|
|
199 |
+ |
.unwrap_err();
|
|
200 |
+ |
assert!(matches!(err, SyncKitError::DecryptionFailed));
|
|
201 |
+ |
}
|
|
202 |
+ |
|
|
203 |
+ |
#[test]
|
|
204 |
+ |
fn wrong_gck_fails_closed() {
|
|
205 |
+ |
let gck = crypto::generate_master_key();
|
|
206 |
+ |
let other = crypto::generate_master_key();
|
|
207 |
+ |
let wire = SyncKitClient::encrypt_group_change_with_key(
|
|
208 |
+ |
"grp-1",
|
|
209 |
+ |
insert("t", "r", serde_json::json!(1)),
|
|
210 |
+ |
&gck,
|
|
211 |
+ |
)
|
|
212 |
+ |
.unwrap();
|
|
213 |
+ |
let err = SyncKitClient::decrypt_group_change_to_pulled(
|
|
214 |
+ |
"grp-1",
|
|
215 |
+ |
to_pull(wire, DeviceId::nil(), 1),
|
|
216 |
+ |
&other,
|
|
217 |
+ |
)
|
|
218 |
+ |
.unwrap_err();
|
|
219 |
+ |
assert!(matches!(err, SyncKitError::DecryptionFailed));
|
|
220 |
+ |
}
|
|
221 |
+ |
}
|