Daniel Smith | b4f30fb | 2022-01-15 01:21:28 | [diff] [blame] | 1 | #!/usr/bin/env python3 |
Avi Drissman | dfd88085 | 2022-09-15 20:11:09 | [diff] [blame] | 2 | # Copyright 2016 The Chromium Authors |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 3 | # 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 | |
| 8 | usage: generate_token.py [-h] [--key-file KEY_FILE] |
| 9 | [--expire-days EXPIRE_DAYS | |
| 10 | --expire-timestamp EXPIRE_TIMESTAMP] |
Nan Lin | 854e6035 | 2023-05-08 16:19:58 | [diff] [blame] | 11 | [--is-subdomain | --no-subdomain] |
| 12 | [--is-third-party | --no-third-party] |
Rodney Ding | 66c126d | 2020-06-08 05:14:06 | [diff] [blame] | 13 | [--usage-restriction USAGE_RESTRICTION] |
Jason Chase | ca7f1ea | 2020-05-21 16:58:44 | [diff] [blame] | 14 | --version=VERSION |
| 15 | origin trial_name |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 16 | |
| 17 | Run "generate_token.py -h" for more help on usage. |
| 18 | """ |
Raul Tambre | 26d7db4 | 2019-09-25 11:06:35 | [diff] [blame] | 19 | |
| 20 | from __future__ import print_function |
| 21 | |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 22 | import argparse |
| 23 | import base64 |
iclelland | 0709f4ee | 2016-04-14 16:21:17 | [diff] [blame] | 24 | import json |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 25 | import os |
Lingqi Chi | 22fe5483 | 2021-11-30 07:26:15 | [diff] [blame] | 26 | import re |
iclelland | 0709f4ee | 2016-04-14 16:21:17 | [diff] [blame] | 27 | import struct |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 28 | import sys |
| 29 | import time |
Lingqi Chi | 22fe5483 | 2021-11-30 07:26:15 | [diff] [blame] | 30 | from datetime import datetime |
| 31 | |
| 32 | from six import raise_from |
Daniel Smith | b4f30fb | 2022-01-15 01:21:28 | [diff] [blame] | 33 | from urllib.parse import urlparse |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 34 | |
| 35 | script_dir = os.path.dirname(os.path.realpath(__file__)) |
| 36 | sys.path.insert(0, os.path.join(script_dir, 'third_party', 'ed25519')) |
| 37 | import ed25519 |
| 38 | |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 39 | # Matches a valid DNS name label (alphanumeric plus hyphens, except at the ends, |
| 40 | # no longer than 63 ASCII characters) |
| 41 | DNS_LABEL_REGEX = re.compile(r"^(?!-)[a-z\d-]{1,63}(?<!-)$", re.IGNORECASE) |
| 42 | |
Daniel Smith | b4f30fb | 2022-01-15 01:21:28 | [diff] [blame] | 43 | # Only Version 2 and Version 3 are currently supported. |
| 44 | VERSIONS = {"2": (2, b'\x02'), "3": (3, b'\x03')} |
iclelland | 0709f4ee | 2016-04-14 16:21:17 | [diff] [blame] | 45 | |
Rodney Ding | 66c126d | 2020-06-08 05:14:06 | [diff] [blame] | 46 | # Only empty string and "subset" are currently supoprted in alternative usage |
| 47 | # resetriction. |
| 48 | USAGE_RESTRICTION = ["", "subset"] |
| 49 | |
mgiuca | 84e0cd2 | 2016-08-10 05:47:22 | [diff] [blame] | 50 | # Default key file, relative to script_dir. |
| 51 | DEFAULT_KEY_FILE = 'eftest.key' |
| 52 | |
Rodney Ding | 85a21568 | 2020-05-04 02:16:05 | [diff] [blame] | 53 | |
| 54 | def VersionFromArg(arg): |
| 55 | """Determines whether a string represents a valid version. |
| 56 | Only Version 2 and Version 3 are currently supported. |
| 57 | |
Daniel Smith | b4f30fb | 2022-01-15 01:21:28 | [diff] [blame] | 58 | Returns a tuple of the int and bytes representation of version. |
Rodney Ding | 85a21568 | 2020-05-04 02:16:05 | [diff] [blame] | 59 | Returns None if version is not valid. |
| 60 | """ |
Daniel Smith | b4f30fb | 2022-01-15 01:21:28 | [diff] [blame] | 61 | return VERSIONS.get(arg, None) |
Rodney Ding | 85a21568 | 2020-05-04 02:16:05 | [diff] [blame] | 62 | |
| 63 | |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 64 | def 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] |
iclelland | 08b9e8da | 2016-06-16 08:18:26 | [diff] [blame] | 73 | if "." not in arg and arg != "localhost": |
| 74 | return None |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 75 | if all(DNS_LABEL_REGEX.match(label) for label in arg.split(".")): |
| 76 | return arg.lower() |
Lingqi Chi | 900ecfe | 2021-11-23 02:15:44 | [diff] [blame] | 77 | return None |
| 78 | |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 79 | |
Anton Bershanskyi | a305fe3 | 2024-01-31 19:45:29 | [diff] [blame] | 80 | def 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 | |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 89 | def 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 Njeunje | e45f2bd | 2021-10-12 16:21:58 | [diff] [blame] | 100 | origin = urlparse(arg) |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 101 | 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 Bershanskyi | a305fe3 | 2024-01-31 19:45:29 | [diff] [blame] | 104 | if origin.scheme not in ("https", "http", "chrome-extension"): |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 105 | raise argparse.ArgumentTypeError("%s does not use a recognized URL scheme" % |
| 106 | arg) |
Anton Bershanskyi | a305fe3 | 2024-01-31 19:45:29 | [diff] [blame] | 107 | # 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) |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 113 | # Add default port if it is not specified |
| 114 | try: |
| 115 | port = origin.port |
Lingqi Chi | 900ecfe | 2021-11-23 02:15:44 | [diff] [blame] | 116 | except ValueError as e: |
Lingqi Chi | 22fe5483 | 2021-11-30 07:26:15 | [diff] [blame] | 117 | raise_from( |
| 118 | argparse.ArgumentTypeError("%s is not a hostname or a URL" % arg), e) |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 119 | 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 | |
| 124 | def ExpiryFromArgs(args): |
Joshua Hood | c2da0a5 | 2024-05-16 16:29:51 | [diff] [blame] | 125 | expiry: int |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 126 | if args.expire_timestamp: |
Joshua Hood | c2da0a5 | 2024-05-16 16:29:51 | [diff] [blame] | 127 | expiry = int(args.expire_timestamp) |
| 128 | else: |
| 129 | expiry = (int(time.time()) + (int(args.expire_days) * 86400)) |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 130 | |
Joshua Hood | c2da0a5 | 2024-05-16 16:29:51 | [diff] [blame] | 131 | 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 Ding | 85a21568 | 2020-05-04 02:16:05 | [diff] [blame] | 141 | |
| 142 | def GenerateTokenData(version, origin, is_subdomain, is_third_party, |
Rodney Ding | 66c126d | 2020-06-08 05:14:06 | [diff] [blame] | 143 | usage_restriction, feature_name, expiry): |
chasej | 4f0cb8e | 2016-10-13 21:32:33 | [diff] [blame] | 144 | data = {"origin": origin, |
| 145 | "feature": feature_name, |
| 146 | "expiry": expiry} |
| 147 | if is_subdomain is not None: |
| 148 | data["isSubdomain"] = is_subdomain |
Rodney Ding | 66c126d | 2020-06-08 05:14:06 | [diff] [blame] | 149 | # Only version 3 token supports fields: is_third_party, usage. |
Rodney Ding | 85a21568 | 2020-05-04 02:16:05 | [diff] [blame] | 150 | if version == 3 and is_third_party is not None: |
| 151 | data["isThirdParty"] = is_third_party |
Rodney Ding | 66c126d | 2020-06-08 05:14:06 | [diff] [blame] | 152 | if version == 3 and usage_restriction is not None: |
| 153 | data["usage"] = usage_restriction |
chasej | 4f0cb8e | 2016-10-13 21:32:33 | [diff] [blame] | 154 | return json.dumps(data).encode('utf-8') |
iclelland | 0709f4ee | 2016-04-14 16:21:17 | [diff] [blame] | 155 | |
| 156 | def GenerateDataToSign(version, data): |
| 157 | return version + struct.pack(">I",len(data)) + data |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 158 | |
Daniel Smith | b4f30fb | 2022-01-15 01:21:28 | [diff] [blame] | 159 | |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 160 | def Sign(private_key, data): |
| 161 | return ed25519.signature(data, private_key[:32], private_key[32:]) |
| 162 | |
Daniel Smith | b4f30fb | 2022-01-15 01:21:28 | [diff] [blame] | 163 | |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 164 | def FormatToken(version, signature, data): |
Daniel Smith | b4f30fb | 2022-01-15 01:21:28 | [diff] [blame] | 165 | return base64.b64encode(version + signature + struct.pack(">I", len(data)) + |
| 166 | data).decode("ascii") |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 167 | |
Lingqi Chi | 22fe5483 | 2021-11-30 07:26:15 | [diff] [blame] | 168 | |
| 169 | def ParseArgs(): |
mgiuca | 84e0cd2 | 2016-08-10 05:47:22 | [diff] [blame] | 170 | default_key_file_absolute = os.path.join(script_dir, DEFAULT_KEY_FILE) |
| 171 | |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 172 | parser = argparse.ArgumentParser( |
chasej | 4f0cb8e | 2016-10-13 21:32:33 | [diff] [blame] | 173 | description="Generate tokens for enabling experimental features") |
Jason Chase | ca7f1ea | 2020-05-21 16:58:44 | [diff] [blame] | 174 | parser.add_argument("--version", |
Jonathan Hao | 0480a863 | 2022-10-04 18:01:18 | [diff] [blame] | 175 | help="Token version to use. Currently only version 2 " |
Jason Chase | ca7f1ea | 2020-05-21 16:58:44 | [diff] [blame] | 176 | "and version 3 are supported.", |
Glen Robertson | e861d775 | 2020-09-17 14:23:57 | [diff] [blame] | 177 | default='3', |
Jason Chase | ca7f1ea | 2020-05-21 16:58:44 | [diff] [blame] | 178 | type=VersionFromArg) |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 179 | parser.add_argument("origin", |
chasej | 4f0cb8e | 2016-10-13 21:32:33 | [diff] [blame] | 180 | 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.", |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 183 | 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", |
mgiuca | 84e0cd2 | 2016-08-10 05:47:22 | [diff] [blame] | 190 | default=default_key_file_absolute) |
chasej | 4f0cb8e | 2016-10-13 21:32:33 | [diff] [blame] | 191 | |
| 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 Ding | 85a21568 | 2020-05-04 02:16:05 | [diff] [blame] | 205 | 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 Ding | 66c126d | 2020-06-08 05:14:06 | [diff] [blame] | 220 | 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 | |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 225 | expiry_group = parser.add_mutually_exclusive_group() |
| 226 | expiry_group.add_argument("--expire-days", |
chasej | 4f0cb8e | 2016-10-13 21:32:33 | [diff] [blame] | 227 | help="Days from now when the token should expire", |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 228 | type=int, |
| 229 | default=42) |
| 230 | expiry_group.add_argument("--expire-timestamp", |
| 231 | help="Exact time (seconds since 1970-01-01 " |
chasej | 4f0cb8e | 2016-10-13 21:32:33 | [diff] [blame] | 232 | "00:00:00 UTC) when the token should expire", |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 233 | type=int) |
| 234 | |
Lingqi Chi | 22fe5483 | 2021-11-30 07:26:15 | [diff] [blame] | 235 | return parser.parse_args() |
| 236 | |
| 237 | |
| 238 | def GenerateTokenAndSignature(): |
| 239 | args = ParseArgs() |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 240 | expiry = ExpiryFromArgs(args) |
| 241 | |
Daniel Smith | b4f30fb | 2022-01-15 01:21:28 | [diff] [blame] | 242 | 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) |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 246 | |
| 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 Smith | b4f30fb | 2022-01-15 01:21:28 | [diff] [blame] | 255 | if (not version_int): |
Glen Robertson | e861d775 | 2020-09-17 14:23:57 | [diff] [blame] | 256 | print("Invalid token version. Only version 2 and 3 are supported.") |
Rodney Ding | 85a21568 | 2020-05-04 02:16:05 | [diff] [blame] | 257 | sys.exit(1) |
| 258 | |
Daniel Smith | b4f30fb | 2022-01-15 01:21:28 | [diff] [blame] | 259 | if (args.is_third_party is not None and version_int != 3): |
Rodney Ding | 85a21568 | 2020-05-04 02:16:05 | [diff] [blame] | 260 | print("Only version 3 token supports is_third_party flag.") |
| 261 | sys.exit(1) |
| 262 | |
Rodney Ding | 66c126d | 2020-06-08 05:14:06 | [diff] [blame] | 263 | if (args.usage_restriction is not None): |
Daniel Smith | b4f30fb | 2022-01-15 01:21:28 | [diff] [blame] | 264 | if (version_int != 3): |
Rodney Ding | 66c126d | 2020-06-08 05:14:06 | [diff] [blame] | 265 | print("Only version 3 token supports alternative usage restriction.") |
| 266 | sys.exit(1) |
Rodney Ding | 66c126d | 2020-06-08 05:14:06 | [diff] [blame] | 267 | 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 Smith | b4f30fb | 2022-01-15 01:21:28 | [diff] [blame] | 272 | 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) |
iclelland | 0709f4ee | 2016-04-14 16:21:17 | [diff] [blame] | 276 | signature = Sign(private_key, data_to_sign) |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 277 | |
| 278 | # Verify that that the signature is correct before printing it. |
| 279 | try: |
iclelland | 0709f4ee | 2016-04-14 16:21:17 | [diff] [blame] | 280 | ed25519.checkvalid(signature, data_to_sign, private_key[32:]) |
Jonathan Njeunje | e45f2bd | 2021-10-12 16:21:58 | [diff] [blame] | 281 | except Exception as exc: |
Raul Tambre | 26d7db4 | 2019-09-25 11:06:35 | [diff] [blame] | 282 | print("There was an error generating the signature.") |
| 283 | print("(The original error was: %s)" % exc) |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 284 | sys.exit(1) |
| 285 | |
Daniel Smith | b4f30fb | 2022-01-15 01:21:28 | [diff] [blame] | 286 | 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 Chi | 22fe5483 | 2021-11-30 07:26:15 | [diff] [blame] | 290 | signature = Sign(private_key, data_to_sign) |
Louise Brett | dcac22cb | 2022-01-05 00:01:40 | [diff] [blame] | 291 | return args, token_data, signature, expiry |
Lingqi Chi | 22fe5483 | 2021-11-30 07:26:15 | [diff] [blame] | 292 | |
| 293 | |
| 294 | def main(): |
Louise Brett | dcac22cb | 2022-01-05 00:01:40 | [diff] [blame] | 295 | args, token_data, signature, expiry = GenerateTokenAndSignature() |
Daniel Smith | b4f30fb | 2022-01-15 01:21:28 | [diff] [blame] | 296 | version_int, version_bytes = args.version |
iclelland | 08b9e8da | 2016-06-16 08:18:26 | [diff] [blame] | 297 | |
| 298 | # Output the token details |
Raul Tambre | 26d7db4 | 2019-09-25 11:06:35 | [diff] [blame] | 299 | print("Token details:") |
Daniel Smith | b4f30fb | 2022-01-15 01:21:28 | [diff] [blame] | 300 | print(" Version: %s" % version_int) |
Raul Tambre | 26d7db4 | 2019-09-25 11:06:35 | [diff] [blame] | 301 | print(" Origin: %s" % args.origin) |
| 302 | print(" Is Subdomain: %s" % args.is_subdomain) |
Daniel Smith | b4f30fb | 2022-01-15 01:21:28 | [diff] [blame] | 303 | if version_int == 3: |
Rodney Ding | 85a21568 | 2020-05-04 02:16:05 | [diff] [blame] | 304 | print(" Is Third Party: %s" % args.is_third_party) |
Rodney Ding | 66c126d | 2020-06-08 05:14:06 | [diff] [blame] | 305 | print(" Usage Restriction: %s" % args.usage_restriction) |
Raul Tambre | 26d7db4 | 2019-09-25 11:06:35 | [diff] [blame] | 306 | print(" Feature: %s" % args.trial_name) |
| 307 | print(" Expiry: %d (%s UTC)" % (expiry, datetime.utcfromtimestamp(expiry))) |
Daniel Smith | b4f30fb | 2022-01-15 01:21:28 | [diff] [blame] | 308 | 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 Tambre | 26d7db4 | 2019-09-25 11:06:35 | [diff] [blame] | 311 | print() |
iclelland | 08b9e8da | 2016-06-16 08:18:26 | [diff] [blame] | 312 | |
| 313 | # Output the properly-formatted token. |
Daniel Smith | b4f30fb | 2022-01-15 01:21:28 | [diff] [blame] | 314 | print(FormatToken(version_bytes, signature, token_data)) |
Raul Tambre | 26d7db4 | 2019-09-25 11:06:35 | [diff] [blame] | 315 | |
iclelland | 65322b8d | 2016-02-29 22:05:22 | [diff] [blame] | 316 | |
| 317 | if __name__ == "__main__": |
| 318 | main() |