diff --git a/README.md b/README.md index 996158e49..889a833b6 100644 --- a/README.md +++ b/README.md @@ -1 +1,110 @@ -# 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. +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 [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) +1. [OCI Artifact schema for storing signatures](docs/artifact/README.md) +1. [nv2 prototype scope](#prototype-scope) + +## 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". 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` + +#### Public Certified Content + +1. The user discovers some certified content they wish to acquire +1. The user copies the URI for the content, passing it to the docker cli + - `docker run docker.io/hello-world:latest` +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 +1. The user copies the URI for the content, passing it to the docker cli + - `docker run docker.io/wabbit-networks/net-monitor:latest` +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* + +### 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 + - 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/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/main.go b/cmd/nv2/main.go new file mode 100644 index 000000000..28849c47e --- /dev/null +++ b/cmd/nv2/main.go @@ -0,0 +1,29 @@ +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", + }, + }, + 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..9bd837c2f --- /dev/null +++ b/cmd/nv2/manifest.go @@ -0,0 +1,68 @@ +package main + +import ( + "fmt" + "io" + "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" +) + +func getManifestFromContext(ctx *cli.Context) (signature.Manifest, error) { + if uri := ctx.Args().First(); uri != "" { + return getManfestsFromURI(ctx, 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(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 strings.ToLower(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 + 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) + } + return getManifestFromReader(r) +} diff --git a/cmd/nv2/sign.go b/cmd/nv2/sign.go new file mode 100644 index 000000000..3f4498ca9 --- /dev/null +++ b/cmd/nv2/sign.go @@ -0,0 +1,154 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "strings" + "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" +) + +const signerID = "nv2" + +var signCommand = &cli.Command{ + Name: "sign", + Usage: "signs OCI Artifacts", + 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 [x509]", + TakesFile: true, + }, + &cli.StringFlag{ + Name: "cert", + Aliases: []string{"c"}, + 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{ + 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", + }, + usernameFlag, + passwordFlag, + insecureFlag, + }, + 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" + } + 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) { + 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) { + var ( + signer signature.Signer + err error + ) + switch method := ctx.String("method"); method { + case "x509": + 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 new file mode 100644 index 000000000..1f4c8a30d --- /dev/null +++ b/cmd/nv2/verify.go @@ -0,0 +1,151 @@ +package main + +import ( + "crypto/x509" + "encoding/json" + "fmt" + "os" + + "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" +) + +var verifyCommand = &cli.Command{ + Name: "verify", + Usage: "verifies OCI Artifacts", + ArgsUsage: "[]", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "signature", + Aliases: []string{"s", "f"}, + Usage: "signature file", + Required: true, + TakesFile: true, + }, + &cli.StringSliceFlag{ + 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, + }, + &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]", + }, + usernameFlag, + passwordFlag, + insecureFlag, + }, + 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) { + 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) { + 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) + } + } + 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/docs/artifact/README.md b/docs/artifact/README.md new file mode 100644 index 000000000..780017c16 --- /dev/null +++ b/docs/artifact/README.md @@ -0,0 +1,23 @@ +# 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/artifact/examples/manifest.json b/docs/artifact/examples/manifest.json new file mode 100644 index 000000000..5e57feda6 --- /dev/null +++ b/docs/artifact/examples/manifest.json @@ -0,0 +1,9 @@ +{ + "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/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/spec.yml b/docs/distribution/spec.yml new file mode 100644 index 000000000..777ba9848 --- /dev/null +++ b/docs/distribution/spec.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: "../signature/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: "../signature/examples/x509_x5c.nv2.json" + description: A x509 signature containing a x5c certificate chain. + x509_kid_config: + externalValue: "../signature/examples/x509_kid.nv2.json" + description: A x509 signature containing a signing key ID reference. + gpg_config: + 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 new file mode 100644 index 000000000..e99004fa3 --- /dev/null +++ b/docs/nv2/README.md @@ -0,0 +1,371 @@ +# Notary V2 (nv2) - Prototype + +`nv2` is a command line tool for signing and verifying [OCI Artifacts]. This implementation supports `x509` and `gpg` signing mechanisms. + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [CLI Overview](#cli-overview) +- [Offline signing & verification](#offline-signing-and-verification) + +## 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 nv2 ./cmd/nv2 +``` + +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. + +### Self-signed certificate key generation + +To generate a `x509` self-signed certificate key pair `example.key` and `example.crt`, run + +``` 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 +``` + +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. + +## Offline Signing + +Offline signing is accomplished with the `nv2 sign` command. + +### nv2 sign options + + ```shell + NAME: + nv2 sign - signs OCI Artifacts + + USAGE: + nv2 sign [command options] [] + + OPTIONS: + --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) + ``` + +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. + +### Generating a manifest + +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. + +- Build the hello-world image + + ``` shell + docker build \ + -f Dockerfile.build \ + -t registry.acme-rockets.io/hello-world:v1 \ + https://github.com/docker-library/hello-world.git + ``` + +- Generate a manifest, saving it as `hello-world_v1-manifest.json` + + ``` shell + docker generate manifest hello-world:v1 > hello-world_v1-manifest.json + ``` + +### Signing using `x509` + +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.key \ + -c cert.crt \ + -r registry.acme-rockets.io/hello-world:v1 \ + -o hello-world.signature.config.json \ + file:hello-world_v1-manifest.json +``` + +The formatted x509 signature: `hello-world.signature.config.json` is: + +``` json +{ + "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=" + ] + } + ] +} +``` + +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.key \ + -r registry.acme-rockets.io/hello-world:v1 \ + -o hello-world.signature.config.json \ + file:hello-world_v1-manifest.json +``` + +The formatted x509, without the `x5c` chain signature: `hello-world.signature.config.json` is: + +```json +{ + "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" + } + ] +} +``` + +Within the signature, the claims `alg`, `x5c`, `kid` are specified by [RFC 7515](https://tools.ietf.org/html/rfc7515) + +### 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 +``` + +- `-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": { + "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 " + } + ] +} +``` + +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/hello-world_v1-manifest.json +``` + +### Offline Verification + +Notary v2 verification can be accomplished with the `nv2 verify` command. + +```shell +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.crt \ + file:hello-world_v1-manifest.json +``` + +If the cert isn't self-signed, you can omit the `-c` parameter. + +``` shell +nv2 verify \ + -f hello-world.signature.config.json \ + file:hello-world_v1-manifest.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. + +``` shell +nv2 verify \ + -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 +``` + +## 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`. + +``` 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 +sha256:49a1c8800c94df04e9658809b006fd8a686cab8028d33cfba2cc049724254202 +``` + +It is possible to use `digest` in the reference. For instance: + +``` shell +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`. + +``` 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 + +sha256:0ebe6f409b373c8baf39879fccee6cae5e718003ec3167ded7d54cb2b5da2946 +``` + +**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 + +To sign and verify images from insecure registries accessed via `HTTP`, such as `localhost`, the option `--insecure` is required. + +``` 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 +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 verify -f docker.nv2 docker://docker.io/library/hello-world:latest) +if [ $? -eq 0 ]; then + docker pull docker.io/library/hello-world@$digest +fi +``` + +[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/docs/signature/README.md b/docs/signature/README.md new file mode 100644 index 000000000..ca8f013d1 --- /dev/null +++ b/docs/signature/README.md @@ -0,0 +1,233 @@ +# 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 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 + +- [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) + +### 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* + + 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). + + - **`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. + +- **`signature`** *string* + + 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* + + 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 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 + +### 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, + "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" + ] + } +} +``` + +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" + } + ] +} +``` + +[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/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/docs/signature/schema.json b/docs/signature/schema.json new file mode 100644 index 000000000..b68ddb17d --- /dev/null +++ b/docs/signature/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/go.mod b/go.mod new file mode 100644 index 000000000..9bad1ee32 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +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/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 new file mode 100644 index 000000000..64123900c --- /dev/null +++ b/go.sum @@ -0,0 +1,26 @@ +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/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= +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/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/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/media/acme-rockets-cert.png b/media/acme-rockets-cert.png new file mode 100644 index 000000000..0c3e8cd3d Binary files /dev/null and b/media/acme-rockets-cert.png differ diff --git a/media/example-cert.png b/media/example-cert.png new file mode 100644 index 000000000..3f84aa20d Binary files /dev/null and b/media/example-cert.png differ diff --git a/media/notary-e2e-scenarios.png b/media/notary-e2e-scenarios.png new file mode 100644 index 000000000..2669f3fd4 Binary files /dev/null and b/media/notary-e2e-scenarios.png differ diff --git a/media/nv2-client-components.png b/media/nv2-client-components.png new file mode 100644 index 000000000..1f9ae294e Binary files /dev/null and b/media/nv2-client-components.png differ 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 +} 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/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/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..d5f0a64cc --- /dev/null +++ b/pkg/signature/signature.go @@ -0,0 +1,36 @@ +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"` + Signature []byte `json:"sig"` + Algorithm string `json:"alg,omitempty"` + KeyID string `json:"kid,omitempty"` + X5c [][]byte `json:"x5c,omitempty"` + Issuer string `json:"iss,omitempty"` +} 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..f5e8de755 --- /dev/null +++ b/pkg/signature/x509/signer.go @@ -0,0 +1,92 @@ +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 + keyID string + 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 + } + if certPath == "" { + return NewSigner(key, nil) + } + + 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) { + s := &signer{ + key: key, + keyID: key.KeyID(), + hash: crypto.SHA256, + } + if len(certs) == 0 { + return s, nil + } + + cert := certs[0] + publicKey, err := libtrust.FromCryptoPublicKey(crypto.PublicKey(cert.PublicKey)) + if err != nil { + return nil, err + } + 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 s, nil +} + +func (s *signer) Sign(raw []byte) (signature.Signature, error) { + 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 + } + sigma := signature.Signature{ + Type: Type, + Algorithm: alg, + Signature: sig, + } + + if s.cert != nil { + sigma.X5c = s.rawCerts + } else { + sigma.KeyID = s.keyID + } + return sigma, 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..d4750983d --- /dev/null +++ b/pkg/signature/x509/verifier.go @@ -0,0 +1,148 @@ +package x509 + +import ( + "bytes" + "crypto" + "crypto/x509" + "encoding/json" + "errors" + "strings" + + "github.com/docker/libtrust" + "github.com/notaryproject/nv2/pkg/signature" +) + +type verifier struct { + keys map[string]libtrust.PublicKey + certs map[string]*x509.Certificate + roots *x509.CertPool +} + +// NewVerifier creates a verifier +func NewVerifier(certs []*x509.Certificate, roots *x509.CertPool) (signature.Verifier, error) { + if roots == nil { + 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 + } + keyID := key.KeyID() + keys[keyID] = key + keyedCerts[keyID] = cert + } + + return &verifier{ + keys: keys, + certs: keyedCerts, + 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 + } + + 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) +} + +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 nil, nil, 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 nil, nil, err + } + + key, err := libtrust.FromCryptoPublicKey(crypto.PublicKey(cert.PublicKey)) + if err != nil { + return nil, nil, err + } + return key, cert, nil +} + +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 +}