max / makenotwork
| 1 | -- SyncKit Groups: keep every GCK generation, not just the current one. |
| 2 | -- |
| 3 | -- Rotation (member removal, or a re-key after a suspected compromise) mints a new |
| 4 | -- Group Content Key. Until now each member had exactly one grant row, overwritten |
| 5 | -- on rotation, and sync_group_log carried no generation at all: its schema comment |
| 6 | -- asserted "generation = sync_groups.gck_version", which only holds if every |
| 7 | -- existing entry is re-encrypted under the new key at rotation time. The server |
| 8 | -- cannot do that, because it never sees plaintext. So a rotation would have left |
| 9 | -- every pre-rotation entry sealed under a key no member could obtain any more. |
| 10 | -- |
| 11 | -- Two changes make history survive a rotation: |
| 12 | -- |
| 13 | -- 1. Entries record the generation they were sealed under. A pull returns it and |
| 14 | -- the client resolves that generation's key. |
| 15 | -- 2. Grants are keyed by generation, so a member keeps the grants for every |
| 16 | -- generation they were a member during. Rotation adds rows rather than |
| 17 | -- replacing them. |
| 18 | -- |
| 19 | -- A removed member's grants are deleted outright: they lose access to everything, |
| 20 | -- including entries they could have read before. Whatever they already pulled is |
| 21 | -- in their hands regardless, which is the standard, documented limitation. |
| 22 | -- |
| 23 | -- Design: wiki synckit-groups-design. |
| 24 | |
| 25 | -- Grants, one row per (group, member, generation). |
| 26 | NOT EXISTS sync_group_grants ( |
| 27 | group_id UUID NOT NULL REFERENCES sync_groups(id) ON DELETE CASCADE, |
| 28 | user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, |
| 29 | -- The GCK generation this grant opens. Not a foreign key: generations are a |
| 30 | -- counter on sync_groups, not rows. |
| 31 | gck_version INT NOT NULL, |
| 32 | -- The GCK sealed to this member's X25519 public key (base64), opaque to the |
| 33 | -- server. Produced by the admin via seal_gck_to_member (synckit-client). |
| 34 | sealed_gck TEXT NOT NULL, |
| 35 | granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW, |
| 36 | PRIMARY KEY (group_id, user_id, gck_version) |
| 37 | ); |
| 38 | |
| 39 | -- Carry the existing single grant per member across as that member's grant for |
| 40 | -- the generation it was sealed under. |
| 41 | INSERT INTO sync_group_grants (group_id, user_id, gck_version, sealed_gck, granted_at) |
| 42 | SELECT group_id, user_id, gck_version, sealed_gck, added_at |
| 43 | FROM sync_group_members |
| 44 | ON CONFLICT (group_id, user_id, gck_version) DO NOTHING; |
| 45 | |
| 46 | -- sync_group_members keeps membership: role, the public key rotation re-seals to, |
| 47 | -- and when they joined. The grant columns now live in sync_group_grants, which is |
| 48 | -- the only place a generation's sealed key is recorded. |
| 49 | sync_group_members DROP COLUMN IF EXISTS sealed_gck; |
| 50 | sync_group_members DROP COLUMN IF EXISTS gck_version; |
| 51 | |
| 52 | -- Every group entry records the generation its ciphertext was sealed under, so a |
| 53 | -- pull can hand the client the right key generation per row. Existing rows |
| 54 | -- predate any rotation, so they belong to their group's current generation. |
| 55 | sync_group_log ADD COLUMN IF NOT EXISTS gck_version INT; |
| 56 | |
| 57 | UPDATE sync_group_log l |
| 58 | SET gck_version = g.gck_version |
| 59 | FROM sync_groups g |
| 60 | WHERE l.group_id = g.id AND l.gck_version IS NULL; |
| 61 | |
| 62 | -- Groups with no entries leave nothing to backfill; the default covers a row |
| 63 | -- inserted by an older binary mid-deploy. |
| 64 | sync_group_log ALTER COLUMN gck_version SET DEFAULT 1; |
| 65 | sync_group_log ALTER COLUMN gck_version SET NOT NULL; |
| 66 |