Files
seaweedfs/weed/s3api/auth_signature_v2.go
T
74da88fc29 s3, iceberg: record the access key a request was signed with (#11901)
* s3, iceberg: record the access key a request was signed with

Several access keys can belong to one identity, but nothing downstream of
authentication says which one signed a request: the audit log carries the
identity name only, and every table credential the Iceberg catalog vends for
that identity gets the same session name. When an identity is shared (one key
per person or per service), the log cannot tell the holders apart.

The verifier that matched a credential now records that credential's access
key id in the request's identity holder (verifyV4Signature for header,
presigned, streaming and STS requests; both SigV2 verifiers). Nothing re-reads
the request for a key, so a second, unverified key elsewhere in the request
(a junk X-Amz-Credential on a SigV2 request, a junk SigV4 header on a SigV2
presigned URL) is never recorded.

- s3err.AccessLog gains access_key_id (omitempty): a static key's id, or an
  STS session's temporary key id. Every other field is unchanged.
- The Iceberg and Lance catalogs install the holder before authenticating a
  SigV4 request; a Bearer token records the key it was minted from (the token
  is verified with a key derived from it); a Lance x-api-key records its key.
- VendTableCredentials ends the session name with "-k" and the first 12 hex
  digits of SHA-256 of the key id (the table part gives way within the
  64-character limit; the tag is never cut), and carries the key id in the
  session token (src_ak, SessionInfo.SourceAccessKeyId).

Tests: auth_access_key_attribution_test.go and auth_access_key_shapes_test.go
(two keys on one identity, the deny path, SigV2 and presigned SigV2 with a
junk key elsewhere, streaming seed, STS session, failed signatures record
nothing), s3api_iceberg_session_name_test.go, iceberg and lance Auth tests,
sts source_access_key_test.go.

* s3: record the access key of a verified POST-policy form and of a retargeted signature

Two verified signatures left access_key_id empty in the audit log:

- A browser POST upload is verified in PostPolicyBucketHandler from its form
  fields by doesPolicySignatureV2Match / doesPolicySignatureV4Match, which did
  not record the key. Both now take the request context and record the
  credential's key once its signature verifies; a form that fails
  verification records nothing.
- verifyV4Signature refuses a signature that verified for one virtual-hosted
  bucket but was routed to another. The key was recorded only after that
  check, so the refusal carried none. It is now recorded as soon as a host
  candidate verifies.

Test: TestPostPolicyUploadRecordsTheSigningKey (SigV4 and SigV2 forms, and a
form with a wrong signature) and TestSignedVirtualHostRetargetRecordsTheSigningKey;
both fail before this change.

* s3: test VendTableCredentials end to end for the caller's access key

TestVendTableCredentialsRecordsTheCallersKey mints sessions through
VendTableCredentials with a configured STS service, the key recorded the way
the catalog's authentication records it, then validates each session token:
SourceAccessKeyId and the session name's key tag match the key, and a caller
without a key gets an untagged session. It fails if the vendor stops passing
either field.

---------

Co-authored-by: Javier de la Torre <jatorre@lakehousebox.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 20:33:08 -07:00

392 lines
12 KiB
Go

/*
* The following code tries to reverse engineer the Amazon S3 APIs,
* and is mostly copied from minio implementation.
*/
// 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 s3api
import (
"context"
"crypto/hmac"
"crypto/sha1"
"crypto/subtle"
"encoding/base64"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// Whitelist resource list that will be used in query string for signature-V2 calculation.
// The list should be alphabetically sorted
var resourceList = []string{
"acl",
"delete",
"lifecycle",
"location",
"logging",
"notification",
"partNumber",
"policy",
"requestPayment",
"response-cache-control",
"response-content-disposition",
"response-content-encoding",
"response-content-language",
"response-content-type",
"response-expires",
"torrent",
"uploadId",
"uploads",
"versionId",
"versioning",
"versions",
"website",
}
// Verify if request has AWS Signature Version '2'.
func (iam *IdentityAccessManagement) isReqAuthenticatedV2(r *http.Request) (*Identity, s3err.ErrorCode) {
if isRequestSignatureV2(r) {
return iam.doesSignV2Match(r)
}
return iam.doesPresignV2SignatureMatch(r)
}
func (iam *IdentityAccessManagement) doesPolicySignatureV2Match(ctx context.Context, formValues http.Header) (*Identity, s3err.ErrorCode) {
accessKey := formValues.Get("AWSAccessKeyId")
if accessKey == "" {
return nil, s3err.ErrMissingFields
}
identity, cred, found := iam.lookupByAccessKey(accessKey)
if !found {
// Log detailed error information for InvalidAccessKeyId (V2 POST)
iam.m.RLock()
availableKeyCount := len(iam.accessKeyIdent)
iam.m.RUnlock()
glog.Warningf("InvalidAccessKeyId (V2 POST): attempted key '%s' not found. Available keys: %d, Auth enabled: %v",
accessKey, availableKeyCount, iam.isAuthEnabled)
return nil, s3err.ErrInvalidAccessKeyID
}
// Check service account expiration
if cred.isCredentialExpired() {
glog.V(2).Infof("Service account credential %s has expired (expiration: %d, now: %d)",
accessKey, cred.Expiration, time.Now().Unix())
return nil, s3err.ErrAccessDenied
}
policy := formValues.Get("Policy")
if policy == "" {
return nil, s3err.ErrMissingFields
}
signature := formValues.Get("Signature")
if signature == "" {
return nil, s3err.ErrMissingFields
}
if !compareSignatureV2(signature, calculateSignatureV2(policy, cred.SecretKey)) {
return nil, s3err.ErrSignatureDoesNotMatch
}
s3_constants.RecordAccessKeyId(ctx, cred.AccessKey)
return identity, s3err.ErrNone
}
// doesSignV2Match - Verify authorization header with calculated header in accordance with
// - http://docs.aws.amazon.com/AmazonS3/latest/dev/auth-request-sig-v2.html
//
// returns ErrNone if the signature matches.
func (iam *IdentityAccessManagement) doesSignV2Match(r *http.Request) (*Identity, s3err.ErrorCode) {
v2Auth := r.Header.Get("Authorization")
accessKey, errCode := validateV2AuthHeader(v2Auth)
if errCode != s3err.ErrNone {
return nil, errCode
}
identity, cred, found := iam.lookupByAccessKey(accessKey)
if !found {
// Log detailed error information for InvalidAccessKeyId (V2 signed)
iam.m.RLock()
availableKeyCount := len(iam.accessKeyIdent)
iam.m.RUnlock()
glog.Warningf("InvalidAccessKeyId (V2 signed): attempted key '%s' not found. Available keys: %d, Auth enabled: %v",
accessKey, availableKeyCount, iam.isAuthEnabled)
return nil, s3err.ErrInvalidAccessKeyID
}
// Check service account expiration
if cred.isCredentialExpired() {
glog.V(2).Infof("Service account credential %s has expired (expiration: %d, now: %d)",
accessKey, cred.Expiration, time.Now().Unix())
return nil, s3err.ErrAccessDenied
}
expectedAuth := signatureV2(cred, r.Method, r.URL.EscapedPath(), r.URL.Query().Encode(), r.Header)
// Extract signatures from both auth headers
v2Signature := ""
expectedV2Signature := ""
// Extract signature from request header
if idx := strings.LastIndex(v2Auth, ":"); idx != -1 {
v2Signature = v2Auth[idx+1:]
}
// Extract signature from expected auth header
// This should always succeed if signatureV2 is working correctly
if idx := strings.LastIndex(expectedAuth, ":"); idx != -1 {
expectedV2Signature = expectedAuth[idx+1:]
} else {
// This indicates a bug in signatureV2 function
return nil, s3err.ErrSignatureDoesNotMatch
}
if !compareSignatureV2(v2Signature, expectedV2Signature) {
return nil, s3err.ErrSignatureDoesNotMatch
}
s3_constants.RecordAccessKeyId(r.Context(), cred.AccessKey)
return identity, s3err.ErrNone
}
// doesPresignV2SignatureMatch - Verify query headers with calculated header in accordance with
// - http://docs.aws.amazon.com/AmazonS3/latest/dev/auth-request-sig-v2.html
//
// returns ErrNone if the signature matches.
func (iam *IdentityAccessManagement) doesPresignV2SignatureMatch(r *http.Request) (*Identity, s3err.ErrorCode) {
query := r.URL.Query()
expires := query.Get("Expires")
if expires == "" {
return nil, s3err.ErrMissingFields
}
expireTimestamp, err := strconv.ParseInt(expires, 10, 64)
if err != nil {
return nil, s3err.ErrMalformedExpires
}
if time.Unix(expireTimestamp, 0).Before(time.Now().UTC()) {
return nil, s3err.ErrExpiredPresignRequest
}
accessKey := query.Get("AWSAccessKeyId")
if accessKey == "" {
glog.Warningf("InvalidAccessKeyId (V2 presigned): empty access key in request")
return nil, s3err.ErrInvalidAccessKeyID
}
signature := query.Get("Signature")
if signature == "" {
return nil, s3err.ErrMissingFields
}
identity, cred, found := iam.lookupByAccessKey(accessKey)
if !found {
// Log detailed error information for InvalidAccessKeyId (V2 presigned)
iam.m.RLock()
availableKeyCount := len(iam.accessKeyIdent)
iam.m.RUnlock()
glog.Warningf("InvalidAccessKeyId (V2 presigned): attempted key '%s' not found. Available keys: %d, Auth enabled: %v",
accessKey, availableKeyCount, iam.isAuthEnabled)
return nil, s3err.ErrInvalidAccessKeyID
}
// Check service account expiration
if cred.isCredentialExpired() {
glog.V(2).Infof("Service account credential %s has expired (expiration: %d, now: %d)",
accessKey, cred.Expiration, time.Now().Unix())
return nil, s3err.ErrAccessDenied
}
expectedSignature := preSignatureV2(cred, r.Method, r.URL.EscapedPath(), r.URL.Query().Encode(), r.Header, expires)
if !compareSignatureV2(signature, expectedSignature) {
return nil, s3err.ErrSignatureDoesNotMatch
}
s3_constants.RecordAccessKeyId(r.Context(), cred.AccessKey)
return identity, s3err.ErrNone
}
// validateV2AuthHeader validates AWS Signature Version '2' authentication header.
func validateV2AuthHeader(v2Auth string) (accessKey string, errCode s3err.ErrorCode) {
if v2Auth == "" {
return "", s3err.ErrAuthHeaderEmpty
}
// Signature V2 authorization header format:
// Authorization: AWS AKIAIOSFODNN7EXAMPLE:frJIUN8DYpKDtOLCwo//yllqDzg=
const signV2AlgorithmPrefix = signV2Algorithm + " "
if !strings.HasPrefix(v2Auth, signV2AlgorithmPrefix) {
return "", s3err.ErrSignatureVersionNotSupported
}
// Strip off the Algorithm prefix.
v2Auth = v2Auth[len(signV2Algorithm)+1:]
authFields := strings.Split(v2Auth, ":")
if len(authFields) != 2 {
return "", s3err.ErrMissingFields
}
// The first field is Access Key ID.
if authFields[0] == "" {
return "", s3err.ErrInvalidAccessKeyID
}
// The second field is signature.
if authFields[1] == "" {
return "", s3err.ErrMissingFields
}
return authFields[0], s3err.ErrNone
}
// signatureV2 - calculates signature version 2 for request.
func signatureV2(cred *Credential, method string, encodedResource string, encodedQuery string, headers http.Header) string {
stringToSign := getStringToSignV2(method, encodedResource, encodedQuery, headers, "")
signature := calculateSignatureV2(stringToSign, cred.SecretKey)
return signV2Algorithm + " " + cred.AccessKey + ":" + signature
}
// getStringToSignV2 - string to sign in accordance with
// - http://docs.aws.amazon.com/AmazonS3/latest/dev/auth-request-sig-v2.html
func getStringToSignV2(method string, encodedResource, encodedQuery string, headers http.Header, expires string) string {
canonicalHeaders := canonicalizedAmzHeadersV2(headers)
if len(canonicalHeaders) > 0 {
canonicalHeaders += "\n"
}
// From the Amazon docs:
//
// StringToSign = HTTP-Verb + "\n" +
// Content-MD5 + "\n" +
// Content-Type + "\n" +
// Date + "\n" +
// CanonicalizedAmzHeaders +
// CanonicalizedResource;
stringToSign := method + "\n"
stringToSign += headers.Get("Content-Md5") + "\n"
stringToSign += headers.Get("Content-Type") + "\n"
if expires != "" {
stringToSign += expires + "\n"
} else {
stringToSign += headers.Get("Date") + "\n"
if v := headers.Get("x-amz-date"); v != "" {
stringToSign = strings.Replace(stringToSign, headers.Get("Date")+"\n", "\n", -1)
}
}
stringToSign += canonicalHeaders
stringToSign += canonicalizedResourceV2(encodedResource, encodedQuery)
return stringToSign
}
// canonicalizedResourceV2 - canonicalize the resource string for signature V2.
func canonicalizedResourceV2(encodedResource, encodedQuery string) string {
queries := strings.Split(encodedQuery, "&")
keyval := make(map[string]string)
for _, query := range queries {
key := query
val := ""
index := strings.Index(query, "=")
if index != -1 {
key = query[:index]
val = query[index+1:]
}
keyval[key] = val
}
var canonicalQueries []string
for _, resource := range resourceList {
if val, ok := keyval[resource]; ok {
if val == "" {
canonicalQueries = append(canonicalQueries, resource)
continue
}
canonicalQueries = append(canonicalQueries, resource+"="+val)
}
}
// The queries will be already sorted as resourceList is sorted.
if len(canonicalQueries) == 0 {
return encodedResource
}
// If queries are present then the canonicalized resource is set to encodedResource + "?" + strings.Join(canonicalQueries, "&")
return encodedResource + "?" + strings.Join(canonicalQueries, "&")
}
// canonicalizedAmzHeadersV2 - canonicalize the x-amz-* headers for signature V2.
func canonicalizedAmzHeadersV2(headers http.Header) string {
var keys []string
keyval := make(map[string]string)
for key := range headers {
lkey := strings.ToLower(key)
if !strings.HasPrefix(lkey, "x-amz-") {
continue
}
keys = append(keys, lkey)
keyval[lkey] = strings.Join(headers[key], ",")
}
sort.Strings(keys)
var canonicalHeaders []string
for _, key := range keys {
canonicalHeaders = append(canonicalHeaders, key+":"+keyval[key])
}
return strings.Join(canonicalHeaders, "\n")
}
// calculateSignatureV2 - calculates signature version 2.
func calculateSignatureV2(stringToSign string, secret string) string {
hm := hmac.New(sha1.New, []byte(secret))
hm.Write([]byte(stringToSign))
return base64.StdEncoding.EncodeToString(hm.Sum(nil))
}
// compareSignatureV2 returns true if and only if both signatures
// are equal. The signatures are expected to be base64 encoded strings
// according to the AWS S3 signature V2 spec.
func compareSignatureV2(sig1, sig2 string) bool {
// Decode signature string to binary byte-sequence representation is required
// as Base64 encoding of a value is not unique:
// For example "aGVsbG8=" and "aGVsbG8=\r" will result in the same byte slice.
signature1, err := base64.StdEncoding.DecodeString(sig1)
if err != nil {
return false
}
signature2, err := base64.StdEncoding.DecodeString(sig2)
if err != nil {
return false
}
return subtle.ConstantTimeCompare(signature1, signature2) == 1
}
// Return signature-v2 for the presigned request.
func preSignatureV2(cred *Credential, method string, encodedResource string, encodedQuery string, headers http.Header, expires string) string {
stringToSign := getStringToSignV2(method, encodedResource, encodedQuery, headers, expires)
return calculateSignatureV2(stringToSign, cred.SecretKey)
}