| // Copyright 2019 The LUCI Authors. |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| // Package vmtoken implements parsing and verification of signed GCE VM metadata |
| // tokens. |
| // |
| // See https://cloud.google.com/compute/docs/instances/verifying-instance-identity |
| // |
| // Intended to be used from a server environment (e.g. from a GAE), since it |
| // depends on a bunch of luci/server packages that require a properly configured |
| // context. |
| package vmtoken |
| |
| import ( |
| "context" |
| "encoding/base64" |
| "encoding/json" |
| "fmt" |
| "net/http" |
| "strings" |
| |
| "go.chromium.org/luci/common/clock" |
| "go.chromium.org/luci/common/errors" |
| "go.chromium.org/luci/common/logging" |
| "go.chromium.org/luci/common/retry/transient" |
| "go.chromium.org/luci/gae/service/info" |
| "go.chromium.org/luci/server/auth/signing" |
| "go.chromium.org/luci/server/router" |
| "go.chromium.org/luci/server/warmup" |
| ) |
| |
| // Header is the name of the HTTP header where the GCE VM metadata token is |
| // expected. |
| const Header = "X-Luci-Gce-Vm-Token" |
| |
| // Payload is extracted from a verified GCE VM metadata token. |
| // |
| // It identifies a VM that produced the token and the target audience for |
| // the token (as it was supplied to the GCE metadata endpoint via 'audience' |
| // request parameter when generating the token). |
| type Payload struct { |
| Project string // GCE project name, e.g. "my-bots" or "domain.com:my-bots" |
| Zone string // GCE zone name where the VM is, e.g. "us-central1-b" |
| Instance string // VM instance name, e.g. "my-instance-1" |
| Audience string // 'aud' field inside the token, usually the server URL |
| } |
| |
| // Verify parses a GCE VM metadata token, verifies its signature and expiration |
| // time, and extracts interesting parts of it into Payload struct. |
| // |
| // Does NOT verify the audience field. This is responsibility of the caller. |
| // |
| // The token is in JWT form (three dot-separated base64-encoded strings). It is |
| // expected to be signed by Google OAuth2 backends using RS256 algo. |
| func Verify(c context.Context, jwt string) (*Payload, error) { |
| // Grab root Google OAuth2 keys to verify JWT signature. They are most likely |
| // already cached in the process memory. |
| certs, err := signing.FetchGoogleOAuth2Certificates(c) |
| if err != nil { |
| return nil, err |
| } |
| return verifyImpl(c, jwt, certs) |
| } |
| |
| // signatureChecker is used to mock signing.PublicCertificates in tests. |
| type signatureChecker interface { |
| CheckSignature(key string, signed, signature []byte) error |
| } |
| |
| func verifyImpl(c context.Context, jwt string, certs signatureChecker) (*Payload, error) { |
| chunks := strings.Split(jwt, ".") |
| if len(chunks) != 3 { |
| return nil, errors.Reason("bad JWT: expected 3 components separated by '.'").Err() |
| } |
| |
| // Check the header, grab the key ID from it. |
| var hdr struct { |
| Alg string `json:"alg"` |
| Kid string `json:"kid"` |
| } |
| if err := unmarshalB64JSON(chunks[0], &hdr); err != nil { |
| return nil, errors.Annotate(err, "bad JWT header").Err() |
| } |
| if hdr.Alg != "RS256" { |
| return nil, errors.Reason("bad JWT: only RS256 alg is supported, not %q", hdr.Alg).Err() |
| } |
| if hdr.Kid == "" { |
| return nil, errors.Reason("bad JWT: missing the signing key ID in the header").Err() |
| } |
| |
| // Need a raw binary blob with the signature to verify it. |
| sig, err := base64.RawURLEncoding.DecodeString(chunks[2]) |
| if err != nil { |
| return nil, errors.Annotate(err, "bad JWT: can't base64 decode the signature").Err() |
| } |
| |
| // Check that "b64(hdr).b64(payload)" part of the token matches the signature. |
| // If it does, we know the token was created by Google. |
| if err = certs.CheckSignature(hdr.Kid, []byte(chunks[0]+"."+chunks[1]), sig); err != nil { |
| return nil, errors.Annotate(err, "bad JWT: bad signature").Err() |
| } |
| |
| // Decode the payload. There should be no errors here generally, the encoded |
| // payload is signed and the signature was already verified. Note that for the |
| // sake of completeness and documentation we decode all fields usually present |
| // in the token, even though we use only subset of them below. |
| var payload struct { |
| Aud string `json:"aud"` // audience |
| Azp string `json:"azp"` // authorized party (GCE VM service account ID) |
| Email string `json:"email"` // GCE VM service account email |
| EmailVerified bool `json:"email_verified"` // always true |
| Exp int64 `json:"exp"` // "expiry", as unix timestamp |
| Iat int64 `json:"iat"` // "issued at", as unix timestamp |
| Iss string `json:"iss"` // issuer name |
| Sub string `json:"sub"` // subject (GCE VM service account ID) |
| Google struct { |
| ComputeEngine struct { |
| InstanceCreationTimestamp int64 `json:"instance_creation_timestamp"` |
| InstanceID string `json:"instance_id"` |
| InstanceName string `json:"instance_name"` |
| ProjectID string `json:"project_id"` |
| ProjectNumber int64 `json:"project_number"` |
| Zone string `json:"zone"` |
| } `json:"compute_engine"` |
| } `json:"google"` |
| } |
| if err = unmarshalB64JSON(chunks[1], &payload); err != nil { |
| return nil, errors.Annotate(err, "bad JWT payload").Err() |
| } |
| |
| // Tokens can either be in "full" or "standard" format. We want "full", since |
| // "standard" doesn't have details about the VM. |
| if payload.Google.ComputeEngine.ProjectID == "" { |
| return nil, errors.Reason("no google.compute_engine in the GCE VM token, use 'full' format").Err() |
| } |
| |
| // Check token's "issued at" and "expiry" claims. Allow some leeway for clock |
| // discrepancy between us and Google OAuth2 backend. |
| const allowedDriftSec = 30 |
| switch now := clock.Now(c).Unix(); { |
| case now < payload.Iat-allowedDriftSec: |
| return nil, errors.Reason("bad JWT: too early (now %d < iat %d)", now, payload.Iat).Err() |
| case now > payload.Exp+allowedDriftSec: |
| return nil, errors.Reason("bad JWT: expired (now %d > exp %d)", now, payload.Exp).Err() |
| } |
| |
| // The caller is supposed to check 'aud' claim to finish the verification. |
| return &Payload{ |
| Project: payload.Google.ComputeEngine.ProjectID, |
| Zone: payload.Google.ComputeEngine.Zone, |
| Instance: payload.Google.ComputeEngine.InstanceName, |
| Audience: payload.Aud, |
| }, nil |
| } |
| |
| func unmarshalB64JSON(blob string, out interface{}) error { |
| raw, err := base64.RawURLEncoding.DecodeString(blob) |
| if err != nil { |
| return errors.Annotate(err, "not base64").Err() |
| } |
| if err := json.Unmarshal(raw, out); err != nil { |
| return errors.Annotate(err, "not JSON").Err() |
| } |
| return nil |
| } |
| |
| // pldKey is the key to a *Payload in the context. |
| var pldKey = "pld" |
| |
| // withPayload returns a new context with the given *Payload installed. |
| func withPayload(c context.Context, p *Payload) context.Context { |
| return context.WithValue(c, &pldKey, p) |
| } |
| |
| // getPayload returns the *Payload installed in the current context. May be nil. |
| func getPayload(c context.Context) *Payload { |
| p, _ := c.Value(&pldKey).(*Payload) |
| return p |
| } |
| |
| // Clear returns a new context without a GCE VM metadata token installed. |
| func Clear(c context.Context) context.Context { |
| return context.WithValue(c, &pldKey, nil) |
| } |
| |
| // Has returns whether the current context contains a valid GCE VM metadata |
| // token. |
| func Has(c context.Context) bool { |
| return getPayload(c) != nil |
| } |
| |
| // Hostname returns the hostname of the VM stored in the current context. |
| func Hostname(c context.Context) string { |
| p := getPayload(c) |
| if p == nil { |
| return "" |
| } |
| return p.Instance |
| } |
| |
| // CurrentIdentity returns the identity of the VM stored in the current context. |
| func CurrentIdentity(c context.Context) string { |
| p := getPayload(c) |
| if p == nil { |
| return "gce:anonymous" |
| } |
| // GCE hostnames must be unique per project, so <instance, project> suffices. |
| return fmt.Sprintf("gce:%s:%s", p.Instance, p.Project) |
| } |
| |
| // Matches returns whether the current context contains a GCE VM metadata |
| // token matching the given identity. |
| func Matches(c context.Context, host, zone, proj string) bool { |
| p := getPayload(c) |
| if p == nil { |
| return false |
| } |
| logging.Debugf(c, "expecting VM token from %q in %q in %q", host, zone, proj) |
| return p.Instance == host && p.Zone == zone && p.Project == proj |
| } |
| |
| // Middleware embeds a Payload in the context if the request contains a GCE VM |
| // metadata token. |
| func Middleware(c *router.Context, next router.Handler) { |
| if tok := c.Request.Header.Get(Header); tok != "" { |
| // TODO(smut): Support requests to other modules, versions. |
| aud := "https://" + info.DefaultVersionHostname(c.Context) |
| logging.Debugf(c.Context, "expecting VM token for: %s", aud) |
| switch p, err := Verify(c.Context, tok); { |
| case transient.Tag.In(err): |
| logging.WithError(err).Errorf(c.Context, "transient error verifying VM token") |
| http.Error(c.Writer, "error: failed to verify VM token", http.StatusInternalServerError) |
| return |
| case err != nil: |
| logging.WithError(err).Errorf(c.Context, "invalid VM token") |
| http.Error(c.Writer, "error: invalid VM token", http.StatusUnauthorized) |
| return |
| case p.Audience != aud: |
| logging.Errorf(c.Context, "received VM token intended for: %s", p.Audience) |
| http.Error(c.Writer, "error: VM token audience mismatch", http.StatusUnauthorized) |
| return |
| default: |
| logging.Debugf(c.Context, "received VM token from %q in %q in %q for: %s", p.Instance, p.Zone, p.Project, p.Audience) |
| c.Context = withPayload(c.Context, p) |
| } |
| } |
| next(c) |
| } |
| |
| func init() { |
| warmup.Register("gce/vmtoken", func(c context.Context) error { |
| _, err := signing.FetchGoogleOAuth2Certificates(c) |
| return err |
| }) |
| } |