mirror of
https://github.com/versity/versitygw.git
synced 2026-08-18 05:06:27 +00:00
Implements the `AssumeRoleWithWebIdentity` and `GetCallerIdentity` STS actions, letting callers exchange an external OIDC token for temporary credentials scoped to an IAM role. Token handling covers JWT claim parsing, issuer/audience resolution (including `azp` override semantics), JWKS fetching and caching with `singleflight`-deduplicated refresh, and rate-limited forced refresh on unrecognized `kid` values. OIDC provider thumbprint fetching now performs a real TLS handshake verified against the system trust store and the provider hostname (previously `InsecureSkipVerify`), since the observed certificate is persisted as a long-lived trust anchor rather than used once and discarded; all discovery-document and JWKS fetches go through an SSRF-safe HTTP client with bounded redirects and response size.
Adds policy `Condition` block evaluation, supporting `String`, `Numeric`, `Date`, `Bool`, `BinaryEquals`, and `IpAddress` operators along with their `IfExists`/`Not` variants and `ForAllValues`/`ForAnyValues` set qualifiers, plus policy variable substitution (e.g. `${aws:username}`) in supported operators. Adds identity-based inline policy evaluation and a new IAM authorization middleware that authorizes each request against action, resource, and condition context together, applying the session-policy-intersects-role-policy semantics for assumed-role sessions.
Adds a new debug logger `--log-level` flag (`silent`/`debug`/`unsafe`), along with a tree-based XML masker that redacts secrets and tokens at the property level in logged request/response bodies instead of skipping the whole body. The old `--debug/VGW_DEBUG` flag is kept as a deprecated alias for `--log-level=debug`, printing a console warning that points users at `--log-level` for finer-grained control.
Fixes a Vault storage bug where CAS (check-and-set) writes always read the current document version as 0 because `kvVersion` asserted metadata as `float64` while the Vault client actually returns `json.Number`, causing every write past the first to be rejected as a concurrent modification. Also adds a constant-time `SecureCompare` for signature/token comparisons in sigv4 auth.
Adds an integration test suite (`iam_access_control.go`) covering IAM access control across user, role, and session identities.
237 lines
6.3 KiB
Go
237 lines
6.3 KiB
Go
// Copyright 2026 Versity Software
|
|
// This file is 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 sigv4auth
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
)
|
|
|
|
const (
|
|
AlgorithmHMACSHA256 = "AWS4-HMAC-SHA256"
|
|
Terminal = "aws4_request"
|
|
ServiceS3 = "s3"
|
|
ServiceIAM = "iam"
|
|
ServiceSTS = "sts"
|
|
|
|
ISO8601Format = "20060102T150405Z"
|
|
YYYYMMDD = "20060102"
|
|
|
|
// HeaderSecurityToken is the header a temporary credential's
|
|
// SessionToken is presented in, matching AWS's X-Amz-Security-Token.
|
|
HeaderSecurityToken = "X-Amz-Security-Token"
|
|
)
|
|
|
|
type ParseErrorKind string
|
|
|
|
const (
|
|
ErrInvalidAuthorizationHeader ParseErrorKind = "invalid_authorization_header"
|
|
ErrUnsupportedAuthorizationVersion ParseErrorKind = "unsupported_authorization_version"
|
|
ErrInvalidAuthorizationType ParseErrorKind = "invalid_authorization_type"
|
|
ErrMissingComponents ParseErrorKind = "missing_components"
|
|
ErrMissingCredential ParseErrorKind = "missing_credential"
|
|
ErrMissingSignedHeaders ParseErrorKind = "missing_signed_headers"
|
|
ErrMissingSignature ParseErrorKind = "missing_signature"
|
|
ErrMalformedComponent ParseErrorKind = "malformed_component"
|
|
ErrMalformedCredential ParseErrorKind = "malformed_credential"
|
|
ErrIncorrectService ParseErrorKind = "incorrect_service"
|
|
ErrIncorrectTerminal ParseErrorKind = "incorrect_terminal"
|
|
ErrInvalidDateFormat ParseErrorKind = "invalid_date_format"
|
|
)
|
|
|
|
type ParseError struct {
|
|
Kind ParseErrorKind
|
|
Input string
|
|
Value string
|
|
Expected string
|
|
Actual string
|
|
}
|
|
|
|
func (e *ParseError) Error() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
switch e.Kind {
|
|
case ErrIncorrectService, ErrIncorrectTerminal:
|
|
return fmt.Sprintf("sigv4 %s: expected %q, got %q", e.Kind, e.Expected, e.Actual)
|
|
case ErrInvalidAuthorizationType, ErrMalformedComponent, ErrInvalidDateFormat:
|
|
return fmt.Sprintf("sigv4 %s: %q", e.Kind, e.Value)
|
|
default:
|
|
return string(e.Kind)
|
|
}
|
|
}
|
|
|
|
// AuthData is the parsed authorization data from an AWS SigV4 Authorization header.
|
|
type AuthData struct {
|
|
Algorithm string
|
|
Access string
|
|
Region string
|
|
Service string
|
|
SignedHeaders string
|
|
Signature string
|
|
Date string
|
|
}
|
|
|
|
type CredentialsScope struct {
|
|
Access string
|
|
Date string
|
|
Region string
|
|
Service string
|
|
}
|
|
|
|
// HexBytes returns the hex byte representation used by AWS-style diagnostic
|
|
// signature mismatch errors.
|
|
func HexBytes(s string) string {
|
|
b := []byte(s)
|
|
|
|
parts := make([]string, len(b))
|
|
for i, v := range b {
|
|
parts[i] = fmt.Sprintf("%02x", v)
|
|
}
|
|
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func PayloadSHA256Hex(payload []byte) string {
|
|
hashedPayload := sha256.Sum256(payload)
|
|
return hex.EncodeToString(hashedPayload[:])
|
|
}
|
|
|
|
// ParseAuthorization parses and validates an AWS SigV4 Authorization header.
|
|
// The credential scope service must match expectedService.
|
|
func ParseAuthorization(authorization, expectedService string) (AuthData, error) {
|
|
a := AuthData{}
|
|
|
|
authParts := strings.SplitN(authorization, " ", 2)
|
|
for i, el := range authParts {
|
|
if strings.Contains(el, " ") {
|
|
authParts[i] = removeSpace(el)
|
|
}
|
|
}
|
|
|
|
if len(authParts) < 2 {
|
|
return a, &ParseError{Kind: ErrInvalidAuthorizationHeader, Input: authorization}
|
|
}
|
|
|
|
algo := authParts[0]
|
|
if algo == "AWS" {
|
|
return a, &ParseError{Kind: ErrUnsupportedAuthorizationVersion, Value: algo}
|
|
}
|
|
if algo != AlgorithmHMACSHA256 {
|
|
return a, &ParseError{Kind: ErrInvalidAuthorizationType, Value: algo}
|
|
}
|
|
|
|
kvPairs := strings.Split(authParts[1], ",")
|
|
if len(kvPairs) != 3 {
|
|
return a, &ParseError{Kind: ErrMissingComponents, Input: authorization}
|
|
}
|
|
|
|
var access, region, service, signedHeaders, signature, date string
|
|
for i, kv := range kvPairs {
|
|
keyValue := strings.Split(kv, "=")
|
|
if len(keyValue) != 2 {
|
|
return a, &ParseError{Kind: ErrMalformedComponent, Value: kv}
|
|
}
|
|
key, value := keyValue[0], keyValue[1]
|
|
switch i {
|
|
case 0:
|
|
if key != "Credential" {
|
|
return a, &ParseError{Kind: ErrMissingCredential}
|
|
}
|
|
case 1:
|
|
if key != "SignedHeaders" {
|
|
return a, &ParseError{Kind: ErrMissingSignedHeaders}
|
|
}
|
|
case 2:
|
|
if key != "Signature" {
|
|
return a, &ParseError{Kind: ErrMissingSignature}
|
|
}
|
|
}
|
|
|
|
switch key {
|
|
case "Credential":
|
|
creds, err := ParseCredentials(value, expectedService)
|
|
if err != nil {
|
|
return a, err
|
|
}
|
|
access = creds.Access
|
|
date = creds.Date
|
|
region = creds.Region
|
|
service = creds.Service
|
|
case "SignedHeaders":
|
|
signedHeaders = value
|
|
case "Signature":
|
|
signature = value
|
|
}
|
|
}
|
|
|
|
return AuthData{
|
|
Algorithm: algo,
|
|
Access: access,
|
|
Region: region,
|
|
Service: service,
|
|
SignedHeaders: signedHeaders,
|
|
Signature: signature,
|
|
Date: date,
|
|
}, nil
|
|
}
|
|
|
|
func ParseCredentials(input, expectedService string) (*CredentialsScope, error) {
|
|
creds := strings.Split(input, "/")
|
|
if len(creds) != 5 {
|
|
return nil, &ParseError{Kind: ErrMalformedCredential, Input: input}
|
|
}
|
|
if creds[3] != expectedService {
|
|
return nil, &ParseError{
|
|
Kind: ErrIncorrectService,
|
|
Input: input,
|
|
Expected: expectedService,
|
|
Actual: creds[3],
|
|
}
|
|
}
|
|
if creds[4] != Terminal {
|
|
return nil, &ParseError{
|
|
Kind: ErrIncorrectTerminal,
|
|
Input: input,
|
|
Expected: Terminal,
|
|
Actual: creds[4],
|
|
}
|
|
}
|
|
if _, err := time.Parse(YYYYMMDD, creds[1]); err != nil {
|
|
return nil, &ParseError{Kind: ErrInvalidDateFormat, Input: input, Value: creds[1]}
|
|
}
|
|
|
|
return &CredentialsScope{
|
|
Access: creds[0],
|
|
Date: creds[1],
|
|
Region: creds[2],
|
|
Service: creds[3],
|
|
}, nil
|
|
}
|
|
|
|
func removeSpace(str string) string {
|
|
var b strings.Builder
|
|
b.Grow(len(str))
|
|
for _, ch := range str {
|
|
if !unicode.IsSpace(ch) {
|
|
b.WriteRune(ch)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|