/tools/origin_trials/generate_token.py
Python | 291 lines | 275 code | 1 blank | 15 comment | 15 complexity | fda4af72e427127d01d695a935c1132d MD5 | raw file
Possible License(s): MPL-2.0-no-copyleft-exception, Apache-2.0, BSD-3-Clause
- #!/usr/bin/env python3
- # Copyright (c) 2016 The Chromium Authors. All rights reserved.
- # 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()