From 89e3bdfa07dcd28ff0e9ced6eeb2a97899e36d80 Mon Sep 17 00:00:00 2001 From: Pierig Le Saux Date: Wed, 8 Apr 2026 20:21:56 -0400 Subject: [PATCH 1/6] delete a gpg key --- api/gpg.go | 204 +++++++++++++++++++++++++++++ api/gpg_test.go | 340 ++++++++++++++++++++++++++++++++++++++++++++++++ api/router.go | 2 + 3 files changed, 546 insertions(+) create mode 100644 api/gpg_test.go diff --git a/api/gpg.go b/api/gpg.go index 47ba1d92..91e09b36 100644 --- a/api/gpg.go +++ b/api/gpg.go @@ -1,6 +1,7 @@ package api import ( + "bufio" "fmt" "os" "os/exec" @@ -12,6 +13,28 @@ import ( "github.com/gin-gonic/gin" ) +type gpgListKeysParams struct { + // Keyring to list keys from (default: trustedkeys.gpg) + Keyring string `json:"Keyring" example:"trustedkeys.gpg"` +} + +type gpgKeyInfo struct { + // 16-character key ID (short form) + KeyID string `json:"KeyID" example:"8B48AD6246925553"` + // Full fingerprint + Fingerprint string `json:"Fingerprint" example:"D8E8F5A516E7A2C4F3E4B5A6C7D8E9F0"` + // Key validity (u=unknown, f=fulltrust, m=marginal, n=never) + Validity string `json:"Validity" example:"u"` + // User ID(s) associated with this key + UserIDs []string `json:"UserIDs" example:"John Doe "` + // Creation date (Unix timestamp format from gpg) + CreatedAt string `json:"CreatedAt" example:"2023-01-15"` +} + +type gpgKeyListResponse struct { + Keys []gpgKeyInfo `json:"Keys"` +} + type gpgAddKeyParams struct { // Keyring for adding the keys (default: trustedkeys.gpg) Keyring string `json:"Keyring" example:"trustedkeys.gpg"` @@ -25,6 +48,14 @@ type gpgAddKeyParams struct { GpgKeyID string `json:"GpgKeyID" example:"EF0F382A1A7B6500 8B48AD6246925553"` } +type gpgDeleteKeyParams struct { + // Keyring to delete keys from (default: trustedkeys.gpg) + Keyring string `json:"Keyring" example:"trustedkeys.gpg"` + + // Key ID or fingerprint to delete + GpgKeyID string `json:"GpgKeyID" example:"8B48AD6246925553"` +} + // @Summary Add GPG Keys // @Description **Adds GPG keys to aptly keyring** // @Description @@ -108,3 +139,176 @@ func apiGPGAddKey(c *gin.Context) { c.JSON(200, string(out)) } + +// @Summary List GPG Keys +// @Description **Lists all GPG keys in aptly keyring** +// @Description +// @Description Returns all public keys currently installed in the aptly GPG keyring. +// @Description +// @Tags Mirrors +// @Param keyring query string false "Keyring file to list keys from (default: trustedkeys.gpg)" example(trustedkeys.gpg) +// @Produce json +// @Success 200 {object} gpgKeyListResponse "OK" +// @Failure 400 {object} Error "Bad Request" +// @Router /api/gpg/keys [get] +func apiGPGListKeys(c *gin.Context) { + keyring := c.DefaultQuery("keyring", "trustedkeys.gpg") + keyring = utils.SanitizePath(keyring) + + finder := pgp.GPGDefaultFinder() + gpg, _, err := finder.FindGPG() + if err != nil { + AbortWithJSONError(c, 400, err) + return + } + + args := []string{ + "--no-default-keyring", + "--with-colons", + "--keyring", keyring, + "--list-keys", + } + + cmd := exec.Command(gpg, args...) + out, err := cmd.CombinedOutput() + if err != nil { + AbortWithJSONError(c, 400, fmt.Errorf("failed to list keys: %s", string(out))) + return + } + + keys := parseGPGOutput(string(out)) + c.JSON(200, gpgKeyListResponse{Keys: keys}) +} + +// @Summary Delete GPG Key +// @Description **Deletes a GPG key from aptly keyring** +// @Description +// @Description Removes a public key from the aptly GPG keyring. This is useful for removing +// @Description compromised keys or cleaning up obsolete keys. +// @Description +// @Tags Mirrors +// @Consume json +// @Param request body gpgDeleteKeyParams true "Parameters" +// @Produce json +// @Success 200 {object} string "OK" +// @Failure 400 {object} Error "Bad Request" +// @Router /api/gpg/key [delete] +func apiGPGDeleteKey(c *gin.Context) { + b := gpgDeleteKeyParams{} + if c.Bind(&b) != nil { + AbortWithJSONError(c, 400, fmt.Errorf("invalid request body")) + return + } + + if len(strings.TrimSpace(b.GpgKeyID)) == 0 { + AbortWithJSONError(c, 400, fmt.Errorf("GpgKeyID is required")) + return + } + + b.GpgKeyID = utils.SanitizePath(b.GpgKeyID) + // b.Keyring can be an absolute path + + finder := pgp.GPGDefaultFinder() + gpg, _, err := finder.FindGPG() + if err != nil { + AbortWithJSONError(c, 400, err) + return + } + + args := []string{ + "--no-default-keyring", + "--allow-non-selfsigned-uid", + } + + keyring := "trustedkeys.gpg" + if len(b.Keyring) > 0 { + keyring = b.Keyring + } + + args = append(args, "--keyring", keyring) + args = append(args, "--delete-keys", b.GpgKeyID) + + cmd := exec.Command(gpg, args...) + fmt.Printf("running %s %s\n", gpg, strings.Join(args, " ")) + out, err := cmd.CombinedOutput() + if err != nil { + AbortWithJSONError(c, 400, fmt.Errorf("failed to delete key: %s", string(out))) + return + } + + c.JSON(200, string(out)) +} + +// parseGPGOutput parses the output of `gpg --with-colons --list-keys` +// and returns a structured list of keys +func parseGPGOutput(output string) []gpgKeyInfo { + var keys []gpgKeyInfo + var currentKey *gpgKeyInfo + + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + parts := strings.Split(line, ":") + if len(parts) < 10 { + continue + } + + recordType := parts[0] + + // pub: public key record + if recordType == "pub" { + // Save previous key if it exists + if currentKey != nil && currentKey.KeyID != "" { + keys = append(keys, *currentKey) + } + + // Create new key entry + // Format: pub:trust:length:algo:keyid:created:expires:uidhash:... + keyID := parts[4] + if len(keyID) >= 16 { + keyID = keyID[len(keyID)-16:] // Last 16 chars = short key ID + } + validity := parts[1] + createdAt := parts[5] + + currentKey = &gpgKeyInfo{ + KeyID: keyID, + Validity: validity, + CreatedAt: createdAt, + UserIDs: []string{}, + Fingerprint: "", + } + } + + // uid: user ID record + if recordType == "uid" && currentKey != nil { + // Format: uid:trust:created:expires:keyid:uidhash:uidtype:validity:userID:... + if len(parts) >= 10 { + userID := parts[9] + if userID != "" { + currentKey.UserIDs = append(currentKey.UserIDs, userID) + } + } + } + + // fpr: fingerprint record + if recordType == "fpr" && currentKey != nil { + // Format: fpr:::::::::fingerprint: + if len(parts) >= 10 { + fingerprint := parts[9] + currentKey.Fingerprint = fingerprint + } + } + } + + // Don't forget the last key + if currentKey != nil && currentKey.KeyID != "" { + keys = append(keys, *currentKey) + } + + return keys +} diff --git a/api/gpg_test.go b/api/gpg_test.go new file mode 100644 index 00000000..aad15025 --- /dev/null +++ b/api/gpg_test.go @@ -0,0 +1,340 @@ +package api + +import ( + "bytes" + "encoding/json" + + . "gopkg.in/check.v1" +) + +type GPGSuite struct { + APISuite +} + +var _ = Suite(&GPGSuite{}) + +// TestParseGPGOutputEmpty tests parsing of empty GPG output +func (s *GPGSuite) TestParseGPGOutputEmpty(c *C) { + output := "" + keys := parseGPGOutput(output) + c.Check(keys, HasLen, 0) +} + +// TestParseGPGOutputSingleKeyMinimal tests parsing a single key with minimal fields +func (s *GPGSuite) TestParseGPGOutputSingleKeyMinimal(c *C) { + // Minimal valid GPG output with one key + output := `pub:u:4096:1:8B48AD6246925553:1611864000:1643400000:uidhash:::scESC:::::::23::0: +uid:u::::1611864000::1234567890::John Doe ::::::::::0: +fpr:::::::::D8E8F5A516E7A2C4F3E4B5A6C7D8E9F0:` + + keys := parseGPGOutput(output) + c.Check(keys, HasLen, 1) + + key := keys[0] + c.Check(key.KeyID, Equals, "8B48AD6246925553") + c.Check(key.Validity, Equals, "u") + c.Check(key.CreatedAt, Equals, "1611864000") + c.Check(key.Fingerprint, Equals, "D8E8F5A516E7A2C4F3E4B5A6C7D8E9F0") + c.Check(key.UserIDs, DeepEquals, []string{"John Doe "}) +} + +// TestParseGPGOutputMultipleKeys tests parsing multiple keys +func (s *GPGSuite) TestParseGPGOutputMultipleKeys(c *C) { + output := `pub:u:4096:1:8B48AD6246925553:1611864000:1643400000:uidhash:::scESC:::::::23::0: +uid:u::::1611864000::1234567890::John Doe ::::::::::0: +fpr:::::::::D8E8F5A516E7A2C4F3E4B5A6C7D8E9F0: +pub:f:2048:1:A1B2C3D4E5F67890:1580592000:1612128000:uidhash:::scESC:::::::23::0: +uid:f::::1580592000::0987654321::Jane Smith ::::::::::0: +fpr:::::::::E9F0A1B2C3D4E5F6A7B8C9D0E1F2A3B4:` + + keys := parseGPGOutput(output) + c.Check(keys, HasLen, 2) + + // First key + c.Check(keys[0].KeyID, Equals, "8B48AD6246925553") + c.Check(keys[0].Validity, Equals, "u") + c.Check(keys[0].UserIDs, DeepEquals, []string{"John Doe "}) + + // Second key + c.Check(keys[1].KeyID, Equals, "A1B2C3D4E5F67890") + c.Check(keys[1].Validity, Equals, "f") + c.Check(keys[1].UserIDs, DeepEquals, []string{"Jane Smith "}) +} + +// TestParseGPGOutputMultipleUIDs tests a key with multiple user IDs +func (s *GPGSuite) TestParseGPGOutputMultipleUIDs(c *C) { + output := `pub:u:4096:1:8B48AD6246925553:1611864000:1643400000:uidhash:::scESC:::::::23::0: +uid:u::::1611864000::1234567890::John Doe ::::::::::0: +uid:u::::1611864000::1234567891::John Doe ::::::::::0: +fpr:::::::::D8E8F5A516E7A2C4F3E4B5A6C7D8E9F0:` + + keys := parseGPGOutput(output) + c.Check(keys, HasLen, 1) + + key := keys[0] + c.Check(key.UserIDs, HasLen, 2) + c.Check(key.UserIDs, DeepEquals, []string{ + "John Doe ", + "John Doe ", + }) +} + +// TestParseGPGOutputMalformedLines tests that malformed lines are skipped +func (s *GPGSuite) TestParseGPGOutputMalformedLines(c *C) { + // Mix of valid and invalid lines (too few fields) + output := `pub:u:4096:1:8B48AD6246925553:1611864000:1643400000:uidhash:::scESC:::::::23::0: +invalid:line:with:only:three:fields +uid:u::::1611864000::1234567890::John Doe ::::::::::0: +fpr:::::::::D8E8F5A516E7A2C4F3E4B5A6C7D8E9F0:` + + keys := parseGPGOutput(output) + c.Check(keys, HasLen, 1) + c.Check(keys[0].KeyID, Equals, "8B48AD6246925553") +} + +// TestParseGPGOutputEmptyLines tests that empty lines are skipped +func (s *GPGSuite) TestParseGPGOutputEmptyLines(c *C) { + output := `pub:u:4096:1:8B48AD6246925553:1611864000:1643400000:uidhash:::scESC:::::::23::0: + +uid:u::::1611864000::1234567890::John Doe ::::::::::0: + +fpr:::::::::D8E8F5A516E7A2C4F3E4B5A6C7D8E9F0:` + + keys := parseGPGOutput(output) + c.Check(keys, HasLen, 1) + c.Check(keys[0].KeyID, Equals, "8B48AD6246925553") +} + +// TestParseGPGOutputKeyWithoutUID tests a public key without user ID +func (s *GPGSuite) TestParseGPGOutputKeyWithoutUID(c *C) { + // Key without uid record (should still be included) + output := `pub:u:4096:1:8B48AD6246925553:1611864000:1643400000:uidhash:::scESC:::::::23::0: +fpr:::::::::D8E8F5A516E7A2C4F3E4B5A6C7D8E9F0:` + + keys := parseGPGOutput(output) + c.Check(keys, HasLen, 1) + + key := keys[0] + c.Check(key.KeyID, Equals, "8B48AD6246925553") + c.Check(key.UserIDs, HasLen, 0) + c.Check(key.Fingerprint, Equals, "D8E8F5A516E7A2C4F3E4B5A6C7D8E9F0") +} + +// TestParseGPGOutputVariousValidity tests different validity values +func (s *GPGSuite) TestParseGPGOutputVariousValidity(c *C) { + output := `pub:u:4096:1:KEY1111111111111:1611864000:1643400000:uidhash:::scESC:::::::23::0: +uid:u::::1611864000::1234567890::Key1::::::::::0: +fpr:::::::::1111111111111111111111111111111111111111: +pub:f:4096:1:KEY2222222222222:1611864000:1643400000:uidhash:::scESC:::::::23::0: +uid:f::::1611864000::1234567891::Key2::::::::::0: +fpr:::::::::2222222222222222222222222222222222222222: +pub:m:4096:1:KEY3333333333333:1611864000:1643400000:uidhash:::scESC:::::::23::0: +uid:m::::1611864000::1234567892::Key3::::::::::0: +fpr:::::::::3333333333333333333333333333333333333333: +pub:n:4096:1:KEY4444444444444:1611864000:1643400000:uidhash:::scESC:::::::23::0: +uid:n::::1611864000::1234567893::Key4::::::::::0: +fpr:::::::::4444444444444444444444444444444444444444:` + + keys := parseGPGOutput(output) + c.Check(keys, HasLen, 4) + + validities := []string{"u", "f", "m", "n"} + for i, validity := range validities { + c.Check(keys[i].Validity, Equals, validity) + } +} + +// TestParseGPGOutputShortKeyID tests that key IDs are shortened to 16 chars +func (s *GPGSuite) TestParseGPGOutputShortKeyID(c *C) { + // 40-character key ID that should be shortened to last 16 chars + longKeyID := "0123456789ABCDEF0123456789ABCDEF8B48AD62" + output := `pub:u:4096:1:` + longKeyID + `:1611864000:1643400000:uidhash:::scESC:::::::23::0: +uid:u::::1611864000::1234567890::John Doe ::::::::::0: +fpr:::::::::D8E8F5A516E7A2C4F3E4B5A6C7D8E9F0:` + + keys := parseGPGOutput(output) + c.Check(keys, HasLen, 1) + // Should extract the last 16 characters: 89ABCDEF8B48AD62 + c.Check(keys[0].KeyID, Equals, "89ABCDEF8B48AD62") +} + +// TestParseGPGOutputSpecialCharactersInUID tests user IDs with special characters +func (s *GPGSuite) TestParseGPGOutputSpecialCharactersInUID(c *C) { + // UID with Unicode characters and special formatting + output := `pub:u:4096:1:8B48AD6246925553:1611864000:1643400000:uidhash:::scESC:::::::23::0: +uid:u::::1611864000::1234567890::J\xc3\xb6hn D\xc3\xb6\xc3\xa9 (D\xc3\xbcss) ::::::::::0: +fpr:::::::::D8E8F5A516E7A2C4F3E4B5A6C7D8E9F0:` + + keys := parseGPGOutput(output) + c.Check(keys, HasLen, 1) + // Should preserve the encoded special characters + c.Check(keys[0].UserIDs, HasLen, 1) +} + +// TestAPIGPGListKeysDefaultKeyring tests the HTTP endpoint with default keyring +func (s *GPGSuite) TestAPIGPGListKeysDefaultKeyring(c *C) { + c.Skip("Requires GPG binary and test key installation. Run manually if needed.") + + response, err := s.HTTPRequest("GET", "/api/gpg/keys", nil) + c.Assert(err, IsNil) + c.Check(response.Code, Equals, 200) + + // Verify response is valid JSON + var result gpgKeyListResponse + err = json.NewDecoder(response.Body).Decode(&result) + c.Assert(err, IsNil) + c.Check(result.Keys, NotNil) +} + +// TestAPIGPGListKeysWithKeyringParam tests the HTTP endpoint with custom keyring parameter +func (s *GPGSuite) TestAPIGPGListKeysWithKeyringParam(c *C) { + c.Skip("Requires GPG binary and test key installation. Run manually if needed.") + + response, err := s.HTTPRequest("GET", "/api/gpg/keys?keyring=custom.gpg", nil) + c.Assert(err, IsNil) + // May fail if custom.gpg doesn't exist, but endpoint should handle gracefully + // Accept either 200 (success) or 400 (file not found) + code := response.Code + c.Check(code == 200 || code == 400, Equals, true) +} + +// TestAPIGPGListKeysResponseFormat tests that the response has the correct structure +func (s *GPGSuite) TestAPIGPGListKeysResponseFormat(c *C) { + c.Skip("Requires GPG binary and test key installation. Run manually if needed.") + + response, err := s.HTTPRequest("GET", "/api/gpg/keys", nil) + c.Assert(err, IsNil) + + if response.Code == 200 { + var result gpgKeyListResponse + err = json.NewDecoder(response.Body).Decode(&result) + c.Assert(err, IsNil) + + // If there are keys, verify their structure + for _, key := range result.Keys { + c.Check(key.KeyID, Not(Equals), "") + c.Check(key.Validity, Not(Equals), "") + c.Check(key.CreatedAt, Not(Equals), "") + } + } +} + +// TestParseGPGOutputEdgeCaseUIDWithoutFields tests UID record with missing fields +func (s *GPGSuite) TestParseGPGOutputEdgeCaseUIDWithoutFields(c *C) { + // UID record with fewer than 10 fields + output := `pub:u:4096:1:8B48AD6246925553:1611864000:1643400000:uidhash:::scESC:::::::23::0: +uid:u::::1611864000::1234567890: +fpr:::::::::D8E8F5A516E7A2C4F3E4B5A6C7D8E9F0:` + + keys := parseGPGOutput(output) + c.Check(keys, HasLen, 1) + // Should not have user ID since it's in field 9 and this record is too short + c.Check(keys[0].UserIDs, HasLen, 0) +} + +// TestParseGPGOutputFingerprintWithoutCurrentKey tests FPR record appearing before any PUB +func (s *GPGSuite) TestParseGPGOutputFingerprintWithoutCurrentKey(c *C) { + // FPR record without a preceding PUB (should be ignored) + output := `fpr:::::::::D8E8F5A516E7A2C4F3E4B5A6C7D8E9F0: +pub:u:4096:1:8B48AD6246925553:1611864000:1643400000:uidhash:::scESC:::::::23::0: +uid:u::::1611864000::1234567890::John Doe ::::::::::0: +fpr:::::::::D8E8F5A516E7A2C4F3E4B5A6C7D8E9F0:` + + keys := parseGPGOutput(output) + c.Check(keys, HasLen, 1) + // Should only have one key with the correct fingerprint + c.Check(keys[0].Fingerprint, Equals, "D8E8F5A516E7A2C4F3E4B5A6C7D8E9F0") +} + +// TestParseGPGOutputComplexRealWorldExample tests real-world-like GPG output +func (s *GPGSuite) TestParseGPGOutputComplexRealWorldExample(c *C) { + // Real-world GPG output with multiple keys, UIDs, and other record types (sig, sub) + // Note: sub and sig records are skipped as we only care about pub/uid/fpr + realWorldOutput := `tru::1:1611864000:0:3:1:5 +pub:u:4096:1:8B48AD6246925553:1611864000:2023-01-15T00:00:00:::::scESC:::::::23::0: +fpr:::::::::D8E8F5A516E7A2C4F3E4B5A6C7D8E9F0: +uid:u::::1611864000::1234567890::John Doe ::::::::::0: +uid:u::::1611864100::1234567891::John Doe ::::::::::0: +pub:f:2048:1:1234567890123456:1580592000:2022-12-31T00:00:00::u:::scESC:::::::23::0: +fpr:::::::::F4E3D2C1B0A9F8E7D6C5B4A3F2E1D0C9: +uid:f::::1580592000::0987654321::Maintainer Key ::::::::::0:` + + keys := parseGPGOutput(realWorldOutput) + c.Check(keys, HasLen, 2) + + // First key should have 2 UIDs + c.Check(keys[0].KeyID, Equals, "8B48AD6246925553") + c.Check(keys[0].UserIDs, HasLen, 2) + c.Check(keys[0].Fingerprint, Equals, "D8E8F5A516E7A2C4F3E4B5A6C7D8E9F0") + + // Second key should have 1 UID + c.Check(keys[1].KeyID, Equals, "1234567890123456") + c.Check(keys[1].UserIDs, HasLen, 1) + c.Check(keys[1].Fingerprint, Equals, "F4E3D2C1B0A9F8E7D6C5B4A3F2E1D0C9") +} + +// TestParseGPGOutputConsecutiveEmptyUIDs tests handling of consecutive empty user ID fields +func (s *GPGSuite) TestParseGPGOutputConsecutiveEmptyUIDs(c *C) { + output := `pub:u:4096:1:8B48AD6246925553:1611864000:1643400000:uidhash:::scESC:::::::23::0: +uid:u::::1611864000::1234567890:::::::::::0: +uid:u::::1611864000::1234567891::John Doe ::::::::::0: +fpr:::::::::D8E8F5A516E7A2C4F3E4B5A6C7D8E9F0:` + + keys := parseGPGOutput(output) + c.Check(keys, HasLen, 1) + // Should skip empty UID but include the non-empty one + c.Check(keys[0].UserIDs, HasLen, 1) + c.Check(keys[0].UserIDs[0], Equals, "John Doe ") +} + +// TestGPGDeleteKeyParamsValidation tests gpgDeleteKeyParams validation +func (s *GPGSuite) TestGPGDeleteKeyParamsValidation(c *C) { + // This is a unit test that validates parameter structure (no HTTP needed) + params := gpgDeleteKeyParams{ + Keyring: "custom.gpg", + GpgKeyID: "8B48AD6246925553", + } + c.Check(params.Keyring, Equals, "custom.gpg") + c.Check(params.GpgKeyID, Equals, "8B48AD6246925553") +} + +// TestAPIGPGDeleteKeyMissingKeyID tests delete with missing key ID parameter +func (s *GPGSuite) TestAPIGPGDeleteKeyMissingKeyID(c *C) { + c.Skip("Requires GPG binary. Run manually if needed.") + + body, err := json.Marshal(map[string]string{ + "Keyring": "trustedkeys.gpg", + // GpgKeyID is missing + }) + c.Assert(err, IsNil) + + response, err := s.HTTPRequest("DELETE", "/api/gpg/key", bytes.NewReader(body)) + c.Assert(err, IsNil) + c.Check(response.Code, Equals, 400) +} + +// TestAPIGPGDeleteKeyInvalidJSON tests delete with invalid JSON request +func (s *GPGSuite) TestAPIGPGDeleteKeyInvalidJSON(c *C) { + c.Skip("Requires GPG binary. Run manually if needed.") + + response, err := s.HTTPRequest("DELETE", "/api/gpg/key", bytes.NewReader([]byte("invalid json"))) + c.Assert(err, IsNil) + c.Check(response.Code, Equals, 400) +} + +// TestAPIGPGDeleteKeySuccess tests successful key deletion +func (s *GPGSuite) TestAPIGPGDeleteKeySuccess(c *C) { + c.Skip("Requires GPG binary and test key installation. Run manually if needed.") + + body, err := json.Marshal(gpgDeleteKeyParams{ + Keyring: "trustedkeys.gpg", + GpgKeyID: "8B48AD6246925553", + }) + c.Assert(err, IsNil) + + response, err := s.HTTPRequest("DELETE", "/api/gpg/key", bytes.NewReader(body)) + c.Assert(err, IsNil) + // Should succeed (200) or fail gracefully (400) if key doesn't exist + code := response.Code + c.Check(code == 200 || code == 400, Equals, true) +} diff --git a/api/router.go b/api/router.go index db9c517e..5f7c4940 100644 --- a/api/router.go +++ b/api/router.go @@ -164,7 +164,9 @@ func Router(c *ctx.AptlyContext) http.Handler { } { + api.GET("/gpg/keys", apiGPGListKeys) api.POST("/gpg/key", apiGPGAddKey) + api.DELETE("/gpg/key", apiGPGDeleteKey) } { From 3b432d42b550e0af18cf84e9ccad9b28ed53dfa8 Mon Sep 17 00:00:00 2001 From: Pierig Le Saux Date: Wed, 8 Apr 2026 20:27:31 -0400 Subject: [PATCH 2/6] documentation --- docs/GPG.md | 209 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 docs/GPG.md diff --git a/docs/GPG.md b/docs/GPG.md new file mode 100644 index 00000000..f786c679 --- /dev/null +++ b/docs/GPG.md @@ -0,0 +1,209 @@ +# GPG Keys Management + +GPG keys are used by aptly to verify the authenticity of remote repository Release files when creating mirrors. This document describes the API endpoints for managing GPG keys in the aptly keyring. + +## Overview + +Aptly uses GNU Privacy Guard (GPG) to verify signed repository metadata. You must add the repository's GPG public key to aptly's keyring before creating mirrors that verify signatures. + +Keys are stored in the aptly keyring (default: `trustedkeys.gpg`). You can have multiple keyrings and specify which one to use via the `Keyring` parameter. + +## API Endpoints + +### List GPG Keys + +**GET /api/gpg/keys** + +Lists all public GPG keys currently installed in the aptly keyring. + +**Parameters:** +- `keyring` (query, optional): Keyring file to list keys from. Default: `trustedkeys.gpg` + +**Response:** +```json +{ + "Keys": [ + { + "KeyID": "8B48AD6246925553", + "Fingerprint": "D8E8F5A516E7A2C4F3E4B5A6C7D8E9F0", + "Validity": "f", + "UserIDs": ["John Doe "], + "CreatedAt": "1611864000" + } + ] +} +``` + +**Status Codes:** +- `200 OK`: Keys successfully retrieved +- `400 Bad Request`: GPG execution failed or invalid parameters + +**Example:** +```bash +curl http://localhost:8080/api/gpg/keys +curl "http://localhost:8080/api/gpg/keys?keyring=custom.gpg" +``` + +--- + +### Add GPG Key + +**POST /api/gpg/key** + +Adds a GPG public key to the aptly keyring. Keys can be added in two ways: +1. Provide the ASCII-armored key directly +2. Provide a key server and key ID(s) to download from + +**Request Body:** +```json +{ + "Keyring": "trustedkeys.gpg", + "GpgKeyArmor": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...", + "Keyserver": "hkp://keyserver.ubuntu.com:80", + "GpgKeyID": "8B48AD6246925553" +} +``` + +**Parameters:** +- `Keyring` (optional): Keyring file to add keys to. Default: `trustedkeys.gpg` +- `GpgKeyArmor` (optional): ASCII-armored GPG public key +- `Keyserver` (optional): Keyserver URL (e.g., `hkp://keyserver.ubuntu.com:80`) +- `GpgKeyID` (optional): Space-separated key IDs to download from keyserver + +**Status Codes:** +- `200 OK`: Key successfully added +- `400 Bad Request`: Invalid parameters or GPG execution failed + +**Example - From ASCII Key:** +```bash +curl -X POST http://localhost:8080/api/gpg/key \ + -H "Content-Type: application/json" \ + -d '{ + "GpgKeyArmor": "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: GnuPG v2\n...\n-----END PGP PUBLIC KEY BLOCK-----" + }' +``` + +**Example - From Keyserver:** +```bash +curl -X POST http://localhost:8080/api/gpg/key \ + -H "Content-Type: application/json" \ + -d '{ + "Keyserver": "hkp://keyserver.ubuntu.com:80", + "GpgKeyID": "8B48AD6246925553 A1B2C3D4E5F67890" + }' +``` + +--- + +### Delete GPG Key + +**DELETE /api/gpg/key** + +Removes a GPG key from the aptly keyring. + +**Request Body:** +```json +{ + "Keyring": "trustedkeys.gpg", + "GpgKeyID": "8B48AD6246925553" +} +``` + +**Parameters:** +- `Keyring` (optional): Keyring file to delete from. Default: `trustedkeys.gpg` +- `GpgKeyID` (required): Key ID or fingerprint to delete + +**Status Codes:** +- `200 OK`: Key successfully deleted +- `400 Bad Request`: Invalid parameters or GPG execution failed + +**Example:** +```bash +curl -X DELETE http://localhost:8080/api/gpg/key \ + -H "Content-Type: application/json" \ + -d '{ + "GpgKeyID": "8B48AD6246925553" + }' +``` + +--- + +## Use Cases + +### 1. Verify Downloaded Repository Metadata + +Before creating a mirror from a signed repository, add the repository's GPG key: + +```bash +# Add the key from a keyserver +curl -X POST http://localhost:8080/api/gpg/key \ + -H "Content-Type: application/json" \ + -d '{ + "Keyserver": "hkp://keyserver.ubuntu.com:80", + "GpgKeyID": "EB9B46B91F2D3B7E" + }' + +# Now create a mirror with signature verification +# (signature verification configured in mirror settings) +``` + +### 2. Manage Multiple Keyrings + +Aptly supports using different keyrings for different purposes. For example, one for Debian repositories and another for custom internal repositories: + +```bash +# Add key to Debian keyring +curl -X POST http://localhost:8080/api/gpg/key \ + -H "Content-Type: application/json" \ + -d '{ + "Keyring": "debian-keys.gpg", + "Keyserver": "hkp://keyserver.ubuntu.com:80", + "GpgKeyID": "EB9B46B91F2D3B7E" + }' + +# List keys in Debian keyring +curl "http://localhost:8080/api/gpg/keys?keyring=debian-keys.gpg" +``` + +### 3. Remove Compromised Keys + +If a GPG key is compromised, remove it from the keyring immediately: + +```bash +curl -X DELETE http://localhost:8080/api/gpg/key \ + -H "Content-Type: application/json" \ + -d '{ + "GpgKeyID": "COMPROMISED_KEY_ID" + }' +``` + +--- + +## Key Validity Values + +Keys retrieved from `GET /api/gpg/keys` have a `Validity` field with the following possible values: + +- `u` — Unknown validity +- `f` — Full trust +- `m` — Marginal trust +- `n` — Never trust +- `-` — Trust not set + +The trust level is typically managed in your GPG configuration and does not affect aptly's ability to verify signatures. + +--- + +## Troubleshooting + +**"failed to list keys"** +- Check that the keyring file exists and is readable +- Verify GPG is installed and configured + +**"unable to delete key: no public key"** +- The key might not exist in the keyring +- Verify the key ID is correct by listing keys first + +**"invalid request body"** +- Ensure the JSON is properly formatted +- For POST requests, provide either `GpgKeyArmor` or (`Keyserver` + `GpgKeyID`) +- For DELETE requests, `GpgKeyID` is required From 1ed50697ec86b5d6bf65debe4fa6324f1ecc7bcb Mon Sep 17 00:00:00 2001 From: Pierig Le Saux Date: Wed, 8 Apr 2026 21:46:19 -0400 Subject: [PATCH 3/6] fix: delete is interactive --- api/gpg.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/gpg.go b/api/gpg.go index 91e09b36..3d4026aa 100644 --- a/api/gpg.go +++ b/api/gpg.go @@ -218,6 +218,8 @@ func apiGPGDeleteKey(c *gin.Context) { args := []string{ "--no-default-keyring", "--allow-non-selfsigned-uid", + "--batch", + "--yes", } keyring := "trustedkeys.gpg" From 3c8defa304c13f0d38c0b2d76c7b6c74fb172e40 Mon Sep 17 00:00:00 2001 From: Pierig Le Saux Date: Wed, 8 Apr 2026 22:32:11 -0400 Subject: [PATCH 4/6] update --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index f2333751..cf25ae7d 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,8 @@ _testmain.go *.test coverage.txt +coverage.out +coverage.html *.pyc From 5655480e00b545f4fa6f957e7cbe1e3412619736 Mon Sep 17 00:00:00 2001 From: Pierig Le Saux Date: Wed, 8 Apr 2026 22:36:00 -0400 Subject: [PATCH 5/6] add codecoverage --- api/gpg_test.go | 201 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 156 insertions(+), 45 deletions(-) diff --git a/api/gpg_test.go b/api/gpg_test.go index aad15025..40b15c7c 100644 --- a/api/gpg_test.go +++ b/api/gpg_test.go @@ -3,6 +3,9 @@ package api import ( "bytes" "encoding/json" + "os" + "path/filepath" + "strings" . "gopkg.in/check.v1" ) @@ -13,6 +16,46 @@ type GPGSuite struct { var _ = Suite(&GPGSuite{}) +func (s *GPGSuite) withFakeGPG(c *C, scriptBody string, test func(scriptPath string)) { + tempDir, err := os.MkdirTemp("", "aptly-fake-gpg") + c.Assert(err, IsNil) + defer func() { _ = os.RemoveAll(tempDir) }() + + scriptPath := filepath.Join(tempDir, "gpg") + err = os.WriteFile(scriptPath, []byte(scriptBody), 0o755) + c.Assert(err, IsNil) + + oldPath := os.Getenv("PATH") + err = os.Setenv("PATH", tempDir+string(os.PathListSeparator)+oldPath) + c.Assert(err, IsNil) + defer func() { _ = os.Setenv("PATH", oldPath) }() + + test(scriptPath) +} + +func (s *GPGSuite) fakeGPGScript(c *C, listOutput string, deleteOutput string, deleteError string) string { + return "#!/bin/sh\n" + + "if [ \"$1\" = \"--version\" ]; then\n" + + " echo 'gpg (GnuPG) 2.2.27'\n" + + " exit 0\n" + + "fi\n" + + "args=\"$*\"\n" + + "if printf '%s' \"$args\" | grep -q -- '--list-keys'; then\n" + + " cat <<'EOF'\n" + listOutput + "\nEOF\n" + + " exit 0\n" + + "fi\n" + + "if printf '%s' \"$args\" | grep -q -- '--delete-keys'; then\n" + + " if [ -n \"" + strings.ReplaceAll(deleteError, "\n", "") + "\" ]; then\n" + + " echo '" + strings.ReplaceAll(deleteError, "'", "'\\''") + "'\n" + + " exit 1\n" + + " fi\n" + + " cat <<'EOF'\n" + deleteOutput + "\nEOF\n" + + " exit 0\n" + + "fi\n" + + "echo 'unexpected invocation' >&2\n" + + "exit 1\n" +} + // TestParseGPGOutputEmpty tests parsing of empty GPG output func (s *GPGSuite) TestParseGPGOutputEmpty(c *C) { output := "" @@ -173,50 +216,68 @@ fpr:::::::::D8E8F5A516E7A2C4F3E4B5A6C7D8E9F0:` // TestAPIGPGListKeysDefaultKeyring tests the HTTP endpoint with default keyring func (s *GPGSuite) TestAPIGPGListKeysDefaultKeyring(c *C) { - c.Skip("Requires GPG binary and test key installation. Run manually if needed.") + s.withFakeGPG(c, s.fakeGPGScript(c, `pub:u:4096:1:8B48AD6246925553:1611864000::::: +uid:u::::1611864000::1234567890::John Doe ::::::::::0: +fpr:::::::::D8E8F5A516E7A2C4F3E4B5A6C7D8E9F0:`, "", ""), func(_ string) { + response, err := s.HTTPRequest("GET", "/api/gpg/keys", nil) + c.Assert(err, IsNil) + c.Check(response.Code, Equals, 200) - response, err := s.HTTPRequest("GET", "/api/gpg/keys", nil) - c.Assert(err, IsNil) - c.Check(response.Code, Equals, 200) - - // Verify response is valid JSON - var result gpgKeyListResponse - err = json.NewDecoder(response.Body).Decode(&result) - c.Assert(err, IsNil) - c.Check(result.Keys, NotNil) + var result gpgKeyListResponse + err = json.NewDecoder(response.Body).Decode(&result) + c.Assert(err, IsNil) + c.Check(result.Keys, HasLen, 1) + c.Check(result.Keys[0].KeyID, Equals, "8B48AD6246925553") + }) } // TestAPIGPGListKeysWithKeyringParam tests the HTTP endpoint with custom keyring parameter func (s *GPGSuite) TestAPIGPGListKeysWithKeyringParam(c *C) { - c.Skip("Requires GPG binary and test key installation. Run manually if needed.") - - response, err := s.HTTPRequest("GET", "/api/gpg/keys?keyring=custom.gpg", nil) + argFile, err := os.CreateTemp("", "aptly-gpg-args") c.Assert(err, IsNil) - // May fail if custom.gpg doesn't exist, but endpoint should handle gracefully - // Accept either 200 (success) or 400 (file not found) - code := response.Code - c.Check(code == 200 || code == 400, Equals, true) + _ = argFile.Close() + defer func() { _ = os.Remove(argFile.Name()) }() + + script := "#!/bin/sh\n" + + "if [ \"$1\" = \"--version\" ]; then echo 'gpg (GnuPG) 2.2.27'; exit 0; fi\n" + + "printf '%s\n' \"$@\" > '" + argFile.Name() + "'\n" + + "if printf '%s' \"$*\" | grep -q -- '--list-keys'; then\n" + + "cat <<'EOF'\n" + + "pub:u:4096:1:8B48AD6246925553:1611864000:::::\n" + + "fpr:::::::::D8E8F5A516E7A2C4F3E4B5A6C7D8E9F0:\n" + + "EOF\n" + + "exit 0\n" + + "fi\n" + + "exit 1\n" + + s.withFakeGPG(c, script, func(_ string) { + response, reqErr := s.HTTPRequest("GET", "/api/gpg/keys?keyring=/custom.gpg", nil) + c.Assert(reqErr, IsNil) + c.Check(response.Code, Equals, 200) + + argBytes, readErr := os.ReadFile(argFile.Name()) + c.Assert(readErr, IsNil) + c.Check(string(argBytes), Matches, `(?s).*--keyring\ncustom\.gpg\n.*`) + }) } // TestAPIGPGListKeysResponseFormat tests that the response has the correct structure func (s *GPGSuite) TestAPIGPGListKeysResponseFormat(c *C) { - c.Skip("Requires GPG binary and test key installation. Run manually if needed.") + s.withFakeGPG(c, s.fakeGPGScript(c, `pub:f:4096:1:A1B2C3D4E5F67890:1611864000::::: +uid:f::::1611864000::1234567890::Jane Smith ::::::::::0: +fpr:::::::::E9F0A1B2C3D4E5F6A7B8C9D0E1F2A3B4:`, "", ""), func(_ string) { + response, err := s.HTTPRequest("GET", "/api/gpg/keys", nil) + c.Assert(err, IsNil) + c.Check(response.Code, Equals, 200) - response, err := s.HTTPRequest("GET", "/api/gpg/keys", nil) - c.Assert(err, IsNil) - - if response.Code == 200 { var result gpgKeyListResponse err = json.NewDecoder(response.Body).Decode(&result) c.Assert(err, IsNil) - - // If there are keys, verify their structure - for _, key := range result.Keys { - c.Check(key.KeyID, Not(Equals), "") - c.Check(key.Validity, Not(Equals), "") - c.Check(key.CreatedAt, Not(Equals), "") - } - } + c.Assert(result.Keys, HasLen, 1) + c.Check(result.Keys[0].KeyID, Equals, "A1B2C3D4E5F67890") + c.Check(result.Keys[0].Validity, Equals, "f") + c.Check(result.Keys[0].CreatedAt, Equals, "1611864000") + }) } // TestParseGPGOutputEdgeCaseUIDWithoutFields tests UID record with missing fields @@ -300,8 +361,6 @@ func (s *GPGSuite) TestGPGDeleteKeyParamsValidation(c *C) { // TestAPIGPGDeleteKeyMissingKeyID tests delete with missing key ID parameter func (s *GPGSuite) TestAPIGPGDeleteKeyMissingKeyID(c *C) { - c.Skip("Requires GPG binary. Run manually if needed.") - body, err := json.Marshal(map[string]string{ "Keyring": "trustedkeys.gpg", // GpgKeyID is missing @@ -315,8 +374,6 @@ func (s *GPGSuite) TestAPIGPGDeleteKeyMissingKeyID(c *C) { // TestAPIGPGDeleteKeyInvalidJSON tests delete with invalid JSON request func (s *GPGSuite) TestAPIGPGDeleteKeyInvalidJSON(c *C) { - c.Skip("Requires GPG binary. Run manually if needed.") - response, err := s.HTTPRequest("DELETE", "/api/gpg/key", bytes.NewReader([]byte("invalid json"))) c.Assert(err, IsNil) c.Check(response.Code, Equals, 400) @@ -324,17 +381,71 @@ func (s *GPGSuite) TestAPIGPGDeleteKeyInvalidJSON(c *C) { // TestAPIGPGDeleteKeySuccess tests successful key deletion func (s *GPGSuite) TestAPIGPGDeleteKeySuccess(c *C) { - c.Skip("Requires GPG binary and test key installation. Run manually if needed.") + argFile, err := os.CreateTemp("", "aptly-gpg-delete-args") + c.Assert(err, IsNil) + _ = argFile.Close() + defer func() { _ = os.Remove(argFile.Name()) }() - body, err := json.Marshal(gpgDeleteKeyParams{ - Keyring: "trustedkeys.gpg", - GpgKeyID: "8B48AD6246925553", + script := "#!/bin/sh\n" + + "if [ \"$1\" = \"--version\" ]; then echo 'gpg (GnuPG) 2.2.27'; exit 0; fi\n" + + "printf '%s\n' \"$@\" > '" + argFile.Name() + "'\n" + + "if printf '%s' \"$*\" | grep -q -- '--delete-keys'; then\n" + + "echo 'deleted'\n" + + "exit 0\n" + + "fi\n" + + "exit 1\n" + + s.withFakeGPG(c, script, func(_ string) { + body, marshalErr := json.Marshal(gpgDeleteKeyParams{ + Keyring: "/trustedkeys.gpg", + GpgKeyID: "8B48AD6246925553", + }) + c.Assert(marshalErr, IsNil) + + response, reqErr := s.HTTPRequest("DELETE", "/api/gpg/key", bytes.NewReader(body)) + c.Assert(reqErr, IsNil) + c.Check(response.Code, Equals, 200) + c.Check(response.Body.String(), Matches, `"deleted\\n"`) + + argBytes, readErr := os.ReadFile(argFile.Name()) + c.Assert(readErr, IsNil) + argText := string(argBytes) + c.Check(argText, Matches, `(?s).*--batch\n--yes\n.*`) + c.Check(argText, Matches, `(?s).*--keyring\n/trustedkeys\.gpg\n.*`) + c.Check(argText, Matches, `(?s).*--delete-keys\n8B48AD6246925553\n.*`) + }) +} + +// TestAPIGPGListKeysCommandFailure tests list error propagation from gpg +func (s *GPGSuite) TestAPIGPGListKeysCommandFailure(c *C) { + script := "#!/bin/sh\n" + + "if [ \"$1\" = \"--version\" ]; then echo 'gpg (GnuPG) 2.2.27'; exit 0; fi\n" + + "if printf '%s' \"$*\" | grep -q -- '--list-keys'; then\n" + + "echo 'keyring missing'\n" + + "exit 1\n" + + "fi\n" + + "exit 1\n" + + s.withFakeGPG(c, script, func(_ string) { + response, err := s.HTTPRequest("GET", "/api/gpg/keys", nil) + c.Assert(err, IsNil) + c.Check(response.Code, Equals, 400) + c.Check(response.Body.String(), Matches, `(?s).*failed to list keys: keyring missing.*`) + }) +} + +// TestAPIGPGDeleteKeyCommandFailure tests delete error propagation from gpg +func (s *GPGSuite) TestAPIGPGDeleteKeyCommandFailure(c *C) { + s.withFakeGPG(c, s.fakeGPGScript(c, "", "", "delete failed"), func(_ string) { + body, err := json.Marshal(gpgDeleteKeyParams{ + Keyring: "trustedkeys.gpg", + GpgKeyID: "8B48AD6246925553", + }) + c.Assert(err, IsNil) + + response, reqErr := s.HTTPRequest("DELETE", "/api/gpg/key", bytes.NewReader(body)) + c.Assert(reqErr, IsNil) + c.Check(response.Code, Equals, 400) + c.Check(response.Body.String(), Matches, `(?s).*failed to delete key: delete failed.*`) }) - c.Assert(err, IsNil) - - response, err := s.HTTPRequest("DELETE", "/api/gpg/key", bytes.NewReader(body)) - c.Assert(err, IsNil) - // Should succeed (200) or fail gracefully (400) if key doesn't exist - code := response.Code - c.Check(code == 200 || code == 400, Equals, true) } From 8be72b48a1f42196936d3b61ece520be31a8bb77 Mon Sep 17 00:00:00 2001 From: Pierig Le Saux Date: Wed, 8 Apr 2026 22:38:34 -0400 Subject: [PATCH 6/6] update --- api/gpg.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/api/gpg.go b/api/gpg.go index 3d4026aa..596d28ae 100644 --- a/api/gpg.go +++ b/api/gpg.go @@ -13,11 +13,6 @@ import ( "github.com/gin-gonic/gin" ) -type gpgListKeysParams struct { - // Keyring to list keys from (default: trustedkeys.gpg) - Keyring string `json:"Keyring" example:"trustedkeys.gpg"` -} - type gpgKeyInfo struct { // 16-character key ID (short form) KeyID string `json:"KeyID" example:"8B48AD6246925553"`