A few weeks ago, a colleague showed me a small Sinatra application they had written to share small bits of sensitive text without any preconditions on the recipient. The application, which is very nicely branded as Onceler, will accept a small text snippet, store it in memory, and give the uploader a unique URL from which the secret can be downloaded. The recipient then visits the URL, which causes the secret to be downloaded, displayed, and deleted from the server’s memory.

The secret can only be retrieved once, so the service offers some protection for communication channels with limited or non-existent forward secrecy, and the secret URL contains a 120-bit random identifier, which effectively prevents attackers from scanning the whole keyspace looking for uncollected goodies. You’re still effectively publishing sensitive data at a publicly accessible URL, but texting someone a single-use link to retrieve a temporary password is a step up from just texting them the password, especially if you know the recipient will visit the link relatively quickly. The sender and receiver don’t need to have accounts on the same end-to-end encrypted communications service, they don’t need to send each other the public halves of their PGP keys, and they don’t need to have previously agreed on a shared secret (any of which would offer greater security at the expense of convenience).

While I loved the idea, especially for scenarios that would normally have you send something mildly sensitive over SMS or email, I didn’t love the idea of running a persistent server. The implementation uses a plain ruby map to store data, so it relies on locks to prevent concurrent access to a given secret, requires the sender and recipient to interact with the same server process, and would provide interesting heap dumps. I thought it might be viable to implement the same idea using API Gateway and DynamoDB, as the latter supports an atomic get-and-remove operation through the DeleteItem operation’s ReturnValues parameter.

My first stab at this was to use API Gateway’s AWS integration type, which will translate incoming requests to calls to an AWS service using a velocity template. At first glance, this is an attractive option, since you don’t have to write any code or pay for any Lambda invocations or execution time. But in practice, I didn’t like the idea of embedding all routing and processing logic in the OpenAPI definition and nested Velocity templates. The full CloudFormation template ended up doing way too much in YAML configuration for my taste:

  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
Resources:
  SingleUseSecretsApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: prod
      DefinitionBody:
        openapi: 3.0.1
        info:
          title: "Serverless Single-Serving Secrets"
        paths:
          /{id}:
            put:
              parameters:
                - name: id
                  in: path
                  required: true
                  schema:
                    $ref: "#/components/schemas/secretId"
              requestBody:
                required: True
                content:
                  application/json:
                    schema:
                      $ref "#/components/schemas/arbitraryJsonObject"
              responses:
                "201":
                  description: OK response
                "400":
                  description: Client error
                "409":
                  description: Invalid request
                "429":
                  description: Throttle response
                "500":
                  description: Error response
              x-amazon-apigateway-integration:
                type: aws
                uri: arn:aws:apigateway:us-east-1:dynamodb:action/PutItem
                credentials:
                  Fn::GetAtt:
                    - SecretsTableWriteOnlyRole
                    - Arn
                httpMethod: POST
                passthroughBehavior: never
                requestTemplates:
                  application/json:
                    Fn::Sub: |
                      #set($ttl = $context.requestTimeEpoch + 60 * 60 * 72)
                      {
                        "Item": {
                          "id": {
                            "S": "$method.request.path.id"
                          },
                          "value": {
                            "S": "$util.base64Encode($input.body)"
                          },
                          "contentType": {
                            "S": "application/json"
                          },
                          "ttl": {
                            "N": "$ttl"
                          }
                        },
                        "TableName": "${SecretsTable}",
                        "ConditionExpression": "attribute_not_exists(id)"
                      }
                responses:
                  default:
                    statusCode: "201"
                    responseTemplates:
                      application/json: |
                        {"id": "$method.request.path.id"}
                  4\d{2}:
                    statusCode: "400"
                    responseTemplates:
                      application/json: |
                        #if($input.path('$.__type').matches('.+(ConditionalCheckFailedException|TransactionConflictException)'))
                          #set($context.responseOverride.status = 409)
                        #elseif($input.path('$.__type').matches('.+(ProvisionedThroughputExceededException|"RequestLimitExceeded")'))
                          #set($context.responseOverride.status = 429)
                        #end
                  5\d{2}:
                    statusCode: "500"
                    responseTemplates:
                      application/json: ""
            get:
              parameters:
                - name: id
                  in: path
                  required: true
                  schema:
                    $ref: "#/components/schemas/secretId"
              responses:
                "200":
                  description: OK response
                  content:
                    application/json:
                      schema:
                        $ref "#/components/schemas/arbitraryJsonObject"
                "404":
                  description: Secret not found response
                  content:
                    application/json:
                      schema:
                        $ref "#/components/schemas/errorResponse"
                "500":
                  description: Error response
                  content:
                    application/json:
                      schema:
                        $ref "#/components/schemas/errorResponse"
              x-amazon-apigateway-integration:
                type: aws
                uri: arn:aws:apigateway:us-east-1:dynamodb:action/DeleteItem
                credentials:
                  Fn::GetAtt:
                    - SecretsTableDestructiveReadRole
                    - Arn
                httpMethod: POST
                passthroughBehavior: never
                requestTemplates:
                  application/json:
                    Fn::Sub: |
                      {
                        "Key": {
                          "id": {
                            "S": "$input.params('id')"
                          }
                        },
                        "ReturnValues": "ALL_OLD",
                        "TableName": "${SecretsTable}"
                      }
                responses:
                  default:
                    statusCode: "200"
                    responseTemplates:
                      application/json: |
                        #set($secretValue = $input.path('$.Attributes.value.S'))
                        #if($secretValue != "")
                          $util.base64Decode($secretValue)
                        #else
                          #set($context.responseOverride.status = 404)
                          {"message":"No secret found for $input.params('id')"}
                        #end
                  5\d{2}:
                    statusCode: "500"
                    responseTemplates:
                      application/json: |
                        {"message" : "There was an error reading from the database"}
        x-amazon-apigateway-request-validators:
          full:
            validateRequestBody: True
            validateRequestParameters: True
        x-amazon-apigateway-request-validator: full
        components:
          schemas:
            secretId:
              type: string
              pattern: "^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$"
            errorResponse:
              type: object
              required:
                - message
              properties:
                message:
                  type: string
            arbitraryJsonObject:
              type: object
              additionalProperties: True

I also didn’t want to limit the storage to a particular content type (or worse, hand-code templates for every new content type I wished to add).

Luckily, API Gateway just released HTTP APIs as a public beta. These are a pared-down version of their stalwart REST API offering with a reduced featureset offered at a significantly reduced cost. AWS integrations are not available on HTTP APIs, which can only defer to a Lambda or HTTP backend, but even with Lambda costs factored in, the HTTP API is still likely to be a better deal. Assuming Lambda invocations will terminate within Lambda’s minimum billable duration of 100ms, it would cost $1.408 to handle one million requests with an HTTP API (with $1.00 going to API Gateway and $0.408 going to Lambda). With a REST API with AWS integrations, you only pay for API Gateway’s per-request charge, but this is still over twice as expensive at $3.50.

HTTP APIs actually end up being cheaper than using the slightly clunkier CloudFront + Lambda@Edge setup described in Serverless client-side telemetry aggregation given Lambda@Edge’s higher costs compared to Lambda. The same million requests that the HTTP API could handle for $1.408 will cost $1.9125 when handled by Lambda@Edge.

HTTP APIs are still in beta, but the only buggy behavior I encountered was in the service’s OpenAPI import, which does not yet appear to support many of API Gateway’s OpenAPI extensions. There is also not yet any support for API Gateway’s HTTP or WebSocket APIs in Terraform, so I ended up explicitly defining routes and integrations as CloudFormation resources, then having Terraform deploy a small CloudFormation stack. My labor to get everything working with an HTTP API was definitely worth more than the $2.00/month I’ll save if a million people use the API, but it was a learning experience.

Using the API

The API for storing and retrieving secrets has no authentication, so you can use it from any HTTP client. For example, the following bash command will save a message and return the secret’s ID using cURL and jq:

curl -s --data 'my secret' https://onceler.toomanywords.io | jq .id

Any content-type header specified for the request body will be returned when the secret is retrieved.

Retrieving the secret just requires the id:

curl https://onceler.toomanywords.io/{id}

The API also allows CORS access, so it can be called directly from JavaScript. You can view examples of how to use the service with text, small files, and client-side encryption here.