summaryrefslogtreecommitdiff
path: root/aws/request.scm
blob: 764ed43390292f43bc1ccbbe3f7af647d0461330 (about) (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
;;; guile-aws --- Scheme DSL for the AWS APIs
;;; Copyright © 2019, 2020 Ricardo Wurmus <rekado@elephly.net>
;;;
;;; Guile-AWS is free software: you can redistribute it and/or modify
;;; it under the terms of the GNU General Public License as published
;;; by the Free Software Foundation, either version 3 of the License,
;;; or (at your option) any later version.
;;;
;;; Guile-AWS is distributed in the hope that it will be useful, but
;;; WITHOUT ANY WARRANTY; without even the implied warranty of
;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
;;; General Public License for more details.
;;;
;;; You should have received a copy of the GNU General Public License
;;; along with this program.  If not, see
;;; <http://www.gnu.org/licenses/>.

(define-module (aws request)
  #:use-module (aws base)
  #:use-module (ice-9 match)
  #:use-module (ice-9 format)
  #:use-module (srfi srfi-1)
  #:use-module (srfi srfi-19)
  #:use-module (srfi srfi-26)
  #:use-module (gcrypt hash)
  #:use-module (gcrypt hmac)
  #:use-module (rnrs bytevectors)
  #:use-module (web client)
  #:use-module ((web http) #:select (header-writer))
  #:use-module (sxml simple)
  #:export (make-operation->request serialize-aws-value))

;;; Commentary:

;;; See: http://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html
;;; Make a POST request and pass request parameters in the body of the
;;; request.  Auth information is provided in an Authorization header.

;;; Code:

(define algorithm "AWS4-HMAC-SHA256")

(define (sign key msg)
  "Sign the string MSG with the secret key KEY (a bytevector) using the SHA256 algorithm."
  (sign-data key (string->utf8 msg) #:algorithm 'sha256))

(define (hexify bv)
  (format #f "~{~2,'0x~}" (bytevector->u8-list bv)))

;; XXX: Guile's default-val-writer corrupts the Authorization header,
;; because it wraps the value of the SignedHeaders field in quotes.
;; This confuses AWS.
(define (my-default-val-writer k val port)
  (if (or (string-index val #\,)
          (string-index val #\"))
      ((@@ (web http) write-qstring) val port)
      ((@@ (web http) put-string) port val)))
(module-set!
 (resolve-module '(web http))
 'default-val-writer my-default-val-writer)


;; See https://docs.aws.amazon.com/AWSEC2/latest/APIReference/Query-Requests.html
(define* (serialize-aws-value thing #:key (path '()) n (depth 0))
  (define top? (zero? depth))
  (cond
   ((aws-structure? thing)
    (filter-map (lambda (member)
                  (match (aws-member-value member)
                    ('__unspecified__ #f)
                    (value
                     (serialize-aws-value value
                                          #:path
                                          (if top?
                                              (list (or (aws-member-location-name member)
                                                        (aws-member-name member)))
                                              (cons* (or (aws-member-location-name member)
                                                         (aws-member-name member))
                                                     n
                                                     (aws-structure-aws-name thing)
                                                     path))
                                          #:depth
                                          (1+ depth)))))
                (aws-structure-members thing)))
   ((aws-shape? thing)
    (cond
     ((aws-shape-primitive? thing)
      (serialize-aws-value (aws-shape-value thing)
                           #:path path
                           #:depth (1+ depth)))
     (else
      (serialize-aws-value (aws-shape-value thing)
                           #:path
                           (cons (or (aws-shape-location-name thing)
                                     (aws-shape-aws-name thing)) path)
                           #:depth (1+ depth)))))
   ((boolean? thing)
    (serialize-aws-value (or (and thing "true") "false")
                         #:path path
                         #:depth (1+ depth)))
   ((list? thing)
    (append-map (lambda (item n)
                  (serialize-aws-value item
                                       #:path path
                                       #:n n
                                       #:depth (1+ depth)))
                thing
                (iota (length thing) 1)))
   (else (format #f "~a=~a"
                 (string-join (map (cut format #f "~a" <>)
                                   (reverse (filter identity path)))
                              ".")
                 thing))))

(define* (make-operation->request api-metadata)
  "Return a procedure that accepts an operation and returns an HTTP request."
  (define endpoint-prefix
    (assoc-ref api-metadata 'endpointPrefix))
  (define service-name endpoint-prefix)
  (define api-version
    (assoc-ref api-metadata 'apiVersion))

  (lambda* (#:key http operation-name input)
    (define region
      (or (getenv "AWS_DEFAULT_REGION")
          "us-west-2"))
    (define access-key
      (or (getenv "AWS_ACCESS_KEY_ID")
          (error "No access key available.  Set the AWS_ACCESS_KEY_ID environment variable.")))
    (define secret-key
      (or (getenv "AWS_SECRET_ACCESS_KEY")
          (error "No secret access key available.  Set the AWS_SECRET_ACCESS_KEY environment variable.")))
    (define method
      (assoc-ref http "method"))
    (define host
      (string-join (list endpoint-prefix
                         region
                         "amazonaws.com")
                   "."))
    (define endpoint
      (string-append "https://" host "/"))
    (define content-type
      '(application/x-www-form-urlencoded (charset . "utf-8")))

    ;; DynamoDB needs this, others don't.
    (define amz-target (and=> (assoc-ref api-metadata 'targetPrefix)
                              (cut string-append <> "."
                                   operation-name)))

    ;; TODO: some APIs use JSON, others (like EC2) use plain query strings.
    (define request-parameters
      (string-join (cons* (format #f "Action=~a" operation-name)
                          (format #f "Version=~a" api-version)
                          (if input
                              (serialize-aws-value input)
                              '()))
                   "&"))

    (define payload-hash
      (hexify (sha256 (string->utf8 request-parameters))))

    (define now (current-date 0))
    (define amz-date
      (date->string now "~Y~m~dT~H~M~SZ"))
    (define date-stamp
      (date->string now "~Y~m~d"))

    
    ;; https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html

    ;; TODO: Create canonical URI--the part of the URI from domain to query
    ;; string (use '/' if no path)
    (define canonical-uri "/")

    (define headers
      (filter cdr `((content-type . ,content-type)
                    (host         . (,host . #f))
                    (x-amz-content-sha256 . ,payload-hash)
                    (x-amz-date   . ,amz-date)
                    (x-amz-target . ,amz-target))))
    (define authorization-header
      (let* ((canonical-headers
              ;; Header names must be trimmed, lower-case, sorted in
              ;; code point order from low to high!  Note: there must
              ;; be a trailing newline character.
              (string-join (map (match-lambda
                                  ((key . value)
                                   (string-append (symbol->string key) ":"
                                                  (with-output-to-string
                                                    (lambda ()
                                                      ((header-writer key) value (current-output-port)))))))
                                headers)
                           "\n" 'suffix))
             (signed-headers
              ;; This lists the headers in the canonical-headers list,
              ;; delimited with ";" and in alpha order.  The request
              ;; can include any headers; canonical-headers and
              ;; signed-headers include those that you want to be
              ;; included in the hash of the request. "Host" and
              ;; "x-amz-date" are always required.
              (string-join (map (compose symbol->string first) headers) ";"))
             ;; The query string is blank because parameters are passed
             ;; in the body of the request.
             (canonical-querystring "")
             (canonical-request
              (string-join (list method
                                 canonical-uri
                                 canonical-querystring
                                 canonical-headers
                                 signed-headers
                                 payload-hash)
                           "\n"))
             (credential-scope
              (string-join (list date-stamp
                                 region
                                 service-name
                                 "aws4_request") "/"))
             (string-to-sign
              (string-join (list algorithm
                                 amz-date
                                 credential-scope
                                 (hexify (sha256 (string->utf8 canonical-request))))
                           "\n"))
             (signature
              (let* ((kdate       (sign (string->utf8 (string-append "AWS4" secret-key)) date-stamp))
                     (kregion     (sign kdate region))
                     (kservice    (sign kregion service-name))
                     (signing-key (sign kservice "aws4_request")))
                (hexify (sign signing-key string-to-sign)))))
        `(,(string->symbol algorithm)
          (Credential . ,(string-append access-key "/" credential-scope))
          (SignedHeaders . ,signed-headers)
          (Signature . ,signature))))

    ;; For DynamoDB, the request can include any headers, but MUST
    ;; include "host", "x-amz-date", "x-amz-target", "content-type",
    ;; and "Authorization".  Except for the authorization header, the
    ;; headers must be included in the canonical-headers and
    ;; signed-headers values, as noted earlier.  Order here is not
    ;; significant.
    (define new-headers
      (cons `(Authorization . ,authorization-header)
            (filter cdr headers)))

    (call-with-values
        (lambda ()
          (http-post endpoint
                     ;#:method (string->symbol method)
                        #:body (string->utf8 request-parameters)
                        #:headers new-headers))
      (lambda (response body)
        (xml->sxml (match body
                     ((? bytevector? bv)
                      (utf8->string bv))
                     ((? string? s) s)))))))