feat: Minstrel can put and list objects in a path-style S3 bucket, signed with SigV4 and no SDK (#6771)
release / govulncheck (push) Successful in 14s
release / go (push) Failing after 1m7s
release / web (push) Successful in 1m14s
release / android (push) Successful in 4m37s
release / Build signed APK (push) Successful in 4m47s
release / integration (push) Failing after 5m3s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped

Bucket.Put stores a body under a key, signed over its SHA-256; Bucket.List
pages through ListObjectsV2. A backup only adds and lists, so an
append-only key is enough. Keys are encoded once, exactly as signed, so
music paths with spaces, plus signs and accents reach the bucket intact.
A refusal names S3's code in words ("the secret key is wrong").

The signer is pinned to AWS's published GET Object, PUT Object and
ListObjects examples.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 13:51:02 -05:00
co-authored by Claude Opus 5.5
parent 8be69b752a
commit b6ae205862
3 changed files with 474 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
package backup
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"net/url"
"slices"
"strings"
"time"
)
// emptySHA256 is the payload hash of a request with no body.
const emptySHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
// sign adds AWS Signature Version 4 headers for S3 to req: X-Amz-Date,
// X-Amz-Content-Sha256 and Authorization. Every header already on the
// request is signed, along with the host.
func sign(req *http.Request, payloadSHA256, accessKey, secretKey, region string, at time.Time) {
at = at.UTC()
stamp := at.Format("20060102T150405Z")
date := at.Format("20060102")
req.Header.Set("X-Amz-Date", stamp)
req.Header.Set("X-Amz-Content-Sha256", payloadSHA256)
host := req.Host
if host == "" {
host = req.URL.Host
}
headers := map[string]string{"host": host}
for name, values := range req.Header {
headers[strings.ToLower(name)] = strings.TrimSpace(strings.Join(values, ","))
}
names := make([]string, 0, len(headers))
for name := range headers {
names = append(names, name)
}
slices.Sort(names)
var canonHeaders strings.Builder
for _, name := range names {
canonHeaders.WriteString(name + ":" + headers[name] + "\n")
}
signed := strings.Join(names, ";")
canonical := strings.Join([]string{
req.Method,
canonicalPath(req.URL),
canonicalQuery(req.URL.Query()),
canonHeaders.String(),
signed,
payloadSHA256,
}, "\n")
scope := date + "/" + region + "/s3/aws4_request"
toSign := "AWS4-HMAC-SHA256\n" + stamp + "\n" + scope + "\n" + hexSHA256([]byte(canonical))
key := hmacSHA256([]byte("AWS4"+secretKey), date)
key = hmacSHA256(key, region)
key = hmacSHA256(key, "s3")
key = hmacSHA256(key, "aws4_request")
signature := hex.EncodeToString(hmacSHA256(key, toSign))
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+accessKey+"/"+scope+
", SignedHeaders="+signed+", Signature="+signature)
}
// canonicalPath is the request path with each segment URI-encoded once.
func canonicalPath(u *url.URL) string {
p := u.EscapedPath()
if p == "" {
return "/"
}
if unescaped, err := url.PathUnescape(p); err == nil {
p = unescaped
}
segments := strings.Split(p, "/")
for i, s := range segments {
segments[i] = uriEncode(s)
}
return strings.Join(segments, "/")
}
// canonicalQuery is the query sorted by name, names and values URI-encoded.
func canonicalQuery(q url.Values) string {
pairs := make([]string, 0, len(q))
for name, values := range q {
for _, v := range values {
pairs = append(pairs, uriEncode(name)+"="+uriEncode(v))
}
}
slices.Sort(pairs)
return strings.Join(pairs, "&")
}
// uriEncode percent-encodes everything but the unreserved characters, as
// SigV4 requires; url.QueryEscape differs on space and '~'.
func uriEncode(s string) string {
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == '.' || c == '~' {
b.WriteByte(c)
continue
}
b.WriteString("%" + strings.ToUpper(hex.EncodeToString([]byte{c})))
}
return b.String()
}
func hmacSHA256(key []byte, msg string) []byte {
m := hmac.New(sha256.New, key)
m.Write([]byte(msg))
return m.Sum(nil)
}
func hexSHA256(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}