blob: 61513a4aa48ea3898840da43a232d4e7cf5a82e5 [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]
chasej4f0cb8e2016-10-13 21:32:3311 [--is_subdomain | --no-subdomain]
Rodney Ding85a215682020-05-04 02:16:0512 [--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
80def OriginFromArg(arg):
81 """Constructs the origin for the token from a command line argument.
82
83 Returns None if this is not possible (neither a valid hostname nor a
84 valid origin URL was provided.)
85 """
86 # Does it look like a hostname?
87 hostname = HostnameFromArg(arg)
88 if hostname:
89 return "https://" + hostname + ":443"
90 # If not, try to construct an origin URL from the argument
Jonathan Njeunjee45f2bd2021-10-12 16:21:5891 origin = urlparse(arg)
iclelland65322b8d2016-02-29 22:05:2292 if not origin or not origin.scheme or not origin.netloc:
93 raise argparse.ArgumentTypeError("%s is not a hostname or a URL" % arg)
94 # HTTPS or HTTP only
95 if origin.scheme not in ('https','http'):
96 raise argparse.ArgumentTypeError("%s does not use a recognized URL scheme" %
97 arg)
98 # Add default port if it is not specified
99 try:
100 port = origin.port
Lingqi Chi900ecfe2021-11-23 02:15:44101 except ValueError as e:
Lingqi Chi22fe54832021-11-30 07:26:15102 raise_from(
103 argparse.ArgumentTypeError("%s is not a hostname or a URL" % arg), e)
iclelland65322b8d2016-02-29 22:05:22104 if not port:
105 port = {"https": 443, "http": 80}[origin.scheme]
106 # Strip any extra components and return the origin URL:
107 return "{0}://{1}:{2}".format(origin.scheme, origin.hostname, port)
108
109def ExpiryFromArgs(args):
110 if args.expire_timestamp:
111 return int(args.expire_timestamp)
112 return (int(time.time()) + (int(args.expire_days) * 86400))
113
Rodney Ding85a215682020-05-04 02:16:05114
115def GenerateTokenData(version, origin, is_subdomain, is_third_party,
Rodney Ding66c126d2020-06-08 05:14:06116 usage_restriction, feature_name, expiry):
chasej4f0cb8e2016-10-13 21:32:33117 data = {"origin": origin,
118 "feature": feature_name,
119 "expiry": expiry}
120 if is_subdomain is not None:
121 data["isSubdomain"] = is_subdomain
Rodney Ding66c126d2020-06-08 05:14:06122 # Only version 3 token supports fields: is_third_party, usage.
Rodney Ding85a215682020-05-04 02:16:05123 if version == 3 and is_third_party is not None:
124 data["isThirdParty"] = is_third_party
Rodney Ding66c126d2020-06-08 05:14:06125 if version == 3 and usage_restriction is not None:
126 data["usage"] = usage_restriction
chasej4f0cb8e2016-10-13 21:32:33127 return json.dumps(data).encode('utf-8')
iclelland0709f4ee2016-04-14 16:21:17128
129def GenerateDataToSign(version, data):
130 return version + struct.pack(">I",len(data)) + data
iclelland65322b8d2016-02-29 22:05:22131
Daniel Smithb4f30fb2022-01-15 01:21:28132
iclelland65322b8d2016-02-29 22:05:22133def Sign(private_key, data):
134 return ed25519.signature(data, private_key[:32], private_key[32:])
135
Daniel Smithb4f30fb2022-01-15 01:21:28136
iclelland65322b8d2016-02-29 22:05:22137def FormatToken(version, signature, data):
Daniel Smithb4f30fb2022-01-15 01:21:28138 return base64.b64encode(version + signature + struct.pack(">I", len(data)) +
139 data).decode("ascii")
iclelland65322b8d2016-02-29 22:05:22140
Lingqi Chi22fe54832021-11-30 07:26:15141
142def ParseArgs():
mgiuca84e0cd22016-08-10 05:47:22143 default_key_file_absolute = os.path.join(script_dir, DEFAULT_KEY_FILE)
144
iclelland65322b8d2016-02-29 22:05:22145 parser = argparse.ArgumentParser(
chasej4f0cb8e2016-10-13 21:32:33146 description="Generate tokens for enabling experimental features")
Jason Chaseca7f1ea2020-05-21 16:58:44147 parser.add_argument("--version",
Jonathan Hao0480a8632022-10-04 18:01:18148 help="Token version to use. Currently only version 2 "
Jason Chaseca7f1ea2020-05-21 16:58:44149 "and version 3 are supported.",
Glen Robertsone861d7752020-09-17 14:23:57150 default='3',
Jason Chaseca7f1ea2020-05-21 16:58:44151 type=VersionFromArg)
iclelland65322b8d2016-02-29 22:05:22152 parser.add_argument("origin",
chasej4f0cb8e2016-10-13 21:32:33153 help="Origin for which to enable the feature. This can "
154 "be either a hostname (default scheme HTTPS, "
155 "default port 443) or a URL.",
iclelland65322b8d2016-02-29 22:05:22156 type=OriginFromArg)
157 parser.add_argument("trial_name",
158 help="Feature to enable. The current list of "
159 "experimental feature trials can be found in "
160 "RuntimeFeatures.in")
161 parser.add_argument("--key-file",
162 help="Ed25519 private key file to sign the token with",
mgiuca84e0cd22016-08-10 05:47:22163 default=default_key_file_absolute)
chasej4f0cb8e2016-10-13 21:32:33164
165 subdomain_group = parser.add_mutually_exclusive_group()
166 subdomain_group.add_argument("--is-subdomain",
167 help="Token will enable the feature for all "
168 "subdomains that match the origin",
169 dest="is_subdomain",
170 action="store_true")
171 subdomain_group.add_argument("--no-subdomain",
172 help="Token will only match the specified "
173 "origin (default behavior)",
174 dest="is_subdomain",
175 action="store_false")
176 parser.set_defaults(is_subdomain=None)
177
Rodney Ding85a215682020-05-04 02:16:05178 third_party_group = parser.add_mutually_exclusive_group()
179 third_party_group.add_argument(
180 "--is-third-party",
181 help="Token will enable the feature for third "
182 "party origins. This option is only available for token version 3",
183 dest="is_third_party",
184 action="store_true")
185 third_party_group.add_argument(
186 "--no-third-party",
187 help="Token will only match first party origin. This option is only "
188 "available for token version 3",
189 dest="is_third_party",
190 action="store_false")
191 parser.set_defaults(is_third_party=None)
192
Rodney Ding66c126d2020-06-08 05:14:06193 parser.add_argument("--usage-restriction",
194 help="Alternative token usage resctriction. This option "
195 "is only available for token version 3. Currently only "
196 "subset exclusion is supported.")
197
iclelland65322b8d2016-02-29 22:05:22198 expiry_group = parser.add_mutually_exclusive_group()
199 expiry_group.add_argument("--expire-days",
chasej4f0cb8e2016-10-13 21:32:33200 help="Days from now when the token should expire",
iclelland65322b8d2016-02-29 22:05:22201 type=int,
202 default=42)
203 expiry_group.add_argument("--expire-timestamp",
204 help="Exact time (seconds since 1970-01-01 "
chasej4f0cb8e2016-10-13 21:32:33205 "00:00:00 UTC) when the token should expire",
iclelland65322b8d2016-02-29 22:05:22206 type=int)
207
Lingqi Chi22fe54832021-11-30 07:26:15208 return parser.parse_args()
209
210
211def GenerateTokenAndSignature():
212 args = ParseArgs()
iclelland65322b8d2016-02-29 22:05:22213 expiry = ExpiryFromArgs(args)
214
Daniel Smithb4f30fb2022-01-15 01:21:28215 version_int, version_bytes = args.version
216
217 with open(os.path.expanduser(args.key_file), mode="rb") as key_file:
218 private_key = key_file.read(64)
iclelland65322b8d2016-02-29 22:05:22219
220 # Validate that the key file read was a proper Ed25519 key -- running the
221 # publickey method on the first half of the key should return the second
222 # half.
223 if (len(private_key) < 64 or
224 ed25519.publickey(private_key[:32]) != private_key[32:]):
225 print("Unable to use the specified private key file.")
226 sys.exit(1)
227
Daniel Smithb4f30fb2022-01-15 01:21:28228 if (not version_int):
Glen Robertsone861d7752020-09-17 14:23:57229 print("Invalid token version. Only version 2 and 3 are supported.")
Rodney Ding85a215682020-05-04 02:16:05230 sys.exit(1)
231
Daniel Smithb4f30fb2022-01-15 01:21:28232 if (args.is_third_party is not None and version_int != 3):
Rodney Ding85a215682020-05-04 02:16:05233 print("Only version 3 token supports is_third_party flag.")
234 sys.exit(1)
235
Rodney Ding66c126d2020-06-08 05:14:06236 if (args.usage_restriction is not None):
Daniel Smithb4f30fb2022-01-15 01:21:28237 if (version_int != 3):
Rodney Ding66c126d2020-06-08 05:14:06238 print("Only version 3 token supports alternative usage restriction.")
239 sys.exit(1)
Rodney Ding66c126d2020-06-08 05:14:06240 if (args.usage_restriction not in USAGE_RESTRICTION):
241 print(
242 "Only empty string and \"subset\" are supported in alternative usage "
243 "restriction.")
244 sys.exit(1)
Daniel Smithb4f30fb2022-01-15 01:21:28245 token_data = GenerateTokenData(version_int, args.origin, args.is_subdomain,
246 args.is_third_party, args.usage_restriction,
247 args.trial_name, expiry)
248 data_to_sign = GenerateDataToSign(version_bytes, token_data)
iclelland0709f4ee2016-04-14 16:21:17249 signature = Sign(private_key, data_to_sign)
iclelland65322b8d2016-02-29 22:05:22250
251 # Verify that that the signature is correct before printing it.
252 try:
iclelland0709f4ee2016-04-14 16:21:17253 ed25519.checkvalid(signature, data_to_sign, private_key[32:])
Jonathan Njeunjee45f2bd2021-10-12 16:21:58254 except Exception as exc:
Raul Tambre26d7db42019-09-25 11:06:35255 print("There was an error generating the signature.")
256 print("(The original error was: %s)" % exc)
iclelland65322b8d2016-02-29 22:05:22257 sys.exit(1)
258
Daniel Smithb4f30fb2022-01-15 01:21:28259 token_data = GenerateTokenData(version_int, args.origin, args.is_subdomain,
260 args.is_third_party, args.usage_restriction,
261 args.trial_name, expiry)
262 data_to_sign = GenerateDataToSign(version_bytes, token_data)
Lingqi Chi22fe54832021-11-30 07:26:15263 signature = Sign(private_key, data_to_sign)
Louise Brettdcac22cb2022-01-05 00:01:40264 return args, token_data, signature, expiry
Lingqi Chi22fe54832021-11-30 07:26:15265
266
267def main():
Louise Brettdcac22cb2022-01-05 00:01:40268 args, token_data, signature, expiry = GenerateTokenAndSignature()
Daniel Smithb4f30fb2022-01-15 01:21:28269 version_int, version_bytes = args.version
iclelland08b9e8da2016-06-16 08:18:26270
271 # Output the token details
Raul Tambre26d7db42019-09-25 11:06:35272 print("Token details:")
Daniel Smithb4f30fb2022-01-15 01:21:28273 print(" Version: %s" % version_int)
Raul Tambre26d7db42019-09-25 11:06:35274 print(" Origin: %s" % args.origin)
275 print(" Is Subdomain: %s" % args.is_subdomain)
Daniel Smithb4f30fb2022-01-15 01:21:28276 if version_int == 3:
Rodney Ding85a215682020-05-04 02:16:05277 print(" Is Third Party: %s" % args.is_third_party)
Rodney Ding66c126d2020-06-08 05:14:06278 print(" Usage Restriction: %s" % args.usage_restriction)
Raul Tambre26d7db42019-09-25 11:06:35279 print(" Feature: %s" % args.trial_name)
280 print(" Expiry: %d (%s UTC)" % (expiry, datetime.utcfromtimestamp(expiry)))
Daniel Smithb4f30fb2022-01-15 01:21:28281 print(" Signature: %s" % ", ".join('0x%02x' % x for x in signature))
282 b64_signature = base64.b64encode(signature).decode("ascii")
283 print(" Signature (Base64): %s" % b64_signature)
Raul Tambre26d7db42019-09-25 11:06:35284 print()
iclelland08b9e8da2016-06-16 08:18:26285
286 # Output the properly-formatted token.
Daniel Smithb4f30fb2022-01-15 01:21:28287 print(FormatToken(version_bytes, signature, token_data))
Raul Tambre26d7db42019-09-25 11:06:35288
iclelland65322b8d2016-02-29 22:05:22289
290if __name__ == "__main__":
291 main()