summaryrefslogtreecommitdiff
path: root/chromium/tools/origin_trials/generate_token.py
blob: 61513a4aa48ea3898840da43a232d4e7cf5a82e5 (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
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
#!/usr/bin/env python3
# Copyright 2016 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""Utility for generating experimental API tokens

usage: generate_token.py [-h] [--key-file KEY_FILE]
                         [--expire-days EXPIRE_DAYS |
                          --expire-timestamp EXPIRE_TIMESTAMP]
                         [--is_subdomain | --no-subdomain]
                         [--is_third-party | --no-third-party]
                         [--usage-restriction USAGE_RESTRICTION]
                         --version=VERSION
                         origin trial_name

Run "generate_token.py -h" for more help on usage.
"""

from __future__ import print_function

import argparse
import base64
import json
import os
import re
import struct
import sys
import time
from datetime import datetime

from six import raise_from
from urllib.parse import urlparse

script_dir = os.path.dirname(os.path.realpath(__file__))
sys.path.insert(0, os.path.join(script_dir, 'third_party', 'ed25519'))
import ed25519

# Matches a valid DNS name label (alphanumeric plus hyphens, except at the ends,
# no longer than 63 ASCII characters)
DNS_LABEL_REGEX = re.compile(r"^(?!-)[a-z\d-]{1,63}(?<!-)$", re.IGNORECASE)

# Only Version 2 and Version 3 are currently supported.
VERSIONS = {"2": (2, b'\x02'), "3": (3, b'\x03')}

# Only empty string and "subset" are currently supoprted in alternative usage
# resetriction.
USAGE_RESTRICTION = ["", "subset"]

# Default key file, relative to script_dir.
DEFAULT_KEY_FILE = 'eftest.key'


def VersionFromArg(arg):
  """Determines whether a string represents a valid version.
  Only Version 2 and Version 3 are currently supported.

  Returns a tuple of the int and bytes representation of version.
  Returns None if version is not valid.
  """
  return VERSIONS.get(arg, None)


def HostnameFromArg(arg):
  """Determines whether a string represents a valid hostname.

  Returns the canonical hostname if its argument is valid, or None otherwise.
  """
  if not arg or len(arg) > 255:
    return None
  if arg[-1] == ".":
    arg = arg[:-1]
  if "." not in arg and arg != "localhost":
    return None
  if all(DNS_LABEL_REGEX.match(label) for label in arg.split(".")):
    return arg.lower()
  return None


def OriginFromArg(arg):
  """Constructs the origin for the token from a command line argument.

  Returns None if this is not possible (neither a valid hostname nor a
  valid origin URL was provided.)
  """
  # Does it look like a hostname?
  hostname = HostnameFromArg(arg)
  if hostname:
    return "https://" + hostname + ":443"
  # If not, try to construct an origin URL from the argument
  origin = urlparse(arg)
  if not origin or not origin.scheme or not origin.netloc:
    raise argparse.ArgumentTypeError("%s is not a hostname or a URL" % arg)
  # HTTPS or HTTP only
  if origin.scheme not in ('https','http'):
    raise argparse.ArgumentTypeError("%s does not use a recognized URL scheme" %
                                     arg)
  # Add default port if it is not specified
  try:
    port = origin.port
  except ValueError as e:
    raise_from(
        argparse.ArgumentTypeError("%s is not a hostname or a URL" % arg), e)
  if not port:
    port = {"https": 443, "http": 80}[origin.scheme]
  # Strip any extra components and return the origin URL:
  return "{0}://{1}:{2}".format(origin.scheme, origin.hostname, port)

def ExpiryFromArgs(args):
  if args.expire_timestamp:
    return int(args.expire_timestamp)
  return (int(time.time()) + (int(args.expire_days) * 86400))


def GenerateTokenData(version, origin, is_subdomain, is_third_party,
                      usage_restriction, feature_name, expiry):
  data = {"origin": origin,
          "feature": feature_name,
          "expiry": expiry}
  if is_subdomain is not None:
    data["isSubdomain"] = is_subdomain
  # Only version 3 token supports fields: is_third_party, usage.
  if version == 3 and is_third_party is not None:
    data["isThirdParty"] = is_third_party
  if version == 3 and usage_restriction is not None:
    data["usage"] = usage_restriction
  return json.dumps(data).encode('utf-8')

def GenerateDataToSign(version, data):
  return version + struct.pack(">I",len(data)) + data


def Sign(private_key, data):
  return ed25519.signature(data, private_key[:32], private_key[32:])


def FormatToken(version, signature, data):
  return base64.b64encode(version + signature + struct.pack(">I", len(data)) +
                          data).decode("ascii")


def ParseArgs():
  default_key_file_absolute = os.path.join(script_dir, DEFAULT_KEY_FILE)

  parser = argparse.ArgumentParser(
      description="Generate tokens for enabling experimental features")
  parser.add_argument("--version",
                      help="Token version to use. Currently only version 2 "
                      "and version 3 are supported.",
                      default='3',
                      type=VersionFromArg)
  parser.add_argument("origin",
                      help="Origin for which to enable the feature. This can "
                           "be either a hostname (default scheme HTTPS, "
                           "default port 443) or a URL.",
                      type=OriginFromArg)
  parser.add_argument("trial_name",
                      help="Feature to enable. The current list of "
                           "experimental feature trials can be found in "
                           "RuntimeFeatures.in")
  parser.add_argument("--key-file",
                      help="Ed25519 private key file to sign the token with",
                      default=default_key_file_absolute)

  subdomain_group = parser.add_mutually_exclusive_group()
  subdomain_group.add_argument("--is-subdomain",
                               help="Token will enable the feature for all "
                                    "subdomains that match the origin",
                               dest="is_subdomain",
                               action="store_true")
  subdomain_group.add_argument("--no-subdomain",
                               help="Token will only match the specified "
                                    "origin (default behavior)",
                               dest="is_subdomain",
                               action="store_false")
  parser.set_defaults(is_subdomain=None)

  third_party_group = parser.add_mutually_exclusive_group()
  third_party_group.add_argument(
      "--is-third-party",
      help="Token will enable the feature for third "
      "party origins. This option is only available for token version 3",
      dest="is_third_party",
      action="store_true")
  third_party_group.add_argument(
      "--no-third-party",
      help="Token will only match first party origin. This option is only "
      "available for token version 3",
      dest="is_third_party",
      action="store_false")
  parser.set_defaults(is_third_party=None)

  parser.add_argument("--usage-restriction",
                      help="Alternative token usage resctriction. This option "
                      "is only available for token version 3. Currently only "
                      "subset exclusion is supported.")

  expiry_group = parser.add_mutually_exclusive_group()
  expiry_group.add_argument("--expire-days",
                            help="Days from now when the token should expire",
                            type=int,
                            default=42)
  expiry_group.add_argument("--expire-timestamp",
                            help="Exact time (seconds since 1970-01-01 "
                                 "00:00:00 UTC) when the token should expire",
                            type=int)

  return parser.parse_args()


def GenerateTokenAndSignature():
  args = ParseArgs()
  expiry = ExpiryFromArgs(args)

  version_int, version_bytes = args.version

  with open(os.path.expanduser(args.key_file), mode="rb") as key_file:
    private_key = key_file.read(64)

  # Validate that the key file read was a proper Ed25519 key -- running the
  # publickey method on the first half of the key should return the second
  # half.
  if (len(private_key) < 64 or
    ed25519.publickey(private_key[:32]) != private_key[32:]):
    print("Unable to use the specified private key file.")
    sys.exit(1)

  if (not version_int):
    print("Invalid token version. Only version 2 and 3 are supported.")
    sys.exit(1)

  if (args.is_third_party is not None and version_int != 3):
    print("Only version 3 token supports is_third_party flag.")
    sys.exit(1)

  if (args.usage_restriction is not None):
    if (version_int != 3):
      print("Only version 3 token supports alternative usage restriction.")
      sys.exit(1)
    if (args.usage_restriction not in USAGE_RESTRICTION):
      print(
          "Only empty string and \"subset\" are supported in alternative usage "
          "restriction.")
      sys.exit(1)
  token_data = GenerateTokenData(version_int, args.origin, args.is_subdomain,
                                 args.is_third_party, args.usage_restriction,
                                 args.trial_name, expiry)
  data_to_sign = GenerateDataToSign(version_bytes, token_data)
  signature = Sign(private_key, data_to_sign)

  # Verify that that the signature is correct before printing it.
  try:
    ed25519.checkvalid(signature, data_to_sign, private_key[32:])
  except Exception as exc:
    print("There was an error generating the signature.")
    print("(The original error was: %s)" % exc)
    sys.exit(1)

  token_data = GenerateTokenData(version_int, args.origin, args.is_subdomain,
                                 args.is_third_party, args.usage_restriction,
                                 args.trial_name, expiry)
  data_to_sign = GenerateDataToSign(version_bytes, token_data)
  signature = Sign(private_key, data_to_sign)
  return args, token_data, signature, expiry


def main():
  args, token_data, signature, expiry = GenerateTokenAndSignature()
  version_int, version_bytes = args.version

  # Output the token details
  print("Token details:")
  print(" Version: %s" % version_int)
  print(" Origin: %s" % args.origin)
  print(" Is Subdomain: %s" % args.is_subdomain)
  if version_int == 3:
    print(" Is Third Party: %s" % args.is_third_party)
    print(" Usage Restriction: %s" % args.usage_restriction)
  print(" Feature: %s" % args.trial_name)
  print(" Expiry: %d (%s UTC)" % (expiry, datetime.utcfromtimestamp(expiry)))
  print(" Signature: %s" % ", ".join('0x%02x' % x for x in signature))
  b64_signature = base64.b64encode(signature).decode("ascii")
  print(" Signature (Base64): %s" % b64_signature)
  print()

  # Output the properly-formatted token.
  print(FormatToken(version_bytes, signature, token_data))


if __name__ == "__main__":
  main()