diff --git a/pkg/pm/installer/installer.go b/pkg/pm/installer/installer.go index b291c93..96bd3e5 100644 --- a/pkg/pm/installer/installer.go +++ b/pkg/pm/installer/installer.go @@ -15,6 +15,7 @@ import ( "wpm/pkg/archive" "wpm/pkg/pm/registry" + "wpm/pkg/pm/signatures" "wpm/pkg/pm/wpmjson/types" "github.com/pkg/errors" @@ -27,6 +28,7 @@ type Installer struct { tmpDir string client registry.Client extractSem chan struct{} + keysJson signatures.KeysJson } func New(contentDir string, concurrency int, client registry.Client) *Installer { @@ -47,6 +49,13 @@ func New(contentDir string, concurrency int, client registry.Client) *Installer } func (i *Installer) InstallAll(ctx context.Context, plan []Action, progressFn func(Action)) error { + keys, err := i.client.GetKeysJson(ctx) + if err != nil { + return errors.Wrap(err, "failed to fetch public keys for signature verification") + } + + i.keysJson = keys + g, ctx := errgroup.WithContext(ctx) g.SetLimit(i.concurrency) @@ -65,7 +74,7 @@ func (i *Installer) InstallAll(ctx context.Context, plan []Action, progressFn fu }) } - err := g.Wait() + err = g.Wait() os.RemoveAll(i.tmpDir) return err } @@ -87,6 +96,26 @@ func (i *Installer) Install(ctx context.Context, action Action) error { } func (i *Installer) installOrUpdate(ctx context.Context, action Action, targetDir string) error { + manifest, err := i.client.GetPackageManifest(ctx, action.Name, action.Version, true) + if err != nil { + return errors.Wrapf(err, "failed to fetch manifest for %s@%s", action.Name, action.Version) + } + + sigs := manifest.Dist.Signatures + if len(sigs) == 0 { + return errors.Errorf("no signatures found for package %s@%s", action.Name, action.Version) + } + + err = signatures.Verify( + i.keysJson, + sigs[0].KeyID, + sigs[0].Sig, + fmt.Appendf(nil, "%s:%s:%s", action.Name, action.Version, action.Digest), + ) + if err != nil { + return errors.Wrapf(err, "signature verification failed for package %s@%s", action.Name, action.Version) + } + resp, err := i.client.DownloadTarball(ctx, action.Resolved) if err != nil { return errors.Wrapf(err, "failed to download %s", action.Resolved) diff --git a/pkg/pm/registry/client.go b/pkg/pm/registry/client.go index 2ec6a46..304dd0c 100644 --- a/pkg/pm/registry/client.go +++ b/pkg/pm/registry/client.go @@ -8,6 +8,7 @@ import ( "net/http" "wpm/pkg/api" + "wpm/pkg/pm/signatures" "wpm/pkg/pm/wpmjson/manifest" ) @@ -25,6 +26,7 @@ type client struct { // registry type Client interface { Whoami(ctx context.Context, token string) (string, error) + GetKeysJson(ctx context.Context) (signatures.KeysJson, error) DownloadTarball(ctx context.Context, url string) (io.ReadCloser, error) PutPackage(ctx context.Context, data *manifest.Package, tarball io.Reader) error GetPackageManifest(ctx context.Context, packageName, versionOrTag string, force bool) (*manifest.Package, error) @@ -122,3 +124,18 @@ func (c *client) Whoami(ctx context.Context, token string) (string, error) { return response, nil } + +// GetKeysJson retrieves the public keys from the registry +func (c *client) GetKeysJson(ctx context.Context) (signatures.KeysJson, error) { + var keys signatures.KeysJson + + err := c.restClient.Get( + "/keys.json", + &keys, + ) + if err != nil { + return nil, err + } + + return keys, nil +} diff --git a/pkg/pm/signatures/signatures.go b/pkg/pm/signatures/signatures.go new file mode 100644 index 0000000..96ccbed --- /dev/null +++ b/pkg/pm/signatures/signatures.go @@ -0,0 +1,81 @@ +package signatures + +import ( + "crypto/ecdsa" + "crypto/sha256" + "crypto/x509" + "encoding/asn1" + "encoding/base64" + "fmt" + "math/big" + + "github.com/pkg/errors" +) + +const signingAlgorithm = "ECDSA_SHA_256" + +type sig struct { + R, S *big.Int +} + +type keyJson struct { + Expires string `json:"expires"` + Type string `json:"type"` + KeyID string `json:"keyid"` + PubKey string `json:"pubkey"` +} + +type KeysJson []keyJson + +// Verify verifies a Base64 encoded ASN.1 DER signature against a message using a PEM encoded Public Key. +func Verify(keys KeysJson, keyId string, signatureBase64 string, originalMessage []byte) error { + var rawPublicKeyBase64, keyType string + for _, key := range keys { + if key.KeyID == keyId { + keyType = key.Type + rawPublicKeyBase64 = key.PubKey + break + } + } + + if rawPublicKeyBase64 == "" { + return fmt.Errorf("public key with KeyID %s not found", keyId) + } + + if keyType != signingAlgorithm { + return fmt.Errorf("unsupported signing algorithm: %s", keyType) + } + + keyBytes, err := base64.StdEncoding.DecodeString(rawPublicKeyBase64) + if err != nil { + return errors.Wrap(err, "failed to decode base64 public key") + } + + genericPublicKey, err := x509.ParsePKIXPublicKey(keyBytes) + if err != nil { + return fmt.Errorf("failed to parse PKIX public key: %v", err) + } + + publicKey, ok := genericPublicKey.(*ecdsa.PublicKey) + if !ok { + return errors.New("public key is not of type ECDSA") + } + + sigBytes, err := base64.StdEncoding.DecodeString(signatureBase64) + if err != nil { + return fmt.Errorf("failed to decode base64 signature: %v", err) + } + + var sig sig + if _, err := asn1.Unmarshal(sigBytes, &sig); err != nil { + return fmt.Errorf("failed to unmarshal ASN.1 signature: %v", err) + } + + hash := sha256.Sum256(originalMessage) + valid := ecdsa.Verify(publicKey, hash[:], sig.R, sig.S) + if !valid { + return errors.New("signature verification failed: invalid signature") + } + + return nil +} diff --git a/pkg/pm/wpmjson/manifest/manifest.go b/pkg/pm/wpmjson/manifest/manifest.go index e9630cb..d22134f 100644 --- a/pkg/pm/wpmjson/manifest/manifest.go +++ b/pkg/pm/wpmjson/manifest/manifest.go @@ -2,12 +2,18 @@ package manifest import "wpm/pkg/pm/wpmjson/types" +type Signature struct { + KeyID string `json:"keyid"` + Sig string `json:"sig"` +} + // Dist struct to define the distribution metadata type Dist struct { - Digest string `json:"digest"` - TotalFiles int64 `json:"totalFiles"` - PackedSize int64 `json:"packedSize"` - UnpackedSize int64 `json:"unpackedSize"` + Digest string `json:"digest"` + Signatures []Signature `json:"signatures"` + TotalFiles int64 `json:"totalFiles"` + PackedSize int64 `json:"packedSize"` + UnpackedSize int64 `json:"unpackedSize"` } // Package struct to define the package manifest in registry