@@ -0,0 +1,145 @@ |
| 1 | +# GPG keys |
| 2 | + |
| 3 | +OpenPGP public-key CRUD for the authenticating user. JSON shape |
| 4 | +is GitHub-exact — every field on |
| 5 | +[gh's `/user/gpg_keys`](https://docs.github.com/en/rest/users/gpg-keys) |
| 6 | +is present with the same name and nullability so existing clients |
| 7 | +work without per-field shims. |
| 8 | + |
| 9 | +All endpoints require `Authorization: Bearer <pat>` and the |
| 10 | +[common API conventions](overview.md) apply. |
| 11 | + |
| 12 | +## Key shape |
| 13 | + |
| 14 | +```json |
| 15 | +{ |
| 16 | + "id": 17, |
| 17 | + "name": "laptop", |
| 18 | + "primary_key_id": null, |
| 19 | + "key_id": "ABCDEF0123456789", |
| 20 | + "public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n…", |
| 21 | + "raw_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n…", |
| 22 | + "emails": [ |
| 23 | + { "email": "alice@example.com", "verified": true } |
| 24 | + ], |
| 25 | + "subkeys": [ |
| 26 | + { |
| 27 | + "id": 0, |
| 28 | + "primary_key_id": 17, |
| 29 | + "key_id": "FEDC...", |
| 30 | + "public_key": "", |
| 31 | + "emails": [], |
| 32 | + "subkeys": [], |
| 33 | + "can_sign": true, |
| 34 | + "can_encrypt_comms": false, |
| 35 | + "can_encrypt_storage": false, |
| 36 | + "can_certify": false, |
| 37 | + "created_at": "2026-05-12T04:00:00Z", |
| 38 | + "expires_at": null, |
| 39 | + "raw_key": null, |
| 40 | + "revoked": false |
| 41 | + } |
| 42 | + ], |
| 43 | + "can_sign": false, |
| 44 | + "can_encrypt_comms": false, |
| 45 | + "can_encrypt_storage": false, |
| 46 | + "can_certify": true, |
| 47 | + "created_at": "2026-05-12T04:00:00Z", |
| 48 | + "expires_at": null, |
| 49 | + "revoked": false, |
| 50 | + "raw_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n…" |
| 51 | +} |
| 52 | +``` |
| 53 | + |
| 54 | +Field notes: |
| 55 | + |
| 56 | +- `key_id` / `subkeys[].key_id` — uppercase hex. The primary |
| 57 | + surfaces its short 16-hex key id; full 40-hex fingerprints are |
| 58 | + used internally for verification lookups but not exposed in |
| 59 | + the response (gh-parity). |
| 60 | +- `public_key` and `raw_key` — both carry the same armored block. |
| 61 | + gh historically distinguishes them; shithub follows suit for |
| 62 | + client compatibility. |
| 63 | +- `can_encrypt_comms` and `can_encrypt_storage` — RFC 4880 §5.2.3.21 |
| 64 | + splits encryption capability into two bits; both surface |
| 65 | + honestly. Encryption-only keys (no signing subkey) **are |
| 66 | + accepted**; they just can't verify commits. |
| 67 | +- `emails[].verified` — `true` when the UID email matches a |
| 68 | + verified email on the authenticating user's account. |
| 69 | +- `expires_at` — null for keys that don't expire. |
| 70 | +- `revoked` — `true` after a `DELETE` (soft-delete in the DB). |
| 71 | + |
| 72 | +## List GPG keys |
| 73 | + |
| 74 | +``` |
| 75 | +GET /api/v1/user/gpg_keys |
| 76 | +``` |
| 77 | + |
| 78 | +Required scope: `user:read`. Paginated via `?page=` and |
| 79 | +`?per_page=` (≤ 100, default 30). Response carries the standard |
| 80 | +`Link:` header. |
| 81 | + |
| 82 | +## Get a single GPG key |
| 83 | + |
| 84 | +``` |
| 85 | +GET /api/v1/user/gpg_keys/{id} |
| 86 | +``` |
| 87 | + |
| 88 | +Required scope: `user:read`. 404 when the id does not belong to |
| 89 | +the authenticating user (existence-leak safe — the same status |
| 90 | +is returned regardless of whether the row exists for another |
| 91 | +user). |
| 92 | + |
| 93 | +## Add a GPG key |
| 94 | + |
| 95 | +``` |
| 96 | +POST /api/v1/user/gpg_keys |
| 97 | +``` |
| 98 | + |
| 99 | +Required scope: `user:write`. |
| 100 | + |
| 101 | +### Request body |
| 102 | + |
| 103 | +```json |
| 104 | +{ |
| 105 | + "name": "laptop", |
| 106 | + "armored_public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n…" |
| 107 | +} |
| 108 | +``` |
| 109 | + |
| 110 | +| Field | Type | Notes | |
| 111 | +|----------------------|--------|------------------------------------------------------------------------| |
| 112 | +| `name` | string | 1–80 characters; user-visible label. | |
| 113 | +| `armored_public_key` | string | Full ASCII-armored block, including the BEGIN/END envelope. | |
| 114 | + |
| 115 | +Returns `201` on success with the same shape as the list |
| 116 | +endpoint. Successful inserts dispatch a background backfill |
| 117 | +that retroactively stamps verification rows for any existing |
| 118 | +commits whose signing subkey matches. |
| 119 | + |
| 120 | +### Errors |
| 121 | + |
| 122 | +| Status | When | |
| 123 | +|-------:|-------------------------------------------------------------------------------------| |
| 124 | +| 401 | PAT missing/invalid. | |
| 125 | +| 403 | PAT lacks `user:write` scope. | |
| 126 | +| 422 | Block unparseable, key already registered, RSA<2048, no UID, expired primary, etc. | |
| 127 | + |
| 128 | +Rejection classes the parser raises explicitly: private-key |
| 129 | +blocks, signature blocks, RSA<2048, DSA-only, expired primary, |
| 130 | +no-UID entities. Encryption-only keys with valid encryption |
| 131 | +subkeys are accepted with `can_sign: false`. |
| 132 | + |
| 133 | +## Delete a GPG key |
| 134 | + |
| 135 | +``` |
| 136 | +DELETE /api/v1/user/gpg_keys/{id} |
| 137 | +``` |
| 138 | + |
| 139 | +Required scope: `user:write`. Returns `204 No Content` on |
| 140 | +success. Soft-delete: the row stays in the DB with |
| 141 | +`revoked_at = now()`. Verification cache rows that resolved |
| 142 | +against the deleted key are invalidated; affected commits |
| 143 | +revert to no badge until another matching key is uploaded. |
| 144 | + |
| 145 | +404 when the id does not belong to the authenticating user. |