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>
120 lines
3.3 KiB
Go
120 lines
3.3 KiB
Go
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[:])
|
|
}
|