blob: 6e49fbf839b99dbff89a1d3ba976d2b3296a5020 [file] [log] [blame]
#!/usr/bin/env python
# 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: [-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]
origin trial_name
Run " -h" for more help on usage.
from __future__ import print_function
import argparse
import base64
from datetime import datetime
import json
import re
import os
import struct
import sys
import time
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)
# This script generates Version 2 and 3 tokens.
VERSION = {"2": (2, "\x02"), "3": (3, "\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 (version number, version byte) if version is valid.
Returns None if version is not valid.
if not arg or len(arg) > 1:
return None
return VERSION.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()
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.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" %
# Add default port if it is not specified
port = origin.port
except ValueError:
raise argparse.ArgumentTypeError("%s is not a hostname or a URL" % arg)
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)
def main():
default_key_file_absolute = os.path.join(script_dir, DEFAULT_KEY_FILE)
parser = argparse.ArgumentParser(
description="Generate tokens for enabling experimental features")
help="Token version to use. Currently only version 2"
"and version 3 are supported.",
help="Origin for which to enable the feature. This can "
"be either a hostname (default scheme HTTPS, "
"default port 443) or a URL.",
help="Feature to enable. The current list of "
"experimental feature trials can be found in "
help="Ed25519 private key file to sign the token with",
subdomain_group = parser.add_mutually_exclusive_group()
help="Token will enable the feature for all "
"subdomains that match the origin",
help="Token will only match the specified "
"origin (default behavior)",
third_party_group = parser.add_mutually_exclusive_group()
help="Token will enable the feature for third "
"party origins. This option is only available for token version 3",
help="Token will only match first party origin. This option is only "
"available for token version 3",
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()
help="Days from now when the token should expire",
help="Exact time (seconds since 1970-01-01 "
"00:00:00 UTC) when the token should expire",
args = parser.parse_args()
expiry = ExpiryFromArgs(args)
key_file = open(os.path.expanduser(args.key_file), mode="rb")
private_key =
# 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.")
if (not args.version):
print("Invalid token version. Only version 2 and 3 are supported.")
if (args.is_third_party is not None and args.version[0] != 3):
print("Only version 3 token supports is_third_party flag.")
if (args.usage_restriction is not None):
if (args.version[0] != 3):
print("Only version 3 token supports alternative usage restriction.")
if (args.usage_restriction not in USAGE_RESTRICTION):
"Only empty string and \"subset\" are supported in alternative usage "
token_data = GenerateTokenData(args.version[0], args.origin,
args.is_subdomain, args.is_third_party,
args.usage_restriction, args.trial_name,
data_to_sign = GenerateDataToSign(args.version[1], token_data)
signature = Sign(private_key, data_to_sign)
# Verify that that the signature is correct before printing it.
ed25519.checkvalid(signature, data_to_sign, private_key[32:])
except Exception, exc:
print("There was an error generating the signature.")
print("(The original error was: %s)" % exc)
# Output the token details
print("Token details:")
print(" Version: %s" % args.version[0])
print(" Origin: %s" % args.origin)
print(" Is Subdomain: %s" % args.is_subdomain)
if args.version[0] == 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' % ord(x) for x in signature))
print(" Signature (Base64): %s" % base64.b64encode(signature))
# Output the properly-formatted token.
print(FormatToken(args.version[1], signature, token_data))
if __name__ == "__main__":