blob: 5009aa342f307213caad99ff17959ba0fe22ae3f [file] [log] [blame]
Daniel Smithb4f30fb2022-01-15 01:21:281#!/usr/bin/env python3
Avi Drissmandfd880852022-09-15 20:11:092# Copyright 2016 The Chromium Authors
iclelland65322b8d2016-02-29 22:05:223# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""Utility for generating experimental API tokens
7
8usage: generate_token.py [-h] [--key-file KEY_FILE]
9 [--expire-days EXPIRE_DAYS |
10 --expire-timestamp EXPIRE_TIMESTAMP]
Nan Lin854e60352023-05-08 16:19:5811 [--is-subdomain | --no-subdomain]
12 [--is-third-party | --no-third-party]
Rodney Ding66c126d2020-06-08 05:14:0613 [--usage-restriction USAGE_RESTRICTION]
Jason Chaseca7f1ea2020-05-21 16:58:4414 --version=VERSION
15 origin trial_name
iclelland65322b8d2016-02-29 22:05:2216
17Run "generate_token.py -h" for more help on usage.
18"""
Raul Tambre26d7db42019-09-25 11:06:3519
20from __future__ import print_function
21
iclelland65322b8d2016-02-29 22:05:2222import argparse
23import base64
iclelland0709f4ee2016-04-14 16:21:1724import json
iclelland65322b8d2016-02-29 22:05:2225import os
Lingqi Chi22fe54832021-11-30 07:26:1526import re
iclelland0709f4ee2016-04-14 16:21:1727import struct
iclelland65322b8d2016-02-29 22:05:2228import sys
29import time
Lingqi Chi22fe54832021-11-30 07:26:1530from datetime import datetime
31
32from six import raise_from
Daniel Smithb4f30fb2022-01-15 01:21:2833from urllib.parse import urlparse
iclelland65322b8d2016-02-29 22:05:2234
35script_dir = os.path.dirname(os.path.realpath(__file__))
36sys.path.insert(0, os.path.join(script_dir, 'third_party', 'ed25519'))
37import ed25519
38
iclelland65322b8d2016-02-29 22:05:2239# Matches a valid DNS name label (alphanumeric plus hyphens, except at the ends,
40# no longer than 63 ASCII characters)
41DNS_LABEL_REGEX = re.compile(r"^(?!-)[a-z\d-]{1,63}(?<!-)$", re.IGNORECASE)
42
Daniel Smithb4f30fb2022-01-15 01:21:2843# Only Version 2 and Version 3 are currently supported.
44VERSIONS = {"2": (2, b'\x02'), "3": (3, b'\x03')}
iclelland0709f4ee2016-04-14 16:21:1745
Rodney Ding66c126d2020-06-08 05:14:0646# Only empty string and "subset" are currently supoprted in alternative usage
47# resetriction.
48USAGE_RESTRICTION = ["", "subset"]
49
mgiuca84e0cd22016-08-10 05:47:2250# Default key file, relative to script_dir.
51DEFAULT_KEY_FILE = 'eftest.key'
52
Rodney Ding85a215682020-05-04 02:16:0553
54def VersionFromArg(arg):
55 """Determines whether a string represents a valid version.
56 Only Version 2 and Version 3 are currently supported.
57
Daniel Smithb4f30fb2022-01-15 01:21:2858 Returns a tuple of the int and bytes representation of version.
Rodney Ding85a215682020-05-04 02:16:0559 Returns None if version is not valid.
60 """
Daniel Smithb4f30fb2022-01-15 01:21:2861 return VERSIONS.get(arg, None)
Rodney Ding85a215682020-05-04 02:16:0562
63
iclelland65322b8d2016-02-29 22:05:2264def HostnameFromArg(arg):
65 """Determines whether a string represents a valid hostname.
66
67 Returns the canonical hostname if its argument is valid, or None otherwise.
68 """
69 if not arg or len(arg) > 255:
70 return None
71 if arg[-1] == ".":
72 arg = arg[:-1]
iclelland08b9e8da2016-06-16 08:18:2673 if "." not in arg and arg != "localhost":
74 return None
iclelland65322b8d2016-02-29 22:05:2275 if all(DNS_LABEL_REGEX.match(label) for label in arg.split(".")):
76 return arg.lower()
Lingqi Chi900ecfe2021-11-23 02:15:4477 return None
78
iclelland65322b8d2016-02-29 22:05:2279
Anton Bershanskyia305fe32024-01-31 19:45:2980def IsExtensionId(arg):
81 """Determines whether a string represents a valid Chromium extension origin.
82
83 Returns True if the argument is valid extension origin, or False otherwise.
84 """
85 extensionIdRegex = re.compile(r"[a-p]{32}")
86 return bool(extensionIdRegex.fullmatch(arg))
87
88
iclelland65322b8d2016-02-29 22:05:2289def OriginFromArg(arg):
90 """Constructs the origin for the token from a command line argument.
91
92 Returns None if this is not possible (neither a valid hostname nor a
93 valid origin URL was provided.)
94 """
95 # Does it look like a hostname?
96 hostname = HostnameFromArg(arg)
97 if hostname:
98 return "https://" + hostname + ":443"
99 # If not, try to construct an origin URL from the argument
Jonathan Njeunjee45f2bd2021-10-12 16:21:58100 origin = urlparse(arg)
iclelland65322b8d2016-02-29 22:05:22101 if not origin or not origin.scheme or not origin.netloc:
102 raise argparse.ArgumentTypeError("%s is not a hostname or a URL" % arg)
103 # HTTPS or HTTP only
Anton Bershanskyia305fe32024-01-31 19:45:29104 if origin.scheme not in ("https", "http", "chrome-extension"):
iclelland65322b8d2016-02-29 22:05:22105 raise argparse.ArgumentTypeError("%s does not use a recognized URL scheme" %
106 arg)
Anton Bershanskyia305fe32024-01-31 19:45:29107 # Is it a valid extension origin?
108 if origin.scheme == "chrome-extension":
109 if (IsExtensionId(origin.hostname) and not origin.port
110 and not origin.username and not origin.password):
111 return "chrome-extension://{0}".format(origin.hostname)
112 raise argparse.ArgumentTypeError("%s is not a valid extension origin" % arg)
iclelland65322b8d2016-02-29 22:05:22113 # Add default port if it is not specified
114 try:
115 port = origin.port
Lingqi Chi900ecfe2021-11-23 02:15:44116 except ValueError as e:
Lingqi Chi22fe54832021-11-30 07:26:15117 raise_from(
118 argparse.ArgumentTypeError("%s is not a hostname or a URL" % arg), e)
iclelland65322b8d2016-02-29 22:05:22119 if not port:
120 port = {"https": 443, "http": 80}[origin.scheme]
121 # Strip any extra components and return the origin URL:
122 return "{0}://{1}:{2}".format(origin.scheme, origin.hostname, port)
123
124def ExpiryFromArgs(args):
Joshua Hoodc2da0a52024-05-16 16:29:51125 expiry: int
iclelland65322b8d2016-02-29 22:05:22126 if args.expire_timestamp:
Joshua Hoodc2da0a52024-05-16 16:29:51127 expiry = int(args.expire_timestamp)
128 else:
129 expiry = (int(time.time()) + (int(args.expire_days) * 86400))
iclelland65322b8d2016-02-29 22:05:22130
Joshua Hoodc2da0a52024-05-16 16:29:51131 if expiry > 2**31 - 1:
132 # The maximum expiry timestamp is bound by the maximum value of a signed
133 # 32-bit integer (2^31-1).
134 # TODO(crbug.com/40872096): All expiries after 2038-01-19 03:14:07 UTC
135 # will raise this error, so add support for a larger range of values
136 # before then.
137 raise argparse.ArgumentTypeError(
138 "%d (%s UTC) is beyond the range of supported expiries" %
139 (expiry, datetime.utcfromtimestamp(expiry)))
140 return expiry
Rodney Ding85a215682020-05-04 02:16:05141
142def GenerateTokenData(version, origin, is_subdomain, is_third_party,
Rodney Ding66c126d2020-06-08 05:14:06143 usage_restriction, feature_name, expiry):
chasej4f0cb8e2016-10-13 21:32:33144 data = {"origin": origin,
145 "feature": feature_name,
146 "expiry": expiry}
147 if is_subdomain is not None:
148 data["isSubdomain"] = is_subdomain
Rodney Ding66c126d2020-06-08 05:14:06149 # Only version 3 token supports fields: is_third_party, usage.
Rodney Ding85a215682020-05-04 02:16:05150 if version == 3 and is_third_party is not None:
151 data["isThirdParty"] = is_third_party
Rodney Ding66c126d2020-06-08 05:14:06152 if version == 3 and usage_restriction is not None:
153 data["usage"] = usage_restriction
chasej4f0cb8e2016-10-13 21:32:33154 return json.dumps(data).encode('utf-8')
iclelland0709f4ee2016-04-14 16:21:17155
156def GenerateDataToSign(version, data):
157 return version + struct.pack(">I",len(data)) + data
iclelland65322b8d2016-02-29 22:05:22158
Daniel Smithb4f30fb2022-01-15 01:21:28159
iclelland65322b8d2016-02-29 22:05:22160def Sign(private_key, data):
161 return ed25519.signature(data, private_key[:32], private_key[32:])
162
Daniel Smithb4f30fb2022-01-15 01:21:28163
iclelland65322b8d2016-02-29 22:05:22164def FormatToken(version, signature, data):
Daniel Smithb4f30fb2022-01-15 01:21:28165 return base64.b64encode(version + signature + struct.pack(">I", len(data)) +
166 data).decode("ascii")
iclelland65322b8d2016-02-29 22:05:22167
Lingqi Chi22fe54832021-11-30 07:26:15168
169def ParseArgs():
mgiuca84e0cd22016-08-10 05:47:22170 default_key_file_absolute = os.path.join(script_dir, DEFAULT_KEY_FILE)
171
iclelland65322b8d2016-02-29 22:05:22172 parser = argparse.ArgumentParser(
chasej4f0cb8e2016-10-13 21:32:33173 description="Generate tokens for enabling experimental features")
Jason Chaseca7f1ea2020-05-21 16:58:44174 parser.add_argument("--version",
Jonathan Hao0480a8632022-10-04 18:01:18175 help="Token version to use. Currently only version 2 "
Jason Chaseca7f1ea2020-05-21 16:58:44176 "and version 3 are supported.",
Glen Robertsone861d7752020-09-17 14:23:57177 default='3',
Jason Chaseca7f1ea2020-05-21 16:58:44178 type=VersionFromArg)
iclelland65322b8d2016-02-29 22:05:22179 parser.add_argument("origin",
chasej4f0cb8e2016-10-13 21:32:33180 help="Origin for which to enable the feature. This can "
181 "be either a hostname (default scheme HTTPS, "
182 "default port 443) or a URL.",
iclelland65322b8d2016-02-29 22:05:22183 type=OriginFromArg)
184 parser.add_argument("trial_name",
185 help="Feature to enable. The current list of "
186 "experimental feature trials can be found in "
187 "RuntimeFeatures.in")
188 parser.add_argument("--key-file",
189 help="Ed25519 private key file to sign the token with",
mgiuca84e0cd22016-08-10 05:47:22190 default=default_key_file_absolute)
chasej4f0cb8e2016-10-13 21:32:33191
192 subdomain_group = parser.add_mutually_exclusive_group()
193 subdomain_group.add_argument("--is-subdomain",
194 help="Token will enable the feature for all "
195 "subdomains that match the origin",
196 dest="is_subdomain",
197 action="store_true")
198 subdomain_group.add_argument("--no-subdomain",
199 help="Token will only match the specified "
200 "origin (default behavior)",
201 dest="is_subdomain",
202 action="store_false")
203 parser.set_defaults(is_subdomain=None)
204
Rodney Ding85a215682020-05-04 02:16:05205 third_party_group = parser.add_mutually_exclusive_group()
206 third_party_group.add_argument(
207 "--is-third-party",
208 help="Token will enable the feature for third "
209 "party origins. This option is only available for token version 3",
210 dest="is_third_party",
211 action="store_true")
212 third_party_group.add_argument(
213 "--no-third-party",
214 help="Token will only match first party origin. This option is only "
215 "available for token version 3",
216 dest="is_third_party",
217 action="store_false")
218 parser.set_defaults(is_third_party=None)
219
Rodney Ding66c126d2020-06-08 05:14:06220 parser.add_argument("--usage-restriction",
221 help="Alternative token usage resctriction. This option "
222 "is only available for token version 3. Currently only "
223 "subset exclusion is supported.")
224
iclelland65322b8d2016-02-29 22:05:22225 expiry_group = parser.add_mutually_exclusive_group()
226 expiry_group.add_argument("--expire-days",
chasej4f0cb8e2016-10-13 21:32:33227 help="Days from now when the token should expire",
iclelland65322b8d2016-02-29 22:05:22228 type=int,
229 default=42)
230 expiry_group.add_argument("--expire-timestamp",
231 help="Exact time (seconds since 1970-01-01 "
chasej4f0cb8e2016-10-13 21:32:33232 "00:00:00 UTC) when the token should expire",
iclelland65322b8d2016-02-29 22:05:22233 type=int)
234
Lingqi Chi22fe54832021-11-30 07:26:15235 return parser.parse_args()
236
237
238def GenerateTokenAndSignature():
239 args = ParseArgs()
iclelland65322b8d2016-02-29 22:05:22240 expiry = ExpiryFromArgs(args)
241
Daniel Smithb4f30fb2022-01-15 01:21:28242 version_int, version_bytes = args.version
243
244 with open(os.path.expanduser(args.key_file), mode="rb") as key_file:
245 private_key = key_file.read(64)
iclelland65322b8d2016-02-29 22:05:22246
247 # Validate that the key file read was a proper Ed25519 key -- running the
248 # publickey method on the first half of the key should return the second
249 # half.
250 if (len(private_key) < 64 or
251 ed25519.publickey(private_key[:32]) != private_key[32:]):
252 print("Unable to use the specified private key file.")
253 sys.exit(1)
254
Daniel Smithb4f30fb2022-01-15 01:21:28255 if (not version_int):
Glen Robertsone861d7752020-09-17 14:23:57256 print("Invalid token version. Only version 2 and 3 are supported.")
Rodney Ding85a215682020-05-04 02:16:05257 sys.exit(1)
258
Daniel Smithb4f30fb2022-01-15 01:21:28259 if (args.is_third_party is not None and version_int != 3):
Rodney Ding85a215682020-05-04 02:16:05260 print("Only version 3 token supports is_third_party flag.")
261 sys.exit(1)
262
Rodney Ding66c126d2020-06-08 05:14:06263 if (args.usage_restriction is not None):
Daniel Smithb4f30fb2022-01-15 01:21:28264 if (version_int != 3):
Rodney Ding66c126d2020-06-08 05:14:06265 print("Only version 3 token supports alternative usage restriction.")
266 sys.exit(1)
Rodney Ding66c126d2020-06-08 05:14:06267 if (args.usage_restriction not in USAGE_RESTRICTION):
268 print(
269 "Only empty string and \"subset\" are supported in alternative usage "
270 "restriction.")
271 sys.exit(1)
Daniel Smithb4f30fb2022-01-15 01:21:28272 token_data = GenerateTokenData(version_int, args.origin, args.is_subdomain,
273 args.is_third_party, args.usage_restriction,
274 args.trial_name, expiry)
275 data_to_sign = GenerateDataToSign(version_bytes, token_data)
iclelland0709f4ee2016-04-14 16:21:17276 signature = Sign(private_key, data_to_sign)
iclelland65322b8d2016-02-29 22:05:22277
278 # Verify that that the signature is correct before printing it.
279 try:
iclelland0709f4ee2016-04-14 16:21:17280 ed25519.checkvalid(signature, data_to_sign, private_key[32:])
Jonathan Njeunjee45f2bd2021-10-12 16:21:58281 except Exception as exc:
Raul Tambre26d7db42019-09-25 11:06:35282 print("There was an error generating the signature.")
283 print("(The original error was: %s)" % exc)
iclelland65322b8d2016-02-29 22:05:22284 sys.exit(1)
285
Daniel Smithb4f30fb2022-01-15 01:21:28286 token_data = GenerateTokenData(version_int, args.origin, args.is_subdomain,
287 args.is_third_party, args.usage_restriction,
288 args.trial_name, expiry)
289 data_to_sign = GenerateDataToSign(version_bytes, token_data)
Lingqi Chi22fe54832021-11-30 07:26:15290 signature = Sign(private_key, data_to_sign)
Louise Brettdcac22cb2022-01-05 00:01:40291 return args, token_data, signature, expiry
Lingqi Chi22fe54832021-11-30 07:26:15292
293
294def main():
Louise Brettdcac22cb2022-01-05 00:01:40295 args, token_data, signature, expiry = GenerateTokenAndSignature()
Daniel Smithb4f30fb2022-01-15 01:21:28296 version_int, version_bytes = args.version
iclelland08b9e8da2016-06-16 08:18:26297
298 # Output the token details
Raul Tambre26d7db42019-09-25 11:06:35299 print("Token details:")
Daniel Smithb4f30fb2022-01-15 01:21:28300 print(" Version: %s" % version_int)
Raul Tambre26d7db42019-09-25 11:06:35301 print(" Origin: %s" % args.origin)
302 print(" Is Subdomain: %s" % args.is_subdomain)
Daniel Smithb4f30fb2022-01-15 01:21:28303 if version_int == 3:
Rodney Ding85a215682020-05-04 02:16:05304 print(" Is Third Party: %s" % args.is_third_party)
Rodney Ding66c126d2020-06-08 05:14:06305 print(" Usage Restriction: %s" % args.usage_restriction)
Raul Tambre26d7db42019-09-25 11:06:35306 print(" Feature: %s" % args.trial_name)
307 print(" Expiry: %d (%s UTC)" % (expiry, datetime.utcfromtimestamp(expiry)))
Daniel Smithb4f30fb2022-01-15 01:21:28308 print(" Signature: %s" % ", ".join('0x%02x' % x for x in signature))
309 b64_signature = base64.b64encode(signature).decode("ascii")
310 print(" Signature (Base64): %s" % b64_signature)
Raul Tambre26d7db42019-09-25 11:06:35311 print()
iclelland08b9e8da2016-06-16 08:18:26312
313 # Output the properly-formatted token.
Daniel Smithb4f30fb2022-01-15 01:21:28314 print(FormatToken(version_bytes, signature, token_data))
Raul Tambre26d7db42019-09-25 11:06:35315
iclelland65322b8d2016-02-29 22:05:22316
317if __name__ == "__main__":
318 main()