mirror of https://github.com/grafana/grafana
Chore: Use Grafana API to retrieve the public key to validate plugins (#66439)
parent
f612a72f96
commit
98c695c68f
@ -0,0 +1,171 @@ |
||||
package manifestverifier |
||||
|
||||
import ( |
||||
"bytes" |
||||
"encoding/json" |
||||
"errors" |
||||
"fmt" |
||||
"net" |
||||
"net/http" |
||||
"net/url" |
||||
"sync" |
||||
"time" |
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp" |
||||
"github.com/ProtonMail/go-crypto/openpgp/clearsign" |
||||
"github.com/ProtonMail/go-crypto/openpgp/packet" |
||||
"github.com/grafana/grafana/pkg/plugins/config" |
||||
"github.com/grafana/grafana/pkg/plugins/log" |
||||
) |
||||
|
||||
// ManifestKeys is the database representation of public keys
|
||||
// used to verify plugin manifests.
|
||||
type ManifestKeys struct { |
||||
KeyID string `json:"keyId"` |
||||
PublicKey string `json:"public"` |
||||
Since int64 `json:"since"` |
||||
} |
||||
|
||||
type ManifestVerifier struct { |
||||
cfg *config.Cfg |
||||
mlog log.Logger |
||||
|
||||
lock sync.Mutex |
||||
cli http.Client |
||||
publicKeys map[string]ManifestKeys |
||||
} |
||||
|
||||
func New(cfg *config.Cfg, mlog log.Logger) *ManifestVerifier { |
||||
return &ManifestVerifier{ |
||||
cfg: cfg, |
||||
publicKeys: map[string]ManifestKeys{}, |
||||
mlog: mlog, |
||||
cli: makeHttpClient(), |
||||
} |
||||
} |
||||
|
||||
const publicKeyText = `-----BEGIN PGP PUBLIC KEY BLOCK----- |
||||
Version: OpenPGP.js v4.10.1 |
||||
Comment: https://openpgpjs.org
|
||||
|
||||
xpMEXpTXXxMFK4EEACMEIwQBiOUQhvGbDLvndE0fEXaR0908wXzPGFpf0P0Z |
||||
HJ06tsq+0higIYHp7WTNJVEZtcwoYLcPRGaa9OQqbUU63BEyZdgAkPTz3RFd |
||||
5+TkDWZizDcaVFhzbDd500yTwexrpIrdInwC/jrgs7Zy/15h8KA59XXUkdmT |
||||
YB6TR+OA9RKME+dCJozNGUdyYWZhbmEgPGVuZ0BncmFmYW5hLmNvbT7CvAQQ |
||||
EwoAIAUCXpTXXwYLCQcIAwIEFQgKAgQWAgEAAhkBAhsDAh4BAAoJEH5NDGpw |
||||
iGbnaWoCCQGQ3SQnCkRWrG6XrMkXOKfDTX2ow9fuoErN46BeKmLM4f1EkDZQ |
||||
Tpq3SE8+My8B5BIH3SOcBeKzi3S57JHGBdFA+wIJAYWMrJNIvw8GeXne+oUo |
||||
NzzACdvfqXAZEp/HFMQhCKfEoWGJE8d2YmwY2+3GufVRTI5lQnZOHLE8L/Vc |
||||
1S5MXESjzpcEXpTXXxIFK4EEACMEIwQBtHX/SD5Qm3v4V92qpaIZQgtTX0sT |
||||
cFPjYWAHqsQ1iENrYN/vg1wU3ADlYATvydOQYvkTyT/tbDvx2Fse8PL84MQA |
||||
YKKQ6AJ3gLVvmeouZdU03YoV4MYaT8KbnJUkZQZkqdz2riOlySNI9CG3oYmv |
||||
omjUAtzgAgnCcurfGLZkkMxlmY8DAQoJwqQEGBMKAAkFAl6U118CGwwACgkQ |
||||
fk0ManCIZuc0jAIJAVw2xdLr4ZQqPUhubrUyFcqlWoW8dQoQagwO8s8ubmby |
||||
KuLA9FWJkfuuRQr+O9gHkDVCez3aism7zmJBqIOi38aNAgjJ3bo6leSS2jR/ |
||||
x5NqiKVi83tiXDPncDQYPymOnMhW0l7CVA7wj75HrFvvlRI/4MArlbsZ2tBn |
||||
N1c5v9v/4h6qeA== |
||||
=DNbR |
||||
-----END PGP PUBLIC KEY BLOCK----- |
||||
` |
||||
|
||||
// getPublicKey loads public keys from:
|
||||
// - The hard-coded value if the feature flag is not enabled.
|
||||
// - A cached value from memory if it has been already retrieved.
|
||||
// - The Grafana.com API if the database is empty.
|
||||
func (pmv *ManifestVerifier) GetPublicKey(keyID string) (string, error) { |
||||
if pmv.cfg == nil || pmv.cfg.Features == nil || !pmv.cfg.Features.IsEnabled("pluginsAPIManifestKey") { |
||||
return publicKeyText, nil |
||||
} |
||||
|
||||
pmv.lock.Lock() |
||||
defer pmv.lock.Unlock() |
||||
|
||||
key, exist := pmv.publicKeys[keyID] |
||||
if exist { |
||||
return key.PublicKey, nil |
||||
} |
||||
|
||||
// Retrieve the key from the API and store it in the database
|
||||
var data struct { |
||||
Items []ManifestKeys |
||||
} |
||||
|
||||
url, err := url.JoinPath(pmv.cfg.GrafanaComURL, "/api/plugins/ci/keys") // nolint:gosec URL is provided by config
|
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
req, err := http.NewRequest(http.MethodGet, url, nil) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
resp, err := pmv.cli.Do(req) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
defer func() { |
||||
err := resp.Body.Close() |
||||
if err != nil { |
||||
pmv.mlog.Warn("error closing response body", "error", err) |
||||
} |
||||
}() |
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
if len(data.Items) == 0 { |
||||
return "", errors.New("missing public key") |
||||
} |
||||
|
||||
for _, key := range data.Items { |
||||
pmv.publicKeys[key.KeyID] = key |
||||
} |
||||
|
||||
key, exist = pmv.publicKeys[keyID] |
||||
if exist { |
||||
return key.PublicKey, nil |
||||
} |
||||
|
||||
return "", fmt.Errorf("missing public key for %s", keyID) |
||||
} |
||||
|
||||
func (pmv *ManifestVerifier) Verify(keyID string, block *clearsign.Block) error { |
||||
publicKey, err := pmv.GetPublicKey(keyID) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewBufferString(publicKey)) |
||||
if err != nil { |
||||
return fmt.Errorf("%v: %w", "failed to parse public key", err) |
||||
} |
||||
|
||||
if _, err = openpgp.CheckDetachedSignature(keyring, |
||||
bytes.NewBuffer(block.Bytes), |
||||
block.ArmoredSignature.Body, &packet.Config{}); err != nil { |
||||
return fmt.Errorf("%v: %w", "failed to check signature", err) |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// Same configuration as pkg/plugins/repo/client.go
|
||||
func makeHttpClient() http.Client { |
||||
tr := &http.Transport{ |
||||
Proxy: http.ProxyFromEnvironment, |
||||
DialContext: (&net.Dialer{ |
||||
Timeout: 30 * time.Second, |
||||
KeepAlive: 30 * time.Second, |
||||
}).DialContext, |
||||
MaxIdleConns: 100, |
||||
IdleConnTimeout: 90 * time.Second, |
||||
TLSHandshakeTimeout: 10 * time.Second, |
||||
ExpectContinueTimeout: 1 * time.Second, |
||||
} |
||||
|
||||
return http.Client{ |
||||
Timeout: 10 * time.Second, |
||||
Transport: tr, |
||||
} |
||||
} |
||||
@ -0,0 +1,74 @@ |
||||
package manifestverifier |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"net/http" |
||||
"net/http/httptest" |
||||
"os" |
||||
"testing" |
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp/clearsign" |
||||
"github.com/grafana/grafana/pkg/plugins/config" |
||||
"github.com/grafana/grafana/pkg/plugins/log" |
||||
"github.com/grafana/grafana/pkg/services/featuremgmt" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func Test_Verify(t *testing.T) { |
||||
t.Run("it should verify a manifest with the default key", func(t *testing.T) { |
||||
v := New(&config.Cfg{}, log.New("test")) |
||||
|
||||
body, err := os.ReadFile("../../testdata/test-app/MANIFEST.txt") |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
|
||||
block, _ := clearsign.Decode(body) |
||||
if block == nil { |
||||
t.Fatal("failed to decode") |
||||
} |
||||
|
||||
err = v.Verify("7e4d0c6a708866e7", block) |
||||
require.NoError(t, err) |
||||
}) |
||||
|
||||
t.Run("it should verify a manifest with the API key", func(t *testing.T) { |
||||
cfg := &config.Cfg{ |
||||
Features: featuremgmt.WithFeatures([]interface{}{"pluginsAPIManifestKey"}...), |
||||
} |
||||
v := New(cfg, log.New("test")) |
||||
apiCalled := false |
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
||||
if r.URL.Path == "/api/plugins/ci/keys" { |
||||
w.WriteHeader(http.StatusOK) |
||||
data := struct { |
||||
Items []ManifestKeys `json:"items"` |
||||
}{ |
||||
Items: []ManifestKeys{{PublicKey: publicKeyText, KeyID: "7e4d0c6a708866e7"}}, |
||||
} |
||||
b, err := json.Marshal(data) |
||||
require.NoError(t, err) |
||||
_, err = w.Write(b) |
||||
require.NoError(t, err) |
||||
apiCalled = true |
||||
return |
||||
} |
||||
w.WriteHeader(http.StatusNotFound) |
||||
})) |
||||
cfg.GrafanaComURL = s.URL |
||||
|
||||
body, err := os.ReadFile("../../testdata/test-app/MANIFEST.txt") |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
|
||||
block, _ := clearsign.Decode(body) |
||||
if block == nil { |
||||
t.Fatal("failed to decode") |
||||
} |
||||
|
||||
err = v.Verify("7e4d0c6a708866e7", block) |
||||
require.NoError(t, err) |
||||
require.Equal(t, true, apiCalled) |
||||
}) |
||||
} |
||||
|
Loading…
Reference in new issue