From 8d4858f74fbab0a99efc133bc473115c37d06b97 Mon Sep 17 00:00:00 2001 From: Shiwei Zhang Date: Fri, 17 Jul 2020 14:47:20 +0800 Subject: [PATCH 01/12] basic prototype (#1) --- cmd/nv2/main.go | 33 +++++++++ cmd/nv2/manifest.go | 59 +++++++++++++++ cmd/nv2/sign.go | 127 +++++++++++++++++++++++++++++++++ cmd/nv2/verify.go | 108 ++++++++++++++++++++++++++++ go.mod | 9 +++ go.sum | 17 +++++ internal/crypto/x509.go | 51 +++++++++++++ pkg/signature/errors.go | 10 +++ pkg/signature/interface.go | 12 ++++ pkg/signature/scheme.go | 100 ++++++++++++++++++++++++++ pkg/signature/signature.go | 35 +++++++++ pkg/signature/util.go | 21 ++++++ pkg/signature/x509/signer.go | 76 ++++++++++++++++++++ pkg/signature/x509/type.go | 4 ++ pkg/signature/x509/verifier.go | 98 +++++++++++++++++++++++++ 15 files changed, 760 insertions(+) create mode 100644 cmd/nv2/main.go create mode 100644 cmd/nv2/manifest.go create mode 100644 cmd/nv2/sign.go create mode 100644 cmd/nv2/verify.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/crypto/x509.go create mode 100644 pkg/signature/errors.go create mode 100644 pkg/signature/interface.go create mode 100644 pkg/signature/scheme.go create mode 100644 pkg/signature/signature.go create mode 100644 pkg/signature/util.go create mode 100644 pkg/signature/x509/signer.go create mode 100644 pkg/signature/x509/type.go create mode 100644 pkg/signature/x509/verifier.go diff --git a/cmd/nv2/main.go b/cmd/nv2/main.go new file mode 100644 index 000000000..b2ab1a66d --- /dev/null +++ b/cmd/nv2/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "log" + "os" + + "github.com/urfave/cli/v2" +) + +func main() { + app := &cli.App{ + Name: "nv2", + Usage: "Notary V2 - Prototype", + Version: "0.1.0", + Authors: []*cli.Author{ + { + Name: "Shiwei Zhang", + Email: "shizh@microsoft.com", + }, + { + Name: "Aviral Takkar", + Email: "avtakkar@microsoft.com", + }, + }, + Commands: []*cli.Command{ + signCommand, + verifyCommand, + }, + } + if err := app.Run(os.Args); err != nil { + log.Fatal(err) + } +} diff --git a/cmd/nv2/manifest.go b/cmd/nv2/manifest.go new file mode 100644 index 000000000..03fa4f5dc --- /dev/null +++ b/cmd/nv2/manifest.go @@ -0,0 +1,59 @@ +package main + +import ( + "fmt" + "io" + "math" + "net/url" + "os" + + "github.com/notaryproject/nv2/pkg/signature" + "github.com/opencontainers/go-digest" + "github.com/urfave/cli/v2" +) + +func getManifestFromContext(ctx *cli.Context) (signature.Manifest, error) { + if uri := ctx.Args().First(); uri != "" { + return getManfestsFromURI(uri) + } + return getManifestFromReader(os.Stdin) +} + +func getManifestFromReader(r io.Reader) (signature.Manifest, error) { + lr := &io.LimitedReader{ + R: r, + N: math.MaxInt64, + } + digest, err := digest.SHA256.FromReader(lr) + if err != nil { + return signature.Manifest{}, err + } + return signature.Manifest{ + Digest: digest.String(), + Size: math.MaxInt64 - lr.N, + }, nil +} + +func getManfestsFromURI(uri string) (signature.Manifest, error) { + parsed, err := url.Parse(uri) + if err != nil { + return signature.Manifest{}, err + } + var r io.Reader + switch parsed.Scheme { + case "file": + path := parsed.Path + if parsed.Opaque != "" { + path = parsed.Opaque + } + file, err := os.Open(path) + if err != nil { + return signature.Manifest{}, err + } + defer file.Close() + r = file + default: + return signature.Manifest{}, fmt.Errorf("unsupported URI scheme: %s", parsed.Scheme) + } + return getManifestFromReader(r) +} diff --git a/cmd/nv2/sign.go b/cmd/nv2/sign.go new file mode 100644 index 000000000..41f7e1941 --- /dev/null +++ b/cmd/nv2/sign.go @@ -0,0 +1,127 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "strings" + "time" + + "github.com/notaryproject/nv2/pkg/signature" + "github.com/notaryproject/nv2/pkg/signature/x509" + "github.com/urfave/cli/v2" +) + +const signerID = "nv2" + +var signCommand = &cli.Command{ + Name: "sign", + Usage: "signs artifacts or images", + ArgsUsage: "[]", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "method", + Aliases: []string{"m"}, + Usage: "siging method", + Required: true, + }, + &cli.StringFlag{ + Name: "key", + Aliases: []string{"k"}, + Usage: "siging key file", + TakesFile: true, + }, + &cli.StringFlag{ + Name: "cert", + Aliases: []string{"c"}, + Usage: "siging cert", + TakesFile: true, + }, + &cli.DurationFlag{ + Name: "expiry", + Aliases: []string{"e"}, + Usage: "expire duration", + }, + &cli.StringSliceFlag{ + Name: "reference", + Aliases: []string{"r"}, + Usage: "original references", + }, + &cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "write signature to a specific path", + }, + }, + Action: runSign, +} + +func runSign(ctx *cli.Context) error { + // initialize + scheme, err := getSchemeForSigning(ctx) + if err != nil { + return err + } + + // core process + content, err := prepareContentForSigning(ctx) + if err != nil { + return err + } + sig, err := scheme.Sign(signerID, content) + if err != nil { + return err + } + sigma, err := signature.Pack(content, sig) + if err != nil { + return err + } + + // write out + sigmaJSON, err := json.Marshal(sigma) + if err != nil { + return err + } + path := ctx.String("output") + if path == "" { + path = strings.Split(content.Manifests[0].Digest, ":")[1] + ".nv2" + } + return ioutil.WriteFile(path, sigmaJSON, 0666) +} + +func prepareContentForSigning(ctx *cli.Context) (signature.Content, error) { + manifest, err := getManifestFromContext(ctx) + if err != nil { + return signature.Content{}, err + } + manifest.References = ctx.StringSlice("reference") + now := time.Now() + nowUnix := now.Unix() + content := signature.Content{ + IssuedAt: nowUnix, + Manifests: []signature.Manifest{ + manifest, + }, + } + if expiry := ctx.Duration("expiry"); expiry != 0 { + content.NotBefore = nowUnix + content.Expiration = now.Add(expiry).Unix() + } + + return content, nil +} + +func getSchemeForSigning(ctx *cli.Context) (*signature.Scheme, error) { + scheme := signature.NewScheme() + switch method := ctx.String("method"); method { + case "x509": + signer, err := x509.NewSignerFromFiles(ctx.String("key"), ctx.String("cert")) + if err != nil { + return nil, err + } + scheme.RegisterSigner(signerID, signer) + default: + return nil, fmt.Errorf("unsupported signing method: %s", method) + } + return scheme, nil +} diff --git a/cmd/nv2/verify.go b/cmd/nv2/verify.go new file mode 100644 index 000000000..06290d648 --- /dev/null +++ b/cmd/nv2/verify.go @@ -0,0 +1,108 @@ +package main + +import ( + "crypto/x509" + "encoding/json" + "fmt" + "os" + + "github.com/notaryproject/nv2/internal/crypto" + "github.com/notaryproject/nv2/pkg/signature" + x509nv2 "github.com/notaryproject/nv2/pkg/signature/x509" + "github.com/urfave/cli/v2" +) + +var verifyCommand = &cli.Command{ + Name: "verify", + Usage: "verifies artifacts or images", + ArgsUsage: "[]", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "signature", + Aliases: []string{"s", "f"}, + Usage: "signature file", + Required: true, + TakesFile: true, + }, + &cli.StringSliceFlag{ + Name: "ca-cert", + Aliases: []string{"c"}, + Usage: "CA certs for verification", + TakesFile: true, + }, + }, + Action: runVerify, +} + +func runVerify(ctx *cli.Context) error { + // initialize + scheme, err := getSchemeForVerification(ctx) + if err != nil { + return err + } + sigma, err := readSignatrueFile(ctx.String("signature")) + if err != nil { + return err + } + + // core process + content, _, err := scheme.Verify(sigma) + if err != nil { + return fmt.Errorf("verification failure: %v", err) + } + manifest, err := getManifestFromContext(ctx) + if err != nil { + return err + } + if !containsManifest(content.Manifests, manifest) { + return fmt.Errorf("verification failure: manifest is not signed: %s", manifest.Digest) + } + + // write out + fmt.Println(manifest.Digest) + return nil +} + +func containsManifest(set []signature.Manifest, target signature.Manifest) bool { + for _, manifest := range set { + if manifest.Digest == target.Digest && manifest.Size == target.Size { + return true + } + } + return false +} + +func readSignatrueFile(path string) (sig signature.Signed, err error) { + file, err := os.Open(path) + if err != nil { + return sig, err + } + defer file.Close() + err = json.NewDecoder(file).Decode(&sig) + return sig, err +} + +func getSchemeForVerification(ctx *cli.Context) (*signature.Scheme, error) { + var roots *x509.CertPool + if caCerts := ctx.StringSlice("ca-cert"); len(caCerts) > 0 { + roots = x509.NewCertPool() + for _, path := range caCerts { + certs, err := crypto.ReadCertificateFile(path) + if err != nil { + return nil, err + } + for _, cert := range certs { + roots.AddCert(cert) + } + } + } + + verifier, err := x509nv2.NewVerifier(roots) + if err != nil { + return nil, err + } + + scheme := signature.NewScheme() + scheme.RegisterVerifier(verifier) + return scheme, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..f950daa92 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/notaryproject/nv2 + +go 1.14 + +require ( + github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 + github.com/opencontainers/go-digest v1.0.0 + github.com/urfave/cli/v2 v2.2.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..bf6562f0a --- /dev/null +++ b/go.sum @@ -0,0 +1,17 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4= +github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/urfave/cli v1.22.4 h1:u7tSpNPPswAFymm8IehJhy4uJMlUuU/GmqSkvJ1InXA= +github.com/urfave/cli/v2 v2.2.0 h1:JTTnM6wKzdA0Jqodd966MVj4vWbbquZykeX1sKbe2C4= +github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/crypto/x509.go b/internal/crypto/x509.go new file mode 100644 index 000000000..e53173d06 --- /dev/null +++ b/internal/crypto/x509.go @@ -0,0 +1,51 @@ +package crypto + +import ( + "crypto/x509" + "encoding/pem" + "errors" + "io/ioutil" + + "github.com/docker/libtrust" +) + +// ReadPrivateKeyFile reads a key PEM file as a libtrust key +func ReadPrivateKeyFile(path string) (libtrust.PrivateKey, error) { + raw, err := ioutil.ReadFile(path) + if err != nil { + return nil, err + } + block, _ := pem.Decode(raw) + if block == nil { + return nil, errors.New("no PEM data found") + } + switch block.Type { + case "PRIVATE KEY": + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + return libtrust.FromCryptoPrivateKey(key) + default: + return libtrust.UnmarshalPrivateKeyPEM(raw) + } +} + +// ReadCertificateFile reads a certificate PEM file +func ReadCertificateFile(path string) ([]*x509.Certificate, error) { + raw, err := ioutil.ReadFile(path) + if err != nil { + return nil, err + } + var certs []*x509.Certificate + block, rest := pem.Decode(raw) + for block != nil { + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, err + } + certs = append(certs, cert) + block, rest = pem.Decode(rest) + } + return certs, nil +} diff --git a/pkg/signature/errors.go b/pkg/signature/errors.go new file mode 100644 index 000000000..6c939d883 --- /dev/null +++ b/pkg/signature/errors.go @@ -0,0 +1,10 @@ +package signature + +import "errors" + +// common errors +var ( + ErrInvalidSignatureType = errors.New("invalid signature type") + ErrUnknownSignatureType = errors.New("unknown signature type") + ErrUnknownSigner = errors.New("unknown signer") +) diff --git a/pkg/signature/interface.go b/pkg/signature/interface.go new file mode 100644 index 000000000..e1127e10f --- /dev/null +++ b/pkg/signature/interface.go @@ -0,0 +1,12 @@ +package signature + +// Signer signs content +type Signer interface { + Sign(content []byte) (Signature, error) +} + +// Verifier verifies content +type Verifier interface { + Type() string + Verify(content []byte, signature Signature) error +} diff --git a/pkg/signature/scheme.go b/pkg/signature/scheme.go new file mode 100644 index 000000000..6c3b735d6 --- /dev/null +++ b/pkg/signature/scheme.go @@ -0,0 +1,100 @@ +package signature + +import ( + "encoding/json" + "errors" + "fmt" + "time" +) + +// Scheme is a signature scheme +type Scheme struct { + signers map[string]Signer + verifiers map[string]Verifier +} + +// NewScheme creates a new scheme +func NewScheme() *Scheme { + return &Scheme{ + signers: make(map[string]Signer), + verifiers: make(map[string]Verifier), + } +} + +// RegisterSigner registers signer with a name +func (s *Scheme) RegisterSigner(signerID string, signer Signer) { + s.signers[signerID] = signer +} + +// RegisterVerifier registers verifier +func (s *Scheme) RegisterVerifier(verifier Verifier) { + s.verifiers[verifier.Type()] = verifier +} + +// Sign signs content by a signer +func (s *Scheme) Sign(signerID string, content Content) (Signature, error) { + bytes, err := json.Marshal(content) + if err != nil { + return Signature{}, err + } + return s.SignRaw(signerID, bytes) +} + +// SignRaw signs raw content by a signer +func (s *Scheme) SignRaw(signerID string, content []byte) (Signature, error) { + signer, found := s.signers[signerID] + if !found { + return Signature{}, ErrUnknownSigner + } + return signer.Sign(content) +} + +// Verify verifies signed data +func (s *Scheme) Verify(signed Signed) (Content, Signature, error) { + sig, err := s.verifySignature(signed) + if err != nil { + return Content{}, sig, err + } + + var content Content + if err := json.Unmarshal(signed.Signed, &content); err != nil { + return Content{}, sig, err + } + + return content, sig, s.verifyContent(content) +} + +func (s *Scheme) verifySignature(signed Signed) (Signature, error) { + content := []byte(signed.Signed) + var err error + for _, sig := range signed.Signatures { + verifier, found := s.verifiers[sig.Type] + if !found { + err = ErrUnknownSignatureType + continue + } + if err = verifier.Verify(content, sig); err == nil { + return sig, nil + } + } + switch len(signed.Signatures) { + case 0: + err = errors.New("no signature found") + case 1: + // no op + default: + err = errors.New("no valid signature found") + } + return Signature{}, err +} + +func (s *Scheme) verifyContent(content Content) error { + now := time.Now().Unix() + if content.Expiration != 0 && now > content.Expiration { + return fmt.Errorf("content expired: %d: current: %d", content.Expiration, now) + } + if content.NotBefore != 0 && now < content.NotBefore { + return fmt.Errorf("content is not available yet: %d: current: %d", content.NotBefore, now) + } + return nil +} diff --git a/pkg/signature/signature.go b/pkg/signature/signature.go new file mode 100644 index 000000000..0e876ae80 --- /dev/null +++ b/pkg/signature/signature.go @@ -0,0 +1,35 @@ +package signature + +import ( + "encoding/json" +) + +// Signed is the high level, partially deserialized metadata object +type Signed struct { + Signed json.RawMessage `json:"signed"` + Signatures []Signature `json:"signatures"` +} + +// Content contains the contents to be signed +type Content struct { + Expiration int64 `json:"exp,omitempty"` + NotBefore int64 `json:"nbf,omitempty"` + IssuedAt int64 `json:"iat,omitempty"` + Manifests []Manifest `json:"manifests"` +} + +// Manifest to be signed +type Manifest struct { + Digest string `json:"digest"` + Size int64 `json:"size"` + References []string `json:"references,omitempty"` +} + +// Signature to verify the content +type Signature struct { + Type string `json:"typ"` + Algorithm string `json:"alg,omitempty"` + KeyID string `json:"kid,omitempty"` + X5c [][]byte `json:"x5c,omitempty"` + Signature []byte `json:"sig"` +} diff --git a/pkg/signature/util.go b/pkg/signature/util.go new file mode 100644 index 000000000..7cecc4ecf --- /dev/null +++ b/pkg/signature/util.go @@ -0,0 +1,21 @@ +package signature + +import ( + "encoding/json" + "errors" +) + +// Pack packs content with its signatures +func Pack(content Content, signatures ...Signature) (Signed, error) { + signed, err := json.Marshal(content) + if err != nil { + return Signed{}, err + } + if len(signatures) == 0 { + return Signed{}, errors.New("missing signatures") + } + return Signed{ + Signed: signed, + Signatures: signatures, + }, nil +} diff --git a/pkg/signature/x509/signer.go b/pkg/signature/x509/signer.go new file mode 100644 index 000000000..116c671f4 --- /dev/null +++ b/pkg/signature/x509/signer.go @@ -0,0 +1,76 @@ +package x509 + +import ( + "bytes" + "crypto" + "crypto/x509" + "errors" + + "github.com/docker/libtrust" + cryptoutil "github.com/notaryproject/nv2/internal/crypto" + "github.com/notaryproject/nv2/pkg/signature" +) + +type signer struct { + key libtrust.PrivateKey + cert *x509.Certificate + rawCerts [][]byte + hash crypto.Hash +} + +// NewSignerFromFiles creates a signer from files +func NewSignerFromFiles(keyPath, certPath string) (signature.Signer, error) { + key, err := cryptoutil.ReadPrivateKeyFile(keyPath) + if err != nil { + return nil, err + } + certs, err := cryptoutil.ReadCertificateFile(certPath) + if err != nil { + return nil, err + } + return NewSigner(key, certs) +} + +// NewSigner creates a signer +func NewSigner(key libtrust.PrivateKey, certs []*x509.Certificate) (signature.Signer, error) { + if len(certs) == 0 { + return nil, errors.New("missing certificates") + } + + cert := certs[0] + publicKey, err := libtrust.FromCryptoPublicKey(crypto.PublicKey(cert.PublicKey)) + if err != nil { + return nil, err + } + if key.KeyID() != publicKey.KeyID() { + return nil, errors.New("key and certificate mismatch") + } + + rawCerts := make([][]byte, 0, len(certs)) + for _, cert := range certs { + rawCerts = append(rawCerts, cert.Raw) + } + + return &signer{ + key: key, + cert: cert, + rawCerts: rawCerts, + hash: crypto.SHA256, + }, nil +} + +func (s *signer) Sign(raw []byte) (signature.Signature, error) { + if err := verifyReferences(raw, s.cert); err != nil { + return signature.Signature{}, err + } + sig, alg, err := s.key.Sign(bytes.NewReader(raw), s.hash) + if err != nil { + return signature.Signature{}, err + } + return signature.Signature{ + Type: Type, + Algorithm: alg, + X5c: s.rawCerts, + Signature: sig, + }, nil +} diff --git a/pkg/signature/x509/type.go b/pkg/signature/x509/type.go new file mode 100644 index 000000000..82dd54370 --- /dev/null +++ b/pkg/signature/x509/type.go @@ -0,0 +1,4 @@ +package x509 + +// Type indicates the signature type +const Type = "x509" diff --git a/pkg/signature/x509/verifier.go b/pkg/signature/x509/verifier.go new file mode 100644 index 000000000..9ba0f2712 --- /dev/null +++ b/pkg/signature/x509/verifier.go @@ -0,0 +1,98 @@ +package x509 + +import ( + "bytes" + "crypto" + "crypto/x509" + "encoding/json" + "errors" + "strings" + + "github.com/docker/libtrust" + "github.com/notaryproject/nv2/pkg/signature" +) + +type verifier struct { + roots *x509.CertPool +} + +// NewVerifier creates a verifier +func NewVerifier(roots *x509.CertPool) (signature.Verifier, error) { + if roots == nil { + pool, err := x509.SystemCertPool() + if err != nil { + return nil, err + } + roots = pool + } + + return &verifier{ + roots: roots, + }, nil +} + +func (v *verifier) Type() string { + return Type +} + +func (v *verifier) Verify(content []byte, sig signature.Signature) error { + if sig.Type != Type { + return signature.ErrInvalidSignatureType + } + if len(sig.X5c) == 0 { + return errors.New("empty x509 certificate chain") + } + + certs := make([]*x509.Certificate, 0, len(sig.X5c)) + for _, certBytes := range sig.X5c { + cert, err := x509.ParseCertificate(certBytes) + if err != nil { + return err + } + certs = append(certs, cert) + } + + intermediates := x509.NewCertPool() + for _, cert := range certs[1:] { + intermediates.AddCert(cert) + } + + cert := certs[0] + if _, err := cert.Verify(x509.VerifyOptions{ + Intermediates: intermediates, + Roots: v.roots, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + }); err != nil { + return err + } + + key, err := libtrust.FromCryptoPublicKey(crypto.PublicKey(cert.PublicKey)) + if err != nil { + return err + } + if err := key.Verify(bytes.NewReader(content), sig.Algorithm, sig.Signature); err != nil { + return err + } + + return verifyReferences(content, cert) +} + +func verifyReferences(raw []byte, cert *x509.Certificate) error { + var content signature.Content + if err := json.Unmarshal(raw, &content); err != nil { + return err + } + roots := x509.NewCertPool() + roots.AddCert(cert) + for _, manifest := range content.Manifests { + for _, reference := range manifest.References { + if _, err := cert.Verify(x509.VerifyOptions{ + DNSName: strings.SplitN(reference, "/", 2)[0], + Roots: roots, + }); err != nil { + return err + } + } + } + return nil +} From 63c51a299495b35b39e71c7898d630306c769918 Mon Sep 17 00:00:00 2001 From: Shiwei Zhang Date: Fri, 17 Jul 2020 18:11:55 +0800 Subject: [PATCH 02/12] GPG Support (#2) --- cmd/nv2/sign.go | 35 +++++++++++++++----- cmd/nv2/verify.go | 45 ++++++++++++++++++++------ go.mod | 1 + go.sum | 9 +++++- pkg/signature/gpg/gpg.go | 49 ++++++++++++++++++++++++++++ pkg/signature/gpg/path.go | 28 ++++++++++++++++ pkg/signature/gpg/signer.go | 37 +++++++++++++++++++++ pkg/signature/gpg/type.go | 4 +++ pkg/signature/gpg/verifier.go | 60 +++++++++++++++++++++++++++++++++++ pkg/signature/signature.go | 3 +- 10 files changed, 251 insertions(+), 20 deletions(-) create mode 100644 pkg/signature/gpg/gpg.go create mode 100644 pkg/signature/gpg/path.go create mode 100644 pkg/signature/gpg/signer.go create mode 100644 pkg/signature/gpg/type.go create mode 100644 pkg/signature/gpg/verifier.go diff --git a/cmd/nv2/sign.go b/cmd/nv2/sign.go index 41f7e1941..e39b37ac9 100644 --- a/cmd/nv2/sign.go +++ b/cmd/nv2/sign.go @@ -8,6 +8,7 @@ import ( "time" "github.com/notaryproject/nv2/pkg/signature" + "github.com/notaryproject/nv2/pkg/signature/gpg" "github.com/notaryproject/nv2/pkg/signature/x509" "github.com/urfave/cli/v2" ) @@ -28,13 +29,25 @@ var signCommand = &cli.Command{ &cli.StringFlag{ Name: "key", Aliases: []string{"k"}, - Usage: "siging key file", + Usage: "siging key file [x509]", TakesFile: true, }, &cli.StringFlag{ Name: "cert", Aliases: []string{"c"}, - Usage: "siging cert", + Usage: "siging cert [x509]", + TakesFile: true, + }, + &cli.StringFlag{ + Name: "key-ring", + Usage: "gpg public key ring file [gpg]", + Value: gpg.DefaultSecretKeyRingPath(), + TakesFile: true, + }, + &cli.StringFlag{ + Name: "identity", + Aliases: []string{"i"}, + Usage: "signer identity [gpg]", TakesFile: true, }, &cli.DurationFlag{ @@ -112,16 +125,22 @@ func prepareContentForSigning(ctx *cli.Context) (signature.Content, error) { } func getSchemeForSigning(ctx *cli.Context) (*signature.Scheme, error) { - scheme := signature.NewScheme() + var ( + signer signature.Signer + err error + ) switch method := ctx.String("method"); method { case "x509": - signer, err := x509.NewSignerFromFiles(ctx.String("key"), ctx.String("cert")) - if err != nil { - return nil, err - } - scheme.RegisterSigner(signerID, signer) + signer, err = x509.NewSignerFromFiles(ctx.String("key"), ctx.String("cert")) + case "gpg": + signer, err = gpg.NewSigner(ctx.String("key-ring"), ctx.String("identity")) default: return nil, fmt.Errorf("unsupported signing method: %s", method) } + scheme := signature.NewScheme() + if err != nil { + return nil, err + } + scheme.RegisterSigner(signerID, signer) return scheme, nil } diff --git a/cmd/nv2/verify.go b/cmd/nv2/verify.go index 06290d648..952f03cd3 100644 --- a/cmd/nv2/verify.go +++ b/cmd/nv2/verify.go @@ -8,6 +8,7 @@ import ( "github.com/notaryproject/nv2/internal/crypto" "github.com/notaryproject/nv2/pkg/signature" + "github.com/notaryproject/nv2/pkg/signature/gpg" x509nv2 "github.com/notaryproject/nv2/pkg/signature/x509" "github.com/urfave/cli/v2" ) @@ -27,9 +28,19 @@ var verifyCommand = &cli.Command{ &cli.StringSliceFlag{ Name: "ca-cert", Aliases: []string{"c"}, - Usage: "CA certs for verification", + Usage: "CA certs for verification [x509]", TakesFile: true, }, + &cli.StringFlag{ + Name: "key-ring", + Usage: "gpg public key ring file [gpg]", + Value: gpg.DefaultPublicKeyRingPath(), + TakesFile: true, + }, + &cli.BoolFlag{ + Name: "disable-gpg", + Usage: "disable GPG for verification [gpg]", + }, }, Action: runVerify, } @@ -83,6 +94,28 @@ func readSignatrueFile(path string) (sig signature.Signed, err error) { } func getSchemeForVerification(ctx *cli.Context) (*signature.Scheme, error) { + scheme := signature.NewScheme() + + // add x509 verifier + verifier, err := getX509Verifier(ctx) + if err != nil { + return nil, err + } + scheme.RegisterVerifier(verifier) + + // add gpg verifier + if !ctx.Bool("disable-gpg") { + verifier, err := gpg.NewVerifier(ctx.String("key-ring")) + if err != nil { + return nil, err + } + scheme.RegisterVerifier(verifier) + } + + return scheme, nil +} + +func getX509Verifier(ctx *cli.Context) (signature.Verifier, error) { var roots *x509.CertPool if caCerts := ctx.StringSlice("ca-cert"); len(caCerts) > 0 { roots = x509.NewCertPool() @@ -96,13 +129,5 @@ func getSchemeForVerification(ctx *cli.Context) (*signature.Scheme, error) { } } } - - verifier, err := x509nv2.NewVerifier(roots) - if err != nil { - return nil, err - } - - scheme := signature.NewScheme() - scheme.RegisterVerifier(verifier) - return scheme, nil + return x509nv2.NewVerifier(roots) } diff --git a/go.mod b/go.mod index f950daa92..4cbe3d8a0 100644 --- a/go.mod +++ b/go.mod @@ -6,4 +6,5 @@ require ( github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 github.com/opencontainers/go-digest v1.0.0 github.com/urfave/cli/v2 v2.2.0 + golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 ) diff --git a/go.sum b/go.sum index bf6562f0a..e07c3e42f 100644 --- a/go.sum +++ b/go.sum @@ -5,13 +5,20 @@ github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/urfave/cli v1.22.4 h1:u7tSpNPPswAFymm8IehJhy4uJMlUuU/GmqSkvJ1InXA= github.com/urfave/cli/v2 v2.2.0 h1:JTTnM6wKzdA0Jqodd966MVj4vWbbquZykeX1sKbe2C4= github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 h1:DZhuSZLsGlFL4CmhA8BcRA0mnthyA/nZ00AqCUo7vHg= +golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/pkg/signature/gpg/gpg.go b/pkg/signature/gpg/gpg.go new file mode 100644 index 000000000..94b4df380 --- /dev/null +++ b/pkg/signature/gpg/gpg.go @@ -0,0 +1,49 @@ +package gpg + +import ( + "errors" + "os" + "strings" + + "golang.org/x/crypto/openpgp" +) + +func findEntityFromFile(path, name string) (*openpgp.Entity, string, error) { + list, err := readKeyRingFromFile(path) + if err != nil { + return nil, "", err + } + return findEntity(list, name) +} + +func readKeyRingFromFile(path string) (openpgp.EntityList, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + return openpgp.ReadKeyRing(file) +} + +func findEntity(list openpgp.EntityList, name string) (*openpgp.Entity, string, error) { + var candidate *openpgp.Entity + var candidateIdentity string + for _, entity := range list { + for identity := range entity.Identities { + if identity == name { + return entity, identity, nil + } + if strings.Contains(identity, name) { + if candidate != nil { + return nil, "", errors.New("ambiguous identity: " + name) + } + candidate = entity + candidateIdentity = identity + } + } + } + if candidate == nil { + return nil, "", errors.New("identity not found: " + name) + } + return candidate, candidateIdentity, nil +} diff --git a/pkg/signature/gpg/path.go b/pkg/signature/gpg/path.go new file mode 100644 index 000000000..289eca771 --- /dev/null +++ b/pkg/signature/gpg/path.go @@ -0,0 +1,28 @@ +package gpg + +import ( + "os" + "path/filepath" +) + +// DefaultHomePath returns the default GPG home path +func DefaultHomePath() string { + if path, ok := os.LookupEnv("GNUPGHOME"); ok { + return path + } + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + return filepath.Join(home, ".gnupg") +} + +// DefaultPublicKeyRingPath returns the default GPG public key ring path +func DefaultPublicKeyRingPath() string { + return filepath.Join(DefaultHomePath(), "pubring.gpg") +} + +// DefaultSecretKeyRingPath returns the default GPG secret key ring path +func DefaultSecretKeyRingPath() string { + return filepath.Join(DefaultHomePath(), "secring.gpg") +} diff --git a/pkg/signature/gpg/signer.go b/pkg/signature/gpg/signer.go new file mode 100644 index 000000000..741bd66c9 --- /dev/null +++ b/pkg/signature/gpg/signer.go @@ -0,0 +1,37 @@ +package gpg + +import ( + "bytes" + + "github.com/notaryproject/nv2/pkg/signature" + "golang.org/x/crypto/openpgp" +) + +type signer struct { + entity *openpgp.Entity + identity string +} + +// NewSigner creates a signer +func NewSigner(secretKeyRingPath, identity string) (signature.Signer, error) { + entity, identity, err := findEntityFromFile(secretKeyRingPath, identity) + if err != nil { + return nil, err + } + return &signer{ + entity: entity, + identity: identity, + }, nil +} + +func (s *signer) Sign(raw []byte) (signature.Signature, error) { + sig := bytes.NewBuffer(nil) + if err := openpgp.DetachSign(sig, s.entity, bytes.NewReader(raw), nil); err != nil { + return signature.Signature{}, err + } + return signature.Signature{ + Type: Type, + Issuer: s.identity, + Signature: sig.Bytes(), + }, nil +} diff --git a/pkg/signature/gpg/type.go b/pkg/signature/gpg/type.go new file mode 100644 index 000000000..b27af0fb6 --- /dev/null +++ b/pkg/signature/gpg/type.go @@ -0,0 +1,4 @@ +package gpg + +// Type indicates the signature type +const Type = "gpg" diff --git a/pkg/signature/gpg/verifier.go b/pkg/signature/gpg/verifier.go new file mode 100644 index 000000000..ce00b96a0 --- /dev/null +++ b/pkg/signature/gpg/verifier.go @@ -0,0 +1,60 @@ +package gpg + +import ( + "bytes" + "fmt" + + "github.com/notaryproject/nv2/pkg/signature" + "golang.org/x/crypto/openpgp" +) + +type verifier struct { + keyRing openpgp.EntityList +} + +// NewVerifier creates a verifier +func NewVerifier(publicKeyRingPath string) (signature.Verifier, error) { + keyRing, err := readKeyRingFromFile(publicKeyRingPath) + if err != nil { + return nil, err + } + return &verifier{ + keyRing: keyRing, + }, nil +} + +func (v *verifier) Type() string { + return Type +} + +func (v *verifier) Verify(content []byte, sig signature.Signature) error { + if sig.Type != Type { + return signature.ErrInvalidSignatureType + } + + entity, err := openpgp.CheckDetachedSignature( + v.keyRing, + bytes.NewReader(content), + bytes.NewReader(sig.Signature), + ) + if err != nil { + return err + } + + if sig.Issuer != "" { + found := false + var signer string + for identity := range entity.Identities { + if identity == sig.Issuer { + found = true + signer = identity + break + } + } + if !found { + return fmt.Errorf("signature verified for %q not matching the claimed issuer %q", signer, sig.Issuer) + } + } + + return nil +} diff --git a/pkg/signature/signature.go b/pkg/signature/signature.go index 0e876ae80..d5f0a64cc 100644 --- a/pkg/signature/signature.go +++ b/pkg/signature/signature.go @@ -28,8 +28,9 @@ type Manifest struct { // Signature to verify the content type Signature struct { Type string `json:"typ"` + Signature []byte `json:"sig"` Algorithm string `json:"alg,omitempty"` KeyID string `json:"kid,omitempty"` X5c [][]byte `json:"x5c,omitempty"` - Signature []byte `json:"sig"` + Issuer string `json:"iss,omitempty"` } From 3f190d4566be7197b0d93bd2b07773ee919207b6 Mon Sep 17 00:00:00 2001 From: Shiwei Zhang Date: Mon, 20 Jul 2020 13:17:52 +0800 Subject: [PATCH 03/12] x509 kid support (#3) --- cmd/nv2/verify.go | 41 +++++++++++------ pkg/signature/x509/signer.go | 42 ++++++++++++------ pkg/signature/x509/verifier.go | 80 +++++++++++++++++++++++++++------- 3 files changed, 122 insertions(+), 41 deletions(-) diff --git a/cmd/nv2/verify.go b/cmd/nv2/verify.go index 952f03cd3..a94bba65e 100644 --- a/cmd/nv2/verify.go +++ b/cmd/nv2/verify.go @@ -26,8 +26,13 @@ var verifyCommand = &cli.Command{ TakesFile: true, }, &cli.StringSliceFlag{ - Name: "ca-cert", + Name: "cert", Aliases: []string{"c"}, + Usage: "certs for verification [x509]", + TakesFile: true, + }, + &cli.StringSliceFlag{ + Name: "ca-cert", Usage: "CA certs for verification [x509]", TakesFile: true, }, @@ -116,18 +121,28 @@ func getSchemeForVerification(ctx *cli.Context) (*signature.Scheme, error) { } func getX509Verifier(ctx *cli.Context) (signature.Verifier, error) { - var roots *x509.CertPool - if caCerts := ctx.StringSlice("ca-cert"); len(caCerts) > 0 { - roots = x509.NewCertPool() - for _, path := range caCerts { - certs, err := crypto.ReadCertificateFile(path) - if err != nil { - return nil, err - } - for _, cert := range certs { - roots.AddCert(cert) - } + roots := x509.NewCertPool() + + var certs []*x509.Certificate + for _, path := range ctx.StringSlice("cert") { + bundledCerts, err := crypto.ReadCertificateFile(path) + if err != nil { + return nil, err + } + certs = append(certs, bundledCerts...) + for _, cert := range bundledCerts { + roots.AddCert(cert) } } - return x509nv2.NewVerifier(roots) + for _, path := range ctx.StringSlice("ca-cert") { + bundledCerts, err := crypto.ReadCertificateFile(path) + if err != nil { + return nil, err + } + for _, cert := range bundledCerts { + roots.AddCert(cert) + } + } + + return x509nv2.NewVerifier(certs, roots) } diff --git a/pkg/signature/x509/signer.go b/pkg/signature/x509/signer.go index 116c671f4..f5e8de755 100644 --- a/pkg/signature/x509/signer.go +++ b/pkg/signature/x509/signer.go @@ -13,6 +13,7 @@ import ( type signer struct { key libtrust.PrivateKey + keyID string cert *x509.Certificate rawCerts [][]byte hash crypto.Hash @@ -24,6 +25,10 @@ func NewSignerFromFiles(keyPath, certPath string) (signature.Signer, error) { if err != nil { return nil, err } + if certPath == "" { + return NewSigner(key, nil) + } + certs, err := cryptoutil.ReadCertificateFile(certPath) if err != nil { return nil, err @@ -33,8 +38,13 @@ func NewSignerFromFiles(keyPath, certPath string) (signature.Signer, error) { // NewSigner creates a signer func NewSigner(key libtrust.PrivateKey, certs []*x509.Certificate) (signature.Signer, error) { + s := &signer{ + key: key, + keyID: key.KeyID(), + hash: crypto.SHA256, + } if len(certs) == 0 { - return nil, errors.New("missing certificates") + return s, nil } cert := certs[0] @@ -42,35 +52,41 @@ func NewSigner(key libtrust.PrivateKey, certs []*x509.Certificate) (signature.Si if err != nil { return nil, err } - if key.KeyID() != publicKey.KeyID() { + if s.keyID != publicKey.KeyID() { return nil, errors.New("key and certificate mismatch") } + s.cert = cert rawCerts := make([][]byte, 0, len(certs)) for _, cert := range certs { rawCerts = append(rawCerts, cert.Raw) } + s.rawCerts = rawCerts - return &signer{ - key: key, - cert: cert, - rawCerts: rawCerts, - hash: crypto.SHA256, - }, nil + return s, nil } func (s *signer) Sign(raw []byte) (signature.Signature, error) { - if err := verifyReferences(raw, s.cert); err != nil { - return signature.Signature{}, err + if s.cert != nil { + if err := verifyReferences(raw, s.cert); err != nil { + return signature.Signature{}, err + } } + sig, alg, err := s.key.Sign(bytes.NewReader(raw), s.hash) if err != nil { return signature.Signature{}, err } - return signature.Signature{ + sigma := signature.Signature{ Type: Type, Algorithm: alg, - X5c: s.rawCerts, Signature: sig, - }, nil + } + + if s.cert != nil { + sigma.X5c = s.rawCerts + } else { + sigma.KeyID = s.keyID + } + return sigma, nil } diff --git a/pkg/signature/x509/verifier.go b/pkg/signature/x509/verifier.go index 9ba0f2712..d4750983d 100644 --- a/pkg/signature/x509/verifier.go +++ b/pkg/signature/x509/verifier.go @@ -13,20 +13,43 @@ import ( ) type verifier struct { + keys map[string]libtrust.PublicKey + certs map[string]*x509.Certificate roots *x509.CertPool } // NewVerifier creates a verifier -func NewVerifier(roots *x509.CertPool) (signature.Verifier, error) { +func NewVerifier(certs []*x509.Certificate, roots *x509.CertPool) (signature.Verifier, error) { if roots == nil { - pool, err := x509.SystemCertPool() + if certs == nil { + pool, err := x509.SystemCertPool() + if err != nil { + return nil, err + } + roots = pool + } else { + roots = x509.NewCertPool() + } + for _, cert := range certs { + roots.AddCert(cert) + } + } + + keys := make(map[string]libtrust.PublicKey, len(certs)) + keyedCerts := make(map[string]*x509.Certificate, len(certs)) + for _, cert := range certs { + key, err := libtrust.FromCryptoPublicKey(crypto.PublicKey(cert.PublicKey)) if err != nil { return nil, err } - roots = pool + keyID := key.KeyID() + keys[keyID] = key + keyedCerts[keyID] = cert } return &verifier{ + keys: keys, + certs: keyedCerts, roots: roots, }, nil } @@ -39,15 +62,46 @@ func (v *verifier) Verify(content []byte, sig signature.Signature) error { if sig.Type != Type { return signature.ErrInvalidSignatureType } - if len(sig.X5c) == 0 { - return errors.New("empty x509 certificate chain") + + key, cert, err := v.getVerificationKeyPair(sig) + if err != nil { + return err + } + if err := key.Verify(bytes.NewReader(content), sig.Algorithm, sig.Signature); err != nil { + return err } + return verifyReferences(content, cert) +} - certs := make([]*x509.Certificate, 0, len(sig.X5c)) - for _, certBytes := range sig.X5c { +func (v *verifier) getVerificationKeyPair(sig signature.Signature) (libtrust.PublicKey, *x509.Certificate, error) { + switch { + case len(sig.X5c) > 0: + return v.getVerificationKeyPairFromX5c(sig.X5c) + case sig.KeyID != "": + return v.getVerificationKeyPairFromKeyID(sig.KeyID) + default: + return nil, nil, errors.New("missing verification key") + } +} + +func (v *verifier) getVerificationKeyPairFromKeyID(keyID string) (libtrust.PublicKey, *x509.Certificate, error) { + key, found := v.keys[keyID] + if !found { + return nil, nil, errors.New("key not found: " + keyID) + } + cert, found := v.certs[keyID] + if !found { + return nil, nil, errors.New("cert not found: " + keyID) + } + return key, cert, nil +} + +func (v *verifier) getVerificationKeyPairFromX5c(x5c [][]byte) (libtrust.PublicKey, *x509.Certificate, error) { + certs := make([]*x509.Certificate, 0, len(x5c)) + for _, certBytes := range x5c { cert, err := x509.ParseCertificate(certBytes) if err != nil { - return err + return nil, nil, err } certs = append(certs, cert) } @@ -63,18 +117,14 @@ func (v *verifier) Verify(content []byte, sig signature.Signature) error { Roots: v.roots, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, }); err != nil { - return err + return nil, nil, err } key, err := libtrust.FromCryptoPublicKey(crypto.PublicKey(cert.PublicKey)) if err != nil { - return err - } - if err := key.Verify(bytes.NewReader(content), sig.Algorithm, sig.Signature); err != nil { - return err + return nil, nil, err } - - return verifyReferences(content, cert) + return key, cert, nil } func verifyReferences(raw []byte, cert *x509.Certificate) error { From b3f0d8138f8ac38c1e6971f9e1b86970b21d89e8 Mon Sep 17 00:00:00 2001 From: Shiwei Zhang Date: Mon, 20 Jul 2020 18:11:18 +0800 Subject: [PATCH 04/12] Support remote Docker/OCI manifest (#4) --- cmd/nv2/common.go | 20 +++++++ cmd/nv2/manifest.go | 15 ++++- cmd/nv2/sign.go | 10 +++- cmd/nv2/verify.go | 3 + go.mod | 1 + go.sum | 2 + pkg/registry/client.go | 32 +++++++++++ pkg/registry/docker.go | 7 +++ pkg/registry/manifest.go | 113 ++++++++++++++++++++++++++++++++++++++ pkg/registry/transport.go | 110 +++++++++++++++++++++++++++++++++++++ 10 files changed, 309 insertions(+), 4 deletions(-) create mode 100644 cmd/nv2/common.go create mode 100644 pkg/registry/client.go create mode 100644 pkg/registry/docker.go create mode 100644 pkg/registry/manifest.go create mode 100644 pkg/registry/transport.go diff --git a/cmd/nv2/common.go b/cmd/nv2/common.go new file mode 100644 index 000000000..f6752a827 --- /dev/null +++ b/cmd/nv2/common.go @@ -0,0 +1,20 @@ +package main + +import "github.com/urfave/cli/v2" + +var ( + usernameFlag = &cli.StringFlag{ + Name: "username", + Aliases: []string{"u"}, + Usage: "username for generic remote access", + } + passwordFlag = &cli.StringFlag{ + Name: "password", + Aliases: []string{"p"}, + Usage: "password for generic remote access", + } + insecureFlag = &cli.BoolFlag{ + Name: "insecure", + Usage: "enable insecure remote access", + } +) diff --git a/cmd/nv2/manifest.go b/cmd/nv2/manifest.go index 03fa4f5dc..9bd837c2f 100644 --- a/cmd/nv2/manifest.go +++ b/cmd/nv2/manifest.go @@ -6,7 +6,9 @@ import ( "math" "net/url" "os" + "strings" + "github.com/notaryproject/nv2/pkg/registry" "github.com/notaryproject/nv2/pkg/signature" "github.com/opencontainers/go-digest" "github.com/urfave/cli/v2" @@ -14,7 +16,7 @@ import ( func getManifestFromContext(ctx *cli.Context) (signature.Manifest, error) { if uri := ctx.Args().First(); uri != "" { - return getManfestsFromURI(uri) + return getManfestsFromURI(ctx, uri) } return getManifestFromReader(os.Stdin) } @@ -34,13 +36,13 @@ func getManifestFromReader(r io.Reader) (signature.Manifest, error) { }, nil } -func getManfestsFromURI(uri string) (signature.Manifest, error) { +func getManfestsFromURI(ctx *cli.Context, uri string) (signature.Manifest, error) { parsed, err := url.Parse(uri) if err != nil { return signature.Manifest{}, err } var r io.Reader - switch parsed.Scheme { + switch strings.ToLower(parsed.Scheme) { case "file": path := parsed.Path if parsed.Opaque != "" { @@ -52,6 +54,13 @@ func getManfestsFromURI(uri string) (signature.Manifest, error) { } defer file.Close() r = file + case "docker", "oci": + remote := registry.NewClient(nil, ®istry.ClientOptions{ + Username: ctx.String("username"), + Password: ctx.String("password"), + Insecure: ctx.Bool("insecure"), + }) + return remote.GetManifestMetadata(parsed) default: return signature.Manifest{}, fmt.Errorf("unsupported URI scheme: %s", parsed.Scheme) } diff --git a/cmd/nv2/sign.go b/cmd/nv2/sign.go index e39b37ac9..fb7b2a1bc 100644 --- a/cmd/nv2/sign.go +++ b/cmd/nv2/sign.go @@ -65,6 +65,9 @@ var signCommand = &cli.Command{ Aliases: []string{"o"}, Usage: "write signature to a specific path", }, + usernameFlag, + passwordFlag, + insecureFlag, }, Action: runSign, } @@ -99,7 +102,12 @@ func runSign(ctx *cli.Context) error { if path == "" { path = strings.Split(content.Manifests[0].Digest, ":")[1] + ".nv2" } - return ioutil.WriteFile(path, sigmaJSON, 0666) + if err := ioutil.WriteFile(path, sigmaJSON, 0666); err != nil { + return err + } + + fmt.Println(content.Manifests[0].Digest) + return nil } func prepareContentForSigning(ctx *cli.Context) (signature.Content, error) { diff --git a/cmd/nv2/verify.go b/cmd/nv2/verify.go index a94bba65e..abf1bb0c7 100644 --- a/cmd/nv2/verify.go +++ b/cmd/nv2/verify.go @@ -46,6 +46,9 @@ var verifyCommand = &cli.Command{ Name: "disable-gpg", Usage: "disable GPG for verification [gpg]", }, + usernameFlag, + passwordFlag, + insecureFlag, }, Action: runVerify, } diff --git a/go.mod b/go.mod index 4cbe3d8a0..9bad1ee32 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.14 require ( github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 github.com/opencontainers/go-digest v1.0.0 + github.com/opencontainers/image-spec v1.0.1 github.com/urfave/cli/v2 v2.2.0 golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 ) diff --git a/go.sum b/go.sum index e07c3e42f..64123900c 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI= +github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= diff --git a/pkg/registry/client.go b/pkg/registry/client.go new file mode 100644 index 000000000..8bfd056ae --- /dev/null +++ b/pkg/registry/client.go @@ -0,0 +1,32 @@ +package registry + +import ( + "net/http" +) + +// Client is a customized registry client +type Client struct { + base http.RoundTripper + insecure bool +} + +// ClientOptions configures the client +type ClientOptions struct { + Username string + Password string + Insecure bool +} + +// NewClient creates a new registry client +func NewClient(base http.RoundTripper, opts *ClientOptions) *Client { + if base == nil { + base = http.DefaultTransport + } + if opts == nil { + opts = &ClientOptions{} + } + return &Client{ + base: newV2transport(base, opts.Username, opts.Password), + insecure: opts.Insecure, + } +} diff --git a/pkg/registry/docker.go b/pkg/registry/docker.go new file mode 100644 index 000000000..d6351dcfd --- /dev/null +++ b/pkg/registry/docker.go @@ -0,0 +1,7 @@ +package registry + +// docker media types +const ( + MediaTypeManifestList = "application/vnd.docker.distribution.manifest.list.v2+json" + MediaTypeManifest = "application/vnd.docker.distribution.manifest.v2+json" +) diff --git a/pkg/registry/manifest.go b/pkg/registry/manifest.go new file mode 100644 index 000000000..2d5b38ff9 --- /dev/null +++ b/pkg/registry/manifest.go @@ -0,0 +1,113 @@ +package registry + +import ( + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/notaryproject/nv2/pkg/signature" + v1 "github.com/opencontainers/image-spec/specs-go/v1" +) + +// GetManifestMetadata returns signature manifest information by URI scheme +func (c *Client) GetManifestMetadata(uri *url.URL) (signature.Manifest, error) { + switch scheme := strings.ToLower(uri.Scheme); scheme { + case "docker": + return c.GetDockerManifestMetadata(uri) + case "oci": + return c.GetOCIManifestMetadata(uri) + default: + return signature.Manifest{}, fmt.Errorf("unsupported scheme: %s", scheme) + } +} + +// GetDockerManifestMetadata returns signature manifest information +// from a remote Docker manifest +func (c *Client) GetDockerManifestMetadata(uri *url.URL) (signature.Manifest, error) { + return c.getManifestMetadata(uri, + MediaTypeManifestList, + MediaTypeManifest, + ) +} + +// GetOCIManifestMetadata returns signature manifest information +// from a remote OCI manifest +func (c *Client) GetOCIManifestMetadata(uri *url.URL) (signature.Manifest, error) { + return c.getManifestMetadata(uri, + v1.MediaTypeImageIndex, + v1.MediaTypeImageManifest, + ) +} + +// GetManifestMetadata returns signature manifest information +func (c *Client) getManifestMetadata(uri *url.URL, mediaTypes ...string) (signature.Manifest, error) { + host := uri.Host + if host == "docker.io" { + host = "registry-1.docker.io" + } + var repository string + var reference string + path := strings.TrimPrefix(uri.Path, "/") + if index := strings.Index(path, "@"); index != -1 { + repository = path[:index] + reference = path[index+1:] + } else if index := strings.Index(path, ":"); index != -1 { + repository = path[:index] + reference = path[index+1:] + } else { + repository = path + reference = "latest" + } + scheme := "https" + if c.insecure { + scheme = "http" + } + url := fmt.Sprintf("%s://%s/v2/%s/manifests/%s", + scheme, + host, + repository, + reference, + ) + req, err := http.NewRequest(http.MethodHead, url, nil) + if err != nil { + return signature.Manifest{}, fmt.Errorf("invalid uri: %v", uri) + } + req.Header.Set("Connection", "close") + for _, mediaType := range mediaTypes { + req.Header.Add("Accept", mediaType) + } + + resp, err := c.base.RoundTrip(req) + if err != nil { + return signature.Manifest{}, fmt.Errorf("%v: %v", url, err) + } + resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK: + // no op + case http.StatusUnauthorized, http.StatusNotFound: + return signature.Manifest{}, fmt.Errorf("%v: %s", uri, resp.Status) + default: + return signature.Manifest{}, fmt.Errorf("%v: %s", url, resp.Status) + } + + header := resp.Header + digest := header.Get("Docker-Content-Digest") + if digest == "" { + return signature.Manifest{}, fmt.Errorf("%v: missing Docker-Content-Digest", url) + } + length := header.Get("Content-Length") + if length == "" { + return signature.Manifest{}, fmt.Errorf("%v: missing Content-Length", url) + } + size, err := strconv.ParseInt(length, 10, 64) + if err != nil { + return signature.Manifest{}, fmt.Errorf("%v: invalid Content-Length", url) + } + return signature.Manifest{ + Digest: digest, + Size: size, + }, nil +} diff --git a/pkg/registry/transport.go b/pkg/registry/transport.go new file mode 100644 index 000000000..a4ff6eb21 --- /dev/null +++ b/pkg/registry/transport.go @@ -0,0 +1,110 @@ +package registry + +import ( + "encoding/json" + "net/http" + "net/url" + "regexp" + "strings" +) + +var authHeaderRegex = regexp.MustCompile(`(realm|service|scope)="([^"]*)`) + +type v2Transport struct { + base http.RoundTripper + username string + password string +} + +func newV2transport(base http.RoundTripper, username, password string) http.RoundTripper { + return &v2Transport{ + base: base, + username: username, + password: password, + } +} + +func (t *v2Transport) RoundTrip(originalReq *http.Request) (*http.Response, error) { + req := originalReq.Clone(originalReq.Context()) + if t.username != "" { + req.SetBasicAuth(t.username, t.password) + } + + resp, err := t.base.RoundTrip(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusUnauthorized { + return resp, nil + } + + scheme, params := parseAuthHeader(resp.Header.Get("Www-Authenticate")) + if scheme != "bearer" { + return resp, nil + } + resp.Body.Close() + + token, resp, err := t.fetchToken(params) + if err != nil { + if resp != nil { + return resp, nil + } + return nil, err + } + + req = originalReq.Clone(originalReq.Context()) + req.Header.Set("Authorization", "Bearer "+token) + return t.base.RoundTrip(req) +} + +func (t *v2Transport) fetchToken(params map[string]string) (string, *http.Response, error) { + req, err := http.NewRequest(http.MethodGet, params["realm"], nil) + if err != nil { + return "", nil, err + } + if t.username != "" { + req.SetBasicAuth(t.username, t.password) + } + + query := url.Values{} + if service, ok := params["service"]; ok { + query.Set("service", service) + } + if scope, ok := params["scope"]; ok { + query.Set("scope", scope) + } + req.URL.RawQuery = query.Encode() + + resp, err := t.base.RoundTrip(req) + if err != nil { + return "", nil, err + } + if resp.StatusCode != http.StatusOK { + return "", resp, nil + } + defer resp.Body.Close() + + var result struct { + AccessToken string `json:"access_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", nil, err + } + return result.AccessToken, nil, nil +} + +func parseAuthHeader(header string) (string, map[string]string) { + parts := strings.SplitN(header, " ", 2) + scheme := strings.ToLower(parts[0]) + if len(parts) < 2 { + return scheme, nil + } + + params := make(map[string]string) + result := authHeaderRegex.FindAllStringSubmatch(parts[1], -1) + for _, match := range result { + params[strings.ToLower(match[1])] = match[2] + } + + return scheme, params +} From d28d5036fd30e4f25b282e9a1e444728f1a711f8 Mon Sep 17 00:00:00 2001 From: Shiwei Zhang Date: Tue, 21 Jul 2020 00:35:17 +0800 Subject: [PATCH 05/12] Documentation for nv2 CLI (#6) --- docs/nv2/README.md | 276 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 docs/nv2/README.md diff --git a/docs/nv2/README.md b/docs/nv2/README.md new file mode 100644 index 000000000..26b9349db --- /dev/null +++ b/docs/nv2/README.md @@ -0,0 +1,276 @@ +# Notary V2 (nv2) - Prototype + +`nv2` is a command line tool for signing and verifying manifest-based artifacts or images. This implementation supports `gpg` and `x509` signing mechanisms. + +## Prerequisites + +### Build and Install + +This plugin requires [golang](https://golang.org/dl/) with version `>= 1.14`. + +To build and install, run + +```shell +go install github.com/notaryproject/nv2/cmd/nv2 +``` + +To build and install to an optional path, run + +```shell +go build -o path/to/the/target ./cmd/nv2 +``` + +Next, install optional components: + +- Install [GnuPG](https://gnupg.org/) for `gpg`/`pgp` signing, and key management. +- Install [docker-generate](https://github.com/shizhMSFT/docker-generate) for local Docker manifest generation and local signing. +- Install [OpenSSL](https://www.openssl.org/) for key generation. + +### Key Generation + +#### GnuPG Key Generation + +To generate a `gpg` key, run + +```shell +gpg --gen-key +``` + +By default, all keys sit in the directory `~/.gnupg`. If the `gpg` version is `>= 2.1`, key export is required after key generation + +```shell +# Update to legacy public key ring +[ -f ~/.gnupg/pubring.gpg ] && gpg --export > ~/.gnupg/pubring.gpg + +# Export legacy secret key ring +gpg --export-secret-keys > ~/.gnupg/secring.gpg +``` + +until the issue [golang/go#29082](https://github.com/golang/go/issues/29082) is resolved. + +#### Self-signed Certificate Generation + +To generate a `x509` self-signed certificate key pair `key.pem` and `cert.pem`, run + +```shell +openssl req -x509 -sha256 -nodes -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 +``` + +When generating the certificate, make sure that the Common Name (`CN`) is set properly in the `Subject` field. The Common Name will be verified against the claimed original references. + +## Offline Signing and Verification + +Signing and verification offline can be accomplished by the `nv2 sign` command and the `nv2 verify` command. +In this section, examples are provided for a tour of `nv2` signing and verification. + +Since signing and verification are based on manifests, [docker-generate](https://github.com/shizhMSFT/docker-generate) is used to generate the manifest, which is exactly the same manifest as the `docker push` produces. + +```shell +docker build -t example . +docker generate manifest example > example.json +``` + +The above commands build the image `example:latest` based on the local context, and then generate its manifest file `example.json`. + +### Signing using GnuPG + +To sign the manifest `example.json` using the GnuPG key identified by the identity name `Demo User`, run + +``` +$ nv2 sign -m gpg -i "Demo User" -r example.registry.io/example:latest -e 8760h file:example.json sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55 +``` + +where the optional option `-r` declares the original reference, and the optional option `-e` specifies the expiry time (`8760h = 365 days`). On successful signing, `nv2` prints out the `sha256` digest of the manifest, and writes the `nv2` signature JSON file `.nv2` to the working directory. If the file name is not desired, option `-o` can be specified for the alternative file name. + +In this example, the signature file name is `3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55.nv2`. The formatted signature file is + +```json +{ + "signed": { + "exp": 1626792407, + "nbf": 1595256407, + "iat": 1595256407, + "manifests": [ + { + "digest": "sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55", + "size": 528, + "references": [ + "example.registry.io/example:latest" + ] + } + ] + }, + "signatures": [ + { + "typ": "gpg", + "sig": "wsDcBAABCAAQBQJfFa5XCRBGnsnNNqPeHgAAlM0MAHFXZxyCgsxiGVat8YCRIhR7IoQe2scswGyvGGinYBy88EpKGFEAGO+Kt1frTQNW9kLYWmTw4EFctgMw+XxeDD/CI2rsMSluRh9h2t8xBsO9Ux+7eJoxSEsfU8Jc/YZWpGs/kJOGQ3ERjvPt+SCG0Y8tuNtjnzpV4Gz+8fLSlNZ7b3f+rd7nvvJuB8iWr+yojsCeWh/VGuibyqAXPKVxSrKgkmziyYK3O/0D3KhgyR+CMtjTXL5hP314Gpc415YyN82LC3L44okimN/+X3avX0vQkthiyVw+R+Vgmpa1qk1P/ySrs81yQgFBPBC7+m4n54TqsW46X/UlkQdfP/x5Jg3jUURKgQb0wSLvzbr7Jk1RiThlwjcLhM0VgRIUwbqcqjg/5UNvMRehD44PxQXRz5feZjER2awMyKqRZnImpm8Ub+hAjhqtLGYT34oU2lwctoObV4f4BzffY9kQ0x37PQ3V8aj8k6YFQZbB4vLgwtZdA2c1froVHyuRBUwLzSBevg==", + "iss": "Demo User " + } + ] +} +``` + +where the claims `exp`, `nbf`, `iat`, `iss` are specified by [RFC 7519](https://tools.ietf.org/html/rfc7519), and all those claims will be verified against the GnuPG signature `sig`. + +**NB** It is also possible to read local manifest file via an absolute path. + +```shell +nv2 sign -m gpg -i "Demo User" file:///home/demo/example.json +``` + +### Signing using `x509` + +To sign the manifest `example.json` using the key `key.pem` from the `x509` certificate `cert.pem` with the Common Name `example.registry.io`, run + +```shell +nv2 sign -m x509 -k key.pem -c cert.pem -r example.registry.io/example:latest -o example.nv2 file:example.json +``` + +The formatted signature file `example.nv2` is + +```json +{ + "signed": { + "iat": 1595257070, + "manifests": [ + { + "digest": "sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55", + "size": 528, + "references": [ + "example.registry.io/example:latest" + ] + } + ] + }, + "signatures": [ + { + "typ": "x509", + "sig": "PnY2vpFJV0fayfGOAAxkokthImq932W8XutYCjGLgvBSqdzGM6VgbJhTgXGeettYv5S7A/FO6e319TxEFmx3ogf1bneOUOGDRCdEte+MupDhAISDkiN42Ktci18qFh7MlcR2DXFos5qux0H3Rrc5Rd6Hi4BTTTwHBjsbnNkN1aXuYmyrJZgYmlHBzfdbaDJRcNMo1RAX+j+BWsNZDv+Ae2dtcnoYc2gK5YC2YuNAsvtP4PpR0jtygpCDZjItdVNsJGMwB3dXHUes7Z88IX8hIKlEOt9qv4sq2iOBTju2zvzk4R/pCjUkbD6dOb+t2uyayXbvyAJbi/cEzsfCdwrXjg==", + "alg": "RS256", + "x5c": [ + "MIIDpzCCAo+gAwIBAgIUb6xLgtw1gaM45RnNL9PPhGgvjtEwDQYJKoZIhvcNAQELBQAwYzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEcMBoGA1UEAwwTZXhhbXBsZS5yZWdpc3RyeS5pbzAeFw0yMDA3MjAxNDU1MjJaFw0yMTA3MjAxNDU1MjJaMGMxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxHDAaBgNVBAMME2V4YW1wbGUucmVnaXN0cnkuaW8wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDYW9yzMDsdVpZVU5bTj64iww0AMDGY0qptRaD5ivJGE3RA1WVS/M6gWsOmIHxINiURvuwyG77iUPGq/dXUXjbkypWLcdyEpgpsQRss/lMKjYVayi11nOe6L9nv/1vJ7FPuaBLZJLUIX7+6+/GwQCIe5SmbFmEERNPZ24HdTA+q5jAynYJqJQAx1ReUXNu8jKMo9ZPq787VJIK8eiLn4gty/JfZ0VyobFHaCClbVp+nvfv6IeV+34pFcnPX0UaA4b0zerQIYfkaAAu5pQcR7W5KNQgMR0HIMdvw7Kkuzx30pwJA3i8X49D9nyalyW41wWRnNe8emAjgFkMFXMqlxNmxAgMBAAGjUzBRMB0GA1UdDgQWBBTrHl7XtUeE0biwJngTM2DtVz2LqTAfBgNVHSMEGDAWgBTrHl7XtUeE0biwJngTM2DtVz2LqTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBAua2m0+i920V+KhzmAukJaEh1CkOXr1nFRN2eMuNO4H5l1NsMhtR2XXQkhap9GfSnS0BSIvJ9WbDWm7YBJFXo0zD9pbnlLEbnVtegDzUtEf+0yydKatTc+ClGVM+Cugrbbc7Jzb+hauh6WodYxUAMLUL7Ld4ae7x17VlpgQtRSMELJVrDXaabQXT7sY2pSomFBY5/3NnCJGUOLX0XLRU9dgjHqx1ARWeiJpvH/hV9w2o0jAM+W/vKJHXi4gz1StFLRv4C66cZbMH3yX7d4tlLB7V54ZU0jkRUOcWKFC9Cn4dRrs2dEjYgHRTuk2G3dcqxUCwWCaquuhjk1koi9xYA" + ] + } + ] +} +``` + +If the embedded cert chain `x5c` is not desired, it can be replaced by a key ID `kid` by omitting the `-c` option. + +```shell +nv2 sign -m x509 -k key.pem -r example.registry.io/example:latest -o example.nv2 file:example.json +``` + +The formatted resulted signature file `example.nv2` is + +```json +{ + "signed": { + "iat": 1595259542, + "manifests": [ + { + "digest": "sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55", + "size": 528, + "references": [ + "example.registry.io/example:latest" + ] + } + ] + }, + "signatures": [ + { + "typ": "x509", + "sig": "CIas/ACj5bI0aQHuQCGFRK5I7wKAFltide2a/7u5h5g5xIthbeDjGKUL8JNV9r1Bl2TlCQjyv8695eq8jpe4nlyWWkdf4S+79njLkvWhJiUakLHq4KV1gFUy1dUKSOLRA1YGiS30q0ZKUOiUUdiEF+OUqGc4bHvtrL9ByHA8QBffYvBHSqnzowu/yTwwmX9QvnGwh4ic4Hi4YhJpPwbIYvmcuiXtSgqj/oo2d+aVc+uj9QYp0/ETVl3h7HFZ5XjGB4SxxF77TxqsghpyojMOxf8bT8KxR7V05I1Acy6jmyXyh1pliF9ENdmvHQgSEbtXaWs+8tqkdZd+Y6BxpUA2tQ==", + "alg": "RS256", + "kid": "L7YO:TIUS:TSSY:DV6I:HOU4:YAIC:5HLB:JR7Y:W2EK:XU7W:L27M:YYHY" + } + ] +} +``` + +where the claims `alg`, `x5c`, `kid` are specified by [RFC 7515](https://tools.ietf.org/html/rfc7515), + +### Verifying + +To verify a manifest `example.json` with a signature file `example.nv2`, run + +```shell +nv2 verify -f example.nv2 file://example.json +``` + +Since the manifest was signed by a self-signed certificate, that certificate `cert.pem` is required to be provided to `nv2`. + +``` +$ nv2 verify -f example.nv2 -c cert.pem file:example.json +sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55 +``` + +On successful verification, the `sha256` digest of the manifest is printed. Otherwise, `nv2` prints error messages and returns non-zero values. + +The command `nv2 verify` takes care of all signing methods. Since the original references of a manifest signed using `gpg` does not imply that it is signed by the domain owner, we should disable the `gpg` verification by setting the `--disable-gpg` option. + +``` +$ nv2 verify -f gpg.nv2 --disable-gpg file:example.json +2020/07/20 23:54:35 verification failure: unknown signature type +``` + +## Remote Manifests + +With `nv2`, it is also possible to sign and verify a manifest or a manifest list in a remote registry where the registry can be a docker registry or an OCI registry. + +### Docker Registry + +Here is an example to sign and verify the image `hello-world` in DockerHub, i.e. `docker.io/library/hello-world:latest`, using `gpg`. + +``` +$ nv2 sign -m gpg -i demo -o docker.nv2 docker://docker.io/library/hello-world:latest +sha256:49a1c8800c94df04e9658809b006fd8a686cab8028d33cfba2cc049724254202 +$ nv2 verify -f docker.nv2 docker://docker.io/library/hello-world:latest +sha256:49a1c8800c94df04e9658809b006fd8a686cab8028d33cfba2cc049724254202 +``` + +It is possible to use `digest` in the reference. For instance, + +``` +docker.io/library/hello-world@sha256:49a1c8800c94df04e9658809b006fd8a686cab8028d33cfba2cc049724254202 +``` + +If neither `tag` nor `digest` is specified, the default tag `latest` is used. + +### OCI Registry + +OCI registry works the same as Docker but with the scheme `oci`. + +``` +$ nv2 sign -m gpg -i demo -o oci.nv2 oci://docker.io/library/hello-world:latest +sha256:0ebe6f409b373c8baf39879fccee6cae5e718003ec3167ded7d54cb2b5da2946 +$ nv2 verify -f oci.nv2 oci://docker.io/library/hello-world:latest +sha256:0ebe6f409b373c8baf39879fccee6cae5e718003ec3167ded7d54cb2b5da2946 +``` + +**NB** The digest of the OCI manifest is different from the Docker manifest for the same image since their format is different. Therefore, the signer should be careful with the manifest type when signing. + +### Insecure Registries + +To sign and verify images from insecure registries accessed via `HTTP`, such as `localhost`, the option `--insecure` is required. + +``` +$ docker tag example localhost:5000/example +$ docker push localhost:5000/example +The push refers to repository [localhost:5000/example] +50644c29ef5a: Pushed +latest: digest: sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55 size: 528 +$ nv2 verify -f gpg.nv2 --insecure docker://localhost:5000/example +sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55 +``` + +### Secure Image Pulling + +Since the tag might be changed during the verification process, it is required to pull by digest after verification. + +```shell +digest=$(nv2 sign -m gpg -i demo -o docker.nv2 docker://docker.io/library/hello-world:latest) +if [ $? -eq 0 ]; then + docker pull docker.io/library/hello-world@$digest +fi +``` \ No newline at end of file From 2b0178718f70520fb38309776eb87ad1ecf9c803 Mon Sep 17 00:00:00 2001 From: Aviral Takkar Date: Mon, 20 Jul 2020 06:39:19 -0700 Subject: [PATCH 06/12] Add docs --- README.md | 64 +++++- docs/artifacts/examples/nv2_manifest.json | 9 + .../examples/signature-config/gpg_config.json | 24 +++ .../signature-config/x509_kid_config.json | 25 +++ .../signature-config/x509_x5c_config.json | 25 +++ docs/artifacts/nv2_artifact.md | 57 ++++++ docs/client/nv2_client.md | 174 ++++++++++++++++ docs/distribution/nv2_distribution.md | 138 +++++++++++++ schemas/signature-config-schema.json | 93 +++++++++ specs/distribution/signatures.yml | 190 ++++++++++++++++++ 10 files changed, 798 insertions(+), 1 deletion(-) create mode 100644 docs/artifacts/examples/nv2_manifest.json create mode 100644 docs/artifacts/examples/signature-config/gpg_config.json create mode 100644 docs/artifacts/examples/signature-config/x509_kid_config.json create mode 100644 docs/artifacts/examples/signature-config/x509_x5c_config.json create mode 100644 docs/artifacts/nv2_artifact.md create mode 100644 docs/client/nv2_client.md create mode 100644 docs/distribution/nv2_distribution.md create mode 100644 schemas/signature-config-schema.json create mode 100644 specs/distribution/signatures.yml diff --git a/README.md b/README.md index 996158e49..ceebca084 100644 --- a/README.md +++ b/README.md @@ -1 +1,63 @@ -# nv2 \ No newline at end of file +# Notary V2 (nv2) - Prototype + +nv2 is an incubation and prototype for designing the [Notary v2](http://github.com/notaryproject/) efforts, securing artifacts stored in [oci-distribution](https://github.com/opencontainers/distribution-spec) based registries. + +Public repository is available at GitHub [notaryproject/nv2](https://github.com/notaryproject/nv2). + +## Table of Contents +1. [Problem statement](#problem-statement) +2. [Prototype scope](#prototype-scope) +3. [New registry server APIs](docs/distribution/nv2_distribution.md) +4. [New schema for storing signatures](docs/artifacts/nv2_artifact.md) +5. [Client description](docs/client/nv2_client.md) + +## Problem Statement + +In the world of software, the consumers care about the [authenticity](https://en.wikipedia.org/wiki/Message_authentication) of the software they are using. In terms of OCI registries, the software is referred as images or artifacts where their origin and integrity are the major concerns. + +As the current solution, [Notary v1](https://github.com/theupdateframework/notary) implements [the update framework](https://theupdateframework.io/) (NYU papers [available](https://ssl.engineering.nyu.edu/publications)) to ensure the security not only in the aspect of the authenticity but also in the aspect of timeliness. However, Notary v1 is hard to use in the real world as it is + +- Requires maintaining states at the client +- Signatures (trust collections) are stored in an alternative server +- Confusing tagging system + - The same tag from the notary server may not match the tag in the registry + - The current implementation does not check if the tag in registry has been updated. + - This is reflected by CSS cases + - If an image is pushed to the same tag without signing, the client does not know it. The expected result is that the client should be aware of the new tag and fail the verification. +- Lack of simple signing options + - Some users do not want the complex features but simple signatures + - Some users want to do offline signing / verification +- The root trust of the embedded Public Key Infrastructure (PKI) is uncertain + - Trust on first usage (TOFU) + - Trust pinning + - Only available with the notary-cli. + - Not possible with the docker-cli +- Does not have good support for non-docker products like `ctr`, `oras`, `singularity`, etc. + - Notary is built-in with docker. However, docker is too heavy for IoT scenarios. +- Signatures (trust collections) are bound with the Globally Unique Name (GUN) so that the signatures cannot be moved / copied to other registries. +- Access control is confusing since registry and notary are two standalone services. +- TUF targets generic usages not the images in container registries. + - It does not make sense to make a snapshot of the entire registry, or the entire repository. + - The users should be able to make a snapshot of a collection of selected images or tags. +- Lack of concurrency + - The concurrent trust collection publish process is not safe. + - The notary v1 system is not designed for a distributed system. + +More scenarios are presented in [notaryproject/requirements](https://github.com/notaryproject/requirements/blob/master/scenarios.md#scenarios). + +## Prototype Scope + +The `nv2` prototype covers + +- Client + - CLI experience + - Signing + - Verification + - Binaries plug-in + - Actual pull / push should be done by external binaries +- Server + - Access control + - HTTP API changes + - Registry storage changes + +Key management is offloaded to the underlying signing tools. \ No newline at end of file diff --git a/docs/artifacts/examples/nv2_manifest.json b/docs/artifacts/examples/nv2_manifest.json new file mode 100644 index 000000000..9279518ca --- /dev/null +++ b/docs/artifacts/examples/nv2_manifest.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": 2, + "config": { + "mediaType": "application/vnd.cncf.notary.config.v2+json", + "size": 970, + "digest": "sha256:3050007db1743bfb40df955fa99bfef7ab451a51" + }, + "layers": [] +} \ No newline at end of file diff --git a/docs/artifacts/examples/signature-config/gpg_config.json b/docs/artifacts/examples/signature-config/gpg_config.json new file mode 100644 index 000000000..ce99ef000 --- /dev/null +++ b/docs/artifacts/examples/signature-config/gpg_config.json @@ -0,0 +1,24 @@ +{ + "signed": { + "exp": 1593660592, + "nbf": 1593659992, + "iat": 1593659992, + "manifests": [ + { + "digest": "sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042", + "size": 525, + "references": [ + "docker.io/library/hello-world:latest", + "docker.io/library/hello-world:linux" + ] + } + ] + }, + "signatures": [ + { + "typ": "gpg", + "iss": "Image Developer", + "sig": "iQEzBAABCgAdFiEEwJjX8wKzoB/U5VaL5TFYlOcxMTUFAl79dnkACgkQ5TFYlOcxMTVtPAf9HwVwBDnDal6JA+jqUsy1MqLB00grOAyclSfejUcXsdI5on6BGkPgksiTRexCZhPNKumcYw32uhR/+2V5rkBelP55/ER9xGtV4u00QKBBAwlUWkUe8exO6R4VDiWAYl2bCzDMdaATiiYiOXaM5MujK438qL9P0/QlTUUv51ErvRSE6ofoLmaEB+I0vG7DpmYVVq4iVTpWtK08i9CHlwWttlIBz/+72akxUJ/TjX/WgasgpQM89viBSsxwhftfUyQKexRscL7RruAg4IgLvDwH1CXVqO69oT0UoEFtZxa2CYUcZJscf2zsiWl4wn2aUEa7e4EgDFwpGq8F8C9DfDq5BA==" + } + ] +} \ No newline at end of file diff --git a/docs/artifacts/examples/signature-config/x509_kid_config.json b/docs/artifacts/examples/signature-config/x509_kid_config.json new file mode 100644 index 000000000..179cc20a1 --- /dev/null +++ b/docs/artifacts/examples/signature-config/x509_kid_config.json @@ -0,0 +1,25 @@ +{ + "signed": { + "exp": 1593660592, + "nbf": 1593659992, + "iat": 1593659992, + "manifests": [ + { + "digest": "sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042", + "size": 525, + "references": [ + "docker.io/library/hello-world:latest", + "docker.io/library/hello-world:linux" + ] + } + ] + }, + "signatures": [ + { + "typ": "x509", + "kid": "BF7465E5FC6AA98798AA7737B4072FFD9A38852E", + "alg": "RS256", + "sig": "jzJtWOqxmTA21skwfvKbGEH+8rd8v+R7wpZHzEhCWVXQH51vvsq+XGOmQnSxcklcEbbD7V7HGd9iiSOBlxKKDekZgozyJqSTNRiIoP+Amu9Ggrg1FQW5SKDAGAUiohJiMVNnbdt6Lq1imHyp0OOVfDNI2+2F8YYHIFu51LZhm9QYTcxPyOn/xVqs11l3wzRbW8/ZQyvVXPUvva1t7S2xm0We5SI/O2VjCuNquHKLcf5XpgcXBpEZceHx7WIVX+GXZgbx6IAzt4MMC10/KzADKJqp3bABsJ28Q3CZn4aOpFvkq5fSth8OmCX9vGnV2m4f8J7yTcdvldS28hNJXM/KFA==" + } + ] +} \ No newline at end of file diff --git a/docs/artifacts/examples/signature-config/x509_x5c_config.json b/docs/artifacts/examples/signature-config/x509_x5c_config.json new file mode 100644 index 000000000..96b186a39 --- /dev/null +++ b/docs/artifacts/examples/signature-config/x509_x5c_config.json @@ -0,0 +1,25 @@ +{ + "signed": { + "exp": 1593660592, + "nbf": 1593659992, + "iat": 1593659992, + "manifests": [ + { + "digest": "sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042", + "size": 525, + "references": [ + "docker.io/library/hello-world:latest", + "docker.io/library/hello-world:linux" + ] + } + ] + }, + "signatures": [ + { + "typ": "x509", + "x5c": "MIIC+TCCAp+gAwIBAgIBADAKBggqhkjOPQQDAjBGMUQwQgYDVQQDEzsyV05ZOlVLS1I6RE1EUjpSSU9FOkxHNkE6Q1VYVDpNRlVMOkYzSEU6NVAyVTpLSjNGOkNBNlk6SklEUTAeFw0yMDAxMDYyMDUxMTRaFw0yMTAxMjUyMDUxMTRaMEYxRDBCBgNVBAMTO1VBUTc6WFNOUzpUR1QzOkE0WlU6STVHSzpSNjRZOldDSEM6V1ZSSzpNSTNSOktYTTc6SFdTMzpCVlpaMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArxy2onH0SXxxkRBdopX1VsUnAZ/9JYGrMIykzRn14lwP5JEf+XM5ANEmM/0X8Rr5IH7eSXUz+YVhUnqSJsyOR/qwpSxWKYLqVpunQNY8Hk7fFyo7Itmx1j6uvtmVablQOLFI1BMVv6cr1V5wFVQag7JxdEARekZGS9x9Hrs55WqoIR+oFDl1UTc6PEJ6UXgpbhWXvhSDOipOqIXtvdtrhXQiwm8cq2s1tLD3g86ZdXTX7P1EfLE8mc0HxjpF6Gb5lGdXcv59p/R31/kLe/OpDsgUbq0AowplsYKoGeJgU42Zdn9HVFQQQpKFLPM+ZP7DvfeF1cHXXFnR5NJESVulTQIDAQABo4GyMIGvMA4GA1UdDwEB/wQEAwIHgDAPBgNVHSUECDAGBgRVHSUAMEQGA1UdDgQ9BDtVQVE3OlhTTlM6VEdUMzpBNFpVOkk1R0s6UjY0WTpXQ0hDOldWUks6TUkzUjpLWE03OkhXUzM6QlZaWjBGBgNVHSMEPzA9gDsyV05ZOlVLS1I6RE1EUjpSSU9FOkxHNkE6Q1VYVDpNRlVMOkYzSEU6NVAyVTpLSjNGOkNBNlk6SklEUTAKBggqhkjOPQQDAgNIADBFAiEAyyHJISTYsZveVr5a5c6x28kCe93l5BwPTdTNORhPtsECIDLGzXuLnzDjL+sqdd9NEnDn2vvPPVVMM/8CAmDi5nvs", + "alg": "RS256", + "sig": "jzJtWOqxmTA21skwfvKbGEH+8rd8v+R7wpZHzEhCWVXQH51vvsq+XGOmQnSxcklcEbbD7V7HGd9iiSOBlxKKDekZgozyJqSTNRiIoP+Amu9Ggrg1FQW5SKDAGAUiohJiMVNnbdt6Lq1imHyp0OOVfDNI2+2F8YYHIFu51LZhm9QYTcxPyOn/xVqs11l3wzRbW8/ZQyvVXPUvva1t7S2xm0We5SI/O2VjCuNquHKLcf5XpgcXBpEZceHx7WIVX+GXZgbx6IAzt4MMC10/KzADKJqp3bABsJ28Q3CZn4aOpFvkq5fSth8OmCX9vGnV2m4f8J7yTcdvldS28hNJXM/KFA==" + } + ] +} \ No newline at end of file diff --git a/docs/artifacts/nv2_artifact.md b/docs/artifacts/nv2_artifact.md new file mode 100644 index 000000000..d9fc13560 --- /dev/null +++ b/docs/artifacts/nv2_artifact.md @@ -0,0 +1,57 @@ +# Notary v2 Artifact +Notary v2 signing / verification objects are stored as [OCI artifacts](https://github.com/opencontainers/artifacts). Precisely, it is a [OCI manifest](https://github.com/opencontainers/image-spec/blob/master/manifest.md) ([example](examples/nv2_manifest.json)) with a config of type + +- `application/vnd.cncf.notary.config.v2+json` + +and no layers. + +```json +{ + "schemaVersion": 2, + "config": { + "mediaType": "application/vnd.cncf.notary.config.v2+json", + "size": 970, + "digest": "sha256:3050007db1743bfb40df955fa99bfef7ab451a51" + }, + "layers": [] +} +``` + +Note: All JSON files should be compact with no whitespaces in storage. + +## Notary V2 Config +- See [schema](../../schemas/signature-config-schema.json). + +The config JSON file consists two parts `signed` and `signatures`: + +```json +{ + "signed": { + "exp": 1593660592, + "nbf": 1593659992, + "iat": 1593659992, + "manifests": [ + { + "digest": "sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042", + "size": 525, + "references": [ + "docker.io/library/hello-world:sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042" + "docker.io/library/hello-world:latest", + "docker.io/library/hello-world:linux" + ] + } + ] + }, + "signatures": [] +} +``` + +The `signed` part contains the image metadata for notary. + +- The claims `exp`, `nbf`, `iat` follows [RFC7519](https://tools.ietf.org/html/rfc7519#section-10.1.2) and are optional. +- The private claim `manifests` contains the metadata of the manifests / images for notary, and is **required**. A single signing object can notarize multiple manifests for storage and bandwidth efficiency. + - `digest` is the digest of the manifest + - `size` is the size of the manifest + - `references` are the original references of this manifest, and is optional. If `references` is present, signatures are valid only if signatures are signed by a `x509` cert with `Key Usage` extension of `digitalSignature` and its common name `CN` in the `Subject` field, or with explicit trust configured at the client-side. + +The `signatures` part contains signatures for the `signed` part. The signatures have OR logic. \ No newline at end of file diff --git a/docs/client/nv2_client.md b/docs/client/nv2_client.md new file mode 100644 index 000000000..be067f364 --- /dev/null +++ b/docs/client/nv2_client.md @@ -0,0 +1,174 @@ +# Notary V2 Client +We illustrate a sample client flow, exploring scenarios described at [github.com/notaryproject/requirements](https://github.com/notaryproject/requirements/blob/master/scenarios.md). + +## Sample Artifact +- Tag Reference: + - `hello-world:v1.0` +- Digest: + - `sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042` + +## Sample Client Configuration +For purposes of signing and verification, here's an example client configuration file: +```json +{ + TODO +} +``` +------ + +## Scenario #1: Local Build, Sign, Validate + +### Build +Build image `hello-world:dev`. This can be done using a tool of your choice. For example, using `docker`: + - `docker build -t hello-world:dev .` + +### Sign +Sign image `hello-world:dev` + - `nv2 sign docker://hello-world:dev` + - This creates a local verification object `hello-world_dev.nv2` + ```json + { + "signed": { + "exp": 1593660592, + "nbf": 1593659992, + "iat": 1593659992, + "manifests": [ + { + "digest": "sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042", + "size": 525, + "references": [ + "hello-world:dev" + ] + } + ] + }, + "signatures": [ + { + "typ": "gpg", + "iss": "Image Developer", + "sig": "iQEzBAABCgAdFiEEwJjX8wKzoB/U5VaL5TFYlOcxMTUFAl79dnkACgkQ5TFYlOcxMTVtPAf9HwVwBDnDal6JA+jqUsy1MqLB00grOAyclSfejUcXsdI5on6BGkPgksiTRexCZhPNKumcYw32uhR/+2V5rkBelP55/ER9xGtV4u00QKBBAwlUWkUe8exO6R4VDiWAYl2bCzDMdaATiiYiOXaM5MujK438qL9P0/QlTUUv51ErvRSE6ofoLmaEB+I0vG7DpmYVVq4iVTpWtK08i9CHlwWttlIBz/+72akxUJ/TjX/WgasgpQM89viBSsxwhftfUyQKexRscL7RruAg4IgLvDwH1CXVqO69oT0UoEFtZxa2CYUcZJscf2zsiWl4wn2aUEa7e4EgDFwpGq8F8C9DfDq5BA==" + } + ] + } + ``` + +### Validate +Validate image `hello-world:v1.0`. + - `nv2 verify docker://hello-world:v1.0` + - `sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042` + +Run image `hello-world:v1.0` locally. + - `docker run hello-world@sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042` + +## Scenario #2: Sign, Rename, Push, Validate in Dev + +### Sign +Sign image `hello-world:v1.0` + - `nv2 sign docker://hello-world:v1.0` + - This creates a local verification object `hello-world_v1-0.nv2` + + ```json + { + "signed": { + "exp": 1593660592, + "nbf": 1593659992, + "iat": 1593659992, + "manifests": [ + { + "digest": "sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042", + "size": 525, + "references": [ + "hello-world:v1.0" + ] + } + ] + }, + "signatures": [ + { + "typ": "gpg", + "iss": "Image Developer", + "sig": "rMEzBAABCgAdFiEEwJjX8wKzoB/U5VaL5TFYlOcxMTUFAl79dnkACgkQ5TFYlOcxMTVtPAf9HwVwBDnDal6JA+jqUsy1MqLB00grOAyclSfejUcXsdI5on6BGkPgksiTRexCZhPNKumcYw32uhR/+2V5rkBelP55/ER9xGtV4u00QKBBAwlUWkUe8exO6R4VDiWAYl2bCzDMdaATiiYiOXaM5MujK438qL9P0/QlTUUv51ErvRSE6ofoLmaEB+I0vG7DpmYVVq4iVTpWtK08i9CHlwWttlIBz/+72akxUJ/TjX/WgasgpQM89viBSsxwhftfUyQKexRscL7RruAg4IgLvDwH1CXVqO69oT0UoEFtZxa2CYUcZJscf2zsiWl4wn2aUEa7e4EgDFwpGq8F8C9DfDq5CC==" + } + ] + } + ``` + +### Rename +Rename local artifact to include registry FQDN: + - `docker tag hello-world:v1.0 localhost:5000/hello-world:v1.0` + +### Push +Push target artifact, together with its signature. +- `nv2 push --signature hello-world_v1-0.nv2 docker://localhost:5000/hello-world:v1.0` + +This single command does three operations: +1. Push docker image `localhost:5000/hello-world:v1.0` +2. Push signature artifact `hello-world_v1-0.nv2` +3. Link signature `hello-world_v1-0.nv2` to target artifact `localhost:5000/hello-world:v1.0`. + +Pushing the signature and linking it to its target artifact are separate operations for the following reasons: +- A signature artifact is like any other OCI artifact and has no special handling in the registry. +- Separate `push` and `link` operations allow for fine-grained RBAC. + +### Validate +A consumer of the target artifact, such as an orchestrator deploying an image, can verify the signatures on it. +- `nv2 verify docker://localhost:5000/hello-world:v1.0` + +This command would fetch the signatures on this artifact form the registry and verify each one of them (or a configured few.) As in our example, if gpg signatures are used, the consumer needs to have the verification keys configured in their local keyring. + +## Scenario #6: Multiple Signatures +A client may download an artifact from an origin registry, verify its signatures, add its own signature, and then push everything to a private destination registry. + +### Pull artifact from origin +As an example, we use `docker`: +- `docker pull localhost:5000/hello-world:v1.0` + +### Verify signatures +- `nv2 verify docker://localhost:5000/hello-world:v1.0` + +This command will pull the signature artifacts from the registry and verify each one of them. Verification would ensure that the signature includes tag reference metadata the target artifact is referenced by tag. As a part of this verification, the signature `localhost-5000_hello-world_v1-0.nv2` is downloaded. + +### Rename +Rename local artifact to include private registry FQDN: + - `docker tag localhost:5000/hello-world:v1.0 localhost:6000/hello-world:v1.0-test` + +### Add signature +Sign image `localhost:6000/hello-world:v1.0-test`. + - `nv2 sign docker://localhost:6000/hello-world:v1.0-test` + - This creates a local verification object `localhost-6000_hello-world_v1-0-test.nv2` + + ```json + { + "signed": { + "exp": 1693660592, + "nbf": 1693659992, + "iat": 1693659992, + "manifests": [ + { + "digest": "sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042", + "size": 525, + "references": [ + "localhost:6000/hello-world:v1.0-test" + ] + } + ] + }, + "signatures": [ + { + "typ": "gpg", + "iss": "Image Tester", + "sig": "tYEzBAABCgAdFiEEwJjX8wKzoB/U5VaL5TFYlOcxMTUFAl79dnkACgkQ5TFYlOcxMTVtPAf9HwVwBDnDal6JA+jqUsy1MqLB00grOAyclSfejUcXsdI5on6BGkPgksiTRexCZhPNKumcYw32uhR/+2V5rkBelP55/ER9xGtV4u00QKBBAwlUWkUe8exO6R4VDiWAYl2bCzDMdaATiiYiOXaM5MujK438qL9P0/QlTUUv51ErvRSE6ofoLmaEB+I0vG7DpmYVVq4iVTpWtK08i9CHlwWttlIBz/+72akxUJ/TjX/WgasgpQM89viBSsxwhftfUyQKexRscL7RruAg4IgLvDwH1CXVqO69oT0UoEFtZxa2CYUcZJscf2zsiWl4wn2aUEa7e4EgDFwpGq8F8C9DfDq5ER==" + } + ] + } + ``` + +### Push +- `nv2 push --signatures localhost-5000_hello-world_v1-0.nv2,localhost-6000_hello-world_v1-0-test.nv2 docker://localhost:6000/hello-world:v1.0-test` + +This single command does five operations: +1. Push docker image `localhost:6000/hello-world:v1.0-test` +2. Push signature artifact `localhost-5000_hello-world_v1-0.nv2` +3. Link signature `localhost-5000_hello-world_v1-0.nv2` to target artifact `localhost:6000/hello-world:v1.0-test`. +4. Push signature artifact `localhost-6000_hello-world_v1-0-test.nv2` +5. Link signature `localhost-6000_hello-world_v1-0-test.nv2` to target artifact `localhost:6000/hello-world:v1.0-test`. \ No newline at end of file diff --git a/docs/distribution/nv2_distribution.md b/docs/distribution/nv2_distribution.md new file mode 100644 index 000000000..5e6cc353e --- /dev/null +++ b/docs/distribution/nv2_distribution.md @@ -0,0 +1,138 @@ +# OCI Distribution +We introduce new REST APIs in the registry to support storing signature objects together with the target artifacts, and retrieving them for verification. + +- [Registry OpenAPI Spec](../../specs/distribution/signatures.yml) + +Here, we illustrate a few sample requests for the new APIs. + +## GET list of signatures +The list signatures for an artifact can be retrieved from the registry. +### Requests +- Get all signatures: + - `GET http://localhost:5000/v2/hello-world/manifests/sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042/signatures/` +- Get signatures with optional parameters: + - Paginated request: + - `GET http://localhost:5000/v2/hello-world/manifests/sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042/signatures/?last={last}&max={max}` + - Query by signer: + - `GET http://localhost:5000/v2/hello-world/manifests/sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042/signatures/?iss={iss}` + +### URI Parameters +|Parameter|Description| +|---|---| +|`last`|Query parameter for the last item in previous query. Result set will include values lexically after last.| +|`max`|Query parameter for max number of items.| +|`iss`|Query parameter for issuer, example: `Open Image Scanner`| + +### Sample Response +```json +{ + "digest": "sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042", + "signatures": [ + "sha256:2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c", + "sha256:3335d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc88d" + ] +} +``` + +## Link signatures to target artifacts. +The signature objects `sha256:2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c` and `sha256:007170c33ebc4a74a0a554c86ac2b28ddf3454a5ad9cf90ea8cea9f9e75a153b` can be linked to a target artifact `sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042` as follows: + - `PUT https://localhost:6000/v2/hello-world/manifests/sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042/signatures/sha256:2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c` + - `PUT https://localhost:6000/v2/hello-world/manifests/sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042/signatures/sha256:007170c33ebc4a74a0a554c86ac2b28ddf3454a5ad9cf90ea8cea9f9e75a153b` + +## Registry storage layout +Here we illustrate how signature objects are stored in the registry storage backend as different OCI objects are pushed and linked together. + +On pushing target manifest `sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042` to repository `hello-world`: + +``` + +|__ v2 + |__ repositories + |__ hello-world + |__ _manifests + |__ revisions + |__ sha256 + |__ 90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042 + |__ link +``` + +On pushing signature manifest `sha256:2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c` to repository `hello-world`: + +``` + +|__ v2 + |__ repositories + |__ hello-world + |__ _manifests + |__ revisions + |__ sha256 + |__ 90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042 + |__ link + |__ 2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c + |__ link + +``` + +On pushing signature manifest `sha256:007170c33ebc4a74a0a554c86ac2b28ddf3454a5ad9cf90ea8cea9f9e75a153b` to repository `hello-world`: + +``` + +|__ v2 + |__ repositories + |__ hello-world + |__ _manifests + |__ revisions + |__ sha256 + |__ 90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042 + |__ link + |__ 2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c + |__ link + |__ 007170c33ebc4a74a0a554c86ac2b28ddf3454a5ad9cf90ea8cea9f9e75a153b + |__ link +``` + +On linking signature `sha256:2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c` to target manifest `90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042`: + +``` + +|__ v2 + |__ repositories + |__ hello-world + |__ _manifests + |__ revisions + |__ sha256 + |__ 90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042 + |__ link + |__ signatures + |__ sha256 + |__ 2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c + |__ link + |__ 2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c + |__ link + |__ 007170c33ebc4a74a0a554c86ac2b28ddf3454a5ad9cf90ea8cea9f9e75a153b + |__ link +``` + +On linking signature `sha256:007170c33ebc4a74a0a554c86ac2b28ddf3454a5ad9cf90ea8cea9f9e75a153b` to target manifest `90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042`: + +``` + +|__ v2 + |__ repositories + |__ hello-world + |__ _manifests + |__ revisions + |__ sha256 + |__ 90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042 + |__ link + |__ signatures + |__ sha256 + |__ 2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c + |__ link + |__ 007170c33ebc4a74a0a554c86ac2b28ddf3454a5ad9cf90ea8cea9f9e75a153b + |__ link + |__ 2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c + |__ link + |__ 007170c33ebc4a74a0a554c86ac2b28ddf3454a5ad9cf90ea8cea9f9e75a153b + |__ link +``` \ No newline at end of file diff --git a/schemas/signature-config-schema.json b/schemas/signature-config-schema.json new file mode 100644 index 000000000..b68ddb17d --- /dev/null +++ b/schemas/signature-config-schema.json @@ -0,0 +1,93 @@ +{ + "description": "Notary V2 Signature Config Specification", + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "https://localhost:5000/schema/signature/config", + "type": "object", + "properties": { + "signed": { + "type": "object", + "properties": { + "exp": { + "type": "integer", + "description": "Expiration time. Ref RFC7519." + }, + "nbf": { + "type": "integer", + "description": "Not before time. Ref RFC7519." + }, + "iat": { + "type": "integer", + "description": "Issued at time. Ref RFC7519." + }, + "manifests": { + "type": "object", + "description": "This is a collection of all the objects being signed.", + "properties": { + "digest": { + "description": "The cryptographic checksum digest of the object, in the pattern ':'", + "$ref": "defs-descriptor.json#/definitions/digest" + }, + "size": { + "description": "The size in bytes of the referenced object.", + "$ref": "defs.json#/definitions/int64" + }, + "references": { + "type": "array", + "description": "Each element in this array represents a fully qualified tag reference to the object.", + "minItems": 1, + "items": { + "type": "string", + "description": "Example: localhost:5000/hello-world:latest" + } + } + } + } + } + }, + "signatures": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "typ": { + "type": "string", + "description": "Media type. Ref RFC7519.", + "enum": [ + "x509", + "gpg" + ] + }, + "iss": { + "type": "string", + "description": "Issuer claim. Ref RFC7519." + }, + "sig": { + "type": "string", + "description": "The signature blob." + }, + "alg": { + "type": "string", + "description": "Signing algorithm. Ref RFC7515." + }, + "x5c": { + "type": "string", + "description": "X509 public key certificate or certificate chain. Ref RFC7515." + }, + "kid": { + "type": "string", + "description": "Signing key hint. Ref RFC7515." + } + }, + "required": [ + "typ", + "sig" + ] + } + } + }, + "required": [ + "signed", + "signatures" + ] +} \ No newline at end of file diff --git a/specs/distribution/signatures.yml b/specs/distribution/signatures.yml new file mode 100644 index 000000000..d3e6bfc46 --- /dev/null +++ b/specs/distribution/signatures.yml @@ -0,0 +1,190 @@ +openapi: '3.0.2' + +info: + title: Content Trust in OCI Distribution + description: Metadata API for signature related operations in an [OCI registry](https://github.com/opencontainers/distribution-spec). + version: '1.0' + +servers: + - url: http://localhost:5000/v2 + +components: + securitySchemes: + basicAuth: + type: http + scheme: basic + bearerAuth: + type: http + scheme: bearer + + schemas: + signatures: + type: object + description: The list of signatures available for a given artifact. + properties: + digest: + type: string + description: The digest of the artifact manifest to which the signatures apply. + example: sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042 + signatures: + type: array + items: + type: string + description: The digest of the signature manifest. + example: sha256:2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c + + signature_config: + $ref: "../../schemas/signature-config-schema.json" + + manifest: + $ref: "https://raw.githubusercontent.com/opencontainers/image-spec/master/schema/image-manifest-schema.json" + + parameters: + repoParam: + name: repository + in: path + description: The name of the repository. + required: true + schema: + type: string + + digestParam: + name: digest + in: path + description: The cryptographic checksum digest of the object, in the pattern ':' + required: true + schema: + type: string + + signatureManifestDigestParam: + $ref: "#/components/parameters/digestParam" + +paths: + /{repository}/manifests/{digest}: + get: + summary: Get manifest of object corresponding to the given digest in the given repository. + operationId: getManifest + security: + - basicAuth: [] + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/repoParam" + - $ref: "#/components/parameters/digestParam" + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/manifest" + + /{repository}/manifests/{digest}/signatures/: + get: + summary: Get all available signatures that reference the given manifest digest in the given repository. + operationId: getSignatures + security: + - basicAuth: [] + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/repoParam" + - $ref: "#/components/parameters/digestParam" + - in: query + name: iss + description: Query parameter for issuer. + required: false + example: Open Image Scanner + schema: + type: string + - in: query + name: max + description: Query parameter for max number of items. + required: false + example: 10 + schema: + type: integer + - in: query + name: last + description: Query parameter for the last item in previous query. Result set will include values lexically after last. + example: 5 + schema: + type: integer + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/signatures" + + /{repository}/manifests/{digest}/signature/{digest}: + put: + summary: Link a signature to an image. + operationId: putSignature + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/repoParam" + - $ref: "#/components/parameters/digestParam" + - $ref: "#/components/parameters/signatureManifestDigestParam" + responses: + '201': + description: Accepted + + /{repository}/blobs/{digest}: + get: + summary: Get a layer blob. + operationId: getBlob + security: + - basicAuth: [] + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/repoParam" + - $ref: "#/components/parameters/digestParam" + responses: + '307': + description: Temporary Redirect + headers: + Location: + schema: + type: string + description: Location of the blob in the data server. + links: + GetSignatureConfigBlob: + operationRef: '#/paths/~1{blobLocation}/get' + parameters: + blobLocation: '$response.header.Location' + description: The URI in the `Location` header can be used to download the blob from the data server. + + /{blobLocation}: + get: + summary: Download data from blob location. The location may contain a SAS key. + operationId: getContent + servers: + - url: http://localhost:5000/data/ + parameters: + - in: path + name: blobLocation + description: The blob location in the data server. It may be a SAS URI. + schema: + type: string + required: true + responses: + '200': + description: Ok + content: + application/octet-stream: + schema: + oneOf: + - $ref: "#/components/schemas/signature_config" + - type: string + format: binary + examples: + x509_x5c_config: + externalValue: "../../docs/artifacts/examples/signature-config/x509_x5c_config.json" + description: A x509 signature containing a x5c certificate chain. + x509_kid_config: + externalValue: "../../docs/artifacts/examples/signature-config/x509_kid_config.json" + description: A x509 signature containing a signing key ID reference. + gpg_config: + externalValue: "../../docs/artifacts/examples/signature-config/gpg_config.json" + description: A gpg signature containing the public key reference in the signature. \ No newline at end of file From 923b69e2e4df2135e38e3be96a6960fbc5aae1aa Mon Sep 17 00:00:00 2001 From: Shiwei Zhang Date: Wed, 22 Jul 2020 11:34:00 +0800 Subject: [PATCH 07/12] minor doc cli revise (#7) --- docs/nv2/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/nv2/README.md b/docs/nv2/README.md index 26b9349db..08bedc1f7 100644 --- a/docs/nv2/README.md +++ b/docs/nv2/README.md @@ -40,7 +40,7 @@ By default, all keys sit in the directory `~/.gnupg`. If the `gpg` version is `> ```shell # Update to legacy public key ring -[ -f ~/.gnupg/pubring.gpg ] && gpg --export > ~/.gnupg/pubring.gpg +[ ! -f ~/.gnupg/pubring.gpg ] && gpg --export > ~/.gnupg/pubring.gpg # Export legacy secret key ring gpg --export-secret-keys > ~/.gnupg/secring.gpg @@ -77,7 +77,8 @@ The above commands build the image `example:latest` based on the local context, To sign the manifest `example.json` using the GnuPG key identified by the identity name `Demo User`, run ``` -$ nv2 sign -m gpg -i "Demo User" -r example.registry.io/example:latest -e 8760h file:example.json sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55 +$ nv2 sign -m gpg -i "Demo User" -r example.registry.io/example:latest -e 8760h file:example.json +sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55 ``` where the optional option `-r` declares the original reference, and the optional option `-e` specifies the expiry time (`8760h = 365 days`). On successful signing, `nv2` prints out the `sha256` digest of the manifest, and writes the `nv2` signature JSON file `.nv2` to the working directory. If the file name is not desired, option `-o` can be specified for the alternative file name. @@ -269,7 +270,7 @@ sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55 Since the tag might be changed during the verification process, it is required to pull by digest after verification. ```shell -digest=$(nv2 sign -m gpg -i demo -o docker.nv2 docker://docker.io/library/hello-world:latest) +digest=$(nv2 verify -f docker.nv2 docker://docker.io/library/hello-world:latest) if [ $? -eq 0 ]; then docker pull docker.io/library/hello-world@$digest fi From e3ec209b1c933a1ab97c1212bbaa23d5610eae42 Mon Sep 17 00:00:00 2001 From: Shiwei Zhang Date: Wed, 22 Jul 2020 20:25:41 +0800 Subject: [PATCH 08/12] Revise documentation (#8) --- README.md | 64 +------ cmd/nv2/main.go | 4 - docs/artifact/README.md | 22 +++ .../examples/manifest.json} | 16 +- .../examples/signature-config/gpg_config.json | 24 --- .../signature-config/x509_kid_config.json | 25 --- .../signature-config/x509_x5c_config.json | 25 --- docs/artifacts/nv2_artifact.md | 57 ------ docs/client/nv2_client.md | 174 ----------------- docs/distribution/README.md | 146 +++++++++++++++ docs/distribution/nv2_distribution.md | 138 -------------- .../distribution/spec.yml | 8 +- docs/distribution/workflow.md | 101 ++++++++++ docs/nv2/README.md | 51 ++++- docs/signature/README.md | 177 ++++++++++++++++++ docs/signature/examples/gpg.nv2.json | 1 + docs/signature/examples/x509_kid.nv2.json | 1 + docs/signature/examples/x509_x5c.nv2.json | 1 + .../signature/schema.json | 0 19 files changed, 512 insertions(+), 523 deletions(-) create mode 100644 docs/artifact/README.md rename docs/{artifacts/examples/nv2_manifest.json => artifact/examples/manifest.json} (54%) delete mode 100644 docs/artifacts/examples/signature-config/gpg_config.json delete mode 100644 docs/artifacts/examples/signature-config/x509_kid_config.json delete mode 100644 docs/artifacts/examples/signature-config/x509_x5c_config.json delete mode 100644 docs/artifacts/nv2_artifact.md delete mode 100644 docs/client/nv2_client.md create mode 100644 docs/distribution/README.md delete mode 100644 docs/distribution/nv2_distribution.md rename specs/distribution/signatures.yml => docs/distribution/spec.yml (91%) create mode 100644 docs/distribution/workflow.md create mode 100644 docs/signature/README.md create mode 100644 docs/signature/examples/gpg.nv2.json create mode 100644 docs/signature/examples/x509_kid.nv2.json create mode 100644 docs/signature/examples/x509_x5c.nv2.json rename schemas/signature-config-schema.json => docs/signature/schema.json (100%) diff --git a/README.md b/README.md index ceebca084..996158e49 100644 --- a/README.md +++ b/README.md @@ -1,63 +1 @@ -# Notary V2 (nv2) - Prototype - -nv2 is an incubation and prototype for designing the [Notary v2](http://github.com/notaryproject/) efforts, securing artifacts stored in [oci-distribution](https://github.com/opencontainers/distribution-spec) based registries. - -Public repository is available at GitHub [notaryproject/nv2](https://github.com/notaryproject/nv2). - -## Table of Contents -1. [Problem statement](#problem-statement) -2. [Prototype scope](#prototype-scope) -3. [New registry server APIs](docs/distribution/nv2_distribution.md) -4. [New schema for storing signatures](docs/artifacts/nv2_artifact.md) -5. [Client description](docs/client/nv2_client.md) - -## Problem Statement - -In the world of software, the consumers care about the [authenticity](https://en.wikipedia.org/wiki/Message_authentication) of the software they are using. In terms of OCI registries, the software is referred as images or artifacts where their origin and integrity are the major concerns. - -As the current solution, [Notary v1](https://github.com/theupdateframework/notary) implements [the update framework](https://theupdateframework.io/) (NYU papers [available](https://ssl.engineering.nyu.edu/publications)) to ensure the security not only in the aspect of the authenticity but also in the aspect of timeliness. However, Notary v1 is hard to use in the real world as it is - -- Requires maintaining states at the client -- Signatures (trust collections) are stored in an alternative server -- Confusing tagging system - - The same tag from the notary server may not match the tag in the registry - - The current implementation does not check if the tag in registry has been updated. - - This is reflected by CSS cases - - If an image is pushed to the same tag without signing, the client does not know it. The expected result is that the client should be aware of the new tag and fail the verification. -- Lack of simple signing options - - Some users do not want the complex features but simple signatures - - Some users want to do offline signing / verification -- The root trust of the embedded Public Key Infrastructure (PKI) is uncertain - - Trust on first usage (TOFU) - - Trust pinning - - Only available with the notary-cli. - - Not possible with the docker-cli -- Does not have good support for non-docker products like `ctr`, `oras`, `singularity`, etc. - - Notary is built-in with docker. However, docker is too heavy for IoT scenarios. -- Signatures (trust collections) are bound with the Globally Unique Name (GUN) so that the signatures cannot be moved / copied to other registries. -- Access control is confusing since registry and notary are two standalone services. -- TUF targets generic usages not the images in container registries. - - It does not make sense to make a snapshot of the entire registry, or the entire repository. - - The users should be able to make a snapshot of a collection of selected images or tags. -- Lack of concurrency - - The concurrent trust collection publish process is not safe. - - The notary v1 system is not designed for a distributed system. - -More scenarios are presented in [notaryproject/requirements](https://github.com/notaryproject/requirements/blob/master/scenarios.md#scenarios). - -## Prototype Scope - -The `nv2` prototype covers - -- Client - - CLI experience - - Signing - - Verification - - Binaries plug-in - - Actual pull / push should be done by external binaries -- Server - - Access control - - HTTP API changes - - Registry storage changes - -Key management is offloaded to the underlying signing tools. \ No newline at end of file +# nv2 \ No newline at end of file diff --git a/cmd/nv2/main.go b/cmd/nv2/main.go index b2ab1a66d..28849c47e 100644 --- a/cmd/nv2/main.go +++ b/cmd/nv2/main.go @@ -17,10 +17,6 @@ func main() { Name: "Shiwei Zhang", Email: "shizh@microsoft.com", }, - { - Name: "Aviral Takkar", - Email: "avtakkar@microsoft.com", - }, }, Commands: []*cli.Command{ signCommand, diff --git a/docs/artifact/README.md b/docs/artifact/README.md new file mode 100644 index 000000000..0e5df5c05 --- /dev/null +++ b/docs/artifact/README.md @@ -0,0 +1,22 @@ +# Notary V2 Artifact +[Notary v2 signatures](../signature/README.md) can be stored as [OCI artifacts](https://github.com/opencontainers/artifacts). Precisely, it is a [OCI manifest](https://github.com/opencontainers/image-spec/blob/master/manifest.md) with a config of type + +- `application/vnd.cncf.notary.config.v2+json` + +and no layers. + +## Example Artifact + +Example showing the manifest ([examples/manifest.json](examples/manifest.json)) of an artifact. + +```json +{ + "schemaVersion": 2, + "config": { + "mediaType": "application/vnd.cncf.notary.config.v2+json", + "size": 1906, + "digest": "sha256:c7848182f2c817415f0de63206f9e4220012cbb0bdb750c2ecf8020350239814" + }, + "layers": [] +} +``` diff --git a/docs/artifacts/examples/nv2_manifest.json b/docs/artifact/examples/manifest.json similarity index 54% rename from docs/artifacts/examples/nv2_manifest.json rename to docs/artifact/examples/manifest.json index 9279518ca..5e57feda6 100644 --- a/docs/artifacts/examples/nv2_manifest.json +++ b/docs/artifact/examples/manifest.json @@ -1,9 +1,9 @@ -{ - "schemaVersion": 2, - "config": { - "mediaType": "application/vnd.cncf.notary.config.v2+json", - "size": 970, - "digest": "sha256:3050007db1743bfb40df955fa99bfef7ab451a51" - }, - "layers": [] +{ + "schemaVersion": 2, + "config": { + "mediaType": "application/vnd.cncf.notary.config.v2+json", + "size": 1906, + "digest": "sha256:c7848182f2c817415f0de63206f9e4220012cbb0bdb750c2ecf8020350239814" + }, + "layers": [] } \ No newline at end of file diff --git a/docs/artifacts/examples/signature-config/gpg_config.json b/docs/artifacts/examples/signature-config/gpg_config.json deleted file mode 100644 index ce99ef000..000000000 --- a/docs/artifacts/examples/signature-config/gpg_config.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "signed": { - "exp": 1593660592, - "nbf": 1593659992, - "iat": 1593659992, - "manifests": [ - { - "digest": "sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042", - "size": 525, - "references": [ - "docker.io/library/hello-world:latest", - "docker.io/library/hello-world:linux" - ] - } - ] - }, - "signatures": [ - { - "typ": "gpg", - "iss": "Image Developer", - "sig": "iQEzBAABCgAdFiEEwJjX8wKzoB/U5VaL5TFYlOcxMTUFAl79dnkACgkQ5TFYlOcxMTVtPAf9HwVwBDnDal6JA+jqUsy1MqLB00grOAyclSfejUcXsdI5on6BGkPgksiTRexCZhPNKumcYw32uhR/+2V5rkBelP55/ER9xGtV4u00QKBBAwlUWkUe8exO6R4VDiWAYl2bCzDMdaATiiYiOXaM5MujK438qL9P0/QlTUUv51ErvRSE6ofoLmaEB+I0vG7DpmYVVq4iVTpWtK08i9CHlwWttlIBz/+72akxUJ/TjX/WgasgpQM89viBSsxwhftfUyQKexRscL7RruAg4IgLvDwH1CXVqO69oT0UoEFtZxa2CYUcZJscf2zsiWl4wn2aUEa7e4EgDFwpGq8F8C9DfDq5BA==" - } - ] -} \ No newline at end of file diff --git a/docs/artifacts/examples/signature-config/x509_kid_config.json b/docs/artifacts/examples/signature-config/x509_kid_config.json deleted file mode 100644 index 179cc20a1..000000000 --- a/docs/artifacts/examples/signature-config/x509_kid_config.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "signed": { - "exp": 1593660592, - "nbf": 1593659992, - "iat": 1593659992, - "manifests": [ - { - "digest": "sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042", - "size": 525, - "references": [ - "docker.io/library/hello-world:latest", - "docker.io/library/hello-world:linux" - ] - } - ] - }, - "signatures": [ - { - "typ": "x509", - "kid": "BF7465E5FC6AA98798AA7737B4072FFD9A38852E", - "alg": "RS256", - "sig": "jzJtWOqxmTA21skwfvKbGEH+8rd8v+R7wpZHzEhCWVXQH51vvsq+XGOmQnSxcklcEbbD7V7HGd9iiSOBlxKKDekZgozyJqSTNRiIoP+Amu9Ggrg1FQW5SKDAGAUiohJiMVNnbdt6Lq1imHyp0OOVfDNI2+2F8YYHIFu51LZhm9QYTcxPyOn/xVqs11l3wzRbW8/ZQyvVXPUvva1t7S2xm0We5SI/O2VjCuNquHKLcf5XpgcXBpEZceHx7WIVX+GXZgbx6IAzt4MMC10/KzADKJqp3bABsJ28Q3CZn4aOpFvkq5fSth8OmCX9vGnV2m4f8J7yTcdvldS28hNJXM/KFA==" - } - ] -} \ No newline at end of file diff --git a/docs/artifacts/examples/signature-config/x509_x5c_config.json b/docs/artifacts/examples/signature-config/x509_x5c_config.json deleted file mode 100644 index 96b186a39..000000000 --- a/docs/artifacts/examples/signature-config/x509_x5c_config.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "signed": { - "exp": 1593660592, - "nbf": 1593659992, - "iat": 1593659992, - "manifests": [ - { - "digest": "sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042", - "size": 525, - "references": [ - "docker.io/library/hello-world:latest", - "docker.io/library/hello-world:linux" - ] - } - ] - }, - "signatures": [ - { - "typ": "x509", - "x5c": "MIIC+TCCAp+gAwIBAgIBADAKBggqhkjOPQQDAjBGMUQwQgYDVQQDEzsyV05ZOlVLS1I6RE1EUjpSSU9FOkxHNkE6Q1VYVDpNRlVMOkYzSEU6NVAyVTpLSjNGOkNBNlk6SklEUTAeFw0yMDAxMDYyMDUxMTRaFw0yMTAxMjUyMDUxMTRaMEYxRDBCBgNVBAMTO1VBUTc6WFNOUzpUR1QzOkE0WlU6STVHSzpSNjRZOldDSEM6V1ZSSzpNSTNSOktYTTc6SFdTMzpCVlpaMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArxy2onH0SXxxkRBdopX1VsUnAZ/9JYGrMIykzRn14lwP5JEf+XM5ANEmM/0X8Rr5IH7eSXUz+YVhUnqSJsyOR/qwpSxWKYLqVpunQNY8Hk7fFyo7Itmx1j6uvtmVablQOLFI1BMVv6cr1V5wFVQag7JxdEARekZGS9x9Hrs55WqoIR+oFDl1UTc6PEJ6UXgpbhWXvhSDOipOqIXtvdtrhXQiwm8cq2s1tLD3g86ZdXTX7P1EfLE8mc0HxjpF6Gb5lGdXcv59p/R31/kLe/OpDsgUbq0AowplsYKoGeJgU42Zdn9HVFQQQpKFLPM+ZP7DvfeF1cHXXFnR5NJESVulTQIDAQABo4GyMIGvMA4GA1UdDwEB/wQEAwIHgDAPBgNVHSUECDAGBgRVHSUAMEQGA1UdDgQ9BDtVQVE3OlhTTlM6VEdUMzpBNFpVOkk1R0s6UjY0WTpXQ0hDOldWUks6TUkzUjpLWE03OkhXUzM6QlZaWjBGBgNVHSMEPzA9gDsyV05ZOlVLS1I6RE1EUjpSSU9FOkxHNkE6Q1VYVDpNRlVMOkYzSEU6NVAyVTpLSjNGOkNBNlk6SklEUTAKBggqhkjOPQQDAgNIADBFAiEAyyHJISTYsZveVr5a5c6x28kCe93l5BwPTdTNORhPtsECIDLGzXuLnzDjL+sqdd9NEnDn2vvPPVVMM/8CAmDi5nvs", - "alg": "RS256", - "sig": "jzJtWOqxmTA21skwfvKbGEH+8rd8v+R7wpZHzEhCWVXQH51vvsq+XGOmQnSxcklcEbbD7V7HGd9iiSOBlxKKDekZgozyJqSTNRiIoP+Amu9Ggrg1FQW5SKDAGAUiohJiMVNnbdt6Lq1imHyp0OOVfDNI2+2F8YYHIFu51LZhm9QYTcxPyOn/xVqs11l3wzRbW8/ZQyvVXPUvva1t7S2xm0We5SI/O2VjCuNquHKLcf5XpgcXBpEZceHx7WIVX+GXZgbx6IAzt4MMC10/KzADKJqp3bABsJ28Q3CZn4aOpFvkq5fSth8OmCX9vGnV2m4f8J7yTcdvldS28hNJXM/KFA==" - } - ] -} \ No newline at end of file diff --git a/docs/artifacts/nv2_artifact.md b/docs/artifacts/nv2_artifact.md deleted file mode 100644 index d9fc13560..000000000 --- a/docs/artifacts/nv2_artifact.md +++ /dev/null @@ -1,57 +0,0 @@ -# Notary v2 Artifact -Notary v2 signing / verification objects are stored as [OCI artifacts](https://github.com/opencontainers/artifacts). Precisely, it is a [OCI manifest](https://github.com/opencontainers/image-spec/blob/master/manifest.md) ([example](examples/nv2_manifest.json)) with a config of type - -- `application/vnd.cncf.notary.config.v2+json` - -and no layers. - -```json -{ - "schemaVersion": 2, - "config": { - "mediaType": "application/vnd.cncf.notary.config.v2+json", - "size": 970, - "digest": "sha256:3050007db1743bfb40df955fa99bfef7ab451a51" - }, - "layers": [] -} -``` - -Note: All JSON files should be compact with no whitespaces in storage. - -## Notary V2 Config -- See [schema](../../schemas/signature-config-schema.json). - -The config JSON file consists two parts `signed` and `signatures`: - -```json -{ - "signed": { - "exp": 1593660592, - "nbf": 1593659992, - "iat": 1593659992, - "manifests": [ - { - "digest": "sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042", - "size": 525, - "references": [ - "docker.io/library/hello-world:sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042" - "docker.io/library/hello-world:latest", - "docker.io/library/hello-world:linux" - ] - } - ] - }, - "signatures": [] -} -``` - -The `signed` part contains the image metadata for notary. - -- The claims `exp`, `nbf`, `iat` follows [RFC7519](https://tools.ietf.org/html/rfc7519#section-10.1.2) and are optional. -- The private claim `manifests` contains the metadata of the manifests / images for notary, and is **required**. A single signing object can notarize multiple manifests for storage and bandwidth efficiency. - - `digest` is the digest of the manifest - - `size` is the size of the manifest - - `references` are the original references of this manifest, and is optional. If `references` is present, signatures are valid only if signatures are signed by a `x509` cert with `Key Usage` extension of `digitalSignature` and its common name `CN` in the `Subject` field, or with explicit trust configured at the client-side. - -The `signatures` part contains signatures for the `signed` part. The signatures have OR logic. \ No newline at end of file diff --git a/docs/client/nv2_client.md b/docs/client/nv2_client.md deleted file mode 100644 index be067f364..000000000 --- a/docs/client/nv2_client.md +++ /dev/null @@ -1,174 +0,0 @@ -# Notary V2 Client -We illustrate a sample client flow, exploring scenarios described at [github.com/notaryproject/requirements](https://github.com/notaryproject/requirements/blob/master/scenarios.md). - -## Sample Artifact -- Tag Reference: - - `hello-world:v1.0` -- Digest: - - `sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042` - -## Sample Client Configuration -For purposes of signing and verification, here's an example client configuration file: -```json -{ - TODO -} -``` ------- - -## Scenario #1: Local Build, Sign, Validate - -### Build -Build image `hello-world:dev`. This can be done using a tool of your choice. For example, using `docker`: - - `docker build -t hello-world:dev .` - -### Sign -Sign image `hello-world:dev` - - `nv2 sign docker://hello-world:dev` - - This creates a local verification object `hello-world_dev.nv2` - ```json - { - "signed": { - "exp": 1593660592, - "nbf": 1593659992, - "iat": 1593659992, - "manifests": [ - { - "digest": "sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042", - "size": 525, - "references": [ - "hello-world:dev" - ] - } - ] - }, - "signatures": [ - { - "typ": "gpg", - "iss": "Image Developer", - "sig": "iQEzBAABCgAdFiEEwJjX8wKzoB/U5VaL5TFYlOcxMTUFAl79dnkACgkQ5TFYlOcxMTVtPAf9HwVwBDnDal6JA+jqUsy1MqLB00grOAyclSfejUcXsdI5on6BGkPgksiTRexCZhPNKumcYw32uhR/+2V5rkBelP55/ER9xGtV4u00QKBBAwlUWkUe8exO6R4VDiWAYl2bCzDMdaATiiYiOXaM5MujK438qL9P0/QlTUUv51ErvRSE6ofoLmaEB+I0vG7DpmYVVq4iVTpWtK08i9CHlwWttlIBz/+72akxUJ/TjX/WgasgpQM89viBSsxwhftfUyQKexRscL7RruAg4IgLvDwH1CXVqO69oT0UoEFtZxa2CYUcZJscf2zsiWl4wn2aUEa7e4EgDFwpGq8F8C9DfDq5BA==" - } - ] - } - ``` - -### Validate -Validate image `hello-world:v1.0`. - - `nv2 verify docker://hello-world:v1.0` - - `sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042` - -Run image `hello-world:v1.0` locally. - - `docker run hello-world@sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042` - -## Scenario #2: Sign, Rename, Push, Validate in Dev - -### Sign -Sign image `hello-world:v1.0` - - `nv2 sign docker://hello-world:v1.0` - - This creates a local verification object `hello-world_v1-0.nv2` - - ```json - { - "signed": { - "exp": 1593660592, - "nbf": 1593659992, - "iat": 1593659992, - "manifests": [ - { - "digest": "sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042", - "size": 525, - "references": [ - "hello-world:v1.0" - ] - } - ] - }, - "signatures": [ - { - "typ": "gpg", - "iss": "Image Developer", - "sig": "rMEzBAABCgAdFiEEwJjX8wKzoB/U5VaL5TFYlOcxMTUFAl79dnkACgkQ5TFYlOcxMTVtPAf9HwVwBDnDal6JA+jqUsy1MqLB00grOAyclSfejUcXsdI5on6BGkPgksiTRexCZhPNKumcYw32uhR/+2V5rkBelP55/ER9xGtV4u00QKBBAwlUWkUe8exO6R4VDiWAYl2bCzDMdaATiiYiOXaM5MujK438qL9P0/QlTUUv51ErvRSE6ofoLmaEB+I0vG7DpmYVVq4iVTpWtK08i9CHlwWttlIBz/+72akxUJ/TjX/WgasgpQM89viBSsxwhftfUyQKexRscL7RruAg4IgLvDwH1CXVqO69oT0UoEFtZxa2CYUcZJscf2zsiWl4wn2aUEa7e4EgDFwpGq8F8C9DfDq5CC==" - } - ] - } - ``` - -### Rename -Rename local artifact to include registry FQDN: - - `docker tag hello-world:v1.0 localhost:5000/hello-world:v1.0` - -### Push -Push target artifact, together with its signature. -- `nv2 push --signature hello-world_v1-0.nv2 docker://localhost:5000/hello-world:v1.0` - -This single command does three operations: -1. Push docker image `localhost:5000/hello-world:v1.0` -2. Push signature artifact `hello-world_v1-0.nv2` -3. Link signature `hello-world_v1-0.nv2` to target artifact `localhost:5000/hello-world:v1.0`. - -Pushing the signature and linking it to its target artifact are separate operations for the following reasons: -- A signature artifact is like any other OCI artifact and has no special handling in the registry. -- Separate `push` and `link` operations allow for fine-grained RBAC. - -### Validate -A consumer of the target artifact, such as an orchestrator deploying an image, can verify the signatures on it. -- `nv2 verify docker://localhost:5000/hello-world:v1.0` - -This command would fetch the signatures on this artifact form the registry and verify each one of them (or a configured few.) As in our example, if gpg signatures are used, the consumer needs to have the verification keys configured in their local keyring. - -## Scenario #6: Multiple Signatures -A client may download an artifact from an origin registry, verify its signatures, add its own signature, and then push everything to a private destination registry. - -### Pull artifact from origin -As an example, we use `docker`: -- `docker pull localhost:5000/hello-world:v1.0` - -### Verify signatures -- `nv2 verify docker://localhost:5000/hello-world:v1.0` - -This command will pull the signature artifacts from the registry and verify each one of them. Verification would ensure that the signature includes tag reference metadata the target artifact is referenced by tag. As a part of this verification, the signature `localhost-5000_hello-world_v1-0.nv2` is downloaded. - -### Rename -Rename local artifact to include private registry FQDN: - - `docker tag localhost:5000/hello-world:v1.0 localhost:6000/hello-world:v1.0-test` - -### Add signature -Sign image `localhost:6000/hello-world:v1.0-test`. - - `nv2 sign docker://localhost:6000/hello-world:v1.0-test` - - This creates a local verification object `localhost-6000_hello-world_v1-0-test.nv2` - - ```json - { - "signed": { - "exp": 1693660592, - "nbf": 1693659992, - "iat": 1693659992, - "manifests": [ - { - "digest": "sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042", - "size": 525, - "references": [ - "localhost:6000/hello-world:v1.0-test" - ] - } - ] - }, - "signatures": [ - { - "typ": "gpg", - "iss": "Image Tester", - "sig": "tYEzBAABCgAdFiEEwJjX8wKzoB/U5VaL5TFYlOcxMTUFAl79dnkACgkQ5TFYlOcxMTVtPAf9HwVwBDnDal6JA+jqUsy1MqLB00grOAyclSfejUcXsdI5on6BGkPgksiTRexCZhPNKumcYw32uhR/+2V5rkBelP55/ER9xGtV4u00QKBBAwlUWkUe8exO6R4VDiWAYl2bCzDMdaATiiYiOXaM5MujK438qL9P0/QlTUUv51ErvRSE6ofoLmaEB+I0vG7DpmYVVq4iVTpWtK08i9CHlwWttlIBz/+72akxUJ/TjX/WgasgpQM89viBSsxwhftfUyQKexRscL7RruAg4IgLvDwH1CXVqO69oT0UoEFtZxa2CYUcZJscf2zsiWl4wn2aUEa7e4EgDFwpGq8F8C9DfDq5ER==" - } - ] - } - ``` - -### Push -- `nv2 push --signatures localhost-5000_hello-world_v1-0.nv2,localhost-6000_hello-world_v1-0-test.nv2 docker://localhost:6000/hello-world:v1.0-test` - -This single command does five operations: -1. Push docker image `localhost:6000/hello-world:v1.0-test` -2. Push signature artifact `localhost-5000_hello-world_v1-0.nv2` -3. Link signature `localhost-5000_hello-world_v1-0.nv2` to target artifact `localhost:6000/hello-world:v1.0-test`. -4. Push signature artifact `localhost-6000_hello-world_v1-0-test.nv2` -5. Link signature `localhost-6000_hello-world_v1-0-test.nv2` to target artifact `localhost:6000/hello-world:v1.0-test`. \ No newline at end of file diff --git a/docs/distribution/README.md b/docs/distribution/README.md new file mode 100644 index 000000000..f50f6982d --- /dev/null +++ b/docs/distribution/README.md @@ -0,0 +1,146 @@ +# OCI Distribution + +We introduce new REST APIs in the registry to support storing signature objects together with the target artifacts, and retrieving them for verification. + +- [Registry OpenAPI Spec](../../specs/distribution/signatures.yml) + +Here, we illustrate a few sample requests for the new APIs. + +## GET list of signatures + +The list signatures for an artifact can be retrieved from the registry. + +### Requests + +- Get all signatures: + - `GET http://localhost:5000/v2/hello-world/manifests/sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042/signatures/` +- Get signatures with optional parameters: + - Paginated request: + - `GET http://localhost:5000/v2/hello-world/manifests/sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042/signatures/?last={last}&max={max}` + - Query by signer: + - `GET http://localhost:5000/v2/hello-world/manifests/sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042/signatures/?iss={iss}` + +### URI Parameters + +| Parameter | Description | +| --------- | ------------------------------------------------------------ | +| `last` | Query parameter for the last item in previous query. Result set will include values lexically after last. | +| `max` | Query parameter for max number of items. | +| `iss` | Query parameter for issuer, example: `Open Image Scanner` | + +### Sample Response + +```json +{ + "digest": "sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042", + "signatures": [ + "sha256:2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c", + "sha256:3335d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc88d" + ] +} +``` + +## Link signatures to target artifacts. + +The signature objects `sha256:2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c` and `sha256:007170c33ebc4a74a0a554c86ac2b28ddf3454a5ad9cf90ea8cea9f9e75a153b` can be linked to a target artifact `sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042` as follows: + + - `PUT https://localhost:6000/v2/hello-world/manifests/sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042/signatures/sha256:2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c` + - `PUT https://localhost:6000/v2/hello-world/manifests/sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042/signatures/sha256:007170c33ebc4a74a0a554c86ac2b28ddf3454a5ad9cf90ea8cea9f9e75a153b` + +## Registry storage layout + +Here we illustrate how signature objects are stored in the registry storage backend as different OCI objects are pushed and linked together. + +On pushing target manifest `sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042` to repository `hello-world`: + +``` + +└── v2 + └── repositories + └── hello-world + └── _manifests + └── revisions + └── sha256 + └── 90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042 + └── link +``` + +On pushing signature manifest `sha256:2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c` to repository `hello-world`: + +``` + +└── v2 + └── repositories + └── hello-world + └── _manifests + └── revisions + └── sha256 + ├── 90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042 + │   └── link + └── 2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c + └── link +``` + +On pushing signature manifest `sha256:007170c33ebc4a74a0a554c86ac2b28ddf3454a5ad9cf90ea8cea9f9e75a153b` to repository `hello-world`: + +``` + +└── v2 + └── repositories + └── hello-world + └── _manifests + └── revisions + └── sha256 + ├── 90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042 + │   └── link + ├── 2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c + │   └── link + └── 007170c33ebc4a74a0a554c86ac2b28ddf3454a5ad9cf90ea8cea9f9e75a153b + └── link +``` + +On linking signature `sha256:2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c` to target manifest `90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042`: + +``` + +└── v2 + └── repositories + └── hello-world + └── _manifests + └── revisions + └── sha256 + ├── 90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042 + │   ├── link + │   └── signatures + │   └── sha256 + │   └── 2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c + │      └── link + ├── 2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c + │   └── link + └── 007170c33ebc4a74a0a554c86ac2b28ddf3454a5ad9cf90ea8cea9f9e75a153b + └── link +``` + +On linking signature `sha256:007170c33ebc4a74a0a554c86ac2b28ddf3454a5ad9cf90ea8cea9f9e75a153b` to target manifest `90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042`: + +``` + +└── v2 + └── repositories + └── hello-world + └── _manifests + └── revisions + └── sha256 + ├── 90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042 + │   ├── link + │   └── signatures + │   └── sha256 + │   ├── 2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c + │   │   └── link + │   └── 007170c33ebc4a74a0a554c86ac2b28ddf3454a5ad9cf90ea8cea9f9e75a153b + │   └── link + ├── 2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c + │   └── link + └── 007170c33ebc4a74a0a554c86ac2b28ddf3454a5ad9cf90ea8cea9f9e75a153b + └── link +``` diff --git a/docs/distribution/nv2_distribution.md b/docs/distribution/nv2_distribution.md deleted file mode 100644 index 5e6cc353e..000000000 --- a/docs/distribution/nv2_distribution.md +++ /dev/null @@ -1,138 +0,0 @@ -# OCI Distribution -We introduce new REST APIs in the registry to support storing signature objects together with the target artifacts, and retrieving them for verification. - -- [Registry OpenAPI Spec](../../specs/distribution/signatures.yml) - -Here, we illustrate a few sample requests for the new APIs. - -## GET list of signatures -The list signatures for an artifact can be retrieved from the registry. -### Requests -- Get all signatures: - - `GET http://localhost:5000/v2/hello-world/manifests/sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042/signatures/` -- Get signatures with optional parameters: - - Paginated request: - - `GET http://localhost:5000/v2/hello-world/manifests/sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042/signatures/?last={last}&max={max}` - - Query by signer: - - `GET http://localhost:5000/v2/hello-world/manifests/sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042/signatures/?iss={iss}` - -### URI Parameters -|Parameter|Description| -|---|---| -|`last`|Query parameter for the last item in previous query. Result set will include values lexically after last.| -|`max`|Query parameter for max number of items.| -|`iss`|Query parameter for issuer, example: `Open Image Scanner`| - -### Sample Response -```json -{ - "digest": "sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042", - "signatures": [ - "sha256:2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c", - "sha256:3335d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc88d" - ] -} -``` - -## Link signatures to target artifacts. -The signature objects `sha256:2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c` and `sha256:007170c33ebc4a74a0a554c86ac2b28ddf3454a5ad9cf90ea8cea9f9e75a153b` can be linked to a target artifact `sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042` as follows: - - `PUT https://localhost:6000/v2/hello-world/manifests/sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042/signatures/sha256:2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c` - - `PUT https://localhost:6000/v2/hello-world/manifests/sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042/signatures/sha256:007170c33ebc4a74a0a554c86ac2b28ddf3454a5ad9cf90ea8cea9f9e75a153b` - -## Registry storage layout -Here we illustrate how signature objects are stored in the registry storage backend as different OCI objects are pushed and linked together. - -On pushing target manifest `sha256:90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042` to repository `hello-world`: - -``` - -|__ v2 - |__ repositories - |__ hello-world - |__ _manifests - |__ revisions - |__ sha256 - |__ 90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042 - |__ link -``` - -On pushing signature manifest `sha256:2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c` to repository `hello-world`: - -``` - -|__ v2 - |__ repositories - |__ hello-world - |__ _manifests - |__ revisions - |__ sha256 - |__ 90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042 - |__ link - |__ 2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c - |__ link - -``` - -On pushing signature manifest `sha256:007170c33ebc4a74a0a554c86ac2b28ddf3454a5ad9cf90ea8cea9f9e75a153b` to repository `hello-world`: - -``` - -|__ v2 - |__ repositories - |__ hello-world - |__ _manifests - |__ revisions - |__ sha256 - |__ 90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042 - |__ link - |__ 2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c - |__ link - |__ 007170c33ebc4a74a0a554c86ac2b28ddf3454a5ad9cf90ea8cea9f9e75a153b - |__ link -``` - -On linking signature `sha256:2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c` to target manifest `90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042`: - -``` - -|__ v2 - |__ repositories - |__ hello-world - |__ _manifests - |__ revisions - |__ sha256 - |__ 90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042 - |__ link - |__ signatures - |__ sha256 - |__ 2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c - |__ link - |__ 2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c - |__ link - |__ 007170c33ebc4a74a0a554c86ac2b28ddf3454a5ad9cf90ea8cea9f9e75a153b - |__ link -``` - -On linking signature `sha256:007170c33ebc4a74a0a554c86ac2b28ddf3454a5ad9cf90ea8cea9f9e75a153b` to target manifest `90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042`: - -``` - -|__ v2 - |__ repositories - |__ hello-world - |__ _manifests - |__ revisions - |__ sha256 - |__ 90659bf80b44ce6be8234e6ff90a1ac34acbeb826903b02cfa0da11c82cbc042 - |__ link - |__ signatures - |__ sha256 - |__ 2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c - |__ link - |__ 007170c33ebc4a74a0a554c86ac2b28ddf3454a5ad9cf90ea8cea9f9e75a153b - |__ link - |__ 2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c - |__ link - |__ 007170c33ebc4a74a0a554c86ac2b28ddf3454a5ad9cf90ea8cea9f9e75a153b - |__ link -``` \ No newline at end of file diff --git a/specs/distribution/signatures.yml b/docs/distribution/spec.yml similarity index 91% rename from specs/distribution/signatures.yml rename to docs/distribution/spec.yml index d3e6bfc46..777ba9848 100644 --- a/specs/distribution/signatures.yml +++ b/docs/distribution/spec.yml @@ -34,7 +34,7 @@ components: example: sha256:2235d2d22ae5ef400769fa51c84717264cd1520ac8d93dc071374c1be49cc77c signature_config: - $ref: "../../schemas/signature-config-schema.json" + $ref: "../signature/schema.json" manifest: $ref: "https://raw.githubusercontent.com/opencontainers/image-spec/master/schema/image-manifest-schema.json" @@ -180,11 +180,11 @@ paths: format: binary examples: x509_x5c_config: - externalValue: "../../docs/artifacts/examples/signature-config/x509_x5c_config.json" + externalValue: "../signature/examples/x509_x5c.nv2.json" description: A x509 signature containing a x5c certificate chain. x509_kid_config: - externalValue: "../../docs/artifacts/examples/signature-config/x509_kid_config.json" + externalValue: "../signature/examples/x509_kid.nv2.json" description: A x509 signature containing a signing key ID reference. gpg_config: - externalValue: "../../docs/artifacts/examples/signature-config/gpg_config.json" + externalValue: "../signature/examples/gpg.nv2.json" description: A gpg signature containing the public key reference in the signature. \ No newline at end of file diff --git a/docs/distribution/workflow.md b/docs/distribution/workflow.md new file mode 100644 index 000000000..da2d0c0ac --- /dev/null +++ b/docs/distribution/workflow.md @@ -0,0 +1,101 @@ +# Notary V2 Workflow + +We illustrate a sample client flow, exploring scenarios described at [github.com/notaryproject/requirements](https://github.com/notaryproject/requirements/blob/master/scenarios.md). + +The detailed documentation of the `nv2` command is [available](../nv2/README.md). + +## Scenario #1: Local Build, Sign, Validate + +### Build + +Build image `hello-world:dev`. This can be done using a tool of your choice. For example, using `docker`: + +```shell +docker build -t hello-world:dev . +``` + +### Sign + +Sign image `hello-world:dev`. + +```shell +docker generate manifest hello-world:dev | nv2 sign -m gpg -i demo -o hello-world.nv2 +``` + +This creates a signature `hello-world.nv2`. + +```json +{ + "signed": { + "iat": 1595418878, + "manifests": [ + { + "digest": "sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55", + "size": 528 + } + ] + }, + "signatures": [ + { + "typ": "gpg", + "sig": "wsDcBAABCAAQBQJfGCj+CRDvXc1GQtqlQgAA+0QMABcsQ0wU2oY78SgHkm7MsYyHdsAkrWBpLG1hRT02InRj18LUmnwGrvpl6sZm7h5pYbfAg1tST9ta+KQCCXNzP4axGS6cNwilPh7V8kUCgXSPaYyzHIptpBbr5HIaOGBCNPIJTFmnvKGYum1AZng+YudRY2UalS1K4vYWMFEsS5xUJNwoHk06nr+DY68QEBUpBGf689iSH7eIGE9XN4+1mtpnOHhI33FbjCFf3ksh+caE91gch/H4H4CQ5RRfjuvnD0xEBVDCVA/0XygBR1IGT9upoVFUA8XNbuhtATej1MHpOd3mIfeg1rBb2sP0j5tZrbyjBBBB4EbI2GfRYfczlaqfRvmAug4AI9Ya7/RFaZTX15A9X+zTpLH0I34BWwh6BKF9TwoFybFPJODYdZ0+rOmE9Renlc4GwPn0LnXX/PVQ3h6rlWznpdaVUFSPYhPg4bbQnW3XL9nCM8zPu2oVoQGVVNqhIVZpq1es7zc0BkrTT+n3eJyBG/WiLpxwGJneNw==", + "iss": "Demo User " + } + ] +} + ``` + +### Validate + +Validate image `hello-world:dev`. + +``` +$ docker generate manifest hello-world:dev | nv2 verify -f hello-world.nv2 +sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55 +``` + +## Scenario #2: Sign, Rename, Push, Validate in Dev + +### Sign + +Sign image `hello-world:dev`. + +```shell +docker generate manifest hello-world:dev | nv2 sign -m gpg -i demo -o hello-world.nv2 +``` + +This creates a signature `hello-world.nv2`. + +### Rename + +Rename local artifact to include registry FQDN: + +```shell +docker tag hello-world:dev localhost:5000/hello-world:v1.0 +``` + +### Push + +Push target artifact, together with its signature. + +1. Push docker image `localhost:5000/hello-world:v1.0` +2. Push signature artifact `hello-world.nv2` +3. Link signature `hello-world.nv2` to target artifact `localhost:5000/hello-world:v1.0`. + +Pushing the signature and linking it to its target artifact are separate operations for the following reasons: + +- A signature artifact is like any other OCI artifact and has no special handling in the registry. +- Separate `push` and `link` operations allow for fine-grained RBAC. + +### Validate + +A consumer of the target artifact, such as an orchestrator deploying an image, can verify the signatures on it. + +Fetch the signatures on this artifact form the registry and verify each one of them (or a configured few). After fetching the signature artifact `hello-world.nv2`, the system will do the following equivalent + +``` +$ nv2 verify -f hello-world.nv2 --insecure docker://localhost:5000/hello-world:v1.0 +sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55 +``` + + As in our example, if `gpg` signatures are used, the consumer needs to have the verification keys configured in their local keyring. \ No newline at end of file diff --git a/docs/nv2/README.md b/docs/nv2/README.md index 08bedc1f7..2f7fbef3e 100644 --- a/docs/nv2/README.md +++ b/docs/nv2/README.md @@ -58,6 +58,55 @@ openssl req -x509 -sha256 -nodes -newkey rsa:2048 -keyout key.pem -out cert.pem When generating the certificate, make sure that the Common Name (`CN`) is set properly in the `Subject` field. The Common Name will be verified against the claimed original references. +## CLI Overview + +The two major commands of `nv2` are + +- `nv2 sign` + + ``` + NAME: + nv2 sign - signs artifacts or images + + USAGE: + nv2 sign [command options] [] + + OPTIONS: + --method value, -m value siging method + --key value, -k value siging key file [x509] + --cert value, -c value siging cert [x509] + --key-ring value gpg public key ring file [gpg] (default: "/home/demo/.gnupg/secring.gpg") + --identity value, -i value signer identity [gpg] + --expiry value, -e value expire duration (default: 0s) + --reference value, -r value original references + --output value, -o value write signature to a specific path + --username value, -u value username for generic remote access + --password value, -p value password for generic remote access + --insecure enable insecure remote access (default: false) + --help, -h show help (default: false) + ``` + +- `nv2 verify` + + ``` + NAME: + nv2 verify - verifies artifacts or images + + USAGE: + nv2 verify [command options] [] + + OPTIONS: + --signature value, -s value, -f value signature file + --cert value, -c value certs for verification [x509] + --ca-cert value CA certs for verification [x509] + --key-ring value gpg public key ring file [gpg] (default: "/home/demo/.gnupg/pubring.gpg") + --disable-gpg disable GPG for verification [gpg] (default: false) + --username value, -u value username for generic remote access + --password value, -p value password for generic remote access + --insecure enable insecure remote access (default: false) + --help, -h show help (default: false) + ``` + ## Offline Signing and Verification Signing and verification offline can be accomplished by the `nv2 sign` command and the `nv2 verify` command. @@ -249,7 +298,7 @@ $ nv2 verify -f oci.nv2 oci://docker.io/library/hello-world:latest sha256:0ebe6f409b373c8baf39879fccee6cae5e718003ec3167ded7d54cb2b5da2946 ``` -**NB** The digest of the OCI manifest is different from the Docker manifest for the same image since their format is different. Therefore, the signer should be careful with the manifest type when signing. +**Note** The digest of the OCI manifest is different from the Docker manifest for the same image since their format is different. Therefore, the signer should be careful with the manifest type when signing. ### Insecure Registries diff --git a/docs/signature/README.md b/docs/signature/README.md new file mode 100644 index 000000000..7a57ecde1 --- /dev/null +++ b/docs/signature/README.md @@ -0,0 +1,177 @@ +# Notary V2 Signature Specification + +This section defines the signature file, which is in JSON format with no whitespaces. Its JSON schema is available at [schema.json](schema.json). + +# Signature + +A Notary v2 signature is clear-signed signature of manifest metadata, including but not limited to + +- [OCI Image Index](https://github.com/opencontainers/image-spec/blob/master/image-index.md) +- [OCI Image Manifest](https://github.com/opencontainers/image-spec/blob/master/manifest.md) +- [Docker Image Manifest List](https://docs.docker.com/registry/spec/manifest-v2-2/#manifest-list) +- [Docker Image Manifest](https://docs.docker.com/registry/spec/manifest-v2-2/#image-manifest) + +## *Signature* Property Descriptions + +- **`signed`** *object* + + This REQUIRED property provides the signed content. + + - **`iat`** *integer* + + This OPTIONAL property identities the time at which the manifests were presented to the notary. This field is based on [RFC 7519 Section 4.1.6](https://tools.ietf.org/html/rfc7519#section-4.1.6). When used, it does not imply the issue time of any signature in the `signatures` property. + + - **`nbf`** *integer* + + This OPTIONAL property identifies the time before which the signed content MUST NOT be accepted for processing. This field is based on [RFC 7519 Section 4.1.5](https://tools.ietf.org/html/rfc7519#section-4.1.5). + + - **`exp`** *integer* + + This OPTIONAL property identifies the expiration time on or after which the signed content MUST NOT be accepted for processing. This field is based on [RFC 7519 Section 4.1.4](https://tools.ietf.org/html/rfc7519#section-4.1.4). + + - **`manifests`** *array of objects* + + This REQUIRED property references manifests presented to notary for certifying. + + - **`digest`** *string* + + This REQUIRED property is the *digest* of the target manifest, conforming to the requirements outlined in [Digests](https://github.com/opencontainers/image-spec/blob/master/descriptor.md#digests). If the actual content is fetched according to the *digest*, implementations MUST verify the content against the *digest*. + + - **`size`** *integer* + + This REQUIRED property is the *size* of the target manifest. If the actual content is fetched according the *digest*, implementations MUST verify the content against the *size*. + + - **`references`** *array of strings* + + This OPTIONAL property claims the manifest references of its origin. The format of the value MUST matches the [*reference* grammar](https://github.com/docker/distribution/blob/master/reference/reference.go). With used, the `x509` signatures are valid only if the domain names of all references match the Common Name (`CN`) in the `Subject` field of the certificate. + +- **`signatures`** *array of objects* + + This REQUIRED property provides the signatures of the signed content. The entire signature file is valid if any signature in `signatures` is valid. The `signature` object is influenced by JSON Web Signature (JWS) at [RFC 7515](https://tools.ietf.org/html/rfc7515). + + - **`typ`** *string* + + This REQUIRED property identifies the signature type. Implementations MUST support at least the following types + + - `x509`: X.509 public key certificates. Implementations MUST verify that the certificate of the signing key has the `digitalSignature` `Key Usage` extension ([RFC 5280 Section 4.2.1.3](https://tools.ietf.org/html/rfc5280#section-4.2.1.3)). + + Implementations SHOULD support the following types + + - `gpg`: [GnuPG](https://www.gnupg.org/) detached signatures. + + Implementations MAY support the following types + + - `tuf`: [The update framework](https://theupdateframework.io/). + + - **`sig`** *string* + + This REQUIRED property provides the base64-encoded signature binary of the specified signature type. + + - **`iss`** *string* + + This REQUIRED property for the `gpg` type indicates the name of the signer / issuer. Implementations MUST verify the issuer name against the user ID of the `gpg` signature. + + - **`alg`** *string* + + This REQUIRED property for the `x509` type identifies the cryptographic algorithm used to sign the content. This field is based on [RFC 7515 Section 4.1.1](https://tools.ietf.org/html/rfc7515#section-4.1.1). + + - **`x5c`** *array of strings* + + This OPTIONAL property for the `x509` type contains the X.509 public key certificate or certificate chain corresponding to the key used to digitally sign the content. The certificates are encoded in base64. This field is based on [RFC 7515 Section 4.1.6](https://tools.ietf.org/html/rfc7515#section-4.1.6). + + - **`kid`** *string* + + This OPTIONAL property for the `x509` type is a hint (key ID) indicating which key was used to sign the content. This field is based on [RFC 7515 Section 4.1.4](https://tools.ietf.org/html/rfc7515#section-4.1.4). + +## Example Signatures + +Example showing a formatted `gpg` signature file [examples/gpg.nv2.json](examples/gpg.nv2.json): + +```json +{ + "signed": { + "exp": 1626938668, + "nbf": 1595402668, + "iat": 1595402668, + "manifests": [ + { + "digest": "sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55", + "size": 528, + "references": [ + "registry.example.com/example:latest", + "registry.example.com/example:v1.0" + ] + } + ] + }, + "signatures": [ + { + "typ": "gpg", + "sig": "wsDcBAABCAAQBQJfF+msCRDvXc1GQtqlQgAAJwUMAAZdQdDJCCoHl8VXyseeU2WB7/1Ip+Ei++C/ZFtA4ncsifdi28B4FQlAjOPbIPlIsldl7KtL6aMloHiQTm/sBl+aEys4Z2/xTSu+5//jcUeWwtDEiSur2K2w3F7RmDWhGFSjgXvlkPMt7iaCqy6dEPvrLSYXRgBAVnUEdtS/L/ANMSupt+FZh2AISyWL6TZKOKVcxKSiJ0SR72L7DYE1E6edBPsPHivc485qwRljvjG9q8WwWusvZM4OjBLaddn7d83+R4YQNqGBp8RGvEGiw9oWzu3f+2MCeT5USQWFcIr+KQHJi4R/0cqKGQ9TarUS1vIKSiasmnqufVCi2Ucb+5sj8oaI7/DIyCxYiv0lX1pJE1j/yuS1XtDVzn7J1enkuP9TgiRNSzjZJUc5rLa3IwyuXGaJOtUJm60ma5WU/LoUe1sqC4jpQ2nU4UNHH14KnoeElJzE1WknmmrGck2ewx0yiln7wCrwKQ5dC0kS8suJBoZD7Ms7SAwDMbHL5oj1fg==", + "iss": "Demo User \u003cdemo@example.com\u003e" + } + ] +} +``` + +Example showing a formatted `x509` signature file [examples/x509_x5c.nv2.json](examples/x509_x5c.nv2.json) with certificates provided by `x5c`: + +```json +{ + "signed": { + "exp": 1626938793, + "nbf": 1595402793, + "iat": 1595402793, + "manifests": [ + { + "digest": "sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55", + "size": 528, + "references": [ + "registry.example.com/example:latest", + "registry.example.com/example:v1.0" + ] + } + ] + }, + "signatures": [ + { + "typ": "x509", + "sig": "uFKaCyQ4MtVHemfLVq5gYZyeiClS20tksXzP7hhpeqqjCNK9DiHnoDpkq91sutLqd1o6RCxpfFVuGXy20oqRu1/ZoXXAVC3y7lS6z/wqJ4VDBKSj/H6xyYn7pH3GE8GHR6kjFPqrGsl/OS4yYH2oNXEm9W8Pju2wC381+FCgf4LNf7k6u2Uf4Fb0/Fl40qzvr0m2Fv5pXtRY+wdJctqJb+t408VcXJkNj0U7xoOe0zUr3l1A6xLYqjd0ZY08JBQ8FQul0Vpxrmg0Xdtwd/wEolvia48lxD1x7yphW5bFvJOTd62rOJgd4uI7jYJF3ZLmwjY+geMk5e6Wkp5OyXGjXw==", + "alg": "RS256", + "x5c": [ + "MIIDmzCCAoOgAwIBAgIUFSzsIT4/pKtGzywuZWWE7ydiLBIwDQYJKoZIhvcNAQELBQAwXTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEWMBQGA1UEAwwNKi5leGFtcGxlLmNvbTAeFw0yMDA3MjIwMzA2MTBaFw0yMTA3MjIwMzA2MTBaMF0xCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxFjAUBgNVBAMMDSouZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDM0MNLy/f1SyRM0ZQu3AtJnCU3O5x8nnOeV1mySmZNr2SCqR8+jENAoKE5FrrSi2ffMnFPP/7DqGnbb9+b1nD9ucFNsI1iW7IrF/GlqOM7jJhUMNnOyatz8mddtQgXr3SZ9bigbc/lxuVGacvi64DewoWzMFr4ZMGq8ik7aDnHryUDwXJFE+KGNbsReO1ePqKmPiLvkLG4sBTqeTuCk+Grrr5t1COujwuFWfhMjmRfq34QGqUZ3SHJYXPzOAxgV3fCmBP9IgHuSv/b1udx5Htf1BV7WlARtXfE21xuA6FM1Gq0pANUhcRF39KJRu4/RBZBmAxg7ces8hrZWTQ4LTo/AgMBAAGjUzBRMB0GA1UdDgQWBBR2pI+c2dexlOZCXLy84Baqu8NR8DAfBgNVHSMEGDAWgBR2pI+c2dexlOZCXLy84Baqu8NR8DAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCH2tChjmvs6/2acw+cJYWkEExdXMEyjdUvqEIcs7W7Ce32My7RcMtJxybtqjV+UVghEVUzq1pNf0Dt5FhFkC6BDHnHv2SIO9jq2TvfDUcJgMMgwSZdSaISmxk+iFD9Cll+RU8KgeoYSnwojOixTksyeBRi5rePdO5smz/n4Bd4ToluKaw42tdWhF4SMgx2Y1nlyHpFlkdUYtJ6D8rOvbVRGQaxo8Td3mWCWPMBYcGvjwO9ESCP1JAK+Z6WXD46JWilsIUd3Y+0NrfvOYKUdhLWuz9LrQ5060qi1pHfYBOTAbyXfnW97EB3TAuMtqBBe6h3VNw00c1p7qrilE1Of9uN" + ] + } + ] +} +``` + +Example showing a formatted `x509` signature file [examples/x509_kid.nv2.json](examples/x509_kid.nv2.json) with certificates referenced by `kid`: + +```json +{ + "signed": { + "exp": 1626938803, + "nbf": 1595402803, + "iat": 1595402803, + "manifests": [ + { + "digest": "sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55", + "size": 528, + "references": [ + "registry.example.com/example:latest", + "registry.example.com/example:v1.0" + ] + } + ] + }, + "signatures": [ + { + "typ": "x509", + "sig": "JQWZ9/H1oQyuBxyYsPaKE7Xh4+U0uITmPwRpPOBNFOxe0qnIxmkyQD0g/W5eQRt1Jwa+2hn35EamqERmdT6ji2f/6haqfIwcSjjaiDu1q1sXGDQhk+ZVzOCCcqRaFNV0fPRwaVMwxeizTUy9ENe1ksZqAPI1SCyzSr6pAa5xKeoJXFUToPjjMm1VMzwj9qwphGk8sXhSqCAt9P9/PV50pxuWU1Dbe+y6M6ZlnET2YIswBze3EjloROQtKniy87Xb2ZwJp81R0XUbWRk5LqiJVT9jDN8/RMDBvMj8eymrjbcb/F3TugvZ99jkkEVjk6tH+dvXu9HbS9HtGh0KRO1XQw==", + "alg": "RS256", + "kid": "SE4Z:F3CT:DZ64:ONJX:6CRE:PTD2:Z755:DG7W:TSUI:I5GZ:RFKR:JCHY" + } + ] +} +``` \ No newline at end of file diff --git a/docs/signature/examples/gpg.nv2.json b/docs/signature/examples/gpg.nv2.json new file mode 100644 index 000000000..b05e00757 --- /dev/null +++ b/docs/signature/examples/gpg.nv2.json @@ -0,0 +1 @@ +{"signed":{"exp":1626938668,"nbf":1595402668,"iat":1595402668,"manifests":[{"digest":"sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55","size":528,"references":["registry.example.com/example:latest","registry.example.com/example:v1.0"]}]},"signatures":[{"typ":"gpg","sig":"wsDcBAABCAAQBQJfF+msCRDvXc1GQtqlQgAAJwUMAAZdQdDJCCoHl8VXyseeU2WB7/1Ip+Ei++C/ZFtA4ncsifdi28B4FQlAjOPbIPlIsldl7KtL6aMloHiQTm/sBl+aEys4Z2/xTSu+5//jcUeWwtDEiSur2K2w3F7RmDWhGFSjgXvlkPMt7iaCqy6dEPvrLSYXRgBAVnUEdtS/L/ANMSupt+FZh2AISyWL6TZKOKVcxKSiJ0SR72L7DYE1E6edBPsPHivc485qwRljvjG9q8WwWusvZM4OjBLaddn7d83+R4YQNqGBp8RGvEGiw9oWzu3f+2MCeT5USQWFcIr+KQHJi4R/0cqKGQ9TarUS1vIKSiasmnqufVCi2Ucb+5sj8oaI7/DIyCxYiv0lX1pJE1j/yuS1XtDVzn7J1enkuP9TgiRNSzjZJUc5rLa3IwyuXGaJOtUJm60ma5WU/LoUe1sqC4jpQ2nU4UNHH14KnoeElJzE1WknmmrGck2ewx0yiln7wCrwKQ5dC0kS8suJBoZD7Ms7SAwDMbHL5oj1fg==","iss":"Demo User \u003cdemo@example.com\u003e"}]} \ No newline at end of file diff --git a/docs/signature/examples/x509_kid.nv2.json b/docs/signature/examples/x509_kid.nv2.json new file mode 100644 index 000000000..232cf5378 --- /dev/null +++ b/docs/signature/examples/x509_kid.nv2.json @@ -0,0 +1 @@ +{"signed":{"exp":1626938803,"nbf":1595402803,"iat":1595402803,"manifests":[{"digest":"sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55","size":528,"references":["registry.example.com/example:latest","registry.example.com/example:v1.0"]}]},"signatures":[{"typ":"x509","sig":"JQWZ9/H1oQyuBxyYsPaKE7Xh4+U0uITmPwRpPOBNFOxe0qnIxmkyQD0g/W5eQRt1Jwa+2hn35EamqERmdT6ji2f/6haqfIwcSjjaiDu1q1sXGDQhk+ZVzOCCcqRaFNV0fPRwaVMwxeizTUy9ENe1ksZqAPI1SCyzSr6pAa5xKeoJXFUToPjjMm1VMzwj9qwphGk8sXhSqCAt9P9/PV50pxuWU1Dbe+y6M6ZlnET2YIswBze3EjloROQtKniy87Xb2ZwJp81R0XUbWRk5LqiJVT9jDN8/RMDBvMj8eymrjbcb/F3TugvZ99jkkEVjk6tH+dvXu9HbS9HtGh0KRO1XQw==","alg":"RS256","kid":"SE4Z:F3CT:DZ64:ONJX:6CRE:PTD2:Z755:DG7W:TSUI:I5GZ:RFKR:JCHY"}]} \ No newline at end of file diff --git a/docs/signature/examples/x509_x5c.nv2.json b/docs/signature/examples/x509_x5c.nv2.json new file mode 100644 index 000000000..3a4c35899 --- /dev/null +++ b/docs/signature/examples/x509_x5c.nv2.json @@ -0,0 +1 @@ +{"signed":{"exp":1626938793,"nbf":1595402793,"iat":1595402793,"manifests":[{"digest":"sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55","size":528,"references":["registry.example.com/example:latest","registry.example.com/example:v1.0"]}]},"signatures":[{"typ":"x509","sig":"uFKaCyQ4MtVHemfLVq5gYZyeiClS20tksXzP7hhpeqqjCNK9DiHnoDpkq91sutLqd1o6RCxpfFVuGXy20oqRu1/ZoXXAVC3y7lS6z/wqJ4VDBKSj/H6xyYn7pH3GE8GHR6kjFPqrGsl/OS4yYH2oNXEm9W8Pju2wC381+FCgf4LNf7k6u2Uf4Fb0/Fl40qzvr0m2Fv5pXtRY+wdJctqJb+t408VcXJkNj0U7xoOe0zUr3l1A6xLYqjd0ZY08JBQ8FQul0Vpxrmg0Xdtwd/wEolvia48lxD1x7yphW5bFvJOTd62rOJgd4uI7jYJF3ZLmwjY+geMk5e6Wkp5OyXGjXw==","alg":"RS256","x5c":["MIIDmzCCAoOgAwIBAgIUFSzsIT4/pKtGzywuZWWE7ydiLBIwDQYJKoZIhvcNAQELBQAwXTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEWMBQGA1UEAwwNKi5leGFtcGxlLmNvbTAeFw0yMDA3MjIwMzA2MTBaFw0yMTA3MjIwMzA2MTBaMF0xCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxFjAUBgNVBAMMDSouZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDM0MNLy/f1SyRM0ZQu3AtJnCU3O5x8nnOeV1mySmZNr2SCqR8+jENAoKE5FrrSi2ffMnFPP/7DqGnbb9+b1nD9ucFNsI1iW7IrF/GlqOM7jJhUMNnOyatz8mddtQgXr3SZ9bigbc/lxuVGacvi64DewoWzMFr4ZMGq8ik7aDnHryUDwXJFE+KGNbsReO1ePqKmPiLvkLG4sBTqeTuCk+Grrr5t1COujwuFWfhMjmRfq34QGqUZ3SHJYXPzOAxgV3fCmBP9IgHuSv/b1udx5Htf1BV7WlARtXfE21xuA6FM1Gq0pANUhcRF39KJRu4/RBZBmAxg7ces8hrZWTQ4LTo/AgMBAAGjUzBRMB0GA1UdDgQWBBR2pI+c2dexlOZCXLy84Baqu8NR8DAfBgNVHSMEGDAWgBR2pI+c2dexlOZCXLy84Baqu8NR8DAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCH2tChjmvs6/2acw+cJYWkEExdXMEyjdUvqEIcs7W7Ce32My7RcMtJxybtqjV+UVghEVUzq1pNf0Dt5FhFkC6BDHnHv2SIO9jq2TvfDUcJgMMgwSZdSaISmxk+iFD9Cll+RU8KgeoYSnwojOixTksyeBRi5rePdO5smz/n4Bd4ToluKaw42tdWhF4SMgx2Y1nlyHpFlkdUYtJ6D8rOvbVRGQaxo8Td3mWCWPMBYcGvjwO9ESCP1JAK+Z6WXD46JWilsIUd3Y+0NrfvOYKUdhLWuz9LrQ5060qi1pHfYBOTAbyXfnW97EB3TAuMtqBBe6h3VNw00c1p7qrilE1Of9uN"]}]} \ No newline at end of file diff --git a/schemas/signature-config-schema.json b/docs/signature/schema.json similarity index 100% rename from schemas/signature-config-schema.json rename to docs/signature/schema.json From c8eda439c22fea44a9b8d2cc01b2887aa869fe4a Mon Sep 17 00:00:00 2001 From: Steve Lasker Date: Wed, 22 Jul 2020 19:09:02 -0700 Subject: [PATCH 09/12] Readme updates --- README.md | 38 +++- cmd/nv2/sign.go | 2 +- cmd/nv2/verify.go | 2 +- docs/artifact/README.md | 1 + docs/nv2/README.md | 374 +++++++++++++++++++------------- media/nv2-client-components.png | Bin 0 -> 26127 bytes 6 files changed, 265 insertions(+), 152 deletions(-) create mode 100644 media/nv2-client-components.png diff --git a/README.md b/README.md index 996158e49..b6e8090f7 100644 --- a/README.md +++ b/README.md @@ -1 +1,37 @@ -# nv2 \ No newline at end of file +# Notary V2 (nv2) - Prototype + +nv2 is an incubation and prototype for the [Notary v2][notary-v2] efforts, securing artifacts stored in [distribution-spec][distribution-spec] based registries. + +![nv2-components](media/nv2-client-components.png) + +- The nv2 client (1) will sign any OCI artifact type (2) including a Docker Image, Helm Chart, OPA, SBoM or any OCI Artifact type, generating a Notary v2 signature (3) +- The [ORAS][oras] client (4) can then push the artifact (2) and the Notary v2 signature (3) to an OCI Artifacts supported registry (5) +- In a subsequent prototype, signatures may be retrieved from the OCI Artifacts supported registry (5) + +## Table of Contents + +1. [nv2 signing and verification docs](docs/nv2/README.md) +2. [Notary v2 signature specification](docs/signature/README.md) +3. [OCI Artifact schema for storing signatures](docs/artifact/README.md) +4. [nv2 prototype scope](#prototype-scope) + +## Prototype Scope + +The `nv2` prototype covers the scenarios outlined in [notaryproject/requirements](https://github.com/notaryproject/requirements/blob/master/scenarios.md#scenarios). + +- Client + - CLI experience + - Signing + - Verification + - Binaries plug-in + - Actual pull / push should be done by external binaries +- Server + - Access control + - HTTP API changes + - Registry storage changes + +Key management is offloaded to the underlying signing tools. + +[distribution-spec]: https://github.com/opencontainers/distribution-spec +[notary-v2]: http://github.com/notaryproject/ +[oras]: https://github.com/deislabs/oras \ No newline at end of file diff --git a/cmd/nv2/sign.go b/cmd/nv2/sign.go index fb7b2a1bc..3f4498ca9 100644 --- a/cmd/nv2/sign.go +++ b/cmd/nv2/sign.go @@ -17,7 +17,7 @@ const signerID = "nv2" var signCommand = &cli.Command{ Name: "sign", - Usage: "signs artifacts or images", + Usage: "signs OCI Artifacts", ArgsUsage: "[]", Flags: []cli.Flag{ &cli.StringFlag{ diff --git a/cmd/nv2/verify.go b/cmd/nv2/verify.go index abf1bb0c7..1f4c8a30d 100644 --- a/cmd/nv2/verify.go +++ b/cmd/nv2/verify.go @@ -15,7 +15,7 @@ import ( var verifyCommand = &cli.Command{ Name: "verify", - Usage: "verifies artifacts or images", + Usage: "verifies OCI Artifacts", ArgsUsage: "[]", Flags: []cli.Flag{ &cli.StringFlag{ diff --git a/docs/artifact/README.md b/docs/artifact/README.md index 0e5df5c05..780017c16 100644 --- a/docs/artifact/README.md +++ b/docs/artifact/README.md @@ -1,4 +1,5 @@ # Notary V2 Artifact + [Notary v2 signatures](../signature/README.md) can be stored as [OCI artifacts](https://github.com/opencontainers/artifacts). Precisely, it is a [OCI manifest](https://github.com/opencontainers/image-spec/blob/master/manifest.md) with a config of type - `application/vnd.cncf.notary.config.v2+json` diff --git a/docs/nv2/README.md b/docs/nv2/README.md index 2f7fbef3e..3e650b022 100644 --- a/docs/nv2/README.md +++ b/docs/nv2/README.md @@ -1,8 +1,14 @@ # Notary V2 (nv2) - Prototype -`nv2` is a command line tool for signing and verifying manifest-based artifacts or images. This implementation supports `gpg` and `x509` signing mechanisms. +`nv2` is a command line tool for signing and verifying [OCI Artifacts]. This implementation supports `x509` and `gpg` signing mechanisms. -## Prerequisites +## Table of Contents + +- [Prerequisites](#prerequisites) +- [CLI Overview](#cli-overview) +- [Offline signing & verification](#offline-signing-and-verification) + +## Prerequisites ### Build and Install @@ -17,7 +23,7 @@ go install github.com/notaryproject/nv2/cmd/nv2 To build and install to an optional path, run ```shell -go build -o path/to/the/target ./cmd/nv2 +go build -o nv2 ./cmd/nv2 ``` Next, install optional components: @@ -26,197 +32,222 @@ Next, install optional components: - Install [docker-generate](https://github.com/shizhMSFT/docker-generate) for local Docker manifest generation and local signing. - Install [OpenSSL](https://www.openssl.org/) for key generation. -### Key Generation +### Self-signed certificate key generation -#### GnuPG Key Generation - -To generate a `gpg` key, run +To generate a `x509` self-signed certificate key pair `key.pem` and `cert.pem`, run ```shell -gpg --gen-key +openssl req \ + -x509 \ + -sha256 \ + -nodes \ + -newkey \ + rsa:2048 \ + -days 365 \ + -out cert.pem \ + -keyout key.pem ``` -By default, all keys sit in the directory `~/.gnupg`. If the `gpg` version is `>= 2.1`, key export is required after key generation +Example Parameters -```shell -# Update to legacy public key ring -[ ! -f ~/.gnupg/pubring.gpg ] && gpg --export > ~/.gnupg/pubring.gpg +| Parameter | Value | +| - | - | +| Country Name (2 letter code) [AU] | **US** | +| State or Province Name (full name) [Some-State] | **Washington** | +| Locality Name (eg, city) [] | **Seattle**| +| Organization Name (eg, company) [Internet Widgits Pty Ltd] | **ACME Rockets**| +| Organizational Unit Name (eg, section) [] | **.** | +| Common Name (e.g. server FQDN or YOUR name) [] | **registry.acme-rockets.io**| +| Email Address []:| **.** | -# Export legacy secret key ring -gpg --export-secret-keys > ~/.gnupg/secring.gpg -``` +When generating the certificate, make sure that the Common Name (`CN`) is set properly in the `Subject` field. The Common Name will be verified against the registry name within the signature. -until the issue [golang/go#29082](https://github.com/golang/go/issues/29082) is resolved. +### GnuPG Key Generation -#### Self-signed Certificate Generation +- Generate a `gpg` key -To generate a `x509` self-signed certificate key pair `key.pem` and `cert.pem`, run + ```shell + gpg --gen-key + ``` -```shell -openssl req -x509 -sha256 -nodes -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -``` + Example Parameters -When generating the certificate, make sure that the Common Name (`CN`) is set properly in the `Subject` field. The Common Name will be verified against the claimed original references. + | Parameter | Value | + | - | - | + | Real name | **acme-rockets** | + | Email address | **wabbit@acme-rockets.io** | -## CLI Overview + > **Note:** If the `gpg` version is `>= 2.1`, key export is required as all keys sit in the `~/.gnupg` directory. + > Golang tracking issue [golang/go#29082](https://github.com/golang/go/issues/29082) -The two major commands of `nv2` are + - Update to legacy public key ring -- `nv2 sign` + ```shell + [ ! -f ~/.gnupg/pubring.gpg ] && gpg --export > ~/.gnupg/pubring.gpg + ``` - ``` + - Export legacy secret key ring + + ``` shell + gpg --export-secret-keys > ~/.gnupg/secring.gpg + ``` + +## Offline Signing + +Offline signing is accomplished with the `nv2 sign` command. + +### nv2 sign options + + ```shell NAME: - nv2 sign - signs artifacts or images + nv2 sign - signs OCI Artifacts USAGE: nv2 sign [command options] [] OPTIONS: - --method value, -m value siging method - --key value, -k value siging key file [x509] - --cert value, -c value siging cert [x509] - --key-ring value gpg public key ring file [gpg] (default: "/home/demo/.gnupg/secring.gpg") - --identity value, -i value signer identity [gpg] - --expiry value, -e value expire duration (default: 0s) - --reference value, -r value original references - --output value, -o value write signature to a specific path - --username value, -u value username for generic remote access - --password value, -p value password for generic remote access - --insecure enable insecure remote access (default: false) - --help, -h show help (default: false) + --method value, -m siging method + --key value, -k siging key file [x509] + --cert value, -c siging cert [x509] + --key-ring value gpg public key ring file [gpg] (default: "/home/demo/.gnupg/secring.gpg") + --identity value, -i signer identity [gpg] + --expiry value, -e expire duration (default: 0s) + --reference value, -r original references + --output value, -o write signature to a specific path + --username value, -u username for generic remote access + --password value, -p password for generic remote access + --insecure enable insecure remote access (default: false) + --help, -h show help (default: false) ``` -- `nv2 verify` +Signing and verification are based on [OCI manifests], [docker-generate](https://github.com/shizhMSFT/docker-generate) is used to generate the manifest, which is exactly the same manifest as the `docker push` produces. - ``` - NAME: - nv2 verify - verifies artifacts or images - - USAGE: - nv2 verify [command options] [] - - OPTIONS: - --signature value, -s value, -f value signature file - --cert value, -c value certs for verification [x509] - --ca-cert value CA certs for verification [x509] - --key-ring value gpg public key ring file [gpg] (default: "/home/demo/.gnupg/pubring.gpg") - --disable-gpg disable GPG for verification [gpg] (default: false) - --username value, -u value username for generic remote access - --password value, -p value password for generic remote access - --insecure enable insecure remote access (default: false) - --help, -h show help (default: false) - ``` +### Generating a manifest -## Offline Signing and Verification +Notary v2 signing is accomplished by signing the OCI manifest representing the artifact. When building docker images, the manifest is not generated until the image is pushed to a registry. To accomplish offline/local signing, the manifest must first exist. -Signing and verification offline can be accomplished by the `nv2 sign` command and the `nv2 verify` command. -In this section, examples are provided for a tour of `nv2` signing and verification. +- Build the hello-world image -Since signing and verification are based on manifests, [docker-generate](https://github.com/shizhMSFT/docker-generate) is used to generate the manifest, which is exactly the same manifest as the `docker push` produces. + ``` shell + docker build \ + -f Dockerfile.build \ + -t registry.acme-rockets.io/hello-world:v1 \ + https://github.com/docker-library/hello-world.git + ``` -```shell -docker build -t example . -docker generate manifest example > example.json -``` +- Generate a manifest, saving it as `hello-world_v1-manifest.json` -The above commands build the image `example:latest` based on the local context, and then generate its manifest file `example.json`. + ``` shell + docker generate manifest hello-world:v1 > hello-world_v1-manifest.json + ``` -### Signing using GnuPG +### Signing using `x509` -To sign the manifest `example.json` using the GnuPG key identified by the identity name `Demo User`, run +To sign the manifest `hello-world_v1-manifest.json` using the key `key.pem` from the `x509` certificate `cert.pem` with the Common Name `example.registry.io`, run +```shell +nv2 sign --method x509 \ + -k key.pem \ + -c cert.pem \ + -r registry.acme-rockets.io/hello-world:v1 \ + -o hello-world.signature.config.json \ + file:hello-world_v1-manifest.json ``` -$ nv2 sign -m gpg -i "Demo User" -r example.registry.io/example:latest -e 8760h file:example.json -sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55 -``` - -where the optional option `-r` declares the original reference, and the optional option `-e` specifies the expiry time (`8760h = 365 days`). On successful signing, `nv2` prints out the `sha256` digest of the manifest, and writes the `nv2` signature JSON file `.nv2` to the working directory. If the file name is not desired, option `-o` can be specified for the alternative file name. -In this example, the signature file name is `3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55.nv2`. The formatted signature file is +The formatted x509 signature: `hello-world.signature.config.json` is: -```json +``` json { - "signed": { - "exp": 1626792407, - "nbf": 1595256407, - "iat": 1595256407, - "manifests": [ - { - "digest": "sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55", - "size": 528, - "references": [ - "example.registry.io/example:latest" + "signed": { + "iat": 1595456071, + "manifests": [ + { + "digest": "sha256:407a722870b09ef1c037b3bd9d1e6fa828a1c64964ba8c292a8ebe4dcf3bde56", + "size": 3056, + "references": [ + "registry.acme-rockets.io/hello-world:v1" + ] + } ] - } + }, + "signatures": [ + { + "typ": "x509", + "sig": "BhGxUd+4pKkRvoVQFTx3XJ7P4IZxDHKFma6nIJEr3NehE53p5XBr03SCRQW4sa8Wr6IdRRXBVxXixy/QtdWKcXa6NjOP7b6reM8exJDOd6j9N/y/oH76MDONyibfGU8iA7zY0k6oqdLM7+pNlFv3V3eEGhpMx4ryVr7yUbg4g0swQr6TSdbUyKJGxVncg0RJuTZmeQ2VV+/uGGaN/ZkbYkmogK1Ji/8JvIjp14+99p/I2t388oqVTI9n8UUD0dm8F/7UMRzvbKfb23DTyFwZatLBXo4OP4zAWU1T+Zwp5urnqtJI/IU8x7qzC/1noNWGBEvK+/nd0avHRtTao+CtdA==", + "alg": "RS256", + "x5c": [ + "MIIDoDCCAoigAwIBAgIJAITsiynTSlpWMA0GCSqGSIb3DQEBCwUAMGUxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApXYXNoaW5ndG9uMRAwDgYDVQQHDAdTZWF0dGxlMRUwEwYDVQQKDAxBQ01FIFJvY2tldHMxGDAWBgNVBAMMD2FjbWUtcm9ja2V0cy5pbzAeFw0yMDA3MjIyMjAxMjZaFw0yMTA3MjIyMjAxMjZaMGUxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApXYXNoaW5ndG9uMRAwDgYDVQQHDAdTZWF0dGxlMRUwEwYDVQQKDAxBQ01FIFJvY2tldHMxGDAWBgNVBAMMD2FjbWUtcm9ja2V0cy5pbzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM5l9zOgzubTl/iLquIrVjNgM/7ZlUabsrisPtjN9d05T6FQQS8jYRuJN+XpTU6dWSP6AGf2bJCdX7TI04i/1Uci9TmzQrbp5aOCnIOIhOfX9W1TJ/7RMCw7BsROL8TVVDnMKJ8zde09svCZFDDzFpAbK0vYUnFb1+orlZ3wuALRw9VIxkZDBGrVE0UDqtnGbhw95V13Fiw4XMXN34bS/0alLnSOkTMMZbEXku54H4uNi9orcJ+rLvlvkFw2dQeSHmmHEqHnZkdQxs5HAky/4K2Eq/1DQhVi7Bg/YNC5IrNpw0picn5jqe3l8zjLpdUsVxgYN1G85DDqPDreah+EmCcCAwEAAaNTMFEwHQYDVR0OBBYEFE7L1GPDbahQusbLw3RPldzX5f0LMB8GA1UdIwQYMBaAFE7L1GPDbahQusbLw3RPldzX5f0LMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAJ7Ir7NgAmfaIhX1+oeGhz1hI+eY8EMSilGts+jIS8BOaBBrzbkVcnC4GKMnHQfXDNQUBW+gRML+ju2elSCrteZbbQU6UaO2er6pWXMQTFVw/nkK6lacNHGTGXbZOoKViAcRZkpVwdjKxmAhLDJcQJGwO+NKWf5WEo82HvwgaINvoEooe+NluN0CugQGdUhgJ+EkYx7jTa7XRpReH0aIsklzvjoakPBhCJ1xQ6VL3WV6zZCYwYUYVwpAMS8Gzo3aUhUwPS1W4mioRDqvJ8fKSkttNi8+N+pU65tKtAyRCvfl9KtJHQrgEqPwQYQ9bKt1H/7RI7oI4WoQ55iSgAU4Wyw=" + ] + } ] - }, - "signatures": [ - { - "typ": "gpg", - "sig": "wsDcBAABCAAQBQJfFa5XCRBGnsnNNqPeHgAAlM0MAHFXZxyCgsxiGVat8YCRIhR7IoQe2scswGyvGGinYBy88EpKGFEAGO+Kt1frTQNW9kLYWmTw4EFctgMw+XxeDD/CI2rsMSluRh9h2t8xBsO9Ux+7eJoxSEsfU8Jc/YZWpGs/kJOGQ3ERjvPt+SCG0Y8tuNtjnzpV4Gz+8fLSlNZ7b3f+rd7nvvJuB8iWr+yojsCeWh/VGuibyqAXPKVxSrKgkmziyYK3O/0D3KhgyR+CMtjTXL5hP314Gpc415YyN82LC3L44okimN/+X3avX0vQkthiyVw+R+Vgmpa1qk1P/ySrs81yQgFBPBC7+m4n54TqsW46X/UlkQdfP/x5Jg3jUURKgQb0wSLvzbr7Jk1RiThlwjcLhM0VgRIUwbqcqjg/5UNvMRehD44PxQXRz5feZjER2awMyKqRZnImpm8Ub+hAjhqtLGYT34oU2lwctoObV4f4BzffY9kQ0x37PQ3V8aj8k6YFQZbB4vLgwtZdA2c1froVHyuRBUwLzSBevg==", - "iss": "Demo User " - } - ] } ``` -where the claims `exp`, `nbf`, `iat`, `iss` are specified by [RFC 7519](https://tools.ietf.org/html/rfc7519), and all those claims will be verified against the GnuPG signature `sig`. - -**NB** It is also possible to read local manifest file via an absolute path. - -```shell -nv2 sign -m gpg -i "Demo User" file:///home/demo/example.json -``` - -### Signing using `x509` - -To sign the manifest `example.json` using the key `key.pem` from the `x509` certificate `cert.pem` with the Common Name `example.registry.io`, run +If the embedded cert chain `x5c` is not desired, it can be replaced by a key ID `kid` by omitting the `-c` option. ```shell -nv2 sign -m x509 -k key.pem -c cert.pem -r example.registry.io/example:latest -o example.nv2 file:example.json +nv2 sign -m x509 \ + -k key.pem \ + -r registry.acme-rockets.io/hello-world:v1 \ + -o hello-world.signature.config.json \ + file:hello-world_v1-manifest.json ``` -The formatted signature file `example.nv2` is +The formatted x509, without the `x5c` chain signature: `hello-world.signature.config.json` is: ```json { - "signed": { - "iat": 1595257070, - "manifests": [ - { - "digest": "sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55", - "size": 528, - "references": [ - "example.registry.io/example:latest" + "signed": { + "iat": 1595456231, + "manifests": [ + { + "digest": "sha256:407a722870b09ef1c037b3bd9d1e6fa828a1c64964ba8c292a8ebe4dcf3bde56", + "size": 3056, + "references": [ + "acme-rockets.io/hello-world:v1" + ] + } ] - } + }, + "signatures": [ + { + "typ": "x509", + "sig": "FyRRcAGi1qd0IHT8XoRh0vlGSkE4rjYpJzYEjodRQ2aUO0O/bBIzjV86UxqLnb1Y/GMU817YXeqHqDlySLWYoGKg+/aJGJDqbQpWIxUr6hhjGaxBYDZTt2ayzMAu5X/GNCx0vRLKl5dOOsgTO53QbuKEf4F3xxvQJv3rXHnObJbPSPCavxzs5TNRLepYEZzW1Mp5nkZT4l32/7QLnwwzTsJYGOMTmhGZ7O5LB/eeViKmwBJHXpNzd4rytFXccKlPuyUakSKgsPdTjEvY5UbFpH568wG21HXDQivz6qdST9eSVob2yUx7WV7z+2S2GfmiMZ30BMtKs4Jx1uPOY3Hk8g==", + "alg": "RS256", + "kid": "2MKO:CS4G:GP3F:HELH:TUI2:5YSX:NJNU:3O2N:LYM4:FBHC:T7NN:OM5A" + } ] - }, - "signatures": [ - { - "typ": "x509", - "sig": "PnY2vpFJV0fayfGOAAxkokthImq932W8XutYCjGLgvBSqdzGM6VgbJhTgXGeettYv5S7A/FO6e319TxEFmx3ogf1bneOUOGDRCdEte+MupDhAISDkiN42Ktci18qFh7MlcR2DXFos5qux0H3Rrc5Rd6Hi4BTTTwHBjsbnNkN1aXuYmyrJZgYmlHBzfdbaDJRcNMo1RAX+j+BWsNZDv+Ae2dtcnoYc2gK5YC2YuNAsvtP4PpR0jtygpCDZjItdVNsJGMwB3dXHUes7Z88IX8hIKlEOt9qv4sq2iOBTju2zvzk4R/pCjUkbD6dOb+t2uyayXbvyAJbi/cEzsfCdwrXjg==", - "alg": "RS256", - "x5c": [ - "MIIDpzCCAo+gAwIBAgIUb6xLgtw1gaM45RnNL9PPhGgvjtEwDQYJKoZIhvcNAQELBQAwYzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEcMBoGA1UEAwwTZXhhbXBsZS5yZWdpc3RyeS5pbzAeFw0yMDA3MjAxNDU1MjJaFw0yMTA3MjAxNDU1MjJaMGMxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxHDAaBgNVBAMME2V4YW1wbGUucmVnaXN0cnkuaW8wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDYW9yzMDsdVpZVU5bTj64iww0AMDGY0qptRaD5ivJGE3RA1WVS/M6gWsOmIHxINiURvuwyG77iUPGq/dXUXjbkypWLcdyEpgpsQRss/lMKjYVayi11nOe6L9nv/1vJ7FPuaBLZJLUIX7+6+/GwQCIe5SmbFmEERNPZ24HdTA+q5jAynYJqJQAx1ReUXNu8jKMo9ZPq787VJIK8eiLn4gty/JfZ0VyobFHaCClbVp+nvfv6IeV+34pFcnPX0UaA4b0zerQIYfkaAAu5pQcR7W5KNQgMR0HIMdvw7Kkuzx30pwJA3i8X49D9nyalyW41wWRnNe8emAjgFkMFXMqlxNmxAgMBAAGjUzBRMB0GA1UdDgQWBBTrHl7XtUeE0biwJngTM2DtVz2LqTAfBgNVHSMEGDAWgBTrHl7XtUeE0biwJngTM2DtVz2LqTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBAua2m0+i920V+KhzmAukJaEh1CkOXr1nFRN2eMuNO4H5l1NsMhtR2XXQkhap9GfSnS0BSIvJ9WbDWm7YBJFXo0zD9pbnlLEbnVtegDzUtEf+0yydKatTc+ClGVM+Cugrbbc7Jzb+hauh6WodYxUAMLUL7Ld4ae7x17VlpgQtRSMELJVrDXaabQXT7sY2pSomFBY5/3NnCJGUOLX0XLRU9dgjHqx1ARWeiJpvH/hV9w2o0jAM+W/vKJHXi4gz1StFLRv4C66cZbMH3yX7d4tlLB7V54ZU0jkRUOcWKFC9Cn4dRrs2dEjYgHRTuk2G3dcqxUCwWCaquuhjk1koi9xYA" - ] - } - ] } ``` -If the embedded cert chain `x5c` is not desired, it can be replaced by a key ID `kid` by omitting the `-c` option. +Within the signature, the claims `alg`, `x5c`, `kid` are specified by [RFC 7515](https://tools.ietf.org/html/rfc7515) -```shell -nv2 sign -m x509 -k key.pem -r example.registry.io/example:latest -o example.nv2 file:example.json +### Signing using GnuPG + +To sign the manifest `example.json` using the GnuPG key identified by the identity name `Demo User`, run + +``` shell +nv2 sign -m gpg \ + -i "Demo User" \ + -r registry.acme-rockets.io/hello-world:v1 \ + -e 8760h \ + -o hello-world.signature.config.json \ + file:hello-world_v1-manifest.json ``` -The formatted resulted signature file `example.nv2` is +- `-r` declares the original registry reference - which must match the key. +- `-e` specifies the optional expiry time (`8760h = 365 days`) + +On successful signing, `nv2` prints out the `sha256` digest of the manifest, and writes the `nv2` signature JSON file `.nv2` to the working directory. If the file name is not desired, option `-o` can be specified for the alternative file name. + +The formatted signature file is: ```json { "signed": { - "iat": 1595259542, + "exp": 1626792407, + "nbf": 1595256407, + "iat": 1595256407, "manifests": [ { "digest": "sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55", @@ -229,29 +260,65 @@ The formatted resulted signature file `example.nv2` is }, "signatures": [ { - "typ": "x509", - "sig": "CIas/ACj5bI0aQHuQCGFRK5I7wKAFltide2a/7u5h5g5xIthbeDjGKUL8JNV9r1Bl2TlCQjyv8695eq8jpe4nlyWWkdf4S+79njLkvWhJiUakLHq4KV1gFUy1dUKSOLRA1YGiS30q0ZKUOiUUdiEF+OUqGc4bHvtrL9ByHA8QBffYvBHSqnzowu/yTwwmX9QvnGwh4ic4Hi4YhJpPwbIYvmcuiXtSgqj/oo2d+aVc+uj9QYp0/ETVl3h7HFZ5XjGB4SxxF77TxqsghpyojMOxf8bT8KxR7V05I1Acy6jmyXyh1pliF9ENdmvHQgSEbtXaWs+8tqkdZd+Y6BxpUA2tQ==", - "alg": "RS256", - "kid": "L7YO:TIUS:TSSY:DV6I:HOU4:YAIC:5HLB:JR7Y:W2EK:XU7W:L27M:YYHY" + "typ": "gpg", + "sig": "wsDcBAABCAAQBQJfFa5XCRBGnsnNNqPeHgAAlM0MAHFXZxyCgsxiGVat8YCRIhR7IoQe2scswGyvGGinYBy88EpKGFEAGO+Kt1frTQNW9kLYWmTw4EFctgMw+XxeDD/CI2rsMSluRh9h2t8xBsO9Ux+7eJoxSEsfU8Jc/YZWpGs/kJOGQ3ERjvPt+SCG0Y8tuNtjnzpV4Gz+8fLSlNZ7b3f+rd7nvvJuB8iWr+yojsCeWh/VGuibyqAXPKVxSrKgkmziyYK3O/0D3KhgyR+CMtjTXL5hP314Gpc415YyN82LC3L44okimN/+X3avX0vQkthiyVw+R+Vgmpa1qk1P/ySrs81yQgFBPBC7+m4n54TqsW46X/UlkQdfP/x5Jg3jUURKgQb0wSLvzbr7Jk1RiThlwjcLhM0VgRIUwbqcqjg/5UNvMRehD44PxQXRz5feZjER2awMyKqRZnImpm8Ub+hAjhqtLGYT34oU2lwctoObV4f4BzffY9kQ0x37PQ3V8aj8k6YFQZbB4vLgwtZdA2c1froVHyuRBUwLzSBevg==", + "iss": "Demo User " } ] } ``` -where the claims `alg`, `x5c`, `kid` are specified by [RFC 7515](https://tools.ietf.org/html/rfc7515), +The claims `exp`, `nbf`, `iat`, `iss` are specified by [RFC 7519](https://tools.ietf.org/html/rfc7519), and all those claims will be verified against the GnuPG signature `sig`. -### Verifying +**NB** It is also possible to read local manifest file via an absolute path. -To verify a manifest `example.json` with a signature file `example.nv2`, run +```shell +nv2 sign -m gpg \ + -i "Demo User" \ + file:///home/demo/hello-world_v1-manifest.json +``` + +### Offline Verification + +Notary v2 verification can be accomplished with the `nv2 verify` command. ```shell -nv2 verify -f example.nv2 file://example.json +NAME: + nv2 verify - verifies artifacts or images + +USAGE: + nv2 verify [command options] [] + +OPTIONS: + --signature value, -s, -f signature file + --cert value, -c certs for verification [x509] + --ca-cert value CA certs for verification [x509] + --key-ring value gpg public key ring file [gpg] (default: "/home/demo/.gnupg/pubring.gpg") + --disable-gpg disable GPG for verification [gpg] (default: false) + --username value -u username for generic remote access + --password value, -p password for generic remote access + --insecure enable insecure remote access (default: false) + --help, -h show help (default: false) ``` +To verify a manifest `example.json` with a signature file `example.nv2`, run + Since the manifest was signed by a self-signed certificate, that certificate `cert.pem` is required to be provided to `nv2`. +```shell +nv2 verify \ + -f hello-world.signature.config.json \ + -c cert.pem \ + file:hello-world_v1-manifest.json ``` -$ nv2 verify -f example.nv2 -c cert.pem file:example.json + +If the cert isn't self-signed, you can omit the `-c` parameter. + +``` shell +nv2 verify \ + -f example.nv2 \ + -c cert.pem \ + file:example.json sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55 ``` @@ -259,8 +326,9 @@ On successful verification, the `sha256` digest of the manifest is printed. Othe The command `nv2 verify` takes care of all signing methods. Since the original references of a manifest signed using `gpg` does not imply that it is signed by the domain owner, we should disable the `gpg` verification by setting the `--disable-gpg` option. -``` -$ nv2 verify -f gpg.nv2 --disable-gpg file:example.json +``` shell +nv2 verify \ + -f gpg.nv2 --disable-gpg file:example.json 2020/07/20 23:54:35 verification failure: unknown signature type ``` @@ -272,14 +340,19 @@ With `nv2`, it is also possible to sign and verify a manifest or a manifest list Here is an example to sign and verify the image `hello-world` in DockerHub, i.e. `docker.io/library/hello-world:latest`, using `gpg`. -``` -$ nv2 sign -m gpg -i demo -o docker.nv2 docker://docker.io/library/hello-world:latest +``` shell +nv2 sign -m gpg \ + -i demo \ + -o hello-world_latest. \ + docker://docker.io/library/hello-world:latest + sha256:49a1c8800c94df04e9658809b006fd8a686cab8028d33cfba2cc049724254202 -$ nv2 verify -f docker.nv2 docker://docker.io/library/hello-world:latest + +nv2 verify -f docker.nv2 docker://docker.io/library/hello-world:latest sha256:49a1c8800c94df04e9658809b006fd8a686cab8028d33cfba2cc049724254202 ``` -It is possible to use `digest` in the reference. For instance, +It is possible to use `digest` in the reference. For instance: ``` docker.io/library/hello-world@sha256:49a1c8800c94df04e9658809b006fd8a686cab8028d33cfba2cc049724254202 @@ -323,4 +396,7 @@ digest=$(nv2 verify -f docker.nv2 docker://docker.io/library/hello-world:latest) if [ $? -eq 0 ]; then docker pull docker.io/library/hello-world@$digest fi -``` \ No newline at end of file +``` + +[oci-artifacts]: https://github.com/opencontainers/artifacts +[oci-manifests]: https://github.com/opencontainers/image-spec/blob/master/manifest.md \ No newline at end of file diff --git a/media/nv2-client-components.png b/media/nv2-client-components.png new file mode 100644 index 0000000000000000000000000000000000000000..1f9ae294e1ac8cc535ecd9f82766770dbd91682e GIT binary patch literal 26127 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 literal 0 HcmV?d00001 From 71c353bb3544f87c1395c2c7845354b6e28cfe14 Mon Sep 17 00:00:00 2001 From: Steve Lasker Date: Fri, 24 Jul 2020 11:52:47 -0700 Subject: [PATCH 10/12] Doc updates for signature spec Signed-off-by: Steve Lasker --- README.md | 75 +++++++++- docs/nv2/README.md | 99 +++++--------- docs/signature/README.md | 196 +++++++++++++++++---------- docs/signature/examples/gpg.nv2.json | 1 - media/acme-rockets-cert.png | Bin 0 -> 17157 bytes media/example-cert.png | Bin 0 -> 18030 bytes media/notary-e2e-scenarios.png | Bin 0 -> 54561 bytes 7 files changed, 229 insertions(+), 142 deletions(-) delete mode 100644 docs/signature/examples/gpg.nv2.json create mode 100644 media/acme-rockets-cert.png create mode 100644 media/example-cert.png create mode 100644 media/notary-e2e-scenarios.png diff --git a/README.md b/README.md index b6e8090f7..e885a01f2 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,86 @@ # Notary V2 (nv2) - Prototype nv2 is an incubation and prototype for the [Notary v2][notary-v2] efforts, securing artifacts stored in [distribution-spec][distribution-spec] based registries. +The `nv2` prototype covers the scenarios outlined in [notaryproject/requirements](https://github.com/notaryproject/requirements/blob/master/scenarios.md#scenarios). +![nv2-components](media/notary-e2e-scenarios.png) -![nv2-components](media/nv2-client-components.png) +To enable the above workflow: - The nv2 client (1) will sign any OCI artifact type (2) including a Docker Image, Helm Chart, OPA, SBoM or any OCI Artifact type, generating a Notary v2 signature (3) - The [ORAS][oras] client (4) can then push the artifact (2) and the Notary v2 signature (3) to an OCI Artifacts supported registry (5) - In a subsequent prototype, signatures may be retrieved from the OCI Artifacts supported registry (5) +![nv2-components](media/nv2-client-components.png) + ## Table of Contents +1. [Scenarios](#scenarios) +1. [nv2 signature spec](./docs/signature/README.md) 1. [nv2 signing and verification docs](docs/nv2/README.md) -2. [Notary v2 signature specification](docs/signature/README.md) -3. [OCI Artifact schema for storing signatures](docs/artifact/README.md) -4. [nv2 prototype scope](#prototype-scope) +1. [OCI Artifact schema for storing signatures](docs/artifact/README.md) +1. [nv2 prototype scope](#prototype-scope) -## Prototype Scope +## Scenarios -The `nv2` prototype covers the scenarios outlined in [notaryproject/requirements](https://github.com/notaryproject/requirements/blob/master/scenarios.md#scenarios). +The current implementation focuses on x509 cert based signatures. Using this approach, the digest and references block are signed, with the cert Common Name required to match the registry references. This enables both the public registry and private registry scenarios. + +### Public Registry + +Public registries generally have two cateogires of content: + +1. Public, certified content. This content is scanned, certified and signed by the registry that wishes to claim the content is "certified". +1. Public, community driven content that is as trusted as the entity that provides the content. The owning entity of the content may choose to sign the content, but unless you trust that entity, there's no additional gaurentee. + +The user works for ACME Rockets. They build `FROM` and use certified content from docker hub. +Their environemt is configured to only trust content from `docker.io` and `acme-rockets.io` + +#### Public Certified Content + +1. The user discovers some certified content they wish to acquire +2. The user copies the URI for the content, passing it to the docker cli. + - `docker run docker.io/hello-world:latest` +3. The image runs, as verification passes. + +#### Public non-certified content + +1. The user discovers some community content they wish to acquire, such as a new +2. The user copies the URI for the content, passing it to the docker cli. + - `docker run docker.io/hello-world:latest` +3. The image runs, as verification passes. + +### Key acquisition + + +### Private Registry + +Private registries serve the follwing scenarios: + +- Host public content, ceritifed for use within an orgnization +- Host privately built content, containing the intellectual property of the orgnization. + + +![acme-rockets cert](../../media/acme-rockets-cert.png) + +```json +{ + "signed": { + "exp": 1626938793, + "nbf": 1595402793, + "iat": 1595402793, + "digest": "sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55", + "size": 528, + "references": [ + "registry.acme-rockets.io/hello-world:latest", + "registry.acme-rockets.io/hello-world:v1.0" + ] + }, + "signatures": [ + { + "typ": "x509", + ... +``` + +## Prototype Scope - Client - CLI experience diff --git a/docs/nv2/README.md b/docs/nv2/README.md index 3e650b022..e99004fa3 100644 --- a/docs/nv2/README.md +++ b/docs/nv2/README.md @@ -28,70 +28,27 @@ go build -o nv2 ./cmd/nv2 Next, install optional components: -- Install [GnuPG](https://gnupg.org/) for `gpg`/`pgp` signing, and key management. - Install [docker-generate](https://github.com/shizhMSFT/docker-generate) for local Docker manifest generation and local signing. - Install [OpenSSL](https://www.openssl.org/) for key generation. ### Self-signed certificate key generation -To generate a `x509` self-signed certificate key pair `key.pem` and `cert.pem`, run +To generate a `x509` self-signed certificate key pair `example.key` and `example.crt`, run -```shell +``` shell openssl req \ -x509 \ -sha256 \ -nodes \ - -newkey \ - rsa:2048 \ + -newkey rsa:2048 \ -days 365 \ - -out cert.pem \ - -keyout key.pem + -subj "/CN=registry.example.com/O=example inc/C=US/ST=Washington/L=Seattle" \ + -keyout example.key \ + -out example.crt ``` -Example Parameters - -| Parameter | Value | -| - | - | -| Country Name (2 letter code) [AU] | **US** | -| State or Province Name (full name) [Some-State] | **Washington** | -| Locality Name (eg, city) [] | **Seattle**| -| Organization Name (eg, company) [Internet Widgits Pty Ltd] | **ACME Rockets**| -| Organizational Unit Name (eg, section) [] | **.** | -| Common Name (e.g. server FQDN or YOUR name) [] | **registry.acme-rockets.io**| -| Email Address []:| **.** | - When generating the certificate, make sure that the Common Name (`CN`) is set properly in the `Subject` field. The Common Name will be verified against the registry name within the signature. -### GnuPG Key Generation - -- Generate a `gpg` key - - ```shell - gpg --gen-key - ``` - - Example Parameters - - | Parameter | Value | - | - | - | - | Real name | **acme-rockets** | - | Email address | **wabbit@acme-rockets.io** | - - > **Note:** If the `gpg` version is `>= 2.1`, key export is required as all keys sit in the `~/.gnupg` directory. - > Golang tracking issue [golang/go#29082](https://github.com/golang/go/issues/29082) - - - Update to legacy public key ring - - ```shell - [ ! -f ~/.gnupg/pubring.gpg ] && gpg --export > ~/.gnupg/pubring.gpg - ``` - - - Export legacy secret key ring - - ``` shell - gpg --export-secret-keys > ~/.gnupg/secring.gpg - ``` - ## Offline Signing Offline signing is accomplished with the `nv2 sign` command. @@ -147,8 +104,8 @@ To sign the manifest `hello-world_v1-manifest.json` using the key `key.pem` from ```shell nv2 sign --method x509 \ - -k key.pem \ - -c cert.pem \ + -k key.key \ + -c cert.crt \ -r registry.acme-rockets.io/hello-world:v1 \ -o hello-world.signature.config.json \ file:hello-world_v1-manifest.json @@ -187,7 +144,7 @@ If the embedded cert chain `x5c` is not desired, it can be replaced by a key ID ```shell nv2 sign -m x509 \ - -k key.pem \ + -k key.key \ -r registry.acme-rockets.io/hello-world:v1 \ -o hello-world.signature.config.json \ file:hello-world_v1-manifest.json @@ -308,7 +265,7 @@ Since the manifest was signed by a self-signed certificate, that certificate `ce ```shell nv2 verify \ -f hello-world.signature.config.json \ - -c cert.pem \ + -c cert.crt \ file:hello-world_v1-manifest.json ``` @@ -316,9 +273,9 @@ If the cert isn't self-signed, you can omit the `-c` parameter. ``` shell nv2 verify \ - -f example.nv2 \ - -c cert.pem \ - file:example.json + -f hello-world.signature.config.json \ + file:hello-world_v1-manifest.json + sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55 ``` @@ -328,7 +285,10 @@ The command `nv2 verify` takes care of all signing methods. Since the original r ``` shell nv2 verify \ - -f gpg.nv2 --disable-gpg file:example.json + -f hello-world.signature.config.json \ + --disable-gpg \ + file:hello-world_v1-manifest.json + 2020/07/20 23:54:35 verification failure: unknown signature type ``` @@ -354,7 +314,7 @@ sha256:49a1c8800c94df04e9658809b006fd8a686cab8028d33cfba2cc049724254202 It is possible to use `digest` in the reference. For instance: -``` +``` shell docker.io/library/hello-world@sha256:49a1c8800c94df04e9658809b006fd8a686cab8028d33cfba2cc049724254202 ``` @@ -364,10 +324,18 @@ If neither `tag` nor `digest` is specified, the default tag `latest` is used. OCI registry works the same as Docker but with the scheme `oci`. -``` -$ nv2 sign -m gpg -i demo -o oci.nv2 oci://docker.io/library/hello-world:latest +``` shell +nv2 sign -m gpg \ + -i demo \ + -o oci.nv2 \ + oci://docker.io/library/hello-world:latest + sha256:0ebe6f409b373c8baf39879fccee6cae5e718003ec3167ded7d54cb2b5da2946 -$ nv2 verify -f oci.nv2 oci://docker.io/library/hello-world:latest + +nv2 verify \ + -f oci.nv2 \ + oci://docker.io/library/hello-world:latest + sha256:0ebe6f409b373c8baf39879fccee6cae5e718003ec3167ded7d54cb2b5da2946 ``` @@ -377,13 +345,14 @@ sha256:0ebe6f409b373c8baf39879fccee6cae5e718003ec3167ded7d54cb2b5da2946 To sign and verify images from insecure registries accessed via `HTTP`, such as `localhost`, the option `--insecure` is required. -``` -$ docker tag example localhost:5000/example -$ docker push localhost:5000/example +``` shell +docker tag example localhost:5000/example +docker push localhost:5000/example The push refers to repository [localhost:5000/example] 50644c29ef5a: Pushed latest: digest: sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55 size: 528 -$ nv2 verify -f gpg.nv2 --insecure docker://localhost:5000/example + +nv2 verify -f gpg.nv2 --insecure docker://localhost:5000/example sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55 ``` diff --git a/docs/signature/README.md b/docs/signature/README.md index 7a57ecde1..ca8f013d1 100644 --- a/docs/signature/README.md +++ b/docs/signature/README.md @@ -2,7 +2,18 @@ This section defines the signature file, which is in JSON format with no whitespaces. Its JSON schema is available at [schema.json](schema.json). -# Signature +## Signature Goals + +- Offline signature creation +- Persistance within an [OCI Artifact][oci-artifacts] enabled, [distribution-spec][distribution-spec] based registry +- Artifact and signature copying within and across [OCI Artifact][oci-artifacts] enabled, [distribution-spec][distribution-spec] based registries +- Support public registry acquisition of content - where the public registry may host certified content as well as public, non-certified content +- Support private registries, where public content may be copied to, and new content originated within +- Air-gapped environments, where the originating registry of content is not accessable +- Multiple signatures per artifact, enabling the originating vendor signature, public registry certification and user/environment signatures +- Maintain the original artifact digest and collection of associated tags, supporting dev/ops deployment definitions + +## Signature A Notary v2 signature is clear-signed signature of manifest metadata, including but not limited to @@ -11,6 +22,87 @@ A Notary v2 signature is clear-signed signature of manifest metadata, including - [Docker Image Manifest List](https://docs.docker.com/registry/spec/manifest-v2-2/#manifest-list) - [Docker Image Manifest](https://docs.docker.com/registry/spec/manifest-v2-2/#image-manifest) +### Signing an Artifact Manifest + +For any [OCI Artifact][oci-artifacts] submitted to a registry, an [OCI Manifest][oci-manifest] and an optional [OCI Manifest List/Index][oci-manifest-list] is required. + +The nv2 prototype signs a combination of: + +- Key properties +- The target artifact manifest digest +- *optional:* list of associated tags + +#### Generating a self-signed x509 cert + +``` shell +openssl req \ + -x509 \ + -sha256 \ + -nodes \ + -newkey rsa:2048 \ + -days 365 \ + -subj "/CN=registry.example.com/O=example inc/C=US/ST=Washington/L=Seattle" \ + -keyout example.key \ + -out example.crt +``` + +An nv2 client would generate the following content to be signed: + +``` JSON +{ + "signed": { + "exp": 1626938793, + "nbf": 1595402793, + "iat": 1595402793, + "digest": "sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55", + "size": 528, + "references": [ + "registry.example.com/hello-world:latest", + "registry.example.com/hello-world:v1.0" + ] + } +``` + +The signature of the above would be represented as: + +``` JSON +{ + "signature": { + "typ": "x509", + "sig": "uFKaCyQ4MtVHemfLVq5gYZyeiClS20tksXzP7hhpeqqjCNK9DiHnoDpkq91sutLqd1o6RCxpfFVuGXy20oqRu1/ZoXXAVC3y7lS6z/wqJ4VDBKSj/H6xyYn7pH3GE8GHR6kjFPqrGsl/OS4yYH2oNXEm9W8Pju2wC381+FCgf4LNf7k6u2Uf4Fb0/Fl40qzvr0m2Fv5pXtRY+wdJctqJb+t408VcXJkNj0U7xoOe0zUr3l1A6xLYqjd0ZY08JBQ8FQul0Vpxrmg0Xdtwd/wEolvia48lxD1x7yphW5bFvJOTd62rOJgd4uI7jYJF3ZLmwjY+geMk5e6Wkp5OyXGjXw==", + "alg": "RS256", + "x5c": [ + "MIIDmzCCAoOgAwIBAgIUFSzsIT4/pKtGzywuZWWE7ydiLBIwDQYJKoZIhvcNAQELBQAwXTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEWMBQGA1UEAwwNKi5leGFtcGxlLmNvbTAeFw0yMDA3MjIwMzA2MTBaFw0yMTA3MjIwMzA2MTBaMF0xCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxFjAUBgNVBAMMDSouZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDM0MNLy/f1SyRM0ZQu3AtJnCU3O5x8nnOeV1mySmZNr2SCqR8+jENAoKE5FrrSi2ffMnFPP/7DqGnbb9+b1nD9ucFNsI1iW7IrF/GlqOM7jJhUMNnOyatz8mddtQgXr3SZ9bigbc/lxuVGacvi64DewoWzMFr4ZMGq8ik7aDnHryUDwXJFE+KGNbsReO1ePqKmPiLvkLG4sBTqeTuCk+Grrr5t1COujwuFWfhMjmRfq34QGqUZ3SHJYXPzOAxgV3fCmBP9IgHuSv/b1udx5Htf1BV7WlARtXfE21xuA6FM1Gq0pANUhcRF39KJRu4/RBZBmAxg7ces8hrZWTQ4LTo/AgMBAAGjUzBRMB0GA1UdDgQWBBR2pI+c2dexlOZCXLy84Baqu8NR8DAfBgNVHSMEGDAWgBR2pI+c2dexlOZCXLy84Baqu8NR8DAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCH2tChjmvs6/2acw+cJYWkEExdXMEyjdUvqEIcs7W7Ce32My7RcMtJxybtqjV+UVghEVUzq1pNf0Dt5FhFkC6BDHnHv2SIO9jq2TvfDUcJgMMgwSZdSaISmxk+iFD9Cll+RU8KgeoYSnwojOixTksyeBRi5rePdO5smz/n4Bd4ToluKaw42tdWhF4SMgx2Y1nlyHpFlkdUYtJ6D8rOvbVRGQaxo8Td3mWCWPMBYcGvjwO9ESCP1JAK+Z6WXD46JWilsIUd3Y+0NrfvOYKUdhLWuz9LrQ5060qi1pHfYBOTAbyXfnW97EB3TAuMtqBBe6h3VNw00c1p7qrilE1Of9uN" + ] + } +} +``` + +### Signature Persisted within an OCI Artifact Enabled Registry + +Both values are persisted in a `signature.json` file. The file would be submitted to a registry as an Artifact with null layers. +The `signature.json` would be persisted wthin the `manifest.config` object + +``` SHELL +oras push \ + registry.example.com/hello-world:v1.0 \ + --manifest-config signature.json:application/vnd.cncf.notary.config.v2+json +``` + +Would push the following manifest: + +``` JSON +{ + "schemaVersion": 2, + "config": { + "mediaType": "application/vnd.cncf.notary.config.v2+json", + "size": 1906, + "digest": "sha256:c7848182f2c817415f0de63206f9e4220012cbb0bdb750c2ecf8020350239814" + }, + "layers": [] +} +``` + ## *Signature* Property Descriptions - **`signed`** *object* @@ -29,25 +121,21 @@ A Notary v2 signature is clear-signed signature of manifest metadata, including This OPTIONAL property identifies the expiration time on or after which the signed content MUST NOT be accepted for processing. This field is based on [RFC 7519 Section 4.1.4](https://tools.ietf.org/html/rfc7519#section-4.1.4). - - **`manifests`** *array of objects* + - **`digest`** *string* - This REQUIRED property references manifests presented to notary for certifying. + This REQUIRED property is the *digest* of the target manifest, conforming to the requirements outlined in [Digests](https://github.com/opencontainers/image-spec/blob/master/descriptor.md#digests). If the actual content is fetched according to the *digest*, implementations MUST verify the content against the *digest*. - - **`digest`** *string* + - **`size`** *integer* - This REQUIRED property is the *digest* of the target manifest, conforming to the requirements outlined in [Digests](https://github.com/opencontainers/image-spec/blob/master/descriptor.md#digests). If the actual content is fetched according to the *digest*, implementations MUST verify the content against the *digest*. + This REQUIRED property is the *size* of the target manifest. If the actual content is fetched according the *digest*, implementations MUST verify the content against the *size*. - - **`size`** *integer* + - **`references`** *array of strings* - This REQUIRED property is the *size* of the target manifest. If the actual content is fetched according the *digest*, implementations MUST verify the content against the *size*. + This OPTIONAL property claims the manifest references of its origin. The format of the value MUST matches the [*reference* grammar](https://github.com/docker/distribution/blob/master/reference/reference.go). With used, the `x509` signatures are valid only if the domain names of all references match the Common Name (`CN`) in the `Subject` field of the certificate. - - **`references`** *array of strings* +- **`signature`** *string* - This OPTIONAL property claims the manifest references of its origin. The format of the value MUST matches the [*reference* grammar](https://github.com/docker/distribution/blob/master/reference/reference.go). With used, the `x509` signatures are valid only if the domain names of all references match the Common Name (`CN`) in the `Subject` field of the certificate. - -- **`signatures`** *array of objects* - - This REQUIRED property provides the signatures of the signed content. The entire signature file is valid if any signature in `signatures` is valid. The `signature` object is influenced by JSON Web Signature (JWS) at [RFC 7515](https://tools.ietf.org/html/rfc7515). + This REQUIRED property provides the signature of the signed content. The entire signature file is valid if any signature is valid. The `signature` object is influenced by JSON Web Signature (JWS) at [RFC 7515](https://tools.ietf.org/html/rfc7515). - **`typ`** *string* @@ -55,10 +143,6 @@ A Notary v2 signature is clear-signed signature of manifest metadata, including - `x509`: X.509 public key certificates. Implementations MUST verify that the certificate of the signing key has the `digitalSignature` `Key Usage` extension ([RFC 5280 Section 4.2.1.3](https://tools.ietf.org/html/rfc5280#section-4.2.1.3)). - Implementations SHOULD support the following types - - - `gpg`: [GnuPG](https://www.gnupg.org/) detached signatures. - Implementations MAY support the following types - `tuf`: [The update framework](https://theupdateframework.io/). @@ -85,64 +169,31 @@ A Notary v2 signature is clear-signed signature of manifest metadata, including ## Example Signatures -Example showing a formatted `gpg` signature file [examples/gpg.nv2.json](examples/gpg.nv2.json): - -```json -{ - "signed": { - "exp": 1626938668, - "nbf": 1595402668, - "iat": 1595402668, - "manifests": [ - { - "digest": "sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55", - "size": 528, - "references": [ - "registry.example.com/example:latest", - "registry.example.com/example:v1.0" - ] - } - ] - }, - "signatures": [ - { - "typ": "gpg", - "sig": "wsDcBAABCAAQBQJfF+msCRDvXc1GQtqlQgAAJwUMAAZdQdDJCCoHl8VXyseeU2WB7/1Ip+Ei++C/ZFtA4ncsifdi28B4FQlAjOPbIPlIsldl7KtL6aMloHiQTm/sBl+aEys4Z2/xTSu+5//jcUeWwtDEiSur2K2w3F7RmDWhGFSjgXvlkPMt7iaCqy6dEPvrLSYXRgBAVnUEdtS/L/ANMSupt+FZh2AISyWL6TZKOKVcxKSiJ0SR72L7DYE1E6edBPsPHivc485qwRljvjG9q8WwWusvZM4OjBLaddn7d83+R4YQNqGBp8RGvEGiw9oWzu3f+2MCeT5USQWFcIr+KQHJi4R/0cqKGQ9TarUS1vIKSiasmnqufVCi2Ucb+5sj8oaI7/DIyCxYiv0lX1pJE1j/yuS1XtDVzn7J1enkuP9TgiRNSzjZJUc5rLa3IwyuXGaJOtUJm60ma5WU/LoUe1sqC4jpQ2nU4UNHH14KnoeElJzE1WknmmrGck2ewx0yiln7wCrwKQ5dC0kS8suJBoZD7Ms7SAwDMbHL5oj1fg==", - "iss": "Demo User \u003cdemo@example.com\u003e" - } - ] -} -``` +### x509 Signature Example showing a formatted `x509` signature file [examples/x509_x5c.nv2.json](examples/x509_x5c.nv2.json) with certificates provided by `x5c`: ```json { - "signed": { - "exp": 1626938793, - "nbf": 1595402793, - "iat": 1595402793, - "manifests": [ - { - "digest": "sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55", - "size": 528, - "references": [ - "registry.example.com/example:latest", - "registry.example.com/example:v1.0" - ] - } - ] - }, - "signatures": [ - { - "typ": "x509", - "sig": "uFKaCyQ4MtVHemfLVq5gYZyeiClS20tksXzP7hhpeqqjCNK9DiHnoDpkq91sutLqd1o6RCxpfFVuGXy20oqRu1/ZoXXAVC3y7lS6z/wqJ4VDBKSj/H6xyYn7pH3GE8GHR6kjFPqrGsl/OS4yYH2oNXEm9W8Pju2wC381+FCgf4LNf7k6u2Uf4Fb0/Fl40qzvr0m2Fv5pXtRY+wdJctqJb+t408VcXJkNj0U7xoOe0zUr3l1A6xLYqjd0ZY08JBQ8FQul0Vpxrmg0Xdtwd/wEolvia48lxD1x7yphW5bFvJOTd62rOJgd4uI7jYJF3ZLmwjY+geMk5e6Wkp5OyXGjXw==", - "alg": "RS256", - "x5c": [ - "MIIDmzCCAoOgAwIBAgIUFSzsIT4/pKtGzywuZWWE7ydiLBIwDQYJKoZIhvcNAQELBQAwXTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEWMBQGA1UEAwwNKi5leGFtcGxlLmNvbTAeFw0yMDA3MjIwMzA2MTBaFw0yMTA3MjIwMzA2MTBaMF0xCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxFjAUBgNVBAMMDSouZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDM0MNLy/f1SyRM0ZQu3AtJnCU3O5x8nnOeV1mySmZNr2SCqR8+jENAoKE5FrrSi2ffMnFPP/7DqGnbb9+b1nD9ucFNsI1iW7IrF/GlqOM7jJhUMNnOyatz8mddtQgXr3SZ9bigbc/lxuVGacvi64DewoWzMFr4ZMGq8ik7aDnHryUDwXJFE+KGNbsReO1ePqKmPiLvkLG4sBTqeTuCk+Grrr5t1COujwuFWfhMjmRfq34QGqUZ3SHJYXPzOAxgV3fCmBP9IgHuSv/b1udx5Htf1BV7WlARtXfE21xuA6FM1Gq0pANUhcRF39KJRu4/RBZBmAxg7ces8hrZWTQ4LTo/AgMBAAGjUzBRMB0GA1UdDgQWBBR2pI+c2dexlOZCXLy84Baqu8NR8DAfBgNVHSMEGDAWgBR2pI+c2dexlOZCXLy84Baqu8NR8DAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCH2tChjmvs6/2acw+cJYWkEExdXMEyjdUvqEIcs7W7Ce32My7RcMtJxybtqjV+UVghEVUzq1pNf0Dt5FhFkC6BDHnHv2SIO9jq2TvfDUcJgMMgwSZdSaISmxk+iFD9Cll+RU8KgeoYSnwojOixTksyeBRi5rePdO5smz/n4Bd4ToluKaw42tdWhF4SMgx2Y1nlyHpFlkdUYtJ6D8rOvbVRGQaxo8Td3mWCWPMBYcGvjwO9ESCP1JAK+Z6WXD46JWilsIUd3Y+0NrfvOYKUdhLWuz9LrQ5060qi1pHfYBOTAbyXfnW97EB3TAuMtqBBe6h3VNw00c1p7qrilE1Of9uN" - ] - } + "signed": { + "exp": 1626938793, + "nbf": 1595402793, + "iat": 1595402793, + "digest": "sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55", + "size": 528, + "references": [ + "registry.example.com/example:latest", + "registry.example.com/example:v1.0" + ] + }, + "signature": { + "typ": "x509", + "sig": "uFKaCyQ4MtVHemfLVq5gYZyeiClS20tksXzP7hhpeqqjCNK9DiHnoDpkq91sutLqd1o6RCxpfFVuGXy20oqRu1/ZoXXAVC3y7lS6z/wqJ4VDBKSj/H6xyYn7pH3GE8GHR6kjFPqrGsl/OS4yYH2oNXEm9W8Pju2wC381+FCgf4LNf7k6u2Uf4Fb0/Fl40qzvr0m2Fv5pXtRY+wdJctqJb+t408VcXJkNj0U7xoOe0zUr3l1A6xLYqjd0ZY08JBQ8FQul0Vpxrmg0Xdtwd/wEolvia48lxD1x7yphW5bFvJOTd62rOJgd4uI7jYJF3ZLmwjY+geMk5e6Wkp5OyXGjXw==", + "alg": "RS256", + "x5c": [ + "MIIDmzCCAoOgAwIBAgIUFSzsIT4/pKtGzywuZWWE7ydiLBIwDQYJKoZIhvcNAQELBQAwXTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEWMBQGA1UEAwwNKi5leGFtcGxlLmNvbTAeFw0yMDA3MjIwMzA2MTBaFw0yMTA3MjIwMzA2MTBaMF0xCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxFjAUBgNVBAMMDSouZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDM0MNLy/f1SyRM0ZQu3AtJnCU3O5x8nnOeV1mySmZNr2SCqR8+jENAoKE5FrrSi2ffMnFPP/7DqGnbb9+b1nD9ucFNsI1iW7IrF/GlqOM7jJhUMNnOyatz8mddtQgXr3SZ9bigbc/lxuVGacvi64DewoWzMFr4ZMGq8ik7aDnHryUDwXJFE+KGNbsReO1ePqKmPiLvkLG4sBTqeTuCk+Grrr5t1COujwuFWfhMjmRfq34QGqUZ3SHJYXPzOAxgV3fCmBP9IgHuSv/b1udx5Htf1BV7WlARtXfE21xuA6FM1Gq0pANUhcRF39KJRu4/RBZBmAxg7ces8hrZWTQ4LTo/AgMBAAGjUzBRMB0GA1UdDgQWBBR2pI+c2dexlOZCXLy84Baqu8NR8DAfBgNVHSMEGDAWgBR2pI+c2dexlOZCXLy84Baqu8NR8DAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCH2tChjmvs6/2acw+cJYWkEExdXMEyjdUvqEIcs7W7Ce32My7RcMtJxybtqjV+UVghEVUzq1pNf0Dt5FhFkC6BDHnHv2SIO9jq2TvfDUcJgMMgwSZdSaISmxk+iFD9Cll+RU8KgeoYSnwojOixTksyeBRi5rePdO5smz/n4Bd4ToluKaw42tdWhF4SMgx2Y1nlyHpFlkdUYtJ6D8rOvbVRGQaxo8Td3mWCWPMBYcGvjwO9ESCP1JAK+Z6WXD46JWilsIUd3Y+0NrfvOYKUdhLWuz9LrQ5060qi1pHfYBOTAbyXfnW97EB3TAuMtqBBe6h3VNw00c1p7qrilE1Of9uN" ] + } } ``` @@ -174,4 +225,9 @@ Example showing a formatted `x509` signature file [examples/x509_kid.nv2.json](e } ] } -``` \ No newline at end of file +``` + +[distribution-spec]: https://github.com/opencontainers/distribution-spec +[oci-artifacts]: https://github.com/opencontainers/artifacts +[oci-manifest]: https://github.com/opencontainers/image-spec/blob/master/manifest.md +[oci-manifest-list]: https://github.com/opencontainers/image-spec/blob/master/image-index.md diff --git a/docs/signature/examples/gpg.nv2.json b/docs/signature/examples/gpg.nv2.json deleted file mode 100644 index b05e00757..000000000 --- a/docs/signature/examples/gpg.nv2.json +++ /dev/null @@ -1 +0,0 @@ -{"signed":{"exp":1626938668,"nbf":1595402668,"iat":1595402668,"manifests":[{"digest":"sha256:3351c53952446db17d21b86cfe5829ae70f823aff5d410fbf09dff820a39ab55","size":528,"references":["registry.example.com/example:latest","registry.example.com/example:v1.0"]}]},"signatures":[{"typ":"gpg","sig":"wsDcBAABCAAQBQJfF+msCRDvXc1GQtqlQgAAJwUMAAZdQdDJCCoHl8VXyseeU2WB7/1Ip+Ei++C/ZFtA4ncsifdi28B4FQlAjOPbIPlIsldl7KtL6aMloHiQTm/sBl+aEys4Z2/xTSu+5//jcUeWwtDEiSur2K2w3F7RmDWhGFSjgXvlkPMt7iaCqy6dEPvrLSYXRgBAVnUEdtS/L/ANMSupt+FZh2AISyWL6TZKOKVcxKSiJ0SR72L7DYE1E6edBPsPHivc485qwRljvjG9q8WwWusvZM4OjBLaddn7d83+R4YQNqGBp8RGvEGiw9oWzu3f+2MCeT5USQWFcIr+KQHJi4R/0cqKGQ9TarUS1vIKSiasmnqufVCi2Ucb+5sj8oaI7/DIyCxYiv0lX1pJE1j/yuS1XtDVzn7J1enkuP9TgiRNSzjZJUc5rLa3IwyuXGaJOtUJm60ma5WU/LoUe1sqC4jpQ2nU4UNHH14KnoeElJzE1WknmmrGck2ewx0yiln7wCrwKQ5dC0kS8suJBoZD7Ms7SAwDMbHL5oj1fg==","iss":"Demo User \u003cdemo@example.com\u003e"}]} \ No newline at end of file diff --git a/media/acme-rockets-cert.png b/media/acme-rockets-cert.png new file mode 100644 index 0000000000000000000000000000000000000000..0c3e8cd3df8179b5e6bf25a2a92fe7411ed443c4 GIT binary patch literal 17157 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 literal 0 HcmV?d00001 diff --git a/media/example-cert.png b/media/example-cert.png new file mode 100644 index 0000000000000000000000000000000000000000..3f84aa20dbea5dbf1c6d4cc2e4850417e3d00175 GIT binary patch literal 18030 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 S1^HPO6nPmH>2k@BU;Zy!4&}7~ literal 0 HcmV?d00001 diff --git a/media/notary-e2e-scenarios.png b/media/notary-e2e-scenarios.png new file mode 100644 index 0000000000000000000000000000000000000000..2669f3fd447a8bf92873dddc415f09484256f03d GIT binary patch literal 54561 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%0DNEEB07lzZwGE$5@hY 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 Date: Fri, 24 Jul 2020 12:37:39 -0700 Subject: [PATCH 11/12] Doc updates - public registry scenarios Signed-off-by: Steve Lasker --- README.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e885a01f2..e5d87c1e4 100644 --- a/README.md +++ b/README.md @@ -43,13 +43,19 @@ Their environemt is configured to only trust content from `docker.io` and `acme- #### Public non-certified content -1. The user discovers some community content they wish to acquire, such as a new -2. The user copies the URI for the content, passing it to the docker cli. - - `docker run docker.io/hello-world:latest` -3. The image runs, as verification passes. +1. The user discovers some community content they wish to acquire, such as a new network-monitor project +2. The user copies the URI for the content, passing it to the docker cli + - `docker run docker.io/wabbit-networks/net-monitor:latest` +3. The image fails to run as the user has trust-required and doesn't have the wabbit-networks key.The docker cli produces an error with a url for acquiring the wabbit-networks key. +4. The user can disable `trust-requried`, or acquire the required key. +5. The user acquires the wabbit-networks key, saves it in their local store +6. The user again runs: + - `docker run docker.io/wabbit-networks/net-monitor:latest` + and the image is sucessfully run ### Key acquisition +TBD by the key-management working group ### Private Registry From 7b972d070d5295f5e1d76ea0a0bad0f45baa6119 Mon Sep 17 00:00:00 2001 From: Steve Lasker Date: Fri, 24 Jul 2020 12:54:39 -0700 Subject: [PATCH 12/12] Doc updates - public registry scenarios Signed-off-by: Steve Lasker --- README.md | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index e5d87c1e4..889a833b6 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # Notary V2 (nv2) - Prototype nv2 is an incubation and prototype for the [Notary v2][notary-v2] efforts, securing artifacts stored in [distribution-spec][distribution-spec] based registries. -The `nv2` prototype covers the scenarios outlined in [notaryproject/requirements](https://github.com/notaryproject/requirements/blob/master/scenarios.md#scenarios). +The `nv2` prototype covers the scenarios outlined in [notaryproject/requirements](https://github.com/notaryproject/requirements/blob/master/scenarios.md#scenarios). It also follows the [prototyping approach described here](https://github.com/stevelasker/nv2#prototyping-approach). + ![nv2-components](media/notary-e2e-scenarios.png) To enable the above workflow: -- The nv2 client (1) will sign any OCI artifact type (2) including a Docker Image, Helm Chart, OPA, SBoM or any OCI Artifact type, generating a Notary v2 signature (3) +- The nv2 client (1) will sign any OCI artifact type (2) (including a Docker Image, Helm Chart, OPA, SBoM or any OCI Artifact type), generating a Notary v2 signature (3) - The [ORAS][oras] client (4) can then push the artifact (2) and the Notary v2 signature (3) to an OCI Artifacts supported registry (5) - In a subsequent prototype, signatures may be retrieved from the OCI Artifacts supported registry (5) @@ -28,8 +29,10 @@ The current implementation focuses on x509 cert based signatures. Using this app Public registries generally have two cateogires of content: -1. Public, certified content. This content is scanned, certified and signed by the registry that wishes to claim the content is "certified". -1. Public, community driven content that is as trusted as the entity that provides the content. The owning entity of the content may choose to sign the content, but unless you trust that entity, there's no additional gaurentee. +1. Public, certified content. This content is scanned, certified and signed by the registry that wishes to claim the content is "certified". It may be additionaly signed by the originating vendor. +2. Public, community driven content. Community content is a choice for the consumer to trust (downloading their key), or accept as un-trusted. + +#### End to End Experience The user works for ACME Rockets. They build `FROM` and use certified content from docker hub. Their environemt is configured to only trust content from `docker.io` and `acme-rockets.io` @@ -37,25 +40,26 @@ Their environemt is configured to only trust content from `docker.io` and `acme- #### Public Certified Content 1. The user discovers some certified content they wish to acquire -2. The user copies the URI for the content, passing it to the docker cli. +1. The user copies the URI for the content, passing it to the docker cli - `docker run docker.io/hello-world:latest` -3. The image runs, as verification passes. +1. The user already has the `docker.io` certificate, enabling all certified content from docker hub +1. The image runs, as verification passes #### Public non-certified content 1. The user discovers some community content they wish to acquire, such as a new network-monitor project -2. The user copies the URI for the content, passing it to the docker cli +1. The user copies the URI for the content, passing it to the docker cli - `docker run docker.io/wabbit-networks/net-monitor:latest` -3. The image fails to run as the user has trust-required and doesn't have the wabbit-networks key.The docker cli produces an error with a url for acquiring the wabbit-networks key. -4. The user can disable `trust-requried`, or acquire the required key. -5. The user acquires the wabbit-networks key, saves it in their local store -6. The user again runs: +1. The image fails to run as the user has `trust-required` enabled, and doesn't have the wabbit-networks key.The docker cli produces an error with a url for acquiring the wabbit-networks key. + - The user can disable `trust-requried`, or acquire the required key. +1. The user acquires the wabbit-networks key, saves it in their local store +1. The user again runs: - `docker run docker.io/wabbit-networks/net-monitor:latest` and the image is sucessfully run ### Key acquisition -TBD by the key-management working group +*TBD by the key-management working group* ### Private Registry @@ -65,7 +69,7 @@ Private registries serve the follwing scenarios: - Host privately built content, containing the intellectual property of the orgnization. -![acme-rockets cert](../../media/acme-rockets-cert.png) +![acme-rockets cert](./media/acme-rockets-cert.png) ```json {