source/http: add PGP verification and shared pgpsign

Add detached PGP verification for HTTP sources during metadata resolution
and expose LLB options/caps/attrs for signature validation.

Extract shared OpenPGP verification/parsing logic into util/pgpsign and
reuse it from git signing, plus add integration and source-level tests.

Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
This commit is contained in:
Tonis Tiigi committed 2026-02-20 08:13:38 -08:00
1 parent 7f2cbc61ba
commit e3fba18b63
11 files changed
+660 -301

No files matched your search

+88
View File
@@ -120,6 +120,7 @@ var allTests = []func(t *testing.T, sb integration.Sandbox){
testBuildHTTPSourceAuthHeaderSecret,
testBuildHTTPSourceHostTokenSecret,
testBuildHTTPSourceHeader,
testBuildHTTPSourcePGPSignatureVerify,
testBuildPushAndValidate,
testBuildExportWithUncompressed,
testBuildExportScratch,
@@ -3616,6 +3617,93 @@ func testBuildHTTPSourceHeader(t *testing.T, sb integration.Sandbox) {
require.Equal(t, "fooagent", allReqs[0].Header.Get("user-agent"))
}
func testBuildHTTPSourcePGPSignatureVerify(t *testing.T, sb integration.Sandbox) {
c, err := New(sb.Context(), sb.Address())
require.NoError(t, err)
defer c.Close()
signFixturesPath, ok := os.LookupEnv("BUILDKIT_TEST_SIGN_FIXTURES")
if !ok {
t.Skip("missing BUILDKIT_TEST_SIGN_FIXTURES")
}
payload, err := os.ReadFile(filepath.Join(signFixturesPath, "user1.http.artifact"))
require.NoError(t, err)
sigData, err := os.ReadFile(filepath.Join(signFixturesPath, "user1.http.artifact.asc"))
require.NoError(t, err)
pubKeyData, err := os.ReadFile(filepath.Join(signFixturesPath, "user1.gpg.pub"))
require.NoError(t, err)
wrongPubKeyData, err := os.ReadFile(filepath.Join(signFixturesPath, "user2.gpg.pub"))
require.NoError(t, err)
httpSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/artifact.txt" {
http.NotFound(w, r)
return
}
_, _ = w.Write(payload)
}))
defer httpSrv.Close()
solve := func(t *testing.T, state llb.State) error {
def, err := state.Marshal(sb.Context())
require.NoError(t, err)
tmpdir := t.TempDir()
_, err = c.Solve(sb.Context(), def, SolveOpt{
Exports: []ExportEntry{
{
Type: ExporterLocal,
OutputDir: tmpdir,
},
},
}, nil)
if err != nil {
return err
}
dt, err := os.ReadFile(filepath.Join(tmpdir, "artifact.txt"))
require.NoError(t, err)
require.Equal(t, payload, dt)
return nil
}
t.Run("valid-signature", func(t *testing.T) {
validState := llb.HTTP(
httpSrv.URL+"/artifact.txt",
llb.VerifyPGPSignature(llb.HTTPSignatureInfo{
PubKey: pubKeyData,
Signature: sigData,
}),
)
require.NoError(t, solve(t, validState))
})
t.Run("wrong-pubkey", func(t *testing.T) {
invalidState := llb.HTTP(
httpSrv.URL+"/artifact.txt",
llb.VerifyPGPSignature(llb.HTTPSignatureInfo{
PubKey: wrongPubKeyData,
Signature: sigData,
}),
)
err = solve(t, invalidState)
require.Error(t, err)
require.ErrorContains(t, err, "failed to verify pgp signature")
})
t.Run("concatenated-pubkeys-right-key-second", func(t *testing.T) {
mergedPubKeys := append(append([]byte{}, wrongPubKeyData...), '\n')
mergedPubKeys = append(mergedPubKeys, pubKeyData...)
state := llb.HTTP(
httpSrv.URL+"/artifact.txt",
llb.VerifyPGPSignature(llb.HTTPSignatureInfo{
PubKey: mergedPubKeys,
Signature: sigData,
}),
)
require.NoError(t, solve(t, state))
})
}
func testResolveAndHosts(t *testing.T, sb integration.Sandbox) {
requiresLinux(t)
c, err := New(sb.Context(), sb.Address())
+31
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"os"
"path"
"slices"
"strconv"
"strings"
@@ -705,12 +706,30 @@ func HTTP(url string, opts ...HTTPOption) State {
hi.Header.setAttrs(attrs)
addCap(&hi.Constraints, pb.CapSourceHTTPHeader)
}
if hi.Signature != nil {
if len(hi.Signature.PubKey) > 0 {
attrs[pb.AttrHTTPSignatureVerifyPubKey] = string(hi.Signature.PubKey)
}
if len(hi.Signature.Signature) > 0 {
attrs[pb.AttrHTTPSignatureVerify] = string(hi.Signature.Signature)
}
addCap(&hi.Constraints, pb.CapSourceHTTPSignatureVerify)
}
addCap(&hi.Constraints, pb.CapSourceHTTP)
source := NewSource(url, attrs, hi.Constraints)
return NewState(source.Output())
}
// HTTPSignatureInfo configures detached-signature verification for HTTP
// sources. The current implementation uses inline armored signatures.
type HTTPSignatureInfo struct {
PubKey []byte
// Signature is an inline detached armored OpenPGP signature.
Signature []byte
}
type HTTPInfo struct {
constraintsWrapper
Checksum digest.Digest
@@ -720,6 +739,7 @@ type HTTPInfo struct {
GID int
AuthHeaderSecret string
Header *HTTPHeader
Signature *HTTPSignatureInfo
}
type HTTPOption interface {
@@ -757,6 +777,17 @@ func Chown(uid, gid int) HTTPOption {
})
}
// VerifyPGPSignature returns an [HTTPOption] for detached OpenPGP signature
// verification of the downloaded HTTP payload.
func VerifyPGPSignature(info HTTPSignatureInfo) HTTPOption {
return httpOptionFunc(func(hi *HTTPInfo) {
hi.Signature = &HTTPSignatureInfo{
PubKey: slices.Clone(info.PubKey),
Signature: slices.Clone(info.Signature),
}
})
}
// Header returns an [HTTPOption] that ensures additional request headers will
// be sent when retrieving the HTTP source.
func Header(header HTTPHeader) HTTPOption {
+20 -92
View File
@@ -1,12 +1,10 @@
package client
import (
"bytes"
"context"
"crypto"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"encoding/json"
"fmt"
"hash"
@@ -20,8 +18,6 @@ import (
"testing"
"time"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/packet"
"github.com/containerd/platforms"
intoto "github.com/in-toto/in-toto-golang/in_toto"
"github.com/moby/buildkit/client/llb"
@@ -31,7 +27,7 @@ import (
opspb "github.com/moby/buildkit/solver/pb"
sourcepolicypb "github.com/moby/buildkit/sourcepolicy/pb"
"github.com/moby/buildkit/sourcepolicy/policysession"
"github.com/moby/buildkit/util/gitutil/gitsign"
"github.com/moby/buildkit/util/pgpsign"
"github.com/moby/buildkit/util/testutil/integration"
"github.com/moby/buildkit/util/testutil/workers"
policyimage "github.com/moby/policy-helpers/image"
@@ -934,10 +930,9 @@ func testSourcePolicySessionHTTPChecksumAssist(t *testing.T, sb integration.Sand
require.NoError(t, err)
pubKeyData, err := os.ReadFile(filepath.Join(signFixturesPath, "user1.gpg.pub"))
require.NoError(t, err)
sig, err := gitsign.ParseSignature(sigData)
sig, _, err := pgpsign.ParseArmoredDetachedSignature(sigData)
require.NoError(t, err)
require.NotNil(t, sig.PGPSignature)
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(pubKeyData))
keyring, err := pgpsign.ReadAllArmoredKeyRings(pubKeyData)
require.NoError(t, err)
httpSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -953,9 +948,9 @@ func testSourcePolicySessionHTTPChecksumAssist(t *testing.T, sb integration.Sand
require.NoError(t, err)
t.Run("valid checksum request", func(t *testing.T) {
algo, err := toPBChecksumAlgo(sig.PGPSignature.Hash)
algo, err := toPBChecksumAlgo(sig.Hash)
require.NoError(t, err)
expectedDigest, err := payloadWithSuffixDigest(sig.PGPSignature.Hash, payload, sig.PGPSignature.HashSuffix)
expectedDigest, err := payloadWithSuffixDigest(sig.Hash, payload, sig.HashSuffix)
require.NoError(t, err)
callCounter := 0
@@ -969,7 +964,7 @@ func testSourcePolicySessionHTTPChecksumAssist(t *testing.T, sb integration.Sand
HTTP: &pb.ResolveSourceHTTPRequest{
ChecksumRequest: &pb.ChecksumRequest{
Algo: algo,
Suffix: slices.Clone(sig.PGPSignature.HashSuffix),
Suffix: slices.Clone(sig.HashSuffix),
},
},
}, nil
@@ -978,11 +973,13 @@ func testSourcePolicySessionHTTPChecksumAssist(t *testing.T, sb integration.Sand
require.NotNil(t, req.Source.HTTP)
require.NotNil(t, req.Source.HTTP.ChecksumResponse)
require.Equal(t, expectedDigest, req.Source.HTTP.ChecksumResponse.Digest)
require.Equal(t, sig.PGPSignature.HashSuffix, req.Source.HTTP.ChecksumResponse.Suffix)
require.NoError(t, verifySignatureWithChecksumDigest(sig.PGPSignature, keyring, req.Source.HTTP.ChecksumResponse.Digest))
require.Equal(t, sig.HashSuffix, req.Source.HTTP.ChecksumResponse.Suffix)
responseDigest, err := digest.Parse(req.Source.HTTP.ChecksumResponse.Digest)
require.NoError(t, err)
require.NoError(t, pgpsign.VerifySignatureWithDigest(sig, keyring, responseDigest))
// Negative check: tampered digest must fail signature verification.
badDigest := tamperDigestHex(req.Source.HTTP.ChecksumResponse.Digest)
err := verifySignatureWithChecksumDigest(sig.PGPSignature, keyring, badDigest)
badDigest := tamperDigestHex(responseDigest)
err = pgpsign.VerifySignatureWithDigest(sig, keyring, badDigest)
require.Error(t, err)
require.ErrorContains(t, err, "failed to verify signature with checksum digest")
return &policysession.DecisionResponse{
@@ -1089,84 +1086,15 @@ func payloadWithSuffixDigest(algo crypto.Hash, payload, suffix []byte) (string,
return fmt.Sprintf("%s:%x", algoName, h.Sum(nil)), nil
}
func verifySignatureWithChecksumDigest(sig *packet.Signature, keyring openpgp.EntityList, digestStr string) error {
algoPart, hexPart, ok := strings.Cut(digestStr, ":")
if !ok {
return errors.Errorf("invalid digest format %q", digestStr)
func tamperDigestHex(dgst digest.Digest) digest.Digest {
hexPart := []byte(dgst.Encoded())
if len(hexPart) == 0 {
return dgst
}
algoName, err := checksumAlgoName(sig.Hash)
if err != nil {
return err
}
if algoPart != algoName {
return errors.Errorf("digest algorithm mismatch: %s != %s", algoPart, algoName)
}
sum, err := hex.DecodeString(hexPart)
if err != nil {
return errors.Wrap(err, "invalid digest hex")
}
h := &staticHash{sum: sum}
// simplistic verification only for testing purposes
for _, e := range keyring {
if e.PrimaryKey != nil && e.PrimaryKey.VerifySignature(h, sig) == nil {
return nil
}
for _, sub := range e.Subkeys {
if sub.PublicKey != nil && sub.PublicKey.VerifySignature(h, sig) == nil {
return nil
}
}
}
return errors.New("failed to verify signature with checksum digest")
}
func tamperDigestHex(digestStr string) string {
algoPart, hexPart, ok := strings.Cut(digestStr, ":")
if !ok || hexPart == "" {
return digestStr
}
h := []byte(hexPart)
if h[len(h)-1] == '0' {
h[len(h)-1] = '1'
if hexPart[len(hexPart)-1] == '0' {
hexPart[len(hexPart)-1] = '1'
} else {
h[len(h)-1] = '0'
hexPart[len(hexPart)-1] = '0'
}
return algoPart + ":" + string(h)
}
func checksumAlgoName(in crypto.Hash) (string, error) {
switch in {
case crypto.SHA256:
return "sha256", nil
case crypto.SHA384:
return "sha384", nil
case crypto.SHA512:
return "sha512", nil
default:
return "", errors.Errorf("unsupported signature hash algorithm %v", in)
}
}
type staticHash struct {
sum []byte
}
func (s *staticHash) Write(p []byte) (n int, err error) {
return len(p), nil
}
func (s *staticHash) Sum(b []byte) []byte {
return append(b, s.sum...)
}
func (s *staticHash) Reset() {}
func (s *staticHash) Size() int {
return len(s.sum)
}
func (s *staticHash) BlockSize() int {
return len(s.sum)
return digest.NewDigestFromEncoded(dgst.Algorithm(), string(hexPart))
}
+2
View File
@@ -32,6 +32,8 @@ const AttrHTTPUID = "http.uid"
const AttrHTTPGID = "http.gid"
const AttrHTTPAuthHeaderSecret = "http.authheadersecret"
const AttrHTTPHeaderPrefix = "http.header."
const AttrHTTPSignatureVerifyPubKey = "http.sig.pubkey"
const AttrHTTPSignatureVerify = "http.sig.signature"
const AttrImageResolveMode = "image.resolvemode"
const AttrImageResolveModeDefault = "default"
+9 -2
View File
@@ -40,8 +40,9 @@ const (
CapSourceHTTPChecksum apicaps.CapID = "source.http.checksum"
CapSourceHTTPPerm apicaps.CapID = "source.http.perm"
// NOTE the historical typo
CapSourceHTTPUIDGID apicaps.CapID = "soruce.http.uidgid"
CapSourceHTTPHeader apicaps.CapID = "source.http.header"
CapSourceHTTPUIDGID apicaps.CapID = "soruce.http.uidgid"
CapSourceHTTPHeader apicaps.CapID = "source.http.header"
CapSourceHTTPSignatureVerify apicaps.CapID = "source.http.signatureverify"
CapSourceOCILayout apicaps.CapID = "source.ocilayout"
@@ -288,6 +289,12 @@ func init() {
Status: apicaps.CapStatusExperimental,
})
Caps.Init(apicaps.Cap{
ID: CapSourceHTTPSignatureVerify,
Enabled: true,
Status: apicaps.CapStatusExperimental,
})
Caps.Init(apicaps.Cap{
ID: CapSourceOCILayout,
Enabled: true,
+3 -2
View File
@@ -30,6 +30,7 @@ import (
"github.com/moby/buildkit/util/gitutil"
"github.com/moby/buildkit/util/gitutil/gitobject"
"github.com/moby/buildkit/util/gitutil/gitsign"
"github.com/moby/buildkit/util/pgpsign"
"github.com/moby/buildkit/util/progress/logs"
"github.com/moby/buildkit/util/urlutil"
"github.com/moby/locker"
@@ -308,7 +309,7 @@ func verifyGitSignature(md *Metadata, opts *GitSignatureVerifyOptions) error {
if err := tagObj.VerifyChecksum(md.Checksum); err != nil {
return errors.Wrap(err, "tag object checksum verification failed")
}
tagVerifyError = gitsign.VerifySignature(tagObj, opts.PubKey, &gitsign.VerifyPolicy{
tagVerifyError = gitsign.VerifySignature(tagObj, opts.PubKey, &pgpsign.VerifyPolicy{
RejectExpiredKeys: opts.RejectExpiredKeys,
})
if tagVerifyError == nil {
@@ -333,7 +334,7 @@ func verifyGitSignature(md *Metadata, opts *GitSignatureVerifyOptions) error {
if err := commitObj.VerifyChecksum(expected); err != nil {
return errors.Wrap(err, "commit object checksum verification failed")
}
return gitsign.VerifySignature(commitObj, opts.PubKey, &gitsign.VerifyPolicy{
return gitsign.VerifySignature(commitObj, opts.PubKey, &pgpsign.VerifyPolicy{
RejectExpiredKeys: opts.RejectExpiredKeys,
})
}
+6
View File
@@ -27,6 +27,12 @@ type HTTPIdentifier struct {
GID int
AuthHeaderSecret string
Header []HeaderField
VerifySignature *HTTPSignatureVerifyOptions
}
type HTTPSignatureVerifyOptions struct {
PubKey []byte
Signature []byte
}
type HeaderField struct {
+83
View File
@@ -29,6 +29,7 @@ import (
srctypes "github.com/moby/buildkit/source/types"
"github.com/moby/buildkit/util/bklog"
"github.com/moby/buildkit/util/cachedigest"
"github.com/moby/buildkit/util/pgpsign"
"github.com/moby/buildkit/util/tracing"
"github.com/moby/buildkit/version"
digest "github.com/opencontainers/go-digest"
@@ -111,6 +112,16 @@ func (hs *Source) Identifier(scheme, ref string, attrs map[string]string, platfo
id.GID = int(i)
case pb.AttrHTTPAuthHeaderSecret:
id.AuthHeaderSecret = v
case pb.AttrHTTPSignatureVerifyPubKey:
if id.VerifySignature == nil {
id.VerifySignature = &HTTPSignatureVerifyOptions{}
}
id.VerifySignature.PubKey = []byte(v)
case pb.AttrHTTPSignatureVerify:
if id.VerifySignature == nil {
id.VerifySignature = &HTTPSignatureVerifyOptions{}
}
id.VerifySignature.Signature = []byte(v)
default:
if name, found := strings.CutPrefix(k, pb.AttrHTTPHeaderPrefix); found {
name = http.CanonicalHeaderKey(name)
@@ -127,6 +138,10 @@ func (hs *Source) Identifier(scheme, ref string, attrs map[string]string, platfo
return cmp.Compare(a.Name, b.Name)
})
if err := validateSignatureVerifyOptions(id.VerifySignature); err != nil {
return nil, err
}
return id, nil
}
@@ -258,10 +273,17 @@ func (hs *httpSourceHandler) resolveMetadata(ctx context.Context, jobCtx solver.
if err := validateChecksumRequest(opt.ChecksumReq); err != nil {
return nil, err
}
if err := validateSignatureVerifyOptions(hs.src.VerifySignature); err != nil {
return nil, err
}
md, err := hs.resolveMetadataStatic(ctx, jobCtx)
if err != nil {
return nil, err
}
md, err = hs.handleSignatureVerification(ctx, jobCtx, md)
if err != nil {
return nil, err
}
return hs.handleChecksumRequest(ctx, jobCtx, md, opt)
}
@@ -550,6 +572,36 @@ func validateChecksumRequest(req *MetadataChecksumRequest) error {
}
}
func validateSignatureVerifyOptions(opt *HTTPSignatureVerifyOptions) error {
if opt == nil {
return nil
}
hasPubKey := len(opt.PubKey) > 0
hasSignature := len(opt.Signature) > 0
if hasPubKey == hasSignature {
return nil
}
return errors.New("http signature verification requires both pubkey and signature")
}
func (hs *httpSourceHandler) handleSignatureVerification(ctx context.Context, jobCtx solver.JobContext, in *Metadata) (*Metadata, error) {
out := *in
if hs.src.VerifySignature == nil {
return &out, nil
}
if hs.resolved == nil || hs.resolved.refID == "" {
md, err := hs.resolveMetadataRef(ctx, jobCtx)
if err != nil {
return nil, errors.Wrap(err, "failed to resolve source reference for signature verification")
}
out = *md
}
if err := hs.verifySignature(ctx, out.Filename, hs.resolved.refID, hs.src.VerifySignature); err != nil {
return nil, err
}
return &out, nil
}
func (hs *httpSourceHandler) handleChecksumRequest(ctx context.Context, jobCtx solver.JobContext, in *Metadata, opt MetadataOpts) (*Metadata, error) {
out := *in
if opt.ChecksumReq == nil {
@@ -571,6 +623,37 @@ func (hs *httpSourceHandler) handleChecksumRequest(ctx context.Context, jobCtx s
return &out, nil
}
func (hs *httpSourceHandler) verifySignature(ctx context.Context, filename, refID string, opts *HTTPSignatureVerifyOptions) error {
ref, err := hs.cache.Get(ctx, refID, nil)
if err != nil {
return errors.Wrap(err, "failed to load http source reference")
}
defer ref.Release(context.WithoutCancel(ctx))
mount, err := ref.Mount(ctx, false, nil)
if err != nil {
return errors.Wrap(err, "failed to mount http source reference")
}
lm := snapshot.LocalMounter(mount)
dir, err := lm.Mount()
if err != nil {
return errors.Wrap(err, "failed to mount local http source reference")
}
defer lm.Unmount()
fp := filepath.Join(dir, filepath.Base(filepath.Join("/", filename)))
f, err := os.Open(fp)
if err != nil {
return errors.Wrap(err, "failed to open http source payload")
}
defer f.Close()
if err := pgpsign.VerifyArmoredDetachedSignature(f, opts.Signature, opts.PubKey, nil); err != nil {
return errors.Wrap(err, "failed to verify pgp signature")
}
return nil
}
func (hs *httpSourceHandler) computeChecksumResponse(ctx context.Context, filename, refID string, req *MetadataChecksumRequest) (*MetadataChecksumResponse, error) {
algo, err := checksumAlgo(req.Algo)
if err != nil {
+100
View File
@@ -29,6 +29,8 @@ import (
bolt "go.etcd.io/bbolt"
)
const signFixturesPathEnv = "BUILDKIT_TEST_SIGN_FIXTURES"
func TestHTTPSource(t *testing.T) {
t.Parallel()
ctx := context.TODO()
@@ -296,6 +298,91 @@ func TestHTTPChecksum(t *testing.T) {
ref = nil
}
func TestHTTPSignatureVerification(t *testing.T) {
t.Parallel()
ctx := context.TODO()
fixturesPath, ok := os.LookupEnv(signFixturesPathEnv)
if !ok {
t.Skip("missing BUILDKIT_TEST_SIGN_FIXTURES")
}
payload := readSignFixture(t, fixturesPath, "user1.http.artifact")
signature := readSignFixture(t, fixturesPath, "user1.http.artifact.asc")
pubKeyUser1 := readSignFixture(t, fixturesPath, "user1.gpg.pub")
pubKeyUser2 := readSignFixture(t, fixturesPath, "user2.gpg.pub")
hs, err := newHTTPSource(t)
require.NoError(t, err)
server := httpserver.NewTestServer(map[string]*httpserver.Response{
"/artifact": {
Etag: identity.NewID(),
Content: payload,
},
})
defer server.Close()
t.Run("valid-signature", func(t *testing.T) {
id := &HTTPIdentifier{
URL: server.URL + "/artifact",
VerifySignature: &HTTPSignatureVerifyOptions{
PubKey: pubKeyUser1,
Signature: signature,
},
}
h, err := hs.Resolve(ctx, id, nil, nil)
require.NoError(t, err)
_, _, _, _, err = h.CacheKey(ctx, nil, 0)
require.NoError(t, err)
})
t.Run("wrong-pubkey", func(t *testing.T) {
id := &HTTPIdentifier{
URL: server.URL + "/artifact",
VerifySignature: &HTTPSignatureVerifyOptions{
PubKey: pubKeyUser2,
Signature: signature,
},
}
h, err := hs.Resolve(ctx, id, nil, nil)
require.NoError(t, err)
_, _, _, _, err = h.CacheKey(ctx, nil, 0)
require.Error(t, err)
require.ErrorContains(t, err, "failed to verify pgp signature")
})
t.Run("multiblock-pubkey", func(t *testing.T) {
merged := append(append([]byte{}, pubKeyUser2...), '\n')
merged = append(merged, pubKeyUser1...)
id := &HTTPIdentifier{
URL: server.URL + "/artifact",
VerifySignature: &HTTPSignatureVerifyOptions{
PubKey: merged,
Signature: signature,
},
}
h, err := hs.Resolve(ctx, id, nil, nil)
require.NoError(t, err)
_, _, _, _, err = h.CacheKey(ctx, nil, 0)
require.NoError(t, err)
})
t.Run("missing-signature", func(t *testing.T) {
id := &HTTPIdentifier{
URL: server.URL + "/artifact",
VerifySignature: &HTTPSignatureVerifyOptions{
PubKey: pubKeyUser1,
},
}
h, err := hs.Resolve(ctx, id, nil, nil)
require.NoError(t, err)
_, _, _, _, err = h.CacheKey(ctx, nil, 0)
require.Error(t, err)
require.ErrorContains(t, err, "requires both pubkey and signature")
})
}
func TestPruneAfterCacheKey(t *testing.T) {
t.Parallel()
ctx := context.TODO()
@@ -511,3 +598,16 @@ func (s *simpleJobContext) Release() error {
func (s *simpleJobContext) ResolverCache() solver.ResolverCache {
return nil
}
func readSignFixture(t *testing.T, fixturesPath, name string) []byte {
t.Helper()
p := filepath.Join(fixturesPath, name)
dt, err := os.ReadFile(p)
if err != nil {
if os.IsNotExist(err) {
t.Skipf("missing signing fixture at %s", p)
}
require.NoError(t, err)
}
return dt
}
+23 -205
View File
@@ -2,39 +2,23 @@ package gitsign
import (
"bytes"
"crypto"
"crypto/rsa"
"encoding/pem"
"io"
"strings"
"time"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/ProtonMail/go-crypto/openpgp/packet"
"github.com/hiddeco/sshsig"
"github.com/moby/buildkit/util/gitutil/gitobject"
"github.com/moby/buildkit/util/pgpsign"
"github.com/pkg/errors"
"golang.org/x/crypto/ssh"
)
type sigType int
const (
sigTypePGP sigType = iota
sigTypeSSH
)
type Signature struct {
PGPSignature *packet.Signature
SSHSignature *sshsig.Signature
}
type VerifyPolicy struct {
RejectExpiredKeys bool
}
func VerifySignature(obj *gitobject.GitObject, pubKeyData []byte, policy *VerifyPolicy) error {
func VerifySignature(obj *gitobject.GitObject, pubKeyData []byte, policy *pgpsign.VerifyPolicy) error {
if len(obj.Signature) == 0 {
return errors.New("git object is not signed")
}
@@ -44,91 +28,20 @@ func VerifySignature(obj *gitobject.GitObject, pubKeyData []byte, policy *Verify
return err
}
if s.PGPSignature != nil {
return verifyPGPSignature(obj, s.PGPSignature, pubKeyData, policy)
return verifyPGPSignature(obj, pubKeyData, policy)
} else if s.SSHSignature != nil {
return verifySSHSignature(obj, s.SSHSignature, pubKeyData)
}
return errors.New("no valid signature found")
}
func verifyPGPSignature(obj *gitobject.GitObject, sig *packet.Signature, pubKeyData []byte, policy *VerifyPolicy) error {
sigBlock, _, err := parseSignatureBlock([]byte(obj.Signature))
if err != nil {
return err
}
ents, err := readAllArmoredKeyRings(pubKeyData)
if err != nil {
return err
}
// add addition algorithm constraints
if err := checkAlgoPolicy(sig); err != nil {
return err
}
config := &packet.Config{}
if policy == nil || !policy.RejectExpiredKeys {
config.Time = func() time.Time {
return sig.CreationTime
}
}
signer, err := openpgp.CheckDetachedSignature(
ents,
func verifyPGPSignature(obj *gitobject.GitObject, pubKeyData []byte, policy *pgpsign.VerifyPolicy) error {
return pgpsign.VerifyArmoredDetachedSignature(
bytes.NewReader([]byte(obj.SignedData)),
bytes.NewReader(sigBlock),
config,
[]byte(obj.Signature),
pubKeyData,
policy,
)
if err != nil {
if sig.IssuerKeyId != nil {
return errors.Wrapf(err, "signature by %X", *sig.IssuerKeyId)
}
return err
}
if err := checkEntityUsableForSigning(signer, time.Now(), policy); err != nil {
return err
}
if err := checkCreationTime(sig.CreationTime, time.Now()); err != nil {
return err
}
return nil
}
// readAllArmoredKeyRings parses one or more concatenated armored OpenPGP key
// blocks and returns a combined entity list for signature verification.
func readAllArmoredKeyRings(pubKeyData []byte) (openpgp.EntityList, error) {
var ents openpgp.EntityList
r := bytes.NewReader(pubKeyData)
for {
block, err := armor.Decode(r)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, errors.Wrap(err, "failed to decode armored public key")
}
if block.Type != openpgp.PublicKeyType && block.Type != openpgp.PrivateKeyType {
return nil, errors.Errorf("expected public or private key block, got: %s", block.Type)
}
el, err := openpgp.ReadKeyRing(block.Body)
if err != nil {
return nil, errors.Wrap(err, "failed to read armored public key")
}
ents = append(ents, el...)
}
if len(ents) == 0 {
return nil, errors.New("failed to read armored public key: no armored data found")
}
return ents, nil
}
func verifySSHSignature(obj *gitobject.GitObject, sig *sshsig.Signature, pubKeyData []byte) error {
@@ -158,130 +71,35 @@ func verifySSHSignature(obj *gitobject.GitObject, sig *sshsig.Signature, pubKeyD
return nil
}
func checkEntityUsableForSigning(e *openpgp.Entity, now time.Time, policy *VerifyPolicy) error {
if e == nil || e.PrimaryKey == nil {
return errors.New("nil entity or key")
}
// Expiry
if policy != nil && policy.RejectExpiredKeys {
if id := e.PrimaryIdentity(); id != nil && id.SelfSignature != nil {
if exp := id.SelfSignature.KeyLifetimeSecs; exp != nil && *exp > 0 {
expiry := e.PrimaryKey.CreationTime.Add(time.Duration(*exp) * time.Second)
if now.After(expiry) {
return errors.Errorf("key expired at %v", expiry)
}
}
}
}
// Revocation
if err := checkEntityRevocation(e); err != nil {
return err
}
// RSA bit length (optional)
if rsaPub, ok := e.PrimaryKey.PublicKey.(*rsa.PublicKey); ok {
if rsaPub.N.BitLen() < 2048 {
return errors.Errorf("RSA key too short: %d bits", rsaPub.N.BitLen())
}
}
return nil
}
func checkEntityRevocation(e *openpgp.Entity) error {
if e == nil {
return nil
}
for _, r := range e.Revocations {
if r == nil || r.SigType != packet.SigTypeKeyRevocation {
continue
}
if err := e.PrimaryKey.VerifyRevocationSignature(r); err != nil {
continue // ignore malformed or unverified revocations
}
if r.RevocationReasonText != "" {
return errors.Errorf("key revoked: %s", r.RevocationReasonText)
}
return errors.New("key revoked")
}
return nil
}
func parseSignatureBlock(data []byte) ([]byte, sigType, error) {
func parseSignatureBlock(data []byte) ([]byte, error) {
if strings.HasPrefix(string(data), "-----BEGIN SSH SIGNATURE-----") {
block, _ := pem.Decode(data)
if block == nil || block.Type != "SSH SIGNATURE" {
return nil, 0, errors.New("failed to decode ssh signature PEM block")
return nil, errors.New("failed to decode ssh signature PEM block")
}
return block.Bytes, sigTypeSSH, nil
} else if strings.HasPrefix(string(data), "-----BEGIN PGP SIGNATURE-----") {
block, err := armor.Decode(bytes.NewReader(data))
if err != nil {
return nil, 0, errors.Wrap(err, "failed to decode armored signature")
}
dt, err := io.ReadAll(block.Body)
if err != nil {
return nil, 0, errors.Wrap(err, "failed to read armored signature body")
}
return dt, sigTypePGP, nil
return block.Bytes, nil
}
return nil, 0, errors.Errorf("invalid signature format")
return nil, errors.Errorf("invalid signature format")
}
func ParseSignature(data []byte) (*Signature, error) {
sigBlock, typ, err := parseSignatureBlock(data)
if err != nil {
return nil, err
if strings.HasPrefix(string(data), "-----BEGIN PGP SIGNATURE-----") {
sig, _, err := pgpsign.ParseArmoredDetachedSignature(data)
if err != nil {
return nil, err
}
return &Signature{PGPSignature: sig}, nil
}
switch typ {
case sigTypePGP:
pr := packet.NewReader(bytes.NewReader(sigBlock))
for {
p, err := pr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, errors.Wrap(err, "failed to read next packet")
}
sig, ok := p.(*packet.Signature)
if !ok {
continue
}
return &Signature{PGPSignature: sig}, nil
if strings.HasPrefix(string(data), "-----BEGIN SSH SIGNATURE-----") {
sigBlock, err := parseSignatureBlock(data)
if err != nil {
return nil, err
}
case sigTypeSSH:
sig, err := sshsig.ParseSignature(sigBlock)
if err != nil {
return nil, errors.Wrap(err, "failed to parse ssh signature")
}
return &Signature{SSHSignature: sig}, nil
}
return nil, errors.Errorf("no signature packet found")
}
func checkAlgoPolicy(sig *packet.Signature) error {
switch sig.Hash {
case crypto.SHA256, crypto.SHA384, crypto.SHA512:
// ok
default:
return errors.Errorf("rejecting weak/unknown hash: %v", sig.Hash)
}
// Pubkey policy
switch sig.PubKeyAlgo {
case packet.PubKeyAlgoEdDSA, packet.PubKeyAlgoECDSA, packet.PubKeyAlgoRSA, packet.PubKeyAlgoRSASignOnly:
default:
return errors.Errorf("rejecting unsupported pubkey algorithm: %v", sig.PubKeyAlgo)
}
return nil
}
func checkCreationTime(sigTime, now time.Time) error {
if sigTime.After(now.Add(5 * time.Minute)) {
return errors.Errorf("signature creation time is in the future: %v", sigTime)
}
return nil
return nil, errors.Errorf("invalid signature format")
}
+295
View File
@@ -0,0 +1,295 @@
package pgpsign
import (
"bytes"
"crypto"
"crypto/rsa"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"io"
"time"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/ProtonMail/go-crypto/openpgp/packet"
digest "github.com/opencontainers/go-digest"
"github.com/pkg/errors"
)
// VerifyPolicy defines validation policy for OpenPGP signature verification.
type VerifyPolicy struct {
RejectExpiredKeys bool
}
// ParseArmoredDetachedSignature parses a detached armored OpenPGP signature and
// returns the first signature packet and the decoded binary signature payload.
func ParseArmoredDetachedSignature(data []byte) (*packet.Signature, []byte, error) {
block, err := armor.Decode(bytes.NewReader(data))
if err != nil {
return nil, nil, errors.Wrap(err, "failed to decode armored signature")
}
sigBlock, err := io.ReadAll(block.Body)
if err != nil {
return nil, nil, errors.Wrap(err, "failed to read armored signature body")
}
pr := packet.NewReader(bytes.NewReader(sigBlock))
for {
p, err := pr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, nil, errors.Wrap(err, "failed to read next packet")
}
sig, ok := p.(*packet.Signature)
if !ok {
continue
}
return sig, sigBlock, nil
}
return nil, nil, errors.New("no signature packet found")
}
// ReadAllArmoredKeyRings parses one or more concatenated armored OpenPGP key
// blocks and returns a combined entity list.
func ReadAllArmoredKeyRings(pubKeyData []byte) (openpgp.EntityList, error) {
var ents openpgp.EntityList
r := bytes.NewReader(pubKeyData)
for {
block, err := armor.Decode(r)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, errors.Wrap(err, "failed to decode armored public key")
}
if block.Type != openpgp.PublicKeyType && block.Type != openpgp.PrivateKeyType {
return nil, errors.Errorf("expected public or private key block, got: %s", block.Type)
}
el, err := openpgp.ReadKeyRing(block.Body)
if err != nil {
return nil, errors.Wrap(err, "failed to read armored public key")
}
ents = append(ents, el...)
}
if len(ents) == 0 {
return nil, errors.New("failed to read armored public key: no armored data found")
}
return ents, nil
}
// VerifyArmoredDetachedSignature verifies an armored detached OpenPGP
// signature against signedData using one or more armored public key blocks.
func VerifyArmoredDetachedSignature(signedData io.Reader, signatureData, pubKeyData []byte, policy *VerifyPolicy) error {
sig, sigBlock, err := ParseArmoredDetachedSignature(signatureData)
if err != nil {
return err
}
ents, err := ReadAllArmoredKeyRings(pubKeyData)
if err != nil {
return err
}
if err := checkAlgoPolicy(sig); err != nil {
return err
}
config := &packet.Config{}
if policy == nil || !policy.RejectExpiredKeys {
config.Time = func() time.Time {
return sig.CreationTime
}
}
signer, err := openpgp.CheckDetachedSignature(
ents,
signedData,
bytes.NewReader(sigBlock),
config,
)
if err != nil {
if sig.IssuerKeyId != nil {
return errors.Wrapf(err, "signature by %X", *sig.IssuerKeyId)
}
return err
}
now := time.Now()
if err := checkEntityUsableForSigning(signer, now, policy); err != nil {
return err
}
if err := checkCreationTime(sig.CreationTime, now); err != nil {
return err
}
return nil
}
// VerifySignatureWithDigest verifies a parsed signature against a digest of
// the signed payload plus OpenPGP hash suffix (payload || suffix) using the
// provided keyring.
func VerifySignatureWithDigest(sig *packet.Signature, keyring openpgp.EntityList, dgst digest.Digest) error {
if sig == nil {
return errors.New("nil signature")
}
expectedAlgo, err := signatureDigestAlgorithm(sig.Hash)
if err != nil {
return err
}
if dgst.Algorithm() != expectedAlgo {
return errors.Errorf("digest algorithm mismatch: %s != %s", dgst.Algorithm(), expectedAlgo)
}
sum, err := hex.DecodeString(dgst.Encoded())
if err != nil {
return errors.Wrap(err, "invalid digest hex")
}
h := &staticHash{sum: sum, algo: sig.Hash}
if len(sum) != h.Size() {
return errors.Errorf("digest size mismatch: got %d, expected %d", len(sum), h.Size())
}
for _, e := range keyring {
if e.PrimaryKey != nil && e.PrimaryKey.VerifySignature(h, sig) == nil {
return nil
}
for _, sub := range e.Subkeys {
if sub.PublicKey != nil && sub.PublicKey.VerifySignature(h, sig) == nil {
return nil
}
}
}
return errors.New("failed to verify signature with checksum digest")
}
func signatureDigestAlgorithm(h crypto.Hash) (digest.Algorithm, error) {
switch h {
case crypto.SHA256:
return digest.SHA256, nil
case crypto.SHA384:
return digest.SHA384, nil
case crypto.SHA512:
return digest.SHA512, nil
default:
return "", errors.Errorf("unsupported signature hash algorithm %v", h)
}
}
type staticHash struct {
sum []byte
algo crypto.Hash
}
func (s *staticHash) Write(p []byte) (n int, err error) {
return len(p), nil
}
func (s *staticHash) Sum(b []byte) []byte {
return append(b, s.sum...)
}
func (s *staticHash) Reset() {}
func (s *staticHash) Size() int {
switch s.algo {
case crypto.SHA256:
return sha256.Size
case crypto.SHA384:
return sha512.Size384
case crypto.SHA512:
return sha512.Size
default:
return len(s.sum)
}
}
func (s *staticHash) BlockSize() int {
switch s.algo {
case crypto.SHA256:
return sha256.BlockSize
case crypto.SHA384, crypto.SHA512:
return sha512.BlockSize
default:
return 0
}
}
func checkAlgoPolicy(sig *packet.Signature) error {
switch sig.Hash {
case crypto.SHA256, crypto.SHA384, crypto.SHA512:
// ok
default:
return errors.Errorf("rejecting weak/unknown hash: %v", sig.Hash)
}
switch sig.PubKeyAlgo {
case packet.PubKeyAlgoEdDSA, packet.PubKeyAlgoECDSA, packet.PubKeyAlgoRSA, packet.PubKeyAlgoRSASignOnly:
default:
return errors.Errorf("rejecting unsupported pubkey algorithm: %v", sig.PubKeyAlgo)
}
return nil
}
func checkEntityUsableForSigning(e *openpgp.Entity, now time.Time, policy *VerifyPolicy) error {
if e == nil || e.PrimaryKey == nil {
return errors.New("nil entity or key")
}
if policy != nil && policy.RejectExpiredKeys {
if id := e.PrimaryIdentity(); id != nil && id.SelfSignature != nil {
if exp := id.SelfSignature.KeyLifetimeSecs; exp != nil && *exp > 0 {
expiry := e.PrimaryKey.CreationTime.Add(time.Duration(*exp) * time.Second)
if now.After(expiry) {
return errors.Errorf("key expired at %v", expiry)
}
}
}
}
if err := checkEntityRevocation(e); err != nil {
return err
}
if rsaPub, ok := e.PrimaryKey.PublicKey.(*rsa.PublicKey); ok {
if rsaPub.N.BitLen() < 2048 {
return errors.Errorf("RSA key too short: %d bits", rsaPub.N.BitLen())
}
}
return nil
}
func checkEntityRevocation(e *openpgp.Entity) error {
if e == nil {
return nil
}
for _, r := range e.Revocations {
if r == nil || r.SigType != packet.SigTypeKeyRevocation {
continue
}
if err := e.PrimaryKey.VerifyRevocationSignature(r); err != nil {
continue
}
if r.RevocationReasonText != "" {
return errors.Errorf("key revoked: %s", r.RevocationReasonText)
}
return errors.New("key revoked")
}
return nil
}
func checkCreationTime(sigTime, now time.Time) error {
if sigTime.After(now.Add(5 * time.Minute)) {
return errors.Errorf("signature creation time is in the future: %v", sigTime)
}
return nil
}