diff --git a/cmd/notation/internal/sign/sign.go b/cmd/notation/internal/sign/sign.go index 5dbecc166..e19c31c2c 100644 --- a/cmd/notation/internal/sign/sign.go +++ b/cmd/notation/internal/sign/sign.go @@ -16,8 +16,16 @@ package sign import ( "context" + "crypto" + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" "errors" + "fmt" + "github.com/notaryproject/notation-core-go/signature" "github.com/notaryproject/notation-go" "github.com/notaryproject/notation-go/config" "github.com/notaryproject/notation-go/dir" @@ -26,6 +34,54 @@ import ( "github.com/notaryproject/notation/v2/cmd/notation/internal/flag" ) +// localPrimitiveSigner implements signature.Signer for local key signing +// using PKCS#1 v1.5 (RSA) or ECDSA, required for PKCS#7 dm-verity signatures. +type localPrimitiveSigner struct { + keySpec signature.KeySpec + key crypto.PrivateKey + certs []*x509.Certificate +} + +func (s *localPrimitiveSigner) Sign(payload []byte) ([]byte, []*x509.Certificate, error) { + var hash crypto.Hash + switch s.keySpec.Size { + case 256: + hash = crypto.SHA256 + case 384: + hash = crypto.SHA384 + case 512, 521: + hash = crypto.SHA512 + default: + hash = crypto.SHA256 + } + + h := hash.New() + h.Write(payload) + digest := h.Sum(nil) + + var sig []byte + var err error + + switch k := s.key.(type) { + case *rsa.PrivateKey: + sig, err = rsa.SignPKCS1v15(rand.Reader, k, hash, digest) + case *ecdsa.PrivateKey: + sig, err = ecdsa.SignASN1(rand.Reader, k, digest) + default: + return nil, nil, fmt.Errorf("unsupported key type: %T", s.key) + } + + if err != nil { + return nil, nil, err + } + + return sig, s.certs, nil +} + +func (s *localPrimitiveSigner) KeySpec() (signature.KeySpec, error) { + return s.keySpec, nil +} + // Signer is embedded with notation.BlobSigner and notation.Signer. type Signer interface { notation.BlobSigner @@ -82,3 +138,104 @@ func resolveKey(name string) (config.KeySuite, error) { } return signingKeys.Get(name) } + +// SigningSchemeConfigKey is the plugin config key for requesting PKCS#1 v1.5 signing. +const SigningSchemeConfigKey = "signing_scheme" + +// SigningSchemePKCS1v15 requests RSASSA-PKCS1-v1_5 from plugins (required for kernel dm-verity). +const SigningSchemePKCS1v15 = "rsassa-pkcs1-v1_5" + +// GetPrimitiveSigner returns a signature.Signer for PKCS#7 dm-verity signing. +// Unlike GetSigner (which returns notation.Signer for JWS/COSE), this returns +// a raw signer used with the PKCS#7 envelope. For plugins, it automatically +// sets signing_scheme=rsassa-pkcs1-v1_5 since the Linux kernel only supports +// PKCS#1 v1.5 signature verification. +func GetPrimitiveSigner(ctx context.Context, opts *flag.SignerFlagOpts) (signature.Signer, error) { + if opts.KeyID != "" && opts.PluginName != "" && opts.Key == "" { + mgr := plugin.NewCLIManager(dir.PluginFS()) + signPlugin, err := mgr.Get(ctx, opts.PluginName) + if err != nil { + return nil, err + } + + pluginConfig := map[string]string{ + SigningSchemeConfigKey: SigningSchemePKCS1v15, + } + + keySpec, err := signer.GetKeySpecFromPlugin(ctx, signPlugin, opts.KeyID, pluginConfig) + if err != nil { + return nil, err + } + + return signer.NewPluginPrimitiveSigner(ctx, signPlugin, opts.KeyID, keySpec, pluginConfig), nil + } + + key, err := resolveKey(opts.Key) + if err != nil { + return nil, err + } + + if key.X509KeyPair != nil { + return NewLocalSignerFromFiles(key.X509KeyPair.KeyPath, key.X509KeyPair.CertificatePath) + } + + if key.ExternalKey != nil { + mgr := plugin.NewCLIManager(dir.PluginFS()) + signPlugin, err := mgr.Get(ctx, key.PluginName) + if err != nil { + return nil, err + } + + pluginConfig := make(map[string]string) + for k, v := range key.PluginConfig { + pluginConfig[k] = v + } + pluginConfig[SigningSchemeConfigKey] = SigningSchemePKCS1v15 + + keySpec, err := signer.GetKeySpecFromPlugin(ctx, signPlugin, key.ExternalKey.ID, pluginConfig) + if err != nil { + return nil, err + } + + return signer.NewPluginPrimitiveSigner(ctx, signPlugin, key.ExternalKey.ID, keySpec, pluginConfig), nil + } + + return nil, errors.New("unsupported key for primitive signing, either provide a local key and certificate file paths, or a key name in config.json") +} + +// NewLocalSignerFromFiles creates a signature.Signer from local key and certificate files. +func NewLocalSignerFromFiles(keyPath, certPath string) (signature.Signer, error) { + if keyPath == "" { + return nil, errors.New("key path not specified") + } + if certPath == "" { + return nil, errors.New("certificate path not specified") + } + + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return nil, err + } + if len(cert.Certificate) == 0 { + return nil, fmt.Errorf("%q does not contain certificate", certPath) + } + + certs := make([]*x509.Certificate, len(cert.Certificate)) + for i, c := range cert.Certificate { + certs[i], err = x509.ParseCertificate(c) + if err != nil { + return nil, err + } + } + + keySpec, err := signature.ExtractKeySpec(certs[0]) + if err != nil { + return nil, fmt.Errorf("failed to extract key spec: %w", err) + } + + return &localPrimitiveSigner{ + keySpec: keySpec, + key: cert.PrivateKey, + certs: certs, + }, nil +} diff --git a/cmd/notation/sign.go b/cmd/notation/sign.go index 92ddd566b..0c1c719d6 100644 --- a/cmd/notation/sign.go +++ b/cmd/notation/sign.go @@ -14,7 +14,9 @@ package main import ( + "bytes" "context" + "encoding/json" "errors" "fmt" "net/http" @@ -28,13 +30,17 @@ import ( "github.com/notaryproject/notation/v2/cmd/notation/internal/experimental" "github.com/notaryproject/notation/v2/cmd/notation/internal/flag" "github.com/notaryproject/notation/v2/cmd/notation/internal/sign" + "github.com/notaryproject/notation/v2/internal/dmverity" "github.com/notaryproject/notation/v2/internal/envelope" "github.com/notaryproject/notation/v2/internal/httputil" + "github.com/notaryproject/notation/v2/internal/registryutil" clirev "github.com/notaryproject/notation/v2/internal/revocation" nx509 "github.com/notaryproject/notation/v2/internal/x509" "github.com/notaryproject/tspclient-go" + "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/spf13/cobra" + "oras.land/oras-go/v2/registry" "oras.land/oras-go/v2/registry/remote" ) @@ -55,6 +61,7 @@ type signOpts struct { inputType inputType tsaServerURL string tsaRootCertificatePath string + dmVerity bool } func signCommand(opts *signOpts) *cobra.Command { @@ -90,6 +97,9 @@ Example - Sign an OCI artifact and store signature using the Referrers API. If i Example - Sign an OCI artifact with timestamping: notation sign --timestamp-url --timestamp-root-cert /@ + +Example - Sign an OCI artifact with dm-verity layer signatures (PKCS#7) and manifest signature (JWS/COSE): + notation sign --dm-verity --id /@ ` experimentalExamples := ` Example - [Experimental] Sign an OCI artifact referenced in an OCI layout @@ -97,6 +107,9 @@ Example - [Experimental] Sign an OCI artifact referenced in an OCI layout Example - [Experimental] Sign an OCI artifact identified by a tag and referenced in an OCI layout notation sign --oci-layout ":" + +Example - [Experimental] Sign an OCI artifact with dm-verity layer signatures (PKCS#7) and manifest signature (COSE): + notation sign --dm-verity --signature-format cose --id /@ ` command := &cobra.Command{ @@ -114,7 +127,7 @@ Example - [Experimental] Sign an OCI artifact identified by a tag and referenced if opts.ociLayout { opts.inputType = inputTypeOCILayout } - return experimental.CheckFlagsAndWarn(cmd, "oci-layout") + return experimental.CheckFlagsAndWarn(cmd, "oci-layout", "dm-verity") }, RunE: func(cmd *cobra.Command, args []string) error { // timestamping @@ -127,6 +140,11 @@ Example - [Experimental] Sign an OCI artifact identified by a tag and referenced } } + // dm-verity mode: layers use PKCS#7, manifest uses specified format (default: JWS) + if opts.dmVerity { + // Validation complete - dm-verity mode active + } + return runSign(cmd, opts) }, } @@ -140,18 +158,48 @@ Example - [Experimental] Sign an OCI artifact identified by a tag and referenced command.Flags().StringVar(&opts.tsaRootCertificatePath, "timestamp-root-cert", "", "filepath of timestamp authority root certificate") flag.SetPflagReferrersTag(command.Flags(), &opts.forceReferrersTag, "force to store signatures using the referrers tag schema") command.Flags().BoolVar(&opts.ociLayout, "oci-layout", false, "[Experimental] sign the artifact stored as OCI image layout") + command.Flags().BoolVar(&opts.dmVerity, "dm-verity", false, `[Experimental] sign image layers with dm-verity for kernel-level integrity verification. +Generates PKCS#7 signatures of dm-verity root hashes for each layer, compatible with +Linux kernel dm-verity and containerd erofs-snapshotter. Requires: mkfs.erofs, veritysetup`) command.MarkFlagsMutuallyExclusive("oci-layout", "force-referrers-tag") command.MarkFlagsRequiredTogether("timestamp-url", "timestamp-root-cert") - experimental.HideFlags(command, experimentalExamples, []string{"oci-layout"}) + experimental.HideFlags(command, experimentalExamples, []string{"oci-layout", "dm-verity"}) return command } +// fetchImageManifest fetches and parses an OCI manifest using registryutil.BlobFetcher. +func fetchImageManifest(ctx context.Context, secureOpts *flag.SecureFlagOpts, manifestDesc ocispec.Descriptor, reference string) (*ocispec.Manifest, error) { + ref, err := registry.ParseReference(reference) + if err != nil { + return nil, fmt.Errorf("failed to parse reference: %w", err) + } + + remoteRepo, err := getRepositoryClient(ctx, secureOpts, ref) + if err != nil { + return nil, fmt.Errorf("failed to get repository client: %w", err) + } + + fetcher, err := registryutil.NewBlobFetcher(ctx, reference, remoteRepo) + if err != nil { + return nil, fmt.Errorf("failed to create blob fetcher: %w", err) + } + + manifest, err := fetcher.FetchManifest(ctx, manifestDesc) + if err != nil { + return nil, fmt.Errorf("failed to fetch manifest: %w", err) + } + + return manifest, nil +} + func runSign(command *cobra.Command, cmdOpts *signOpts) error { // set log level ctx := cmdOpts.LoggingFlagOpts.InitializeLogger(command.Context()) // initialize - signer, err := sign.GetSigner(ctx, &cmdOpts.SignerFlagOpts) + var signer sign.Signer + var err error + signer, err = sign.GetSigner(ctx, &cmdOpts.SignerFlagOpts) if err != nil { return err } @@ -172,7 +220,67 @@ func runSign(command *cobra.Command, cmdOpts *signOpts) error { signOpts.ArtifactReference = manifestDesc.Digest.String() // core process - artifactManifestDesc, sigManifestDesc, err := notation.SignOCI(ctx, signer, sigRepo, signOpts) + var artifactManifestDesc, sigManifestDesc ocispec.Descriptor + if cmdOpts.dmVerity { + // dm-verity flow: sign layers with PKCS#7, push signatures, sign manifest with JWS/COSE + manifest, err := fetchImageManifest(ctx, &cmdOpts.SecureFlagOpts, manifestDesc, cmdOpts.reference) + if err != nil { + return fmt.Errorf("failed to fetch manifest: %w", err) + } + + ref, err := registry.ParseReference(cmdOpts.reference) + if err != nil { + return fmt.Errorf("failed to parse reference: %w", err) + } + remoteRepo, err := getRepositoryClient(ctx, &cmdOpts.SecureFlagOpts, ref) + if err != nil { + return fmt.Errorf("failed to get repository client: %w", err) + } + blobFetcher, err := registryutil.NewBlobFetcher(ctx, cmdOpts.reference, remoteRepo) + if err != nil { + return fmt.Errorf("failed to create blob fetcher: %w", err) + } + + primitiveSigner, err := sign.GetPrimitiveSigner(ctx, &cmdOpts.SignerFlagOpts) + if err != nil { + return fmt.Errorf("failed to get primitive signer for dm-verity: %w", err) + } + + layerSignatures, err := dmverity.SignImageLayers(ctx, primitiveSigner, blobFetcher, *manifest) + if err != nil { + return fmt.Errorf("failed to sign layers with dm-verity: %w", err) + } + + sigManifest, err := dmverity.CreateSignatureManifest(layerSignatures, manifestDesc) + if err != nil { + return fmt.Errorf("failed to create signature manifest: %w", err) + } + + manifestJSON, err := json.MarshalIndent(sigManifest, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal signature manifest: %w", err) + } + + manifestDigest := digest.FromBytes(manifestJSON) + + logger := log.GetLogger(ctx) + separator := strings.Repeat("=", 100) + logger.Debugf("\n%s\n", separator) + logger.Debugf("Digest: %s\n", manifestDigest) + logger.Debugf("Size: %d bytes\n", len(manifestJSON)) + logger.Debugf("%s\n", separator) + logger.Debugf("%s\n", string(manifestJSON)) + logger.Debugf("%s\n\n", separator) + + layerSigManifestDesc, err := pushDmVerityManifest(ctx, remoteRepo, sigManifest, layerSignatures) + if err != nil { + return fmt.Errorf("failed to push dm-verity manifest: %w", err) + } + + fmt.Fprintf(os.Stderr, "Pushed dm-verity layer signatures: %s\n", layerSigManifestDesc.Digest) + } + + artifactManifestDesc, sigManifestDesc, err = notation.SignOCI(ctx, signer, sigRepo, signOpts) if err != nil { var referrerError *remote.ReferrersError if !errors.As(err, &referrerError) || !referrerError.IsReferrersIndexDelete() { @@ -230,3 +338,61 @@ func prepareSigningOpts(ctx context.Context, opts *signOpts) (notation.SignOptio } return signOpts, nil } + +// pushDmVerityManifest pushes the dm-verity signature manifest and layer signatures to the registry, returns descriptor of the pushed manifest. +// It pushes the layer signatures as blobs, then pushes the manifest referencing those blobs. It verifies that all blobs are present in the registry before pushing the manifest to avoid a broken state where the manifest is pushed with missing blobs. +func pushDmVerityManifest(ctx context.Context, repo registry.Repository, sigManifest *dmverity.SignatureManifest, layerSignatures []dmverity.SignatureEnvelope) (ocispec.Descriptor, error) { + manifestJSON, err := json.MarshalIndent(sigManifest, "", " ") + if err != nil { + return ocispec.Descriptor{}, fmt.Errorf("failed to marshal signature manifest: %w", err) + } + + manifestDigest := digest.FromBytes(manifestJSON) + manifestDesc := ocispec.Descriptor{ + MediaType: sigManifest.MediaType, + Digest: manifestDigest, + Size: int64(len(manifestJSON)), + } + + // Push the empty config blob (OCI spec requires config even for artifacts) + emptyConfig := []byte("{}") + emptyConfigDesc := sigManifest.Config + + // Try to push; ignore if already exists + err = repo.Blobs().Push(ctx, emptyConfigDesc, bytes.NewReader(emptyConfig)) + if err != nil { + _, statErr := repo.Blobs().Resolve(ctx, emptyConfigDesc.Digest.String()) + if statErr != nil { + return ocispec.Descriptor{}, fmt.Errorf("failed to push empty config blob: push: %w, stat: %v", err, statErr) + } + } + + for i, sig := range layerSignatures { + desc := sigManifest.Layers[i] + err := repo.Blobs().Push(ctx, desc, bytes.NewReader(sig.Signature)) + if err != nil { + _, statErr := repo.Blobs().Resolve(ctx, desc.Digest.String()) + if statErr != nil { + return ocispec.Descriptor{}, fmt.Errorf("failed to push signature blob for layer %s: push: %w, stat: %v", sig.LayerDigest, err, statErr) + } + } + } + + // Verify all blobs are present before pushing manifest + if _, err := repo.Blobs().Resolve(ctx, emptyConfigDesc.Digest.String()); err != nil { + return ocispec.Descriptor{}, fmt.Errorf("empty config blob missing before manifest push: %w", err) + } + for i := range layerSignatures { + desc := sigManifest.Layers[i] + if _, err := repo.Blobs().Resolve(ctx, desc.Digest.String()); err != nil { + return ocispec.Descriptor{}, fmt.Errorf("signature blob %s missing before manifest push: %w", desc.Digest, err) + } + } + + err = repo.Manifests().Push(ctx, manifestDesc, bytes.NewReader(manifestJSON)) + if err != nil { + return ocispec.Descriptor{}, fmt.Errorf("failed to push signature manifest: %w", err) + } + + return manifestDesc, nil +} diff --git a/go.mod b/go.mod index 8a5da565a..43cbd38e7 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,8 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.7 + go.mozilla.org/pkcs7 v0.9.0 + golang.org/x/term v0.34.0 golang.org/x/term v0.37.0 oras.land/oras-go/v2 v2.6.0 ) diff --git a/go.sum b/go.sum index 49c516960..97ad05062 100644 --- a/go.sum +++ b/go.sum @@ -62,6 +62,8 @@ github.com/veraison/go-cose v1.3.0 h1:2/H5w8kdSpQJyVtIhx8gmwPJ2uSz1PkyWFx0idbd7r github.com/veraison/go-cose v1.3.0/go.mod h1:df09OV91aHoQWLmy1KsDdYiagtXgyAwAl8vFeFn1gMc= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.mozilla.org/pkcs7 v0.9.0 h1:yM4/HS9dYv7ri2biPtxt8ikvB37a980dg69/pKmS+eI= +go.mozilla.org/pkcs7 v0.9.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= diff --git a/internal/dmverity/dmverity.go b/internal/dmverity/dmverity.go new file mode 100644 index 000000000..d9c82a477 --- /dev/null +++ b/internal/dmverity/dmverity.go @@ -0,0 +1,156 @@ +// Copyright The Notary Project Authors. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package dmverity provides dm-verity signing functionality for OCI image layers +package dmverity + +import ( + "context" + "encoding/base64" + "fmt" + "time" + + "github.com/notaryproject/notation-core-go/signature" + "github.com/notaryproject/notation-core-go/signature/pkcs7" + "github.com/notaryproject/notation/v2/internal/erofs" + "github.com/notaryproject/notation/v2/internal/registryutil" + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +// SignatureEnvelope holds a dm-verity PKCS#7 signature envelope for a single layer. +type SignatureEnvelope struct { + LayerDigest string + RootHash string + Signature []byte +} + +// SignatureManifest is the OCI referrer artifact containing dm-verity layer signatures. +type SignatureManifest struct { + SchemaVersion int `json:"schemaVersion"` + MediaType string `json:"mediaType"` + ArtifactType string `json:"artifactType"` + Config ocispec.Descriptor `json:"config"` + Layers []ocispec.Descriptor `json:"layers"` + Subject *ocispec.Descriptor `json:"subject,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` +} + +// SignImageLayers signs all layer root hashes from OCI blob, generates PKCS#7 envelope, returns array of envelopes +func SignImageLayers(ctx context.Context, primitiveSigner signature.Signer, fetcher *registryutil.BlobFetcher, manifest ocispec.Manifest) ([]SignatureEnvelope, error) { + var signatures []SignatureEnvelope + + for _, layer := range manifest.Layers { + layerData, err := fetcher.FetchBlob(ctx, layer) + if err != nil { + return nil, fmt.Errorf("failed to fetch layer blob %s from registry: %w", layer.Digest.String(), err) + } + + rootHash, err := ComputeRootHash(layerData) + if err != nil { + return nil, fmt.Errorf("failed to generate dm-verity root hash for layer %s: %w", layer.Digest.String(), err) + } + + sig, err := signRootHashPKCS7(primitiveSigner, rootHash) + if err != nil { + return nil, fmt.Errorf("failed to sign root hash for layer %s: %w", layer.Digest.String(), err) + } + + layerSig := SignatureEnvelope{ + LayerDigest: layer.Digest.String(), + RootHash: rootHash, + Signature: sig, + } + signatures = append(signatures, layerSig) + } + + return signatures, nil +} + +// ComputeRootHash converts a compressed layer blob to an EROFS image and computes its root hash. +func ComputeRootHash(layerData []byte) (string, error) { + ctx := context.Background() + + converter := erofs.NewConverter("") + erofsData, err := converter.ConvertLayerToEROFS(ctx, layerData) + if err != nil { + return "", fmt.Errorf("EROFS conversion failed: %w", err) + } + + calculator := erofs.NewVerityCalculator("") + opts := erofs.DefaultVeritysetupOptions() + rootHash, err := calculator.CalculateRootHash(ctx, erofsData, &opts) + if err != nil { + return "", fmt.Errorf("dm-verity root hash calculation failed: %w", err) + } + + return rootHash, nil +} + +// signRootHashPKCS7 creates a PKCS#7 signature envelope for the given root hash using the provided signer. +func signRootHashPKCS7(primitiveSigner signature.Signer, rootHash string) ([]byte, error) { + env := pkcs7.NewEnvelope() + + req := &signature.SignRequest{ + Signer: primitiveSigner, + Payload: signature.Payload{ + ContentType: pkcs7.MediaTypeEnvelope, + Content: []byte(rootHash), + }, + } + + sig, err := env.Sign(req) + if err != nil { + return nil, fmt.Errorf("PKCS#7 signing failed: %w", err) + } + + if len(sig) == 0 { + return nil, fmt.Errorf("PKCS#7 signer produced empty signature for root hash %s", rootHash) + } + + return sig, nil +} + +// CreateSignatureManifest builds an OCI referrer manifest for dm-verity signatures. +func CreateSignatureManifest(signatures []SignatureEnvelope, subjectManifest ocispec.Descriptor) (*SignatureManifest, error) { + sigManifest := &SignatureManifest{ + SchemaVersion: 2, + MediaType: "application/vnd.oci.image.manifest.v1+json", + ArtifactType: "application/vnd.oci.mt.pkcs7", + Config: ocispec.DescriptorEmptyJSON, + Subject: &subjectManifest, + Annotations: map[string]string{ + "org.opencontainers.image.created": time.Now().UTC().Format(time.RFC3339), + }, + } + + for _, sig := range signatures { + sigDigest := digest.FromBytes(sig.Signature) + sigBase64 := base64.StdEncoding.EncodeToString(sig.Signature) + + layerDesc := ocispec.Descriptor{ + MediaType: "application/vnd.oci.image.layer.v1.erofs.sig", + Digest: sigDigest, + Size: int64(len(sig.Signature)), + Annotations: map[string]string{ + "image.layer.digest": sig.LayerDigest, + "image.layer.root_hash": sig.RootHash, + "image.layer.signature": sigBase64, + "signature.blob.name": fmt.Sprintf("signature_for_layer_%s.json", sig.LayerDigest[7:]), // Remove "sha256:" prefix + }, + } + sigManifest.Layers = append(sigManifest.Layers, layerDesc) + } + + return sigManifest, nil +} diff --git a/internal/dmverity/dmverity_test.go b/internal/dmverity/dmverity_test.go new file mode 100644 index 000000000..57b1a3f80 --- /dev/null +++ b/internal/dmverity/dmverity_test.go @@ -0,0 +1,41 @@ +// Copyright The Notary Project Authors. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dmverity + +import ( + "testing" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +func TestCreateSignatureManifest(t *testing.T) { + sigs := []LayerSignature{ + {LayerDigest: "sha256:abc", RootHash: "hash1", Signature: []byte("sig")}, + } + subject := ocispec.Descriptor{Digest: "sha256:subject"} + + m, err := CreateSignatureManifest(sigs, subject) + if err != nil { + t.Fatal(err) + } + if m.SchemaVersion != 2 { + t.Fatalf("SchemaVersion = %d, want 2", m.SchemaVersion) + } + if len(m.Layers) != 1 { + t.Fatalf("Layers = %d, want 1", len(m.Layers)) + } + if m.Layers[0].Annotations["image.layer.digest"] != "sha256:abc" { + t.Fatal("layer digest annotation mismatch") + } +} diff --git a/internal/erofs/converter.go b/internal/erofs/converter.go new file mode 100644 index 000000000..a342823af --- /dev/null +++ b/internal/erofs/converter.go @@ -0,0 +1,167 @@ +// Copyright The Notary Project Authors. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package erofs converts OCI layer tarballs to EROFS filesystem format +// using mkfs.erofs --tar=i (tar index mode). The output must match +// containerd's erofs-snapshotter for compatible dm-verity root hashes. +package erofs + +import ( + "bytes" + "compress/gzip" + "context" + "fmt" + "io" + "os" + "os/exec" + "time" +) + +const ( + mkfsErofsTimeout = 5 * time.Minute + blockAlignment = 512 // Must match erofs-snapshotter EROFS_BLOCK_ALIGNMENT + fixedMetadataUUID = "c1b9d5a2-f162-11cf-9ece-0020afc76f16" // Must match erofs-snapshotter EROFS_METADATA_UUID +) + +// Converter converts OCI layers to EROFS using tar-index mode. +type Converter struct { + TempDir string +} + +// NewConverter creates a new EROFS converter. If tempDir is empty, os.TempDir() is used. +func NewConverter(tempDir string) *Converter { + if tempDir == "" { + tempDir = os.TempDir() + } + return &Converter{ + TempDir: tempDir, + } +} + +// ConvertLayerToEROFS converts a compressed OCI layer (tar.gz) to EROFS format. +// Returns EROFS metadata + tar data, aligned to 512-byte boundary for dm-verity. +func (c *Converter) ConvertLayerToEROFS(ctx context.Context, layerData []byte) ([]byte, error) { + if len(layerData) == 0 { + return nil, fmt.Errorf("layer data is empty") + } + + tarData, err := c.decompressGzip(layerData) + if err != nil { + return nil, fmt.Errorf("failed to decompress gzip: %w", err) + } + + erofsData, err := c.buildEROFSImage(ctx, tarData) + if err != nil { + return nil, fmt.Errorf("failed to build EROFS image: %w", err) + } + + return erofsData, nil +} + +// decompressGzip decompresses gzip-compressed data and returns the raw tar data. +func (c *Converter) decompressGzip(compressedData []byte) ([]byte, error) { + gzReader, err := gzip.NewReader(bytes.NewReader(compressedData)) + if err != nil { + return nil, fmt.Errorf("layer data is not valid gzip (expected tar.gz layer): %w", err) + } + defer gzReader.Close() + + var buf bytes.Buffer + if _, err := io.Copy(&buf, gzReader); err != nil { + return nil, fmt.Errorf("gzip decompression failed (corrupted layer data?): %w", err) + } + return buf.Bytes(), nil +} + +// buildEROFSImage creates an EROFS image from tar data using mkfs.erofs --tar=i. +// It generates EROFS metadata, appends the original tar data, and aligns the result +// to 512 bytes for dm-verity compatibility. +func (c *Converter) buildEROFSImage(ctx context.Context, tarData []byte) ([]byte, error) { + if _, err := exec.LookPath("mkfs.erofs"); err != nil { + return nil, fmt.Errorf("mkfs.erofs not found in PATH: install 'erofs-utils' package (apt install erofs-utils / dnf install erofs-utils): %w", err) + } + + tarFile, err := os.CreateTemp(c.TempDir, "layer-*.tar") + if err != nil { + return nil, fmt.Errorf("failed to create temp tar file: %w", err) + } + tarPath := tarFile.Name() + defer os.Remove(tarPath) + + if _, err := tarFile.Write(tarData); err != nil { + tarFile.Close() + return nil, fmt.Errorf("failed to write tar data: %w", err) + } + tarFile.Close() + + erofsFile, err := os.CreateTemp(c.TempDir, "erofs-metadata-*.img") + if err != nil { + return nil, fmt.Errorf("failed to create temp EROFS file: %w", err) + } + erofsPath := erofsFile.Name() + erofsFile.Close() + defer os.Remove(erofsPath) + + cmdCtx, cancel := context.WithTimeout(ctx, mkfsErofsTimeout) + defer cancel() + + // Flags must match erofs-snapshotter's mkfs.erofs invocation + cmd := exec.CommandContext(cmdCtx, "mkfs.erofs", + "--tar=i", // tar index mode + "-T", "0", // Zero unix time + "--mkfs-time", // Clear mkfs time in superblock + "-U", fixedMetadataUUID, // Fixed UUID for deterministic builds + "--aufs", // Convert OCI whiteouts to overlayfs metadata + "--quiet", // Quiet mode + erofsPath, + tarPath, + ) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("mkfs.erofs failed: %w, stdout: %s, stderr: %s", err, stdout.String(), stderr.String()) + } + + erofsMetadata, err := os.ReadFile(erofsPath) + if err != nil { + return nil, fmt.Errorf("failed to read EROFS metadata: %w", err) + } + + combinedData := appendTarData(erofsMetadata, tarData) + alignedData := alignTo512(combinedData) + + return alignedData, nil +} + +func appendTarData(erofsMetadata, tarData []byte) []byte { + combined := make([]byte, len(erofsMetadata)+len(tarData)) + copy(combined, erofsMetadata) + copy(combined[len(erofsMetadata):], tarData) + return combined +} + +// alignTo512 pads data to 512-byte boundary for dm-verity. +func alignTo512(data []byte) []byte { + remainder := len(data) % blockAlignment + if remainder == 0 { + return data + } + + paddingSize := blockAlignment - remainder + aligned := make([]byte, len(data)+paddingSize) + copy(aligned, data) + return aligned +} diff --git a/internal/erofs/erofs_test.go b/internal/erofs/erofs_test.go new file mode 100644 index 000000000..0ec7ab935 --- /dev/null +++ b/internal/erofs/erofs_test.go @@ -0,0 +1,68 @@ +// Copyright The Notary Project Authors. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package erofs + +import ( + "testing" +) + +func TestAlignTo512(t *testing.T) { + // already aligned — no change + if len(alignTo512(make([]byte, 512))) != 512 { + t.Fatal("expected 512") + } + // not aligned — padded up + if len(alignTo512(make([]byte, 1))) != 512 { + t.Fatal("expected 512") + } +} + +func TestAppendTarData(t *testing.T) { + got := appendTarData([]byte{1, 2}, []byte{3, 4}) + if len(got) != 4 || got[0] != 1 || got[2] != 3 { + t.Fatal("unexpected result") + } +} + +func TestExtractRootHashFromOutput(t *testing.T) { + output := "Root hash:\tabc123def456\n" + hash, err := extractRootHashFromOutput(output) + if err != nil || hash != "abc123def456" { + t.Fatalf("got %q, err %v", hash, err) + } + + // error on empty + if _, err := extractRootHashFromOutput(""); err == nil { + t.Fatal("expected error") + } +} + +func TestDefaultVeritysetupOptions(t *testing.T) { + opts := DefaultVeritysetupOptions() + if opts.HashAlgorithm != "sha256" || opts.DataBlockSize != 512 { + t.Fatal("unexpected defaults") + } +} + +func TestNewConverter(t *testing.T) { + if NewConverter("").TempDir == "" { + t.Fatal("expected non-empty TempDir") + } +} + +func TestNewVerityCalculator(t *testing.T) { + if NewVerityCalculator("").TempDir == "" { + t.Fatal("expected non-empty TempDir") + } +} diff --git a/internal/erofs/veritysetup.go b/internal/erofs/veritysetup.go new file mode 100644 index 000000000..01ec2bbee --- /dev/null +++ b/internal/erofs/veritysetup.go @@ -0,0 +1,217 @@ +// Copyright The Notary Project Authors. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package erofs + +import ( + "bufio" + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +const ( + veritysetupTimeout = 5 * time.Minute + defaultSalt = "0000000000000000000000000000000000000000000000000000000000000000" + defaultHashAlgorithm = "sha256" + defaultBlockSize = 512 // Must match erofs-snapshotter VERITY_BLOCK_SIZE +) + +// VeritysetupOptions configures dm-verity hash calculation. +type VeritysetupOptions struct { + Salt string + HashAlgorithm string + DataBlockSize uint32 + HashBlockSize uint32 + DataBlocks uint64 + HashOffset uint64 +} + +// DefaultVeritysetupOptions returns options matching containerd erofs-snapshotter defaults. +func DefaultVeritysetupOptions() VeritysetupOptions { + return VeritysetupOptions{ + Salt: defaultSalt, + HashAlgorithm: defaultHashAlgorithm, + DataBlockSize: defaultBlockSize, + HashBlockSize: defaultBlockSize, + } +} + +// VerityCalculator computes dm-verity root hashes. +type VerityCalculator struct { + TempDir string +} + +// NewVerityCalculator creates a new calculator. If tempDir is empty, os.TempDir() is used. +func NewVerityCalculator(tempDir string) *VerityCalculator { + if tempDir == "" { + tempDir = os.TempDir() + } + return &VerityCalculator{ + TempDir: tempDir, + } +} + +// CalculateRootHash computes the dm-verity root hash for an EROFS image. +func (v *VerityCalculator) CalculateRootHash(ctx context.Context, erofsData []byte, opts *VeritysetupOptions) (string, error) { + if len(erofsData) == 0 { + return "", fmt.Errorf("EROFS data is empty") + } + + if opts == nil { + defaultOpts := DefaultVeritysetupOptions() + opts = &defaultOpts + } + + if _, err := exec.LookPath("veritysetup"); err != nil { + return "", fmt.Errorf("veritysetup not found in PATH: install 'cryptsetup' package (apt install cryptsetup / dnf install cryptsetup): %w", err) + } + + dataSize := int64(len(erofsData)) + erofsFile, err := os.CreateTemp(v.TempDir, "erofs-verity-*.img") + if err != nil { + return "", fmt.Errorf("failed to create temp EROFS file: %w", err) + } + erofsPath := erofsFile.Name() + defer os.Remove(erofsPath) + + if _, err := erofsFile.Write(erofsData); err != nil { + erofsFile.Close() + return "", fmt.Errorf("failed to write EROFS data: %w", err) + } + + erofsFile.Close() + + if opts.HashOffset == 0 { + opts.HashOffset = uint64(dataSize) + } + + var hashDevicePath string + if opts.HashOffset > 0 { + hashSize := (dataSize / 100) + 8192 + totalSize := dataSize + hashSize + f, err := os.OpenFile(erofsPath, os.O_WRONLY, 0) + if err != nil { + return "", fmt.Errorf("failed to open EROFS file for append: %w", err) + } + if err := f.Truncate(totalSize); err != nil { + f.Close() + return "", fmt.Errorf("failed to extend file for hash tree: %w", err) + } + f.Close() + hashDevicePath = erofsPath + } else { + hashFile, err := os.CreateTemp(v.TempDir, "erofs-verity-hash-*.img") + if err != nil { + return "", fmt.Errorf("failed to create temp hash file: %w", err) + } + hashDevicePath = hashFile.Name() + hashFile.Close() + defer os.Remove(hashDevicePath) + } + + blockSize := uint64(opts.DataBlockSize) + if blockSize == 0 { + blockSize = defaultBlockSize + } + opts.DataBlocks = (uint64(dataSize) + (blockSize - 1)) / blockSize + + rootHash, err := v.runVeritysetupFormat(ctx, erofsPath, hashDevicePath, opts) + if err != nil { + return "", fmt.Errorf("veritysetup format failed: %w", err) + } + + return rootHash, nil +} + +// runVeritysetupFormat executes the veritysetup format command and extracts the root hash from its output. +func (v *VerityCalculator) runVeritysetupFormat(ctx context.Context, dataDevice, hashDevice string, opts *VeritysetupOptions) (string, error) { + cmdCtx, cancel := context.WithTimeout(ctx, veritysetupTimeout) + defer cancel() + + args := []string{"format"} + + if opts.Salt != "" { + args = append(args, fmt.Sprintf("--salt=%s", opts.Salt)) + } + if opts.HashAlgorithm != "" { + args = append(args, fmt.Sprintf("--hash=%s", opts.HashAlgorithm)) + } + if opts.DataBlockSize > 0 { + args = append(args, fmt.Sprintf("--data-block-size=%d", opts.DataBlockSize)) + } + if opts.HashBlockSize > 0 { + args = append(args, fmt.Sprintf("--hash-block-size=%d", opts.HashBlockSize)) + } + if opts.DataBlocks > 0 { + args = append(args, fmt.Sprintf("--data-blocks=%d", opts.DataBlocks)) + } + if opts.HashOffset > 0 { + args = append(args, fmt.Sprintf("--hash-offset=%d", opts.HashOffset)) + } + + args = append(args, dataDevice, hashDevice) + + cmd := exec.CommandContext(cmdCtx, "veritysetup", args...) + + cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C") + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("veritysetup command failed: %w, stdout: %s, stderr: %s", + err, stdout.String(), stderr.String()) + } + + rootHash, err := extractRootHashFromOutput(stdout.String()) + if err != nil { + return "", fmt.Errorf("failed to extract root hash: %w, output: %s", err, stdout.String()) + } + + return rootHash, nil +} + +// extractRootHashFromOutput parses the "Root hash:" line from veritysetup output. +func extractRootHashFromOutput(output string) (string, error) { + if output == "" { + return "", fmt.Errorf("output is empty") + } + + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + line := scanner.Text() + // Look for the "Root hash:" line + if strings.HasPrefix(line, "Root hash:") { + parts := strings.Split(line, ":") + if len(parts) == 2 { + rootHash := strings.TrimSpace(parts[1]) + if rootHash == "" { + return "", fmt.Errorf("root hash is empty") + } + return rootHash, nil + } + } + } + + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("error scanning output: %w", err) + } + + return "", fmt.Errorf("root hash not found in veritysetup output (expected 'Root hash:' line)") +} diff --git a/internal/registryutil/fetcher.go b/internal/registryutil/fetcher.go new file mode 100644 index 000000000..75b751dc3 --- /dev/null +++ b/internal/registryutil/fetcher.go @@ -0,0 +1,84 @@ +// Copyright The Notary Project Authors. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package registryutil provides OCI registry blob fetching utilities. +package registryutil + +import ( + "context" + "encoding/json" + "fmt" + "io" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2/registry" + "oras.land/oras-go/v2/registry/remote" +) + +// BlobFetcher fetches blobs from an OCI registry. +type BlobFetcher struct { + remoteRepo *remote.Repository + reference registry.Reference +} + +// NewBlobFetcher creates a new BlobFetcher. The remoteRepo should have +// authentication already configured. +func NewBlobFetcher(ctx context.Context, reference string, remoteRepo *remote.Repository) (*BlobFetcher, error) { + ref, err := registry.ParseReference(reference) + if err != nil { + return nil, fmt.Errorf("failed to parse reference %s: %w", reference, err) + } + + return &BlobFetcher{ + remoteRepo: remoteRepo, + reference: ref, + }, nil +} + +// FetchManifest fetches the manifest from the registry and extracts it into an ocispec.Manifest struct. +// With a json object returned by the registry, we can iterate through the layers and fetch each layer blob to compute the root hash and signature. +func (f *BlobFetcher) FetchManifest(ctx context.Context, manifestDesc ocispec.Descriptor) (*ocispec.Manifest, error) { + reader, err := f.remoteRepo.Fetch(ctx, manifestDesc) + if err != nil { + return nil, fmt.Errorf("failed to fetch manifest blob: %w", err) + } + defer reader.Close() + + manifestBytes, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("failed to read manifest content: %w", err) + } + + var manifest ocispec.Manifest + if err := json.Unmarshal(manifestBytes, &manifest); err != nil { + return nil, fmt.Errorf("failed to unmarshal manifest JSON: %w", err) + } + + return &manifest, nil +} + +// FetchBlob fetches a blob by descriptor and returns its raw content. +func (f *BlobFetcher) FetchBlob(ctx context.Context, desc ocispec.Descriptor) ([]byte, error) { + reader, err := f.remoteRepo.Fetch(ctx, desc) + if err != nil { + return nil, fmt.Errorf("failed to fetch blob %s: %w", desc.Digest, err) + } + defer reader.Close() + + content, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("failed to read blob content: %w", err) + } + + return content, nil +}