magpie/s3auth.go
Maxfield Luke ddbd19f153 Initial commit on Forgejo
Fresh repository history for elektrine/magpie hosted at
https://git.elektrine.com/elektrine/magpie.
2026-07-29 04:56:34 -04:00

312 lines
8.2 KiB
Go

package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"net/http"
"net/url"
"sort"
"strings"
"time"
)
const awsAlgorithm = "AWS4-HMAC-SHA256"
func (s *Server) authorizedS3(r *http.Request) bool {
if len(s.cfg.S3Keys) == 0 {
return s.authorized(r)
}
if r.URL.Query().Get("X-Amz-Algorithm") != "" {
return s.authorizedS3Presigned(r)
}
return s.authorizedS3Header(r)
}
func (s *Server) authorizedS3Presigned(r *http.Request) bool {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
return false
}
query := r.URL.Query()
if query.Get("X-Amz-Algorithm") != awsAlgorithm {
return false
}
if query.Get("X-Amz-Signature") == "" {
return false
}
credential := query.Get("X-Amz-Credential")
accessKey, scope, ok := splitCredential(credential)
key, ok := s.accessKey(accessKey)
if !ok || !key.can(permissionForMethod(r.Method)) {
return false
}
amzDate := query.Get("X-Amz-Date")
if !s.presignedTimeValid(amzDate, query.Get("X-Amz-Expires")) {
return false
}
if !s.scopeAllowed(scope, amzDate) {
return false
}
signedHeaders := query.Get("X-Amz-SignedHeaders")
canonicalRequest := canonicalRequest(r, signedHeaders, "UNSIGNED-PAYLOAD", true)
expected := signature(key.Secret, amzDate, scope, canonicalRequest)
return hmac.Equal([]byte(expected), []byte(query.Get("X-Amz-Signature")))
}
func (s *Server) authorizedS3Header(r *http.Request) bool {
return s.authorizedS3HeaderPermission(r, permissionForMethod(r.Method))
}
func (s *Server) authorizedS3HeaderPermission(r *http.Request, permission string) bool {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, awsAlgorithm+" ") {
return false
}
parts := parseAuthParams(strings.TrimPrefix(auth, awsAlgorithm+" "))
credential := parts["Credential"]
signedHeaders := parts["SignedHeaders"]
providedSignature := parts["Signature"]
accessKey, scope, ok := splitCredential(credential)
key, keyOK := s.accessKey(accessKey)
if !ok || !keyOK || !key.can(permission) || signedHeaders == "" || providedSignature == "" {
return false
}
amzDate := r.Header.Get("X-Amz-Date")
if !headerTimeValid(amzDate) {
return false
}
if !s.scopeAllowed(scope, amzDate) {
return false
}
payloadHash := r.Header.Get("X-Amz-Content-Sha256")
if payloadHash == "" {
payloadHash = "UNSIGNED-PAYLOAD"
}
if permission == "write" && r.Header.Get("X-Magpie-Replication") != "1" && payloadHash == "UNSIGNED-PAYLOAD" && !s.cfg.AllowUnsignedLocal {
return false
}
canonicalRequest := canonicalRequest(r, signedHeaders, payloadHash, false)
expected := signature(key.Secret, amzDate, scope, canonicalRequest)
return hmac.Equal([]byte(expected), []byte(providedSignature))
}
func (s *Server) accessKey(id string) (AccessKey, bool) {
for _, key := range s.cfg.S3Keys {
if subtle.ConstantTimeCompare([]byte(key.ID), []byte(id)) == 1 {
return key, true
}
}
return AccessKey{}, false
}
func (s *Server) requestAccessKey(r *http.Request) (AccessKey, bool) {
credential := r.URL.Query().Get("X-Amz-Credential")
if credential == "" {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, awsAlgorithm+" ") {
return AccessKey{}, false
}
credential = parseAuthParams(strings.TrimPrefix(auth, awsAlgorithm+" "))["Credential"]
}
accessKey, _, ok := splitCredential(credential)
if !ok {
return AccessKey{}, false
}
return s.accessKey(accessKey)
}
func (key AccessKey) can(permission string) bool {
return key.Permissions["admin"] || key.Permissions[permission]
}
func permissionForMethod(method string) string {
switch method {
case http.MethodGet, http.MethodHead:
return "read"
case http.MethodPut, http.MethodPost, http.MethodDelete:
return "write"
default:
return "admin"
}
}
func signature(secret, amzDate, scope, canonicalRequest string) string {
date := strings.Split(scope, "/")[0]
stringToSign := strings.Join([]string{
awsAlgorithm,
amzDate,
scope,
hexSHA256(canonicalRequest),
}, "\n")
key := signingKey(secret, date, scope)
sig := hmacSHA256(key, []byte(stringToSign))
return hex.EncodeToString(sig)
}
func signingKey(secret, date, scope string) []byte {
parts := strings.Split(scope, "/")
region := "auto"
service := "s3"
if len(parts) >= 3 {
region = parts[1]
service = parts[2]
}
kDate := hmacSHA256([]byte("AWS4"+secret), []byte(date))
kRegion := hmacSHA256(kDate, []byte(region))
kService := hmacSHA256(kRegion, []byte(service))
return hmacSHA256(kService, []byte("aws4_request"))
}
func (s *Server) scopeAllowed(scope string, amzDate string) bool {
parts := strings.Split(scope, "/")
if len(parts) != 4 {
return false
}
if len(amzDate) < 8 || parts[0] != amzDate[:8] {
return false
}
if parts[1] != s.cfg.S3Region || parts[2] != "s3" || parts[3] != "aws4_request" {
return false
}
return true
}
func canonicalRequest(r *http.Request, signedHeaders, payloadHash string, presigned bool) string {
return strings.Join([]string{
r.Method,
canonicalURI(r.URL.Path),
canonicalQuery(r.URL.Query(), presigned),
canonicalHeaders(r, signedHeaders),
signedHeaders,
payloadHash,
}, "\n")
}
func canonicalURI(path string) string {
if path == "" {
return "/"
}
segments := strings.Split(path, "/")
for i, segment := range segments {
segments[i] = awsEscape(segment)
}
return strings.Join(segments, "/")
}
func canonicalQuery(values url.Values, presigned bool) string {
type pair struct{ key, value string }
pairs := make([]pair, 0)
for key, vals := range values {
if presigned && key == "X-Amz-Signature" {
continue
}
for _, value := range vals {
pairs = append(pairs, pair{awsEscape(key), awsEscape(value)})
}
}
sort.Slice(pairs, func(i, j int) bool {
if pairs[i].key == pairs[j].key {
return pairs[i].value < pairs[j].value
}
return pairs[i].key < pairs[j].key
})
parts := make([]string, len(pairs))
for i, pair := range pairs {
parts[i] = pair.key + "=" + pair.value
}
return strings.Join(parts, "&")
}
func canonicalHeaders(r *http.Request, signedHeaders string) string {
headers := strings.Split(signedHeaders, ";")
var b strings.Builder
for _, header := range headers {
name := strings.ToLower(strings.TrimSpace(header))
value := ""
if name == "host" {
value = r.Host
} else {
value = strings.Join(r.Header.Values(name), ",")
}
b.WriteString(name)
b.WriteString(":")
b.WriteString(compressSpaces(value))
b.WriteString("\n")
}
return b.String()
}
func splitCredential(credential string) (string, string, bool) {
parts := strings.SplitN(credential, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", false
}
scopeParts := strings.Split(parts[1], "/")
if len(scopeParts) != 4 || scopeParts[3] != "aws4_request" {
return "", "", false
}
return parts[0], parts[1], true
}
func parseAuthParams(value string) map[string]string {
params := map[string]string{}
for _, part := range strings.Split(value, ",") {
keyValue := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(keyValue) == 2 {
params[keyValue[0]] = keyValue[1]
}
}
return params
}
func (s *Server) presignedTimeValid(amzDate, expiresRaw string) bool {
created, err := time.Parse("20060102T150405Z", amzDate)
if err != nil {
return false
}
expires, err := time.ParseDuration(expiresRaw + "s")
if err != nil || expires <= 0 || expires > s.cfg.PresignedMaxExpiry {
return false
}
now := time.Now().UTC()
return now.After(created.Add(-15*time.Minute)) && now.Before(created.Add(expires))
}
func headerTimeValid(amzDate string) bool {
created, err := time.Parse("20060102T150405Z", amzDate)
if err != nil {
return false
}
now := time.Now().UTC()
return now.After(created.Add(-15*time.Minute)) && now.Before(created.Add(15*time.Minute))
}
func awsEscape(value string) string {
escaped := url.QueryEscape(value)
escaped = strings.ReplaceAll(escaped, "+", "%20")
escaped = strings.ReplaceAll(escaped, "%7E", "~")
return escaped
}
func compressSpaces(value string) string {
return strings.Join(strings.Fields(strings.TrimSpace(value)), " ")
}
func hexSHA256(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func hmacSHA256(key []byte, value []byte) []byte {
mac := hmac.New(sha256.New, key)
_, _ = mac.Write(value)
return mac.Sum(nil)
}