Skip to content

Commit b7c1955

Browse files
fix(db): restore personal E2EE witness publication
- Accept derived workspace key IDs for personal witness envelopes while preserving shared active-key enforcement. - Lock the recovery/workspace key distinction and HTTP mapping in regression tests.
1 parent b1234a0 commit b7c1955

4 files changed

Lines changed: 207 additions & 3 deletions

File tree

crates/api-sync/src/routes/e2ee_witness.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,6 +1102,36 @@ mod tests {
11021102
server.verify().await;
11031103
}
11041104

1105+
#[tokio::test]
1106+
async fn maps_invalid_witness_events_to_bad_request() {
1107+
let server = MockServer::start().await;
1108+
Mock::given(method("POST"))
1109+
.and(path("/rest/v1/rpc/publish_e2ee_freshness_events"))
1110+
.respond_with(
1111+
ResponseTemplate::new(500)
1112+
.set_body_json(json!({ "code": "22023", "message": "invalid event" })),
1113+
)
1114+
.mount(&server)
1115+
.await;
1116+
let response = test_router(&server)
1117+
.oneshot(request(
1118+
Method::POST,
1119+
&format!("/e2ee/witness/{OWNER}"),
1120+
Some(json!({
1121+
"initialize": false,
1122+
"events": [{
1123+
"recordId": RECORD_ID,
1124+
"payloadHash": PAYLOAD_HASH,
1125+
"payload": "opaque"
1126+
}]
1127+
})),
1128+
))
1129+
.await
1130+
.unwrap();
1131+
1132+
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1133+
}
1134+
11051135
#[tokio::test]
11061136
async fn maps_uninitialized_legacy_witnesses_to_conflict() {
11071137
let server = MockServer::start().await;

crates/e2ee/src/lib.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,29 @@ mod tests {
567567
assert_ne!(first.key_id(), other.key_id());
568568
}
569569

570+
#[test]
571+
fn personal_payload_key_id_is_not_the_recovery_identity() {
572+
let recovery = recovery_key();
573+
let workspace = recovery.workspace_key("workspace-a").unwrap();
574+
let sealed = workspace
575+
.seal_field(
576+
"workspace-a",
577+
"sessions",
578+
"session-1",
579+
"title",
580+
"00000000000000000000000000000001",
581+
1,
582+
false,
583+
json!("Planning"),
584+
)
585+
.unwrap();
586+
let envelope: Value = serde_json::from_str(&sealed.payload).unwrap();
587+
588+
assert_ne!(workspace.key_id(), recovery.key_id());
589+
assert_eq!(envelope["key_id"], workspace.key_id());
590+
assert_ne!(envelope["key_id"], recovery.key_id());
591+
}
592+
570593
#[test]
571594
fn fields_round_trip_with_blinded_identifiers() {
572595
let key = recovery_key().workspace_key("workspace-a").unwrap();
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
CREATE OR REPLACE FUNCTION public.publish_e2ee_freshness_events(
2+
p_actor_user_id uuid,
3+
p_workspace_id uuid,
4+
p_initialize boolean,
5+
p_events jsonb
6+
)
7+
RETURNS TABLE (
8+
initialized_at timestamptz,
9+
head_sequence bigint
10+
)
11+
LANGUAGE plpgsql
12+
SECURITY INVOKER
13+
SET search_path = ''
14+
AS $$
15+
DECLARE
16+
v_active_key_id text;
17+
v_initialized_at timestamptz;
18+
v_workspace_kind text;
19+
v_events jsonb := COALESCE(p_events, '[]'::jsonb);
20+
BEGIN
21+
IF p_actor_user_id IS NULL OR p_workspace_id IS NULL OR p_initialize IS NULL THEN
22+
RAISE EXCEPTION 'E2EE freshness request is invalid' USING ERRCODE = '22023';
23+
END IF;
24+
25+
SELECT workspace.e2ee_freshness_initialized_at, workspace.kind::text
26+
INTO v_initialized_at, v_workspace_kind
27+
FROM public.workspaces AS workspace
28+
WHERE workspace.id = p_workspace_id
29+
FOR UPDATE;
30+
31+
IF NOT FOUND THEN
32+
RAISE EXCEPTION 'E2EE freshness publication is not permitted' USING ERRCODE = '42501';
33+
END IF;
34+
35+
SELECT private.active_e2ee_freshness_key_id(p_actor_user_id, p_workspace_id)
36+
INTO v_active_key_id;
37+
38+
IF v_active_key_id IS NULL THEN
39+
RAISE EXCEPTION 'E2EE freshness publication is not permitted' USING ERRCODE = '42501';
40+
END IF;
41+
42+
IF jsonb_typeof(v_events) <> 'array' OR jsonb_array_length(v_events) > 64 THEN
43+
RAISE EXCEPTION 'E2EE freshness event batch is invalid' USING ERRCODE = '22023';
44+
END IF;
45+
46+
IF EXISTS (
47+
SELECT 1
48+
FROM jsonb_array_elements(v_events) AS event(value)
49+
WHERE jsonb_typeof(event.value) <> 'object'
50+
OR COALESCE(event.value->>'record_id', '') !~ '^[A-Za-z0-9_-]{43}$'
51+
OR COALESCE(event.value->>'payload_hash', '') !~ '^[A-Za-z0-9_-]{43}$'
52+
OR octet_length(COALESCE(event.value->>'payload', '')) NOT BETWEEN 1 AND 16777216
53+
OR private.e2ee_freshness_payload_key_id(event.value->>'payload') IS NULL
54+
OR (
55+
v_workspace_kind = 'shared'
56+
AND private.e2ee_freshness_payload_key_id(event.value->>'payload')
57+
IS DISTINCT FROM v_active_key_id
58+
)
59+
OR event.value->>'payload_hash' <> rtrim(
60+
translate(
61+
encode(extensions.digest(event.value->>'payload', 'sha256'), 'base64'),
62+
'+/',
63+
'-_'
64+
),
65+
'='
66+
)
67+
) THEN
68+
RAISE EXCEPTION 'E2EE freshness event is invalid' USING ERRCODE = '22023';
69+
END IF;
70+
71+
IF v_initialized_at IS NULL AND NOT p_initialize THEN
72+
RAISE EXCEPTION 'E2EE freshness witness is not initialized' USING ERRCODE = '55000';
73+
END IF;
74+
75+
IF v_initialized_at IS NULL AND jsonb_array_length(v_events) = 0 THEN
76+
RAISE EXCEPTION 'E2EE freshness initialization requires established state'
77+
USING ERRCODE = '55000';
78+
END IF;
79+
80+
INSERT INTO public.e2ee_freshness_events (
81+
workspace_id,
82+
record_id,
83+
payload_hash,
84+
payload,
85+
created_by
86+
)
87+
SELECT
88+
p_workspace_id,
89+
event.value->>'record_id',
90+
event.value->>'payload_hash',
91+
event.value->>'payload',
92+
p_actor_user_id
93+
FROM jsonb_array_elements(v_events) AS event(value)
94+
ON CONFLICT (workspace_id, record_id, payload_hash) DO NOTHING;
95+
96+
IF v_initialized_at IS NULL THEN
97+
UPDATE public.workspaces AS workspace
98+
SET e2ee_freshness_initialized_at = now(),
99+
updated_at = now()
100+
WHERE workspace.id = p_workspace_id
101+
RETURNING workspace.e2ee_freshness_initialized_at
102+
INTO v_initialized_at;
103+
END IF;
104+
105+
RETURN QUERY
106+
SELECT
107+
v_initialized_at,
108+
COALESCE(MAX(event.sequence), 0)::bigint
109+
FROM public.e2ee_freshness_events AS event
110+
WHERE event.workspace_id = p_workspace_id;
111+
END;
112+
$$;

supabase/tests/021-e2ee-freshness-witness.sql

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
begin;
2-
select plan(18);
2+
select plan(20);
33

44
select tests.create_supabase_user('witness_owner', 'witness-owner@example.com');
55
select tests.create_supabase_user('witness_other', 'witness-other@example.com');
@@ -88,6 +88,15 @@ select is(
8888
'Claiming a new recovery-key identity succeeds'
8989
);
9090

91+
select is(
92+
private.active_e2ee_freshness_key_id(
93+
tests.get_supabase_uid('witness_owner'),
94+
tests.get_supabase_uid('witness_owner')
95+
),
96+
'abcdefghijklmnopqrstuv',
97+
'Personal witness access remains bound to the recovery-key identity'
98+
);
99+
91100
select isnt(
92101
(
93102
select e2ee_freshness_initialized_at
@@ -114,7 +123,7 @@ select results_eq(
114123
create temporary table witness_event as
115124
select
116125
repeat('r', 43)::text as record_id,
117-
'{"version":1,"key_id":"abcdefghijklmnopqrstuv","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","ciphertext":"opaque"}'::text as payload;
126+
'{"version":1,"key_id":"ABCDEFGHIJKLMNOPQRSTUV","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","ciphertext":"opaque"}'::text as payload;
118127

119128
alter table witness_event add column payload_hash text;
120129
update witness_event
@@ -123,6 +132,36 @@ set payload_hash = rtrim(
123132
'='
124133
);
125134

135+
select throws_ok(
136+
format(
137+
$$
138+
select *
139+
from public.publish_e2ee_freshness_events(
140+
%L,
141+
%L,
142+
false,
143+
jsonb_build_array(jsonb_build_object(
144+
'record_id', repeat('m', 43),
145+
'payload_hash', rtrim(
146+
translate(
147+
encode(extensions.digest('not-an-envelope', 'sha256'), 'base64'),
148+
'+/',
149+
'-_'
150+
),
151+
'='
152+
),
153+
'payload', 'not-an-envelope'
154+
))
155+
)
156+
$$,
157+
tests.get_supabase_uid('witness_owner'),
158+
tests.get_supabase_uid('witness_owner')
159+
),
160+
'22023',
161+
'E2EE freshness event is invalid',
162+
'Personal witness events still require a valid envelope'
163+
);
164+
126165
select isnt(
127166
(
128167
select head_sequence
@@ -207,7 +246,7 @@ select
207246
from (
208247
select ordinal,
209248
format(
210-
'{"version":1,"key_id":"abcdefghijklmnopqrstuv","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","ciphertext":"opaque-%s"}',
249+
'{"version":1,"key_id":"ABCDEFGHIJKLMNOPQRSTUV","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","ciphertext":"opaque-%s"}',
211250
ordinal
212251
)::text as payload
213252
from generate_series(1, 64) as series(ordinal)

0 commit comments

Comments
 (0)