Files
seaweedfs/weed/s3api/auth_signature_v2.go
T
ad89f003a5 s3: end a catalog-vended session with the access key it was vended for (#11945)
* s3: end a catalog-vended session with the access key it was vended for

A table credential the Iceberg catalog vends is a self-contained STS session:
it stays valid until it expires (an hour by default) even after the access key
that loaded the table is deleted or deactivated, or its identity is removed
or disabled. An operator revoking a leaked key cannot cut off the table
credentials that key already obtained.

Sessions are stateless JWTs, so there is no revocation list to consult. This
change follows the same pattern as role sessions, which are already refused
once their role's ID no longer matches (checkSessionRoleBinding): a vended
session carries the access key it was vended for (src_ak, recorded by the
previous change), and validateSTSSessionToken checks it against the identity
table held in memory. A session of provider iceberg-catalog that carries a
source key is refused (InvalidAccessKeyId, as for an unknown key) once its
identity is gone or disabled, or the key is no longer one of that identity's
active, unexpired credentials.

Scope:
- Only catalog-vended sessions are checked; web-identity, AssumeRole and
  every other STS session are validated as before.
- The vendor binds a session to the caller's key only when the caller is a
  stored identity holding that key. A caller that signed with another STS
  session's key, an OIDC user, or a request that recorded no key gets an
  unbound session, as today; binding it would refuse every request made with
  it. A session minted before this change carries no key and is unbound.
- The check is one map lookup under the read lock and a scan of one
  identity's keys (about 11 ns, 0 allocations, with 1,000 identities).

Tests (s3api_iceberg_vended_session_test.go) mint real sessions through the
catalog vendor and validate them the way a data-path request is validated:
deactivating, expiring or deleting key A ends A's session while the session
vended through key B on the same identity keeps working; disabling or
deleting the identity ends it; callers that are not a stored identity holding
the key get unbound sessions that keep working; other STS sessions are not
checked. The first two tests and the third fail without this change.

* s3: keep the vended-session key binding when the key dies before vend

vendedSessionKeyLive gated the binding, so a key revoked between the
request's authentication and the vend skipped it and minted an unbound
session that stayed usable until it expired. Every catalog auth path
pairs the identity name in context with the key that authenticated, so
a stored-identity principal always means the request signed with that
identity's own key. Bind whenever the principal is a stored identity: a
session bound to a now-dead key is refused on its first request, which
is what the binding is for.

* s3: gate the vended-session key binding on the authenticated caller type

lookupByIdentityName(principal) named only that some identity exists, not
that the request signed with a stored key: an STS session or OIDC principal
that collides with an identity name got a session bound to its temporary
key, which vendedSessionKeyLive then refused on every request. Verifiers
now record the verified key as a stored credential, and the vendor binds
only when that marker is present — keeping the binding even when the key
was revoked between authentication and the vend.

* s3: enforce the vended key binding under a dedicated claim

Sessions minted before the binding existed carry the caller's key in the
audit-only SourceAccessKeyId — including temporary STS keys that can never
resolve in the identity table. Enforcing on that field would end live table
credentials at upgrade; the binding now travels under BoundAccessKeyId,
leaving legacy sessions unbound as before.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3: bind vended sessions to a credential fingerprint

The key id alone cannot identify a credential: an operator can delete a
key and re-create it under the same id with a different secret, reviving
sessions minted for the original. Verifiers now record a fingerprint of
the secret at authentication; the session dies unless it still matches.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3: keep recording the caller's key on every catalog session

SourceAccessKeyId was unconditional audit metadata before the binding
existed; gating it on the stored-credential marker dropped the record
for callers that authenticated another way. With the binding under its
own claim there is no ambiguity left, so the field records the caller's
key again regardless of how it authenticated.

* s3: key the vended-session credential fingerprint with the signing key

The bound-credential fingerprint travels inside the vended session
token. As a plain SHA-256 of the secret it gave any token holder an
offline guessing oracle for weak secrets, and CodeQL flags it twice.

Store the verified secret in the request marker instead and HMAC it
with the STS signing key at vend and at validation. The claim can no
longer be mined, and the per-request auth path stops hashing secrets.

---------

Co-authored-by: Javier de la Torre <jatorre@lakehousebox.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-23 03:58:06 -07:00

395 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)
s3_constants.RecordStoredAccessKeyId(ctx, cred.AccessKey, cred.SecretKey)
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)
s3_constants.RecordStoredAccessKeyId(r.Context(), cred.AccessKey, cred.SecretKey)
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)
s3_constants.RecordStoredAccessKeyId(r.Context(), cred.AccessKey, cred.SecretKey)
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)
}