#!/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 IsExtensionId(arg):
"""Determines whether a string represents a valid Chromium extension origin.
Returns True if the argument is valid extension origin, or False otherwise.
"""
extensionIdRegex = re.compile(r"[a-p]{32}")
return bool(extensionIdRegex.fullmatch(arg))
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", "chrome-extension"):
raise argparse.ArgumentTypeError("%s does not use a recognized URL scheme" %
arg)
# Is it a valid extension origin?
if origin.scheme == "chrome-extension":
if (IsExtensionId(origin.hostname) and not origin.port
and not origin.username and not origin.password):
return "chrome-extension://{0}".format(origin.hostname)
raise argparse.ArgumentTypeError("%s is not a valid extension origin" % 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):
expiry: int
if args.expire_timestamp:
expiry = int(args.expire_timestamp)
else:
expiry = (int(time.time()) + (int(args.expire_days) * 86400))
if expiry > 2**31 - 1:
# The maximum expiry timestamp is bound by the maximum value of a signed
# 32-bit integer (2^31-1).
# TODO(crbug.com/40872096): All expiries after 2038-01-19 03:14:07 UTC
# will raise this error, so add support for a larger range of values
# before then.
raise argparse.ArgumentTypeError(
"%d (%s UTC) is beyond the range of supported expiries" %
(expiry, datetime.utcfromtimestamp(expiry)))
return expiry
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()