// Copyright 2020 Google LLC // // 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 storage import ( "crypto" "crypto/rand" "crypto/rsa" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "net/url" "strings" "time" ) // PostPolicyV4Options are used to construct a signed post policy. // Please see https://cloud.google.com/storage/docs/xml-api/post-object // for reference about the fields. type PostPolicyV4Options struct { // GoogleAccessID represents the authorizer of the signed URL generation. // It is typically the Google service account client email address from // the Google Developers Console in the form of "xxx@developer.gserviceaccount.com". // Required. GoogleAccessID string // PrivateKey is the Google service account private key. It is obtainable // from the Google Developers Console. // At https://console.developers.google.com/project//apiui/credential, // create a service account client ID or reuse one of your existing service account // credentials. Click on the "Generate new P12 key" to generate and download // a new private key. Once you download the P12 file, use the following command // to convert it into a PEM file. // // $ openssl pkcs12 -in key.p12 -passin pass:notasecret -out key.pem -nodes // // Provide the contents of the PEM file as a byte slice. // Exactly one of PrivateKey or SignBytes must be non-nil. PrivateKey []byte // SignBytes is a function for implementing custom signing. For example, if // your application is running on Google App Engine, you can use // appengine's internal signing function: // ctx := appengine.NewContext(request) // acc, _ := appengine.ServiceAccount(ctx) // url, err := SignedURL("bucket", "object", &SignedURLOptions{ // GoogleAccessID: acc, // SignBytes: func(b []byte) ([]byte, error) { // _, signedBytes, err := appengine.SignBytes(ctx, b) // return signedBytes, err // }, // // etc. // }) // // Exactly one of PrivateKey or SignBytes must be non-nil. SignBytes func(hashBytes []byte) (signature []byte, err error) // Expires is the expiration time on the signed URL. // It must be a time in the future. // Required. Expires time.Time // Style provides options for the type of URL to use. Options are // PathStyle (default), BucketBoundHostname, and VirtualHostedStyle. See // https://cloud.google.com/storage/docs/request-endpoints for details. // Optional. Style URLStyle // Insecure when set indicates that the generated URL's scheme // will use "http" instead of "https" (default). // Optional. Insecure bool // Fields specifies the attributes of a PostPolicyV4 request. // When Fields is non-nil, its attributes must match those that will // passed into field Conditions. // Optional. Fields *PolicyV4Fields // The conditions that the uploaded file will be expected to conform to. // When used, the failure of an upload to satisfy a condition will result in // a 4XX status code, back with the message describing the problem. // Optional. Conditions []PostPolicyV4Condition } // PolicyV4Fields describes the attributes for a PostPolicyV4 request. type PolicyV4Fields struct { // ACL specifies the access control permissions for the object. // Optional. ACL string // CacheControl specifies the caching directives for the object. // Optional. CacheControl string // ContentType specifies the media type of the object. // Optional. ContentType string // ContentDisposition specifies how the file will be served back to requesters. // Optional. ContentDisposition string // ContentEncoding specifies the decompressive transcoding that the object. // This field is complementary to ContentType in that the file could be // compressed but ContentType specifies the file's original media type. // Optional. ContentEncoding string // Metadata specifies custom metadata for the object. // If any key doesn't begin with "x-goog-meta-", an error will be returned. // Optional. Metadata map[string]string // StatusCodeOnSuccess when set, specifies the status code that Cloud Storage // will serve back on successful upload of the object. // Optional. StatusCodeOnSuccess int // RedirectToURLOnSuccess when set, specifies the URL that Cloud Storage // will serve back on successful upload of the object. // Optional. RedirectToURLOnSuccess string } // PostPolicyV4 describes the URL and respective form fields for a generated PostPolicyV4 request. type PostPolicyV4 struct { // URL is the generated URL that the file upload will be made to. URL string // Fields specifies the generated key-values that the file uploader // must include in their multipart upload form. Fields map[string]string } // PostPolicyV4Condition describes the constraints that the subsequent // object upload's multipart form fields will be expected to conform to. type PostPolicyV4Condition interface { isEmpty() bool json.Marshaler } type startsWith struct { key, value string } func (sw *startsWith) MarshalJSON() ([]byte, error) { return json.Marshal([]string{"starts-with", sw.key, sw.value}) } func (sw *startsWith) isEmpty() bool { return sw.value == "" } // ConditionStartsWith checks that an attributes starts with value. // An empty value will cause this condition to be ignored. func ConditionStartsWith(key, value string) PostPolicyV4Condition { return &startsWith{key, value} } type contentLengthRangeCondition struct { start, end uint64 } func (clr *contentLengthRangeCondition) MarshalJSON() ([]byte, error) { return json.Marshal([]interface{}{"content-length-range", clr.start, clr.end}) } func (clr *contentLengthRangeCondition) isEmpty() bool { return clr.start == 0 && clr.end == 0 } type singleValueCondition struct { name, value string } func (svc *singleValueCondition) MarshalJSON() ([]byte, error) { return json.Marshal(map[string]string{svc.name: svc.value}) } func (svc *singleValueCondition) isEmpty() bool { return svc.value == "" } // ConditionContentLengthRange constraints the limits that the // multipart upload's range header will be expected to be within. func ConditionContentLengthRange(start, end uint64) PostPolicyV4Condition { return &contentLengthRangeCondition{start, end} } func conditionRedirectToURLOnSuccess(redirectURL string) PostPolicyV4Condition { return &singleValueCondition{"success_action_redirect", redirectURL} } func conditionStatusCodeOnSuccess(statusCode int) PostPolicyV4Condition { svc := &singleValueCondition{name: "success_action_status"} if statusCode > 0 { svc.value = fmt.Sprintf("%d", statusCode) } return svc } // GenerateSignedPostPolicyV4 generates a PostPolicyV4 value from bucket, object and opts. // The generated URL and fields will then allow an unauthenticated client to perform multipart uploads. func GenerateSignedPostPolicyV4(bucket, object string, opts *PostPolicyV4Options) (*PostPolicyV4, error) { if bucket == "" { return nil, errors.New("storage: bucket must be non-empty") } if object == "" { return nil, errors.New("storage: object must be non-empty") } now := utcNow() if err := validatePostPolicyV4Options(opts, now); err != nil { return nil, err } var signingFn func(hashedBytes []byte) ([]byte, error) switch { case opts.SignBytes != nil: signingFn = opts.SignBytes case len(opts.PrivateKey) != 0: parsedRSAPrivKey, err := parseKey(opts.PrivateKey) if err != nil { return nil, err } signingFn = func(hashedBytes []byte) ([]byte, error) { return rsa.SignPKCS1v15(rand.Reader, parsedRSAPrivKey, crypto.SHA256, hashedBytes) } default: return nil, errors.New("storage: exactly one of PrivateKey or SignedBytes must be set") } var descFields PolicyV4Fields if opts.Fields != nil { descFields = *opts.Fields } if err := validateMetadata(descFields.Metadata); err != nil { return nil, err } // Build the policy. conds := make([]PostPolicyV4Condition, len(opts.Conditions)) copy(conds, opts.Conditions) conds = append(conds, conditionRedirectToURLOnSuccess(descFields.RedirectToURLOnSuccess), conditionStatusCodeOnSuccess(descFields.StatusCodeOnSuccess), &singleValueCondition{"acl", descFields.ACL}, &singleValueCondition{"cache-control", descFields.CacheControl}, ) YYYYMMDD := now.Format(yearMonthDay) policyFields := map[string]string{ "key": object, "x-goog-date": now.Format(iso8601), "x-goog-credential": opts.GoogleAccessID + "/" + YYYYMMDD + "/auto/storage/goog4_request", "x-goog-algorithm": "GOOG4-RSA-SHA256", "success_action_redirect": descFields.RedirectToURLOnSuccess, "acl": descFields.ACL, } for key, value := range descFields.Metadata { conds = append(conds, &singleValueCondition{key, value}) policyFields[key] = value } // Following from the order expected by the conformance test cases, // hence manually inserting these fields in a specific order. conds = append(conds, &singleValueCondition{"bucket", bucket}, &singleValueCondition{"key", object}, &singleValueCondition{"x-goog-date", now.Format(iso8601)}, &singleValueCondition{ name: "x-goog-credential", value: opts.GoogleAccessID + "/" + YYYYMMDD + "/auto/storage/goog4_request", }, &singleValueCondition{"x-goog-algorithm", "GOOG4-RSA-SHA256"}, ) nonEmptyConds := make([]PostPolicyV4Condition, 0, len(opts.Conditions)) for _, cond := range conds { if cond == nil || !cond.isEmpty() { nonEmptyConds = append(nonEmptyConds, cond) } } condsAsJSON, err := json.Marshal(map[string]interface{}{ "conditions": nonEmptyConds, "expiration": opts.Expires.Format(time.RFC3339), }) if err != nil { return nil, fmt.Errorf("storage: PostPolicyV4 JSON serialization failed: %v", err) } b64Policy := base64.StdEncoding.EncodeToString(condsAsJSON) shaSum := sha256.Sum256([]byte(b64Policy)) signature, err := signingFn(shaSum[:]) if err != nil { return nil, err } policyFields["policy"] = b64Policy policyFields["x-goog-signature"] = fmt.Sprintf("%x", signature) // Construct the URL. scheme := "https" if opts.Insecure { scheme = "http" } path := opts.Style.path(bucket, "") + "/" u := &url.URL{ Path: path, RawPath: pathEncodeV4(path), Host: opts.Style.host(bucket), Scheme: scheme, } if descFields.StatusCodeOnSuccess > 0 { policyFields["success_action_status"] = fmt.Sprintf("%d", descFields.StatusCodeOnSuccess) } // Clear out fields with blanks values. for key, value := range policyFields { if value == "" { delete(policyFields, key) } } pp4 := &PostPolicyV4{ Fields: policyFields, URL: u.String(), } return pp4, nil } // validatePostPolicyV4Options checks that: // * GoogleAccessID is set // * either but not both PrivateKey and SignBytes are set or nil, but not both // * Expires, the deadline is not in the past // * if Style is not set, it'll use PathStyle func validatePostPolicyV4Options(opts *PostPolicyV4Options, now time.Time) error { if opts == nil || opts.GoogleAccessID == "" { return errors.New("storage: missing required GoogleAccessID") } if privBlank, signBlank := len(opts.PrivateKey) == 0, opts.SignBytes == nil; privBlank == signBlank { return errors.New("storage: exactly one of PrivateKey or SignedBytes must be set") } if opts.Expires.Before(now) { return errors.New("storage: expecting Expires to be in the future") } if opts.Style == nil { opts.Style = PathStyle() } return nil } // validateMetadata ensures that all keys passed in have a prefix of "x-goog-meta-", // otherwise it will return an error. func validateMetadata(hdrs map[string]string) (err error) { if len(hdrs) == 0 { return nil } badKeys := make([]string, 0, len(hdrs)) for key := range hdrs { if !strings.HasPrefix(key, "x-goog-meta-") { badKeys = append(badKeys, key) } } if len(badKeys) != 0 { err = errors.New("storage: expected metadata to begin with x-goog-meta-, got " + strings.Join(badKeys, ", ")) } return }