tenseleyflow/shithub / d1ae9af

Browse files

migrations: user_gpg_keys + user_gpg_subkeys tables

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
d1ae9af8ab4cede84fb90e39228344042f289f23
Parents
0945a74
Tree
03d3025

2 changed files

StatusFile+-
A internal/migrationsfs/migrations/0066_user_gpg_keys.sql 64 0
A internal/migrationsfs/migrations/0067_user_gpg_subkeys.sql 46 0
internal/migrationsfs/migrations/0066_user_gpg_keys.sqladded
@@ -0,0 +1,64 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+--
3
+-- OpenPGP public keys associated with users. Used to verify the
4
+-- signature on a commit or annotated tag and render the "Verified"
5
+-- badge. Companion table user_gpg_subkeys (0067) carries the per-
6
+-- subkey reverse-lookup index that the verification hot path joins
7
+-- against — a commit signature carries the *subkey's* fingerprint,
8
+-- not the primary's.
9
+--
10
+-- fingerprint is unique across ALL users (partial index, where
11
+-- revoked_at is null) — two users registering the same key would
12
+-- produce ambiguous verification lookups. Soft-delete via revoked_at
13
+-- preserves audit history and lets re-upload of the same fingerprint
14
+-- after revoke succeed.
15
+--
16
+-- armored holds the ASCII-armored block exactly as uploaded so we
17
+-- can round-trip it back over REST and email; the parsed capability
18
+-- flags + uids + subkey metadata are decoded once at insert time
19
+-- and stored alongside so the REST response doesn't re-parse on read.
20
+--
21
+-- can_encrypt_comms vs can_encrypt_storage split per RFC 4880
22
+-- §5.2.3.21 to match GitHub's /user/gpg_keys response shape exactly.
23
+-- can_authenticate is stored but not surfaced over REST in S51
24
+-- (GitHub doesn't surface it either; the column lets S52/S53 expose
25
+-- it later without a schema change).
26
+
27
+-- +goose Up
28
+CREATE TABLE user_gpg_keys (
29
+    id                  bigserial   PRIMARY KEY,
30
+    user_id             bigint      NOT NULL REFERENCES users(id) ON DELETE CASCADE,
31
+    name                text        NOT NULL DEFAULT '',
32
+    fingerprint         text        NOT NULL,
33
+    key_id              text        NOT NULL,
34
+    armored             text        NOT NULL,
35
+    can_sign            boolean     NOT NULL,
36
+    can_encrypt_comms   boolean     NOT NULL,
37
+    can_encrypt_storage boolean     NOT NULL,
38
+    can_certify         boolean     NOT NULL,
39
+    can_authenticate    boolean     NOT NULL,
40
+    uids                text[]      NOT NULL DEFAULT '{}',
41
+    subkeys             jsonb       NOT NULL DEFAULT '[]'::jsonb,
42
+    primary_algo        text        NOT NULL,
43
+    created_at          timestamptz NOT NULL DEFAULT now(),
44
+    last_used_at        timestamptz,
45
+    revoked_at          timestamptz,
46
+    expires_at          timestamptz,
47
+
48
+    CONSTRAINT user_gpg_keys_name_length        CHECK (char_length(name) <= 80),
49
+    CONSTRAINT user_gpg_keys_fingerprint_format CHECK (fingerprint ~ '^[0-9a-f]{40}$'),
50
+    CONSTRAINT user_gpg_keys_key_id_format     CHECK (key_id ~ '^[0-9a-f]{16}$')
51
+);
52
+
53
+CREATE UNIQUE INDEX user_gpg_keys_fingerprint_uniq
54
+    ON user_gpg_keys (fingerprint)
55
+    WHERE revoked_at IS NULL;
56
+
57
+CREATE INDEX user_gpg_keys_user_id_idx ON user_gpg_keys (user_id, created_at DESC);
58
+CREATE INDEX user_gpg_keys_key_id_idx  ON user_gpg_keys (key_id);
59
+
60
+-- +goose Down
61
+DROP INDEX IF EXISTS user_gpg_keys_key_id_idx;
62
+DROP INDEX IF EXISTS user_gpg_keys_user_id_idx;
63
+DROP INDEX IF EXISTS user_gpg_keys_fingerprint_uniq;
64
+DROP TABLE IF EXISTS user_gpg_keys;
internal/migrationsfs/migrations/0067_user_gpg_subkeys.sqladded
@@ -0,0 +1,46 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+--
3
+-- Reverse-lookup index for OpenPGP subkey fingerprints. A commit's
4
+-- signature packet carries the SIGNING SUBKEY's fingerprint (or key
5
+-- id), not the primary's; the verification hot path needs a fast
6
+-- (fingerprint → user_id) mapping that doesn't require parsing
7
+-- every user_gpg_keys.armored block.
8
+--
9
+-- The same data is also serialized into user_gpg_keys.subkeys (JSONB)
10
+-- so the REST response can nest subkeys under the primary without
11
+-- a join; both representations are populated atomically when the
12
+-- primary key is inserted.
13
+--
14
+-- Global uniqueness on fingerprint (partial, where revoked_at is null)
15
+-- mirrors the primary table's policy.
16
+
17
+-- +goose Up
18
+CREATE TABLE user_gpg_subkeys (
19
+    id                  bigserial   PRIMARY KEY,
20
+    gpg_key_id          bigint      NOT NULL REFERENCES user_gpg_keys(id) ON DELETE CASCADE,
21
+    fingerprint         text        NOT NULL,
22
+    key_id              text        NOT NULL,
23
+    can_sign            boolean     NOT NULL,
24
+    can_encrypt_comms   boolean     NOT NULL,
25
+    can_encrypt_storage boolean     NOT NULL,
26
+    can_certify         boolean     NOT NULL,
27
+    expires_at          timestamptz,
28
+    revoked_at          timestamptz,
29
+    created_at          timestamptz NOT NULL DEFAULT now(),
30
+
31
+    CONSTRAINT user_gpg_subkeys_fingerprint_format CHECK (fingerprint ~ '^[0-9a-f]{40}$'),
32
+    CONSTRAINT user_gpg_subkeys_key_id_format     CHECK (key_id ~ '^[0-9a-f]{16}$')
33
+);
34
+
35
+CREATE UNIQUE INDEX user_gpg_subkeys_fingerprint_uniq
36
+    ON user_gpg_subkeys (fingerprint)
37
+    WHERE revoked_at IS NULL;
38
+
39
+CREATE INDEX user_gpg_subkeys_key_id_idx     ON user_gpg_subkeys (key_id);
40
+CREATE INDEX user_gpg_subkeys_gpg_key_id_idx ON user_gpg_subkeys (gpg_key_id);
41
+
42
+-- +goose Down
43
+DROP INDEX IF EXISTS user_gpg_subkeys_gpg_key_id_idx;
44
+DROP INDEX IF EXISTS user_gpg_subkeys_key_id_idx;
45
+DROP INDEX IF EXISTS user_gpg_subkeys_fingerprint_uniq;
46
+DROP TABLE IF EXISTS user_gpg_subkeys;