| 1 | package main |
| 2 | |
| 3 | // π€ Minimal AWS Signature Version 4 signer β just enough to talk to S3. |
| 4 | // Hand-rolled on purpose (#550 Phase 1): home has two dependencies total and |
| 5 | // the s3Store needs ~6 operations, so pulling in aws-sdk-go-v2 would multiply |
| 6 | // the dependency surface for one HMAC chain. Pinned offline against the |
| 7 | // official AWS worked examples in sigv4_test.go; exercised for real by |
| 8 | // TestS3VaultStore. |
| 9 | |
| 10 | import ( |
| 11 | "crypto/hmac" |
| 12 | "crypto/sha256" |
| 13 | "encoding/hex" |
| 14 | "fmt" |
| 15 | "net/http" |
| 16 | "net/url" |
| 17 | "sort" |
| 18 | "strings" |
| 19 | "time" |
| 20 | ) |
| 21 | |
| 22 | // emptyPayloadHash is sha256("") β the x-amz-content-sha256 for bodyless |
| 23 | // requests (GET/HEAD/DELETE). |
| 24 | const emptyPayloadHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" |
| 25 | |
| 26 | // awsSignV4 computes the SigV4 signature for req and sets its Authorization |
| 27 | // header. The caller must already have set x-amz-date (matching t) and |
| 28 | // x-amz-content-sha256 (the hex payload hash β signed, so the body can't be |
| 29 | // swapped in flight). Signed headers are host plus every x-amz-* header plus |
| 30 | // range/content-type/content-md5 when present; conditional headers (If-Match |
| 31 | // etc.) ride unsigned, which S3 permits β only x-amz-* headers MUST be signed. |
| 32 | func awsSignV4(req *http.Request, accessKey, secretKey, region, service string, t time.Time) { |
| 33 | amzDate := t.UTC().Format("20060102T150405Z") |
| 34 | scope := t.UTC().Format("20060102") + "/" + region + "/" + service + "/aws4_request" |
| 35 | |
| 36 | host := req.Host |
| 37 | if host == "" { |
| 38 | host = req.URL.Host |
| 39 | } |
| 40 | type hdr struct{ name, value string } |
| 41 | signed := []hdr{{"host", host}} |
| 42 | for name, vals := range req.Header { |
| 43 | ln := strings.ToLower(name) |
| 44 | if strings.HasPrefix(ln, "x-amz-") || ln == "range" || ln == "content-type" || ln == "content-md5" { |
| 45 | signed = append(signed, hdr{ln, strings.TrimSpace(strings.Join(vals, ","))}) |
| 46 | } |
| 47 | } |
| 48 | sort.Slice(signed, func(i, j int) bool { return signed[i].name < signed[j].name }) |
| 49 | names := make([]string, len(signed)) |
| 50 | var canonHdrs strings.Builder |
| 51 | for i, h := range signed { |
| 52 | canonHdrs.WriteString(h.name) |
| 53 | canonHdrs.WriteByte(':') |
| 54 | canonHdrs.WriteString(h.value) |
| 55 | canonHdrs.WriteByte('\n') |
| 56 | names[i] = h.name |
| 57 | } |
| 58 | signedHeaders := strings.Join(names, ";") |
| 59 | |
| 60 | path := req.URL.EscapedPath() |
| 61 | if path == "" { |
| 62 | path = "/" |
| 63 | } |
| 64 | canonical := req.Method + "\n" + |
| 65 | path + "\n" + |
| 66 | canonicalQuery(req.URL.Query()) + "\n" + |
| 67 | canonHdrs.String() + "\n" + |
| 68 | signedHeaders + "\n" + |
| 69 | req.Header.Get("x-amz-content-sha256") |
| 70 | |
| 71 | stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + hexSHA256([]byte(canonical)) |
| 72 | |
| 73 | // The HMAC chain: secret β date β region β service β "aws4_request". |
| 74 | key := hmacSHA256([]byte("AWS4"+secretKey), t.UTC().Format("20060102")) |
| 75 | key = hmacSHA256(key, region) |
| 76 | key = hmacSHA256(key, service) |
| 77 | key = hmacSHA256(key, "aws4_request") |
| 78 | sig := hex.EncodeToString(hmacSHA256(key, stringToSign)) |
| 79 | |
| 80 | req.Header.Set("Authorization", |
| 81 | "AWS4-HMAC-SHA256 Credential="+accessKey+"/"+scope+ |
| 82 | ",SignedHeaders="+signedHeaders+",Signature="+sig) |
| 83 | } |
| 84 | |
| 85 | // canonicalQuery renders url.Values in SigV4 canonical form: keys sorted, |
| 86 | // values sorted within a key, strict RFC 3986 escaping (%20 for space, never |
| 87 | // '+'), and flag parameters ("?versions") rendered with a trailing '='. |
| 88 | func canonicalQuery(q url.Values) string { |
| 89 | keys := make([]string, 0, len(q)) |
| 90 | for k := range q { |
| 91 | keys = append(keys, k) |
| 92 | } |
| 93 | sort.Strings(keys) |
| 94 | parts := make([]string, 0, len(q)) |
| 95 | for _, k := range keys { |
| 96 | vs := append([]string(nil), q[k]...) |
| 97 | sort.Strings(vs) |
| 98 | for _, v := range vs { |
| 99 | parts = append(parts, awsURIEncode(k, true)+"="+awsURIEncode(v, true)) |
| 100 | } |
| 101 | } |
| 102 | return strings.Join(parts, "&") |
| 103 | } |
| 104 | |
| 105 | // awsURIEncode percent-encodes per the SigV4 rules: unreserved characters |
| 106 | // (A-Za-z0-9, '-', '.', '_', '~') pass through; '/' passes through only in |
| 107 | // paths (encodeSlash=false); everything else β including '+' and space β |
| 108 | // becomes %XX with uppercase hex. |
| 109 | func awsURIEncode(s string, encodeSlash bool) string { |
| 110 | var b strings.Builder |
| 111 | for i := 0; i < len(s); i++ { |
| 112 | c := s[i] |
| 113 | switch { |
| 114 | case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9', |
| 115 | c == '-', c == '.', c == '_', c == '~': |
| 116 | b.WriteByte(c) |
| 117 | case c == '/' && !encodeSlash: |
| 118 | b.WriteByte(c) |
| 119 | default: |
| 120 | fmt.Fprintf(&b, "%%%02X", c) |
| 121 | } |
| 122 | } |
| 123 | return b.String() |
| 124 | } |
| 125 | |
| 126 | func hexSHA256(b []byte) string { |
| 127 | h := sha256.Sum256(b) |
| 128 | return hex.EncodeToString(h[:]) |
| 129 | } |
| 130 | |
| 131 | func hmacSHA256(key []byte, msg string) []byte { |
| 132 | m := hmac.New(sha256.New, key) |
| 133 | m.Write([]byte(msg)) |
| 134 | return m.Sum(nil) |
| 135 | } |
| 136 | |