diff --git a/README.md b/README.md index 996158e49..1eb1b9ddc 100644 --- a/README.md +++ b/README.md @@ -1 +1,109 @@ -# 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" + ] + }, + "signature": { + "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 diff --git a/cmd/nv2/common.go b/cmd/nv2/common.go new file mode 100644 index 000000000..981b36788 --- /dev/null +++ b/cmd/nv2/common.go @@ -0,0 +1,25 @@ +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", + } + mediaTypeFlag = &cli.StringFlag{ + Name: "media-type", + Usage: "specify the media type of the manifest read from file or stdin", + Value: "application/vnd.docker.distribution.manifest.v2+json", + } +) diff --git a/cmd/nv2/main.go b/cmd/nv2/main.go new file mode 100644 index 000000000..93c82436d --- /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.2.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..588e696a2 --- /dev/null +++ b/cmd/nv2/manifest.go @@ -0,0 +1,71 @@ +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, ctx.String(mediaTypeFlag.Name)) +} + +func getManifestFromReader(r io.Reader, mediaType string) (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{ + Descriptor: signature.Descriptor{ + MediaType: mediaType, + 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(usernameFlag.Name), + Password: ctx.String(passwordFlag.Name), + Insecure: ctx.Bool(insecureFlag.Name), + }) + return remote.GetManifestMetadata(parsed) + default: + return signature.Manifest{}, fmt.Errorf("unsupported URI scheme: %s", parsed.Scheme) + } + return getManifestFromReader(r, ctx.String(mediaTypeFlag.Name)) +} diff --git a/cmd/nv2/sign.go b/cmd/nv2/sign.go new file mode 100644 index 000000000..df79d2af4 --- /dev/null +++ b/cmd/nv2/sign.go @@ -0,0 +1,129 @@ +package main + +import ( + "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 OCI Artifacts", + ArgsUsage: "[]", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "method", + Aliases: []string{"m"}, + Usage: "signing method", + Required: true, + }, + &cli.StringFlag{ + Name: "key", + Aliases: []string{"k"}, + Usage: "signing key file [x509]", + TakesFile: true, + }, + &cli.StringFlag{ + Name: "cert", + Aliases: []string{"c"}, + Usage: "signing cert [x509]", + 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, + mediaTypeFlag, + }, + Action: runSign, +} + +func runSign(ctx *cli.Context) error { + // initialize + scheme, err := getSchemeForSigning(ctx) + if err != nil { + return err + } + + // core process + claims, err := prepareClaimsForSigning(ctx) + if err != nil { + return err + } + sig, err := scheme.Sign(signerID, claims) + if err != nil { + return err + } + + // write out + path := ctx.String("output") + if path == "" { + path = strings.Split(claims.Manifest.Digest, ":")[1] + ".nv2" + } + if err := ioutil.WriteFile(path, []byte(sig), 0666); err != nil { + return err + } + + fmt.Println(claims.Manifest.Digest) + return nil +} + +func prepareClaimsForSigning(ctx *cli.Context) (signature.Claims, error) { + manifest, err := getManifestFromContext(ctx) + if err != nil { + return signature.Claims{}, err + } + manifest.References = ctx.StringSlice("reference") + now := time.Now() + nowUnix := now.Unix() + claims := signature.Claims{ + Manifest: manifest, + IssuedAt: nowUnix, + } + if expiry := ctx.Duration("expiry"); expiry != 0 { + claims.NotBefore = nowUnix + claims.Expiration = now.Add(expiry).Unix() + } + + return claims, 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")) + 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..2db1652a7 --- /dev/null +++ b/cmd/nv2/verify.go @@ -0,0 +1,120 @@ +package main + +import ( + "crypto/x509" + "fmt" + "io/ioutil" + + "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 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, + }, + usernameFlag, + passwordFlag, + insecureFlag, + mediaTypeFlag, + }, + Action: runVerify, +} + +func runVerify(ctx *cli.Context) error { + // initialize + scheme, err := getSchemeForVerification(ctx) + if err != nil { + return err + } + sig, err := readSignatrueFile(ctx.String("signature")) + if err != nil { + return err + } + + // core process + claims, err := scheme.Verify(sig) + if err != nil { + return fmt.Errorf("verification failure: %v", err) + } + manifest, err := getManifestFromContext(ctx) + if err != nil { + return err + } + if manifest.Descriptor != claims.Manifest.Descriptor { + return fmt.Errorf("verification failure: %s: ", manifest.Digest) + } + + // write out + fmt.Println(manifest.Digest) + return nil +} + +func readSignatrueFile(path string) (string, error) { + bytes, err := ioutil.ReadFile(path) + if err != nil { + return "", err + } + return string(bytes), nil +} + +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) + + 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/nv2/README.md b/docs/nv2/README.md new file mode 100644 index 000000000..319cfbfcd --- /dev/null +++ b/docs/nv2/README.md @@ -0,0 +1,290 @@ +# Notary V2 (nv2) - Prototype + +`nv2` is a command line tool for signing and verifying [OCI Artifacts]. This implementation supports `x509` 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 value signing method + --key value, -k value signing key file [x509] + --cert value, -c value signing cert [x509] + --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) + --media-type value specify the media type of the manifest read from file or stdin (default: "application/vnd.docker.distribution.manifest.v2+json") + --help, -h show help (default: false) +``` + +Signing and verification are based on [OCI manifests](https://github.com/opencontainers/image-spec/blob/master/manifest.md), [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 registry.acme-rockets.io/hello-world:v1 > hello-world_v1-manifest.json + ``` + +### Signing using `x509` + +To sign the manifest `hello-world_v1-manifest.json` using the key `key.key` from the `x509` certificate `cert.crt` with the Common Name `registry.acme-rockets.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.jwt \ + file:hello-world_v1-manifest.json +``` + +The formatted x509 signature: `hello-world.signature.config.jwt` is: + +``` json +{ + "typ": "x509", + "alg": "RS256", + "x5c": [ + "MIIDJzCCAg+gAwIBAgIUMwVg7bpx8QmWaFzRcgpRFBN6JoQwDQYJKoZIhvcNAQELBQAwIzEhMB8GA1UEAwwYcmVnaXN0cnkuYWNtZS1yb2NrZXRzLmlvMB4XDTIwMDcyOTExMDIzMloXDTIxMDcyOTExMDIzMlowIzEhMB8GA1UEAwwYcmVnaXN0cnkuYWNtZS1yb2NrZXRzLmlvMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx2mXqcXqkllwxj7S12WhVDsIu6y4ebZ/CwVwwime44yDcd0bcpdJExqIH/Qy6axQd/1zmLCHPeOXGFq48Ul0oS4Bawj1GEeLvB7VFvqB0KaBeAdxrZAvdKXCXIDH5qyFSGnOmvkja1BuR8XrH7tts5u56i+U3KEDBZg5tfx4cQuKKt0DfXZAL+4RZkNh1LoO77X0ThaBThFoRsg6aZA/cEpttoWmvnO6uUkK73oZEVgZNKGGIZZKzhUjnydRSTphp9GmZzbqUHlOiMvbzdtsQYC0qeQeNqua38HN93Ur3p+oH7oSrBWxX1Xlx933oVb+4G6h5oz0aZvMQ0G6gCLzjwIDAQABo1MwUTAdBgNVHQ4EFgQU8l2F7avSjFZ9TvnpHackunxSFcswHwYDVR0jBBgwFoAU8l2F7avSjFZ9TvnpHackunxSFcswDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAwECYhttcbCbqyi7DvOTHw5bixmxplbgD0AmMvE6Ci4P/MrooBququlkri/Jcp58GBaMjxItE4qVsaWwFCEvZEfP2xN4DAbr+rdrIFy9VYuwEIBs5l0ZLRH2H2N3HlqBzhYOjVzNlYfIqnqHUDip2VsUKqhcVFkCmb3cpJ1VNAgjQU2N60JUW28L0XrGyBctBIiicLvdP4NMhHP/hhN2vr2VGIyyo5XtP+QHFi/Uwa48BJ+c9bbVpXeghOMOPMeSJmJ2b/qlp95e/YHlSCfxDXyxZ70N2vBGecrc8ly4tD9KGLb9y3Q7RBgsagOFe7cGQ2db/t60AwTIxP0a9bIyJMg==" + ] +}.{ + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "digest": "sha256:24a74900a4e749ef31295e5aabde7093e3244b119582bd6e64b1a88c71c410d0", + "size": 3056, + "references": [ + "registry.acme-rockets.io/hello-world:v1" + ], + "iat": 1597053936 +}.[Signature] +``` + +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.jwt \ + file:hello-world_v1-manifest.json +``` + +The formatted x509, without the `x5c` chain signature: `hello-world.signature.config.jwt` is: + + +```json +{ + "typ": "x509", + "alg": "RS256", + "kid": "RQGT:OPJI:IABT:DFXB:52VS:FNOJ:4XBS:H4KY:WHGM:HQMC:WSMN:LKXM" +}.{ + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "digest": "sha256:24a74900a4e749ef31295e5aabde7093e3244b119582bd6e64b1a88c71c410d0", + "size": 3056, + "references": [ + "registry.acme-rockets.io/hello-world:v1" + ], + "iat": 1597053992 +}.[Signature] +``` + +The detailed signature specification is [available](../signature/README.md). + +### Offline Verification + +Notary v2 verification can be accomplished with the `nv2 verify` command. + +```shell +NAME: + nv2 verify - verifies OCI Artifacts + +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] + --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) + --media-type value specify the media type of the manifest read from file or stdin (default: "application/vnd.docker.distribution.manifest.v2+json") + --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.jwt \ + -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.jwt \ + 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. + +## 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 `x509`. + +``` shell +nv2 sign -m x509 \ + -k key.key \ + -o hello-world_latest.signature.config.jwt \ + docker://docker.io/library/hello-world:latest + +sha256:49a1c8800c94df04e9658809b006fd8a686cab8028d33cfba2cc049724254202 + +nv2 verify \ + -c cert.crt \ + -f hello-world_latest.signature.config.jwt \ + 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 x509 \ + -k key.key \ + -o hello-world_latest.signature.config.jwt \ + oci://docker.io/library/hello-world:latest + +sha256:0ebe6f409b373c8baf39879fccee6cae5e718003ec3167ded7d54cb2b5da2946 + +nv2 verify \ + -c cert.crt \ + -f hello-world_latest.signature.config.jwt \ + 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 example.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 hello-world_latest.signature.config.jwt -c cert.crt docker://docker.io/library/hello-world:latest) +if [ $? -eq 0 ]; then + docker pull docker.io/library/hello-world@$digest +fi +``` diff --git a/docs/signature/README.md b/docs/signature/README.md new file mode 100644 index 000000000..76bca766a --- /dev/null +++ b/docs/signature/README.md @@ -0,0 +1,239 @@ +# Notary V2 Signature Specification + +This section defines the signature file, which is a [JWT](https://tools.ietf.org/html/rfc7519) variant. + +## Signature Goals + +- Offline signature creation +- Persistence 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 accessible +- 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 header and claims to be signed. + +The header would be a base64 URL encoded string without paddings: + +``` +eyJ0eXAiOiJ4NTA5IiwiYWxnIjoiUlMyNTYiLCJ4NWMiOlsiTUlJRHN6Q0NBcHVnQXdJQkFnSVVMMWFuRVUveUp5NjdWSlRiSGtOWDBiQk5BbkV3RFFZSktvWklodmNOQVFFTEJRQXdhVEVkTUJzR0ExVUVBd3dVY21WbmFYTjBjbmt1WlhoaGJYQnNaUzVqYjIweEZEQVNCZ05WQkFvTUMyVjRZVzF3YkdVZ2FXNWpNUXN3Q1FZRFZRUUdFd0pWVXpFVE1CRUdBMVVFQ0F3S1YyRnphR2x1WjNSdmJqRVFNQTRHQTFVRUJ3d0hVMlZoZEhSc1pUQWVGdzB5TURBM01qY3hORFF6TkRaYUZ3MHlNVEEzTWpjeE5EUXpORFphTUdreEhUQWJCZ05WQkFNTUZISmxaMmx6ZEhKNUxtVjRZVzF3YkdVdVkyOXRNUlF3RWdZRFZRUUtEQXRsZUdGdGNHeGxJR2x1WXpFTE1Ba0dBMVVFQmhNQ1ZWTXhFekFSQmdOVkJBZ01DbGRoYzJocGJtZDBiMjR4RURBT0JnTlZCQWNNQjFObFlYUjBiR1V3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLQW9JQkFRRGtLd0FjVjQ0cHNqTjhubm8xZVozenYxWktVaEpBb3h3Qk9JR2ZJeEllK2lIdHBYTHZGRlZ3azVKYnh1K1BraWcyTjRCM0lscmovVnJ5aTBoeHA0bWFnMDJNNzMzYlhMUkVOU09GT05Sa3NscE84ekhVTjVwWWRuaFRTd1lUTGFwMSsxYmdjRlN1VVhMV2llcVpCNnFjN2tpdjNiajNTUGFmNDIrczQ4VjQ5dC9PcFh4THRnaVdMOVhrdURUWmN0cEpKQTR2SEhrNk91MGJjZzdpR20rTDF4d0lmYjhNbDRvV3ZUMFNGMzVmZ1cwOGJiTFhaMnYxWENMUnNyV1VnYnE0VStLeHRFcEczWElZY1loS3gxcklyVWhmRUprdUh6Z1BnbE0xMWdHNVcrQ3lmZyt3Zk9KaWc1cTZheElLV3pJZjZDOG04bG15NmJNK041RXNEOVN2QWdNQkFBR2pVekJSTUIwR0ExVWREZ1FXQkJUZjFoTTYvaWJHRit1L1NWQUs4OEZVTWp6Um9UQWZCZ05WSFNNRUdEQVdnQlRmMWhNNi9pYkdGK3UvU1ZBSzg4RlVNanpSb1RBUEJnTlZIUk1CQWY4RUJUQURBUUgvTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFCZ3ZWYXU1KzJ3QXVDc21PeXlHMjhoMXp5QzRJUG1NbXBSWlRET3AvcExkd1hlSGpKcjhrRUMzbDkycUpFdmMrV0Fib0oxUm91Y0h5Y1VlN1JXaDJDNlpGL1dQQ0JMeVdHd25seXFHeVJNOS9qODZVSjFPZ2l1Wmw3a2w5enh3V29heFBCQ21IYTBSSG93ZFFCN0FWbHBxZzFjN0ZoS2poVUNCbUdUNFZlOHRWMGhkWnRyWm9RVis2eEhQYlVkMzdLVjFCMUJtZm8zbzRla29KS2hVdTk5RW8wM09wRTNKTHRNMTNBMUh4QUJFdVFHSFRJMHR5Y0RCQmRSbjNiMDNIb0loVTBWbnFqdnBWMUtQdnNyZ1lpLzBWU3RMTmV6WlBnR2UwZkczWGd5OHlla2RCOU5NVW4relpMQVRJNCt6OGo0UUg1V2o1WlBhVWt5b0FEMm9VSk8iXX0 +``` + +The parsed and formatted header would be: + +```json +{ + "typ": "x509", + "alg": "RS256", + "x5c": [ + "MIIDszCCApugAwIBAgIUL1anEU/yJy67VJTbHkNX0bBNAnEwDQYJKoZIhvcNAQELBQAwaTEdMBsGA1UEAwwUcmVnaXN0cnkuZXhhbXBsZS5jb20xFDASBgNVBAoMC2V4YW1wbGUgaW5jMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2FzaGluZ3RvbjEQMA4GA1UEBwwHU2VhdHRsZTAeFw0yMDA3MjcxNDQzNDZaFw0yMTA3MjcxNDQzNDZaMGkxHTAbBgNVBAMMFHJlZ2lzdHJ5LmV4YW1wbGUuY29tMRQwEgYDVQQKDAtleGFtcGxlIGluYzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCldhc2hpbmd0b24xEDAOBgNVBAcMB1NlYXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDkKwAcV44psjN8nno1eZ3zv1ZKUhJAoxwBOIGfIxIe+iHtpXLvFFVwk5Jbxu+Pkig2N4B3Ilrj/Vryi0hxp4mag02M733bXLRENSOFONRkslpO8zHUN5pYdnhTSwYTLap1+1bgcFSuUXLWieqZB6qc7kiv3bj3SPaf42+s48V49t/OpXxLtgiWL9XkuDTZctpJJA4vHHk6Ou0bcg7iGm+L1xwIfb8Ml4oWvT0SF35fgW08bbLXZ2v1XCLRsrWUgbq4U+KxtEpG3XIYcYhKx1rIrUhfEJkuHzgPglM11gG5W+Cyfg+wfOJig5q6axIKWzIf6C8m8lmy6bM+N5EsD9SvAgMBAAGjUzBRMB0GA1UdDgQWBBTf1hM6/ibGF+u/SVAK88FUMjzRoTAfBgNVHSMEGDAWgBTf1hM6/ibGF+u/SVAK88FUMjzRoTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBgvVau5+2wAuCsmOyyG28h1zyC4IPmMmpRZTDOp/pLdwXeHjJr8kEC3l92qJEvc+WAboJ1RoucHycUe7RWh2C6ZF/WPCBLyWGwnlyqGyRM9/j86UJ1OgiuZl7kl9zxwWoaxPBCmHa0RHowdQB7AVlpqg1c7FhKjhUCBmGT4Ve8tV0hdZtrZoQV+6xHPbUd37KV1B1Bmfo3o4ekoJKhUu99Eo03OpE3JLtM13A1HxABEuQGHTI0tycDBBdRn3b03HoIhU0VnqjvpV1KPvsrgYi/0VStLNezZPgGe0fG3Xgy8yekdB9NMUn+zZLATI4+z8j4QH5Wj5ZPaUkyoAD2oUJO" + ] +} +``` + +The claims would be a base64 URL encoded string without paddings: + +``` +eyJtZWRpYVR5cGUiOiJhcHBsaWNhdGlvbi92bmQuZG9ja2VyLmRpc3RyaWJ1dGlvbi5tYW5pZmVzdC52Mitqc29uIiwiZGlnZXN0Ijoic2hhMjU2OmM0NTE2YjhhMzExZTg1ZjFmMmE2MDU3M2FiZjRjNmI3NDBjYTNhZGU0MTI3ZTI5YjA1NjE2ODQ4ZGU0ODdkMzQiLCJzaXplIjo1MjgsInJlZmVyZW5jZXMiOlsicmVnaXN0cnkuZXhhbXBsZS5jb20vZXhhbXBsZTpsYXRlc3QiLCJyZWdpc3RyeS5leGFtcGxlLmNvbS9leGFtcGxlOnYxLjAiXSwiZXhwIjoxNjI4NTg3MTE5LCJpYXQiOjE1OTcwNTExMTksIm5iZiI6MTU5NzA1MTExOX0 +``` + +The parsed and formatted claims would be: + +``` JSON +{ + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "digest": "sha256:c4516b8a311e85f1f2a60573abf4c6b740ca3ade4127e29b05616848de487d34", + "size": 528, + "references": [ + "registry.example.com/example:latest", + "registry.example.com/example:v1.0" + ], + "exp": 1628587119, + "iat": 1597051119, + "nbf": 1597051119 +} +``` + +The signature of the above would be represented as a base64 URL encoded string without paddings: + +``` +MtQBOL2FERM2fMSikruHOMQdHuEXAE1wf6J6TfDY2W_7PfQQllBKbJJE0HqJ5ENAbuqNYHNZeIeKUCYFrNx2XgtrKuTe7WCa1ZZKDtp5bmANp484ekdl6lW23YB8r_SRtseJuibqjI3HuiMyELj9uYV1CdRYaD2BIZ_qxraYH1fMpjDWjehU4RYLI37hsSuDQ90o09BwaNfzbQXYPsGmkSUSmej7rOFPDnuwhNy4WcUed3kRKYEW8eIjO9OUBGQq3PWvhDjxZi3QF4QFDoiKBOXL70AjaiVIveQRkJI9-xHZSYwje9OFEMioeNWB5ceZR-r4L7VzDcU-Fxqjxn79Fw +``` + +Putting everything together: + +``` +eyJ0eXAiOiJ4NTA5IiwiYWxnIjoiUlMyNTYiLCJ4NWMiOlsiTUlJRHN6Q0NBcHVnQXdJQkFnSVVMMWFuRVUveUp5NjdWSlRiSGtOWDBiQk5BbkV3RFFZSktvWklodmNOQVFFTEJRQXdhVEVkTUJzR0ExVUVBd3dVY21WbmFYTjBjbmt1WlhoaGJYQnNaUzVqYjIweEZEQVNCZ05WQkFvTUMyVjRZVzF3YkdVZ2FXNWpNUXN3Q1FZRFZRUUdFd0pWVXpFVE1CRUdBMVVFQ0F3S1YyRnphR2x1WjNSdmJqRVFNQTRHQTFVRUJ3d0hVMlZoZEhSc1pUQWVGdzB5TURBM01qY3hORFF6TkRaYUZ3MHlNVEEzTWpjeE5EUXpORFphTUdreEhUQWJCZ05WQkFNTUZISmxaMmx6ZEhKNUxtVjRZVzF3YkdVdVkyOXRNUlF3RWdZRFZRUUtEQXRsZUdGdGNHeGxJR2x1WXpFTE1Ba0dBMVVFQmhNQ1ZWTXhFekFSQmdOVkJBZ01DbGRoYzJocGJtZDBiMjR4RURBT0JnTlZCQWNNQjFObFlYUjBiR1V3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLQW9JQkFRRGtLd0FjVjQ0cHNqTjhubm8xZVozenYxWktVaEpBb3h3Qk9JR2ZJeEllK2lIdHBYTHZGRlZ3azVKYnh1K1BraWcyTjRCM0lscmovVnJ5aTBoeHA0bWFnMDJNNzMzYlhMUkVOU09GT05Sa3NscE84ekhVTjVwWWRuaFRTd1lUTGFwMSsxYmdjRlN1VVhMV2llcVpCNnFjN2tpdjNiajNTUGFmNDIrczQ4VjQ5dC9PcFh4THRnaVdMOVhrdURUWmN0cEpKQTR2SEhrNk91MGJjZzdpR20rTDF4d0lmYjhNbDRvV3ZUMFNGMzVmZ1cwOGJiTFhaMnYxWENMUnNyV1VnYnE0VStLeHRFcEczWElZY1loS3gxcklyVWhmRUprdUh6Z1BnbE0xMWdHNVcrQ3lmZyt3Zk9KaWc1cTZheElLV3pJZjZDOG04bG15NmJNK041RXNEOVN2QWdNQkFBR2pVekJSTUIwR0ExVWREZ1FXQkJUZjFoTTYvaWJHRit1L1NWQUs4OEZVTWp6Um9UQWZCZ05WSFNNRUdEQVdnQlRmMWhNNi9pYkdGK3UvU1ZBSzg4RlVNanpSb1RBUEJnTlZIUk1CQWY4RUJUQURBUUgvTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFCZ3ZWYXU1KzJ3QXVDc21PeXlHMjhoMXp5QzRJUG1NbXBSWlRET3AvcExkd1hlSGpKcjhrRUMzbDkycUpFdmMrV0Fib0oxUm91Y0h5Y1VlN1JXaDJDNlpGL1dQQ0JMeVdHd25seXFHeVJNOS9qODZVSjFPZ2l1Wmw3a2w5enh3V29heFBCQ21IYTBSSG93ZFFCN0FWbHBxZzFjN0ZoS2poVUNCbUdUNFZlOHRWMGhkWnRyWm9RVis2eEhQYlVkMzdLVjFCMUJtZm8zbzRla29KS2hVdTk5RW8wM09wRTNKTHRNMTNBMUh4QUJFdVFHSFRJMHR5Y0RCQmRSbjNiMDNIb0loVTBWbnFqdnBWMUtQdnNyZ1lpLzBWU3RMTmV6WlBnR2UwZkczWGd5OHlla2RCOU5NVW4relpMQVRJNCt6OGo0UUg1V2o1WlBhVWt5b0FEMm9VSk8iXX0.eyJtZWRpYVR5cGUiOiJhcHBsaWNhdGlvbi92bmQuZG9ja2VyLmRpc3RyaWJ1dGlvbi5tYW5pZmVzdC52Mitqc29uIiwiZGlnZXN0Ijoic2hhMjU2OmM0NTE2YjhhMzExZTg1ZjFmMmE2MDU3M2FiZjRjNmI3NDBjYTNhZGU0MTI3ZTI5YjA1NjE2ODQ4ZGU0ODdkMzQiLCJzaXplIjo1MjgsInJlZmVyZW5jZXMiOlsicmVnaXN0cnkuZXhhbXBsZS5jb20vZXhhbXBsZTpsYXRlc3QiLCJyZWdpc3RyeS5leGFtcGxlLmNvbS9leGFtcGxlOnYxLjAiXSwiZXhwIjoxNjI4NTg3MTE5LCJpYXQiOjE1OTcwNTExMTksIm5iZiI6MTU5NzA1MTExOX0.MtQBOL2FERM2fMSikruHOMQdHuEXAE1wf6J6TfDY2W_7PfQQllBKbJJE0HqJ5ENAbuqNYHNZeIeKUCYFrNx2XgtrKuTe7WCa1ZZKDtp5bmANp484ekdl6lW23YB8r_SRtseJuibqjI3HuiMyELj9uYV1CdRYaD2BIZ_qxraYH1fMpjDWjehU4RYLI37hsSuDQ90o09BwaNfzbQXYPsGmkSUSmej7rOFPDnuwhNy4WcUed3kRKYEW8eIjO9OUBGQq3PWvhDjxZi3QF4QFDoiKBOXL70AjaiVIveQRkJI9-xHZSYwje9OFEMioeNWB5ceZR-r4L7VzDcU-Fxqjxn79Fw +``` + +### Signature Persisted within an OCI Artifact Enabled Registry + +All values are persisted in a `signature.jwt` file. The file would be submitted to a registry as an Artifact with null layers. +The `signature.jwt` would be persisted within the `manifest.config` object + +``` SHELL +oras push \ + registry.example.com/hello-world:v1.0 \ + --manifest-config signature.json:application/vnd.cncf.notary.config.v2+jwt +``` + +Would push the following manifest: + +``` JSON +{ + "schemaVersion": 2, + "config": { + "mediaType": "application/vnd.cncf.notary.config.v2+jwt", + "size": 1906, + "digest": "sha256:c7848182f2c817415f0de63206f9e4220012cbb0bdb750c2ecf8020350239814" + }, + "layers": [] +} +``` + +## *Signature* Property Descriptions + +### Header + +- **`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/). + + Although the signature file is a JWT, type `JWT` is not used as it is not an authentication or authorization token. + +- **`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). + +### Claims + +- **`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). + +- **`mediaType`** *string* + + This REQUIRED property contains the media type of the referenced content. Values MUST comply with [RFC 6838][rfc6838], including the [naming requirements in its section 4.2][rfc6838-s4.2]. + +- **`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. + +## Example Signatures + +### x509 Signature + +Example showing a formatted `x509` signature file [examples/x509_x5c.nv2.jwt](examples/x509_x5c.nv2.jwt) with certificates provided by `x5c`: + +```json +{ + "typ": "x509", + "alg": "RS256", + "x5c": [ + "MIIDszCCApugAwIBAgIUL1anEU/yJy67VJTbHkNX0bBNAnEwDQYJKoZIhvcNAQELBQAwaTEdMBsGA1UEAwwUcmVnaXN0cnkuZXhhbXBsZS5jb20xFDASBgNVBAoMC2V4YW1wbGUgaW5jMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2FzaGluZ3RvbjEQMA4GA1UEBwwHU2VhdHRsZTAeFw0yMDA3MjcxNDQzNDZaFw0yMTA3MjcxNDQzNDZaMGkxHTAbBgNVBAMMFHJlZ2lzdHJ5LmV4YW1wbGUuY29tMRQwEgYDVQQKDAtleGFtcGxlIGluYzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCldhc2hpbmd0b24xEDAOBgNVBAcMB1NlYXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDkKwAcV44psjN8nno1eZ3zv1ZKUhJAoxwBOIGfIxIe+iHtpXLvFFVwk5Jbxu+Pkig2N4B3Ilrj/Vryi0hxp4mag02M733bXLRENSOFONRkslpO8zHUN5pYdnhTSwYTLap1+1bgcFSuUXLWieqZB6qc7kiv3bj3SPaf42+s48V49t/OpXxLtgiWL9XkuDTZctpJJA4vHHk6Ou0bcg7iGm+L1xwIfb8Ml4oWvT0SF35fgW08bbLXZ2v1XCLRsrWUgbq4U+KxtEpG3XIYcYhKx1rIrUhfEJkuHzgPglM11gG5W+Cyfg+wfOJig5q6axIKWzIf6C8m8lmy6bM+N5EsD9SvAgMBAAGjUzBRMB0GA1UdDgQWBBTf1hM6/ibGF+u/SVAK88FUMjzRoTAfBgNVHSMEGDAWgBTf1hM6/ibGF+u/SVAK88FUMjzRoTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBgvVau5+2wAuCsmOyyG28h1zyC4IPmMmpRZTDOp/pLdwXeHjJr8kEC3l92qJEvc+WAboJ1RoucHycUe7RWh2C6ZF/WPCBLyWGwnlyqGyRM9/j86UJ1OgiuZl7kl9zxwWoaxPBCmHa0RHowdQB7AVlpqg1c7FhKjhUCBmGT4Ve8tV0hdZtrZoQV+6xHPbUd37KV1B1Bmfo3o4ekoJKhUu99Eo03OpE3JLtM13A1HxABEuQGHTI0tycDBBdRn3b03HoIhU0VnqjvpV1KPvsrgYi/0VStLNezZPgGe0fG3Xgy8yekdB9NMUn+zZLATI4+z8j4QH5Wj5ZPaUkyoAD2oUJO" + ] +}.{ + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "digest": "sha256:c4516b8a311e85f1f2a60573abf4c6b740ca3ade4127e29b05616848de487d34", + "size": 528, + "references": [ + "registry.example.com/example:latest", + "registry.example.com/example:v1.0" + ], + "exp": 1628587119, + "iat": 1597051119, + "nbf": 1597051119 +}.[Signature] +``` + +Example showing a formatted `x509` signature file [examples/x509_kid.nv2.jwt](examples/x509_kid.nv2.jwt) with certificates referenced by `kid`: + +```json +{ + "typ": "x509", + "alg": "RS256", + "kid": "XP5O:Y7W2:PRB6:O355:56CC:P3A6:CBDV:EDMN:QZCK:W5PO:QMV3:T2LX" +}.{ + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "digest": "sha256:c4516b8a311e85f1f2a60573abf4c6b740ca3ade4127e29b05616848de487d34", + "size": 528, + "references": [ + "registry.example.com/example:latest", + "registry.example.com/example:v1.0" + ], + "exp": 1628587341, + "iat": 1597051341, + "nbf": 1597051341 +}.[Signature] +``` + +[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.jwt b/docs/signature/examples/x509_kid.nv2.jwt new file mode 100644 index 000000000..444cd2790 --- /dev/null +++ b/docs/signature/examples/x509_kid.nv2.jwt @@ -0,0 +1 @@ +eyJ0eXAiOiJ4NTA5IiwiYWxnIjoiUlMyNTYiLCJraWQiOiJYUDVPOlk3VzI6UFJCNjpPMzU1OjU2Q0M6UDNBNjpDQkRWOkVETU46UVpDSzpXNVBPOlFNVjM6VDJMWCJ9.eyJtZWRpYVR5cGUiOiJhcHBsaWNhdGlvbi92bmQuZG9ja2VyLmRpc3RyaWJ1dGlvbi5tYW5pZmVzdC52Mitqc29uIiwiZGlnZXN0Ijoic2hhMjU2OmM0NTE2YjhhMzExZTg1ZjFmMmE2MDU3M2FiZjRjNmI3NDBjYTNhZGU0MTI3ZTI5YjA1NjE2ODQ4ZGU0ODdkMzQiLCJzaXplIjo1MjgsInJlZmVyZW5jZXMiOlsicmVnaXN0cnkuZXhhbXBsZS5jb20vZXhhbXBsZTpsYXRlc3QiLCJyZWdpc3RyeS5leGFtcGxlLmNvbS9leGFtcGxlOnYxLjAiXSwiZXhwIjoxNjI4NTg3MzQxLCJpYXQiOjE1OTcwNTEzNDEsIm5iZiI6MTU5NzA1MTM0MX0.cr9C_Py-IJcgIUXtHAQ9dFmZO4JBEOedPdg67Fm-Av8vMQBHrs7kHZOqZhF33OYR7tuG94v760RlrCrBl1OhUpk5umLjeCOk1-RBqSWUhM7GxwfeIWEIC10gzmolHVI55nb27QQxq0pTqhAC9Nof6QljFG8kyqYqjn0cr3X1zt23ppyJ1CYkcdXdDL0QD8-1EnngHAYcssun8A9dKveld-O-dMq94wk2FkSuKz6WSOM1I5E-thbq6NltB7dzLuZAkU4LXAqODCJ7fTQgUvtapzyEMvV6cQwAG1sUV1yEST0A6t6U_0Tt-X32_kciptVuzbtRLYuOW8Wzv7E41ryU6w \ No newline at end of file diff --git a/docs/signature/examples/x509_x5c.nv2.jwt b/docs/signature/examples/x509_x5c.nv2.jwt new file mode 100644 index 000000000..78ff07e12 --- /dev/null +++ b/docs/signature/examples/x509_x5c.nv2.jwt @@ -0,0 +1 @@ +eyJ0eXAiOiJ4NTA5IiwiYWxnIjoiUlMyNTYiLCJ4NWMiOlsiTUlJRHN6Q0NBcHVnQXdJQkFnSVVMMWFuRVUveUp5NjdWSlRiSGtOWDBiQk5BbkV3RFFZSktvWklodmNOQVFFTEJRQXdhVEVkTUJzR0ExVUVBd3dVY21WbmFYTjBjbmt1WlhoaGJYQnNaUzVqYjIweEZEQVNCZ05WQkFvTUMyVjRZVzF3YkdVZ2FXNWpNUXN3Q1FZRFZRUUdFd0pWVXpFVE1CRUdBMVVFQ0F3S1YyRnphR2x1WjNSdmJqRVFNQTRHQTFVRUJ3d0hVMlZoZEhSc1pUQWVGdzB5TURBM01qY3hORFF6TkRaYUZ3MHlNVEEzTWpjeE5EUXpORFphTUdreEhUQWJCZ05WQkFNTUZISmxaMmx6ZEhKNUxtVjRZVzF3YkdVdVkyOXRNUlF3RWdZRFZRUUtEQXRsZUdGdGNHeGxJR2x1WXpFTE1Ba0dBMVVFQmhNQ1ZWTXhFekFSQmdOVkJBZ01DbGRoYzJocGJtZDBiMjR4RURBT0JnTlZCQWNNQjFObFlYUjBiR1V3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLQW9JQkFRRGtLd0FjVjQ0cHNqTjhubm8xZVozenYxWktVaEpBb3h3Qk9JR2ZJeEllK2lIdHBYTHZGRlZ3azVKYnh1K1BraWcyTjRCM0lscmovVnJ5aTBoeHA0bWFnMDJNNzMzYlhMUkVOU09GT05Sa3NscE84ekhVTjVwWWRuaFRTd1lUTGFwMSsxYmdjRlN1VVhMV2llcVpCNnFjN2tpdjNiajNTUGFmNDIrczQ4VjQ5dC9PcFh4THRnaVdMOVhrdURUWmN0cEpKQTR2SEhrNk91MGJjZzdpR20rTDF4d0lmYjhNbDRvV3ZUMFNGMzVmZ1cwOGJiTFhaMnYxWENMUnNyV1VnYnE0VStLeHRFcEczWElZY1loS3gxcklyVWhmRUprdUh6Z1BnbE0xMWdHNVcrQ3lmZyt3Zk9KaWc1cTZheElLV3pJZjZDOG04bG15NmJNK041RXNEOVN2QWdNQkFBR2pVekJSTUIwR0ExVWREZ1FXQkJUZjFoTTYvaWJHRit1L1NWQUs4OEZVTWp6Um9UQWZCZ05WSFNNRUdEQVdnQlRmMWhNNi9pYkdGK3UvU1ZBSzg4RlVNanpSb1RBUEJnTlZIUk1CQWY4RUJUQURBUUgvTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFCZ3ZWYXU1KzJ3QXVDc21PeXlHMjhoMXp5QzRJUG1NbXBSWlRET3AvcExkd1hlSGpKcjhrRUMzbDkycUpFdmMrV0Fib0oxUm91Y0h5Y1VlN1JXaDJDNlpGL1dQQ0JMeVdHd25seXFHeVJNOS9qODZVSjFPZ2l1Wmw3a2w5enh3V29heFBCQ21IYTBSSG93ZFFCN0FWbHBxZzFjN0ZoS2poVUNCbUdUNFZlOHRWMGhkWnRyWm9RVis2eEhQYlVkMzdLVjFCMUJtZm8zbzRla29KS2hVdTk5RW8wM09wRTNKTHRNMTNBMUh4QUJFdVFHSFRJMHR5Y0RCQmRSbjNiMDNIb0loVTBWbnFqdnBWMUtQdnNyZ1lpLzBWU3RMTmV6WlBnR2UwZkczWGd5OHlla2RCOU5NVW4relpMQVRJNCt6OGo0UUg1V2o1WlBhVWt5b0FEMm9VSk8iXX0.eyJtZWRpYVR5cGUiOiJhcHBsaWNhdGlvbi92bmQuZG9ja2VyLmRpc3RyaWJ1dGlvbi5tYW5pZmVzdC52Mitqc29uIiwiZGlnZXN0Ijoic2hhMjU2OmM0NTE2YjhhMzExZTg1ZjFmMmE2MDU3M2FiZjRjNmI3NDBjYTNhZGU0MTI3ZTI5YjA1NjE2ODQ4ZGU0ODdkMzQiLCJzaXplIjo1MjgsInJlZmVyZW5jZXMiOlsicmVnaXN0cnkuZXhhbXBsZS5jb20vZXhhbXBsZTpsYXRlc3QiLCJyZWdpc3RyeS5leGFtcGxlLmNvbS9leGFtcGxlOnYxLjAiXSwiZXhwIjoxNjI4NTg3MTE5LCJpYXQiOjE1OTcwNTExMTksIm5iZiI6MTU5NzA1MTExOX0.MtQBOL2FERM2fMSikruHOMQdHuEXAE1wf6J6TfDY2W_7PfQQllBKbJJE0HqJ5ENAbuqNYHNZeIeKUCYFrNx2XgtrKuTe7WCa1ZZKDtp5bmANp484ekdl6lW23YB8r_SRtseJuibqjI3HuiMyELj9uYV1CdRYaD2BIZ_qxraYH1fMpjDWjehU4RYLI37hsSuDQ90o09BwaNfzbQXYPsGmkSUSmej7rOFPDnuwhNy4WcUed3kRKYEW8eIjO9OUBGQq3PWvhDjxZi3QF4QFDoiKBOXL70AjaiVIveQRkJI9-xHZSYwje9OFEMioeNWB5ceZR-r4L7VzDcU-Fxqjxn79Fw \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..745083dc8 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..83b110164 --- /dev/null +++ b/go.sum @@ -0,0 +1,19 @@ +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= +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..694d73f1f --- /dev/null +++ b/pkg/registry/manifest.go @@ -0,0 +1,120 @@ +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 + mediaType := header.Get("Content-Type") + if mediaType == "" { + return signature.Manifest{}, fmt.Errorf("%v: missing Content-Type", url) + } + 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{ + Descriptor: signature.Descriptor{ + MediaType: mediaType, + 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/encoding.go b/pkg/signature/encoding.go new file mode 100644 index 000000000..325e8ad55 --- /dev/null +++ b/pkg/signature/encoding.go @@ -0,0 +1,30 @@ +package signature + +import ( + "encoding/base64" + "encoding/json" + "fmt" +) + +// EncodeSegment JWT specific base64url encoding with padding stripped +func EncodeSegment(seg []byte) string { + return base64.RawURLEncoding.EncodeToString(seg) +} + +// DecodeSegment JWT specific base64url encoding with padding stripped +func DecodeSegment(seg string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(seg) +} + +// DecodeClaims JWT specific base64url encoding with padding stripped as Claims +func DecodeClaims(seg string) (Claims, error) { + bytes, err := DecodeSegment(seg) + if err != nil { + return Claims{}, fmt.Errorf("invalid base64 encoded claims: %v", err) + } + var claims Claims + if err := json.Unmarshal(bytes, &claims); err != nil { + return Claims{}, fmt.Errorf("invalid JSON encoded claims: %v", err) + } + return claims, nil +} diff --git a/pkg/signature/errors.go b/pkg/signature/errors.go new file mode 100644 index 000000000..9387af88a --- /dev/null +++ b/pkg/signature/errors.go @@ -0,0 +1,14 @@ +package signature + +import "errors" + +// common errors +var ( + ErrInvalidToken = errors.New("invalid token") + ErrInvalidSignatureType = errors.New("invalid signature type") + ErrUnknownSignatureType = errors.New("unknown signature type") + ErrUnknownSigner = errors.New("unknown signer") + ErrDigestMismatch = errors.New("digest mismatch") + ErrSizeMismatch = errors.New("size mismatch") + ErrMediaTypeMismatch = errors.New("media type mismatch") +) diff --git a/pkg/signature/interface.go b/pkg/signature/interface.go new file mode 100644 index 000000000..0cc0d14d4 --- /dev/null +++ b/pkg/signature/interface.go @@ -0,0 +1,12 @@ +package signature + +// Signer signs content +type Signer interface { + Sign(claims string) (string, []byte, error) +} + +// Verifier verifies content +type Verifier interface { + Type() string + Verify(header Header, signed string, sig []byte) error +} diff --git a/pkg/signature/model.go b/pkg/signature/model.go new file mode 100644 index 000000000..282c48ea7 --- /dev/null +++ b/pkg/signature/model.go @@ -0,0 +1,28 @@ +package signature + +// Header defines the signature header +type Header struct { + Raw []byte `json:"-"` + Type string `json:"typ"` +} + +// Claims contains the claims to be signed +type Claims struct { + Manifest + Expiration int64 `json:"exp,omitempty"` + IssuedAt int64 `json:"iat,omitempty"` + NotBefore int64 `json:"nbf,omitempty"` +} + +// Manifest to be signed +type Manifest struct { + Descriptor + References []string `json:"references,omitempty"` +} + +// Descriptor describes the basic information of the target content +type Descriptor struct { + MediaType string `json:"mediaType,omitempty"` + Digest string `json:"digest"` + Size int64 `json:"size"` +} diff --git a/pkg/signature/scheme.go b/pkg/signature/scheme.go new file mode 100644 index 000000000..d531be54f --- /dev/null +++ b/pkg/signature/scheme.go @@ -0,0 +1,117 @@ +package signature + +import ( + "encoding/json" + "fmt" + "strings" + "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 claims by a signer +func (s *Scheme) Sign(signerID string, claims Claims) (string, error) { + bytes, err := json.Marshal(claims) + if err != nil { + return "", err + } + return s.SignRaw(signerID, bytes) +} + +// SignRaw signs raw content by a signer +func (s *Scheme) SignRaw(signerID string, content []byte) (string, error) { + signer, found := s.signers[signerID] + if !found { + return "", ErrUnknownSigner + } + + signed, sig, err := signer.Sign(EncodeSegment(content)) + if err != nil { + return "", nil + } + + return strings.Join([]string{ + signed, + EncodeSegment(sig), + }, "."), nil +} + +// Verify verifies the JWT-like token +func (s *Scheme) Verify(token string) (Claims, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return Claims{}, ErrInvalidToken + } + + if err := s.verifySignature(parts); err != nil { + return Claims{}, err + } + + claims, err := DecodeClaims(parts[1]) + if err != nil { + return Claims{}, err + } + + return claims, s.verifyClaims(claims) +} + +func (s *Scheme) verifySignature(parts []string) error { + rawHeader, err := DecodeSegment(parts[0]) + if err != nil { + return ErrInvalidToken + } + var header Header + if json.Unmarshal(rawHeader, &header); err != nil { + return ErrInvalidToken + } + header.Raw = rawHeader + + verifier, found := s.verifiers[header.Type] + if !found { + return ErrUnknownSignatureType + } + + sig, err := DecodeSegment(parts[2]) + if err != nil { + return ErrInvalidToken + } + + return verifier.Verify( + header, + strings.Join(parts[:2], "."), + sig, + ) +} + +func (s *Scheme) verifyClaims(claims Claims) error { + now := time.Now().Unix() + if claims.Expiration != 0 && now > claims.Expiration { + return fmt.Errorf("content expired: %d: current: %d", claims.Expiration, now) + } + if claims.NotBefore != 0 && now < claims.NotBefore { + return fmt.Errorf("content is not available yet: %d: current: %d", claims.NotBefore, now) + } + return nil +} diff --git a/pkg/signature/x509/header.go b/pkg/signature/x509/header.go new file mode 100644 index 000000000..fb5ebdddf --- /dev/null +++ b/pkg/signature/x509/header.go @@ -0,0 +1,18 @@ +package x509 + +import ( + "github.com/notaryproject/nv2/pkg/signature" +) + +// Header defines the signature header +type Header struct { + signature.Header + Parameters +} + +// Parameters defines the signature parameters +type Parameters struct { + Algorithm string `json:"alg,omitempty"` + KeyID string `json:"kid,omitempty"` + X5c [][]byte `json:"x5c,omitempty"` +} diff --git a/pkg/signature/x509/signer.go b/pkg/signature/x509/signer.go new file mode 100644 index 000000000..eb39882d0 --- /dev/null +++ b/pkg/signature/x509/signer.go @@ -0,0 +1,113 @@ +package x509 + +import ( + "crypto" + "crypto/x509" + "encoding/json" + "errors" + "io" + "strings" + + "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(claims string) (string, []byte, error) { + if s.cert != nil { + if err := verifyReferences(claims, s.cert); err != nil { + return "", nil, err + } + } + + // Generate header + // We have to sign an empty string for the proper algorithm string first. + _, alg, err := s.key.Sign(io.MultiReader(), s.hash) + if err != nil { + return "", nil, err + } + header := Header{ + Header: signature.Header{ + Type: Type, + }, + Parameters: Parameters{ + Algorithm: alg, + }, + } + if s.cert != nil { + header.X5c = s.rawCerts + } else { + header.KeyID = s.keyID + } + headerJSON, err := json.Marshal(header) + if err != nil { + return "", nil, err + } + + // Generate signature + signed := strings.Join([]string{ + signature.EncodeSegment(headerJSON), + claims, + }, ".") + + sig, _, err := s.key.Sign(strings.NewReader(signed), s.hash) + if err != nil { + return "", nil, err + } + return signed, 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..97eb17c99 --- /dev/null +++ b/pkg/signature/x509/verifier.go @@ -0,0 +1,155 @@ +package x509 + +import ( + "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(header signature.Header, signed string, sig []byte) error { + if header.Type != Type { + return signature.ErrInvalidSignatureType + } + var params Parameters + if err := json.Unmarshal(header.Raw, ¶ms); err != nil { + return err + } + + key, cert, err := v.getVerificationKeyPair(params) + if err != nil { + return err + } + if err := key.Verify(strings.NewReader(signed), params.Algorithm, sig); err != nil { + return err + } + + parts := strings.Split(signed, ".") + if len(parts) != 2 { + return errors.New("invalid signed content") + } + + return verifyReferences(parts[1], cert) +} + +func (v *verifier) getVerificationKeyPair(params Parameters) (libtrust.PublicKey, *x509.Certificate, error) { + switch { + case len(params.X5c) > 0: + return v.getVerificationKeyPairFromX5c(params.X5c) + case params.KeyID != "": + return v.getVerificationKeyPairFromKeyID(params.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(seg string, cert *x509.Certificate) error { + claims, err := signature.DecodeClaims(seg) + if err != nil { + return err + } + roots := x509.NewCertPool() + roots.AddCert(cert) + for _, reference := range claims.Manifest.References { + if _, err := cert.Verify(x509.VerifyOptions{ + DNSName: strings.SplitN(reference, "/", 2)[0], + Roots: roots, + }); err != nil { + return err + } + } + return nil +}