Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions crates/api-sync/src/routes/e2ee_witness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1102,6 +1102,36 @@ mod tests {
server.verify().await;
}

#[tokio::test]
async fn maps_invalid_witness_events_to_bad_request() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/v1/rpc/publish_e2ee_freshness_events"))
.respond_with(
ResponseTemplate::new(500)
.set_body_json(json!({ "code": "22023", "message": "invalid event" })),
)
.mount(&server)
.await;
let response = test_router(&server)
.oneshot(request(
Method::POST,
&format!("/e2ee/witness/{OWNER}"),
Some(json!({
"initialize": false,
"events": [{
"recordId": RECORD_ID,
"payloadHash": PAYLOAD_HASH,
"payload": "opaque"
}]
})),
))
.await
.unwrap();

assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn maps_uninitialized_legacy_witnesses_to_conflict() {
let server = MockServer::start().await;
Expand Down
23 changes: 23 additions & 0 deletions crates/e2ee/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,29 @@ mod tests {
assert_ne!(first.key_id(), other.key_id());
}

#[test]
fn personal_payload_key_id_is_not_the_recovery_identity() {
let recovery = recovery_key();
let workspace = recovery.workspace_key("workspace-a").unwrap();
let sealed = workspace
.seal_field(
"workspace-a",
"sessions",
"session-1",
"title",
"00000000000000000000000000000001",
1,
false,
json!("Planning"),
)
.unwrap();
let envelope: Value = serde_json::from_str(&sealed.payload).unwrap();

assert_ne!(workspace.key_id(), recovery.key_id());
assert_eq!(envelope["key_id"], workspace.key_id());
assert_ne!(envelope["key_id"], recovery.key_id());
}

#[test]
fn fields_round_trip_with_blinded_identifiers() {
let key = recovery_key().workspace_key("workspace-a").unwrap();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
CREATE OR REPLACE FUNCTION public.publish_e2ee_freshness_events(
p_actor_user_id uuid,
p_workspace_id uuid,
p_initialize boolean,
p_events jsonb
)
RETURNS TABLE (
initialized_at timestamptz,
head_sequence bigint
)
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = ''
AS $$
DECLARE
v_active_key_id text;
v_initialized_at timestamptz;
v_workspace_kind text;
v_events jsonb := COALESCE(p_events, '[]'::jsonb);
BEGIN
IF p_actor_user_id IS NULL OR p_workspace_id IS NULL OR p_initialize IS NULL THEN
RAISE EXCEPTION 'E2EE freshness request is invalid' USING ERRCODE = '22023';
END IF;

SELECT workspace.e2ee_freshness_initialized_at, workspace.kind::text
INTO v_initialized_at, v_workspace_kind
FROM public.workspaces AS workspace
WHERE workspace.id = p_workspace_id
FOR UPDATE;

IF NOT FOUND THEN
RAISE EXCEPTION 'E2EE freshness publication is not permitted' USING ERRCODE = '42501';
END IF;

SELECT private.active_e2ee_freshness_key_id(p_actor_user_id, p_workspace_id)
INTO v_active_key_id;

IF v_active_key_id IS NULL THEN
RAISE EXCEPTION 'E2EE freshness publication is not permitted' USING ERRCODE = '42501';
END IF;

IF jsonb_typeof(v_events) <> 'array' OR jsonb_array_length(v_events) > 64 THEN
RAISE EXCEPTION 'E2EE freshness event batch is invalid' USING ERRCODE = '22023';
END IF;

IF EXISTS (
SELECT 1
FROM jsonb_array_elements(v_events) AS event(value)
WHERE jsonb_typeof(event.value) <> 'object'
OR COALESCE(event.value->>'record_id', '') !~ '^[A-Za-z0-9_-]{43}$'
OR COALESCE(event.value->>'payload_hash', '') !~ '^[A-Za-z0-9_-]{43}$'
OR octet_length(COALESCE(event.value->>'payload', '')) NOT BETWEEN 1 AND 16777216
OR private.e2ee_freshness_payload_key_id(event.value->>'payload') IS NULL
OR (
v_workspace_kind = 'shared'
AND private.e2ee_freshness_payload_key_id(event.value->>'payload')
IS DISTINCT FROM v_active_key_id
)
OR event.value->>'payload_hash' <> rtrim(
translate(
encode(extensions.digest(event.value->>'payload', 'sha256'), 'base64'),
'+/',
'-_'
),
'='
)
) THEN
RAISE EXCEPTION 'E2EE freshness event is invalid' USING ERRCODE = '22023';
END IF;

IF v_initialized_at IS NULL AND NOT p_initialize THEN
RAISE EXCEPTION 'E2EE freshness witness is not initialized' USING ERRCODE = '55000';
END IF;

IF v_initialized_at IS NULL AND jsonb_array_length(v_events) = 0 THEN
RAISE EXCEPTION 'E2EE freshness initialization requires established state'
USING ERRCODE = '55000';
END IF;

INSERT INTO public.e2ee_freshness_events (
workspace_id,
record_id,
payload_hash,
payload,
created_by
)
SELECT
p_workspace_id,
event.value->>'record_id',
event.value->>'payload_hash',
event.value->>'payload',
p_actor_user_id
FROM jsonb_array_elements(v_events) AS event(value)
ON CONFLICT (workspace_id, record_id, payload_hash) DO NOTHING;

IF v_initialized_at IS NULL THEN
UPDATE public.workspaces AS workspace
SET e2ee_freshness_initialized_at = now(),
updated_at = now()
WHERE workspace.id = p_workspace_id
RETURNING workspace.e2ee_freshness_initialized_at
INTO v_initialized_at;
END IF;

RETURN QUERY
SELECT
v_initialized_at,
COALESCE(MAX(event.sequence), 0)::bigint
FROM public.e2ee_freshness_events AS event
WHERE event.workspace_id = p_workspace_id;
END;
$$;
45 changes: 42 additions & 3 deletions supabase/tests/021-e2ee-freshness-witness.sql
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
begin;
select plan(18);
select plan(20);

select tests.create_supabase_user('witness_owner', 'witness-owner@example.com');
select tests.create_supabase_user('witness_other', 'witness-other@example.com');
Expand Down Expand Up @@ -88,6 +88,15 @@ select is(
'Claiming a new recovery-key identity succeeds'
);

select is(
private.active_e2ee_freshness_key_id(
tests.get_supabase_uid('witness_owner'),
tests.get_supabase_uid('witness_owner')
),
'abcdefghijklmnopqrstuv',
'Personal witness access remains bound to the recovery-key identity'
);

select isnt(
(
select e2ee_freshness_initialized_at
Expand All @@ -114,7 +123,7 @@ select results_eq(
create temporary table witness_event as
select
repeat('r', 43)::text as record_id,
'{"version":1,"key_id":"abcdefghijklmnopqrstuv","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","ciphertext":"opaque"}'::text as payload;
'{"version":1,"key_id":"ABCDEFGHIJKLMNOPQRSTUV","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","ciphertext":"opaque"}'::text as payload;

alter table witness_event add column payload_hash text;
update witness_event
Expand All @@ -123,6 +132,36 @@ set payload_hash = rtrim(
'='
);

select throws_ok(
format(
$$
select *
from public.publish_e2ee_freshness_events(
%L,
%L,
false,
jsonb_build_array(jsonb_build_object(
'record_id', repeat('m', 43),
'payload_hash', rtrim(
translate(
encode(extensions.digest('not-an-envelope', 'sha256'), 'base64'),
'+/',
'-_'
),
'='
),
'payload', 'not-an-envelope'
))
)
$$,
tests.get_supabase_uid('witness_owner'),
tests.get_supabase_uid('witness_owner')
),
'22023',
'E2EE freshness event is invalid',
'Personal witness events still require a valid envelope'
);

select isnt(
(
select head_sequence
Expand Down Expand Up @@ -207,7 +246,7 @@ select
from (
select ordinal,
format(
'{"version":1,"key_id":"abcdefghijklmnopqrstuv","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","ciphertext":"opaque-%s"}',
'{"version":1,"key_id":"ABCDEFGHIJKLMNOPQRSTUV","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","ciphertext":"opaque-%s"}',
ordinal
)::text as payload
from generate_series(1, 64) as series(ordinal)
Expand Down
Loading