From 89e3bdfa07dcd28ff0e9ced6eeb2a97899e36d80 Mon Sep 17 00:00:00 2001 From: Pierig Le Saux Date: Wed, 8 Apr 2026 20:21:56 -0400 Subject: [PATCH] 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) } {