PageRenderTime 56ms CodeModel.GetById 27ms RepoModel.GetById 1ms app.codeStats 0ms

/tools/origin_trials/generate_token.py

https://github.com/chromium/chromium
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
  1. #!/usr/bin/env python3
  2. # Copyright (c) 2016 The Chromium Authors. All rights reserved.
  3. # Use of this source code is governed by a BSD-style license that can be
  4. # found in the LICENSE file.
  5. """Utility for generating experimental API tokens
  6. usage: generate_token.py [-h] [--key-file KEY_FILE]
  7. [--expire-days EXPIRE_DAYS |
  8. --expire-timestamp EXPIRE_TIMESTAMP]
  9. [--is_subdomain | --no-subdomain]
  10. [--is_third-party | --no-third-party]
  11. [--usage-restriction USAGE_RESTRICTION]
  12. --version=VERSION
  13. origin trial_name
  14. Run "generate_token.py -h" for more help on usage.
  15. """
  16. from __future__ import print_function
  17. import argparse
  18. import base64
  19. import json
  20. import os
  21. import re
  22. import struct
  23. import sys
  24. import time
  25. from datetime import datetime
  26. from six import raise_from
  27. from urllib.parse import urlparse
  28. script_dir = os.path.dirname(os.path.realpath(__file__))
  29. sys.path.insert(0, os.path.join(script_dir, 'third_party', 'ed25519'))
  30. import ed25519
  31. # Matches a valid DNS name label (alphanumeric plus hyphens, except at the ends,
  32. # no longer than 63 ASCII characters)
  33. DNS_LABEL_REGEX = re.compile(r"^(?!-)[a-z\d-]{1,63}(?<!-)$", re.IGNORECASE)
  34. # Only Version 2 and Version 3 are currently supported.
  35. VERSIONS = {"2": (2, b'\x02'), "3": (3, b'\x03')}
  36. # Only empty string and "subset" are currently supoprted in alternative usage
  37. # resetriction.
  38. USAGE_RESTRICTION = ["", "subset"]
  39. # Default key file, relative to script_dir.
  40. DEFAULT_KEY_FILE = 'eftest.key'
  41. def VersionFromArg(arg):
  42. """Determines whether a string represents a valid version.
  43. Only Version 2 and Version 3 are currently supported.
  44. Returns a tuple of the int and bytes representation of version.
  45. Returns None if version is not valid.
  46. """
  47. return VERSIONS.get(arg, None)
  48. def HostnameFromArg(arg):
  49. """Determines whether a string represents a valid hostname.
  50. Returns the canonical hostname if its argument is valid, or None otherwise.
  51. """
  52. if not arg or len(arg) > 255:
  53. return None
  54. if arg[-1] == ".":
  55. arg = arg[:-1]
  56. if "." not in arg and arg != "localhost":
  57. return None
  58. if all(DNS_LABEL_REGEX.match(label) for label in arg.split(".")):
  59. return arg.lower()
  60. return None
  61. def OriginFromArg(arg):
  62. """Constructs the origin for the token from a command line argument.
  63. Returns None if this is not possible (neither a valid hostname nor a
  64. valid origin URL was provided.)
  65. """
  66. # Does it look like a hostname?
  67. hostname = HostnameFromArg(arg)
  68. if hostname:
  69. return "https://" + hostname + ":443"
  70. # If not, try to construct an origin URL from the argument
  71. origin = urlparse(arg)
  72. if not origin or not origin.scheme or not origin.netloc:
  73. raise argparse.ArgumentTypeError("%s is not a hostname or a URL" % arg)
  74. # HTTPS or HTTP only
  75. if origin.scheme not in ('https','http'):
  76. raise argparse.ArgumentTypeError("%s does not use a recognized URL scheme" %
  77. arg)
  78. # Add default port if it is not specified
  79. try:
  80. port = origin.port
  81. except ValueError as e:
  82. raise_from(
  83. argparse.ArgumentTypeError("%s is not a hostname or a URL" % arg), e)
  84. if not port:
  85. port = {"https": 443, "http": 80}[origin.scheme]
  86. # Strip any extra components and return the origin URL:
  87. return "{0}://{1}:{2}".format(origin.scheme, origin.hostname, port)
  88. def ExpiryFromArgs(args):
  89. if args.expire_timestamp:
  90. return int(args.expire_timestamp)
  91. return (int(time.time()) + (int(args.expire_days) * 86400))
  92. def GenerateTokenData(version, origin, is_subdomain, is_third_party,
  93. usage_restriction, feature_name, expiry):
  94. data = {"origin": origin,
  95. "feature": feature_name,
  96. "expiry": expiry}
  97. if is_subdomain is not None:
  98. data["isSubdomain"] = is_subdomain
  99. # Only version 3 token supports fields: is_third_party, usage.
  100. if version == 3 and is_third_party is not None:
  101. data["isThirdParty"] = is_third_party
  102. if version == 3 and usage_restriction is not None:
  103. data["usage"] = usage_restriction
  104. return json.dumps(data).encode('utf-8')
  105. def GenerateDataToSign(version, data):
  106. return version + struct.pack(">I",len(data)) + data
  107. def Sign(private_key, data):
  108. return ed25519.signature(data, private_key[:32], private_key[32:])
  109. def FormatToken(version, signature, data):
  110. return base64.b64encode(version + signature + struct.pack(">I", len(data)) +
  111. data).decode("ascii")
  112. def ParseArgs():
  113. default_key_file_absolute = os.path.join(script_dir, DEFAULT_KEY_FILE)
  114. parser = argparse.ArgumentParser(
  115. description="Generate tokens for enabling experimental features")
  116. parser.add_argument("--version",
  117. help="Token version to use. Currently only version 2"
  118. "and version 3 are supported.",
  119. default='3',
  120. type=VersionFromArg)
  121. parser.add_argument("origin",
  122. help="Origin for which to enable the feature. This can "
  123. "be either a hostname (default scheme HTTPS, "
  124. "default port 443) or a URL.",
  125. type=OriginFromArg)
  126. parser.add_argument("trial_name",
  127. help="Feature to enable. The current list of "
  128. "experimental feature trials can be found in "
  129. "RuntimeFeatures.in")
  130. parser.add_argument("--key-file",
  131. help="Ed25519 private key file to sign the token with",
  132. default=default_key_file_absolute)
  133. subdomain_group = parser.add_mutually_exclusive_group()
  134. subdomain_group.add_argument("--is-subdomain",
  135. help="Token will enable the feature for all "
  136. "subdomains that match the origin",
  137. dest="is_subdomain",
  138. action="store_true")
  139. subdomain_group.add_argument("--no-subdomain",
  140. help="Token will only match the specified "
  141. "origin (default behavior)",
  142. dest="is_subdomain",
  143. action="store_false")
  144. parser.set_defaults(is_subdomain=None)
  145. third_party_group = parser.add_mutually_exclusive_group()
  146. third_party_group.add_argument(
  147. "--is-third-party",
  148. help="Token will enable the feature for third "
  149. "party origins. This option is only available for token version 3",
  150. dest="is_third_party",
  151. action="store_true")
  152. third_party_group.add_argument(
  153. "--no-third-party",
  154. help="Token will only match first party origin. This option is only "
  155. "available for token version 3",
  156. dest="is_third_party",
  157. action="store_false")
  158. parser.set_defaults(is_third_party=None)
  159. parser.add_argument("--usage-restriction",
  160. help="Alternative token usage resctriction. This option "
  161. "is only available for token version 3. Currently only "
  162. "subset exclusion is supported.")
  163. expiry_group = parser.add_mutually_exclusive_group()
  164. expiry_group.add_argument("--expire-days",
  165. help="Days from now when the token should expire",
  166. type=int,
  167. default=42)
  168. expiry_group.add_argument("--expire-timestamp",
  169. help="Exact time (seconds since 1970-01-01 "
  170. "00:00:00 UTC) when the token should expire",
  171. type=int)
  172. return parser.parse_args()
  173. def GenerateTokenAndSignature():
  174. args = ParseArgs()
  175. expiry = ExpiryFromArgs(args)
  176. version_int, version_bytes = args.version
  177. with open(os.path.expanduser(args.key_file), mode="rb") as key_file:
  178. private_key = key_file.read(64)
  179. # Validate that the key file read was a proper Ed25519 key -- running the
  180. # publickey method on the first half of the key should return the second
  181. # half.
  182. if (len(private_key) < 64 or
  183. ed25519.publickey(private_key[:32]) != private_key[32:]):
  184. print("Unable to use the specified private key file.")
  185. sys.exit(1)
  186. if (not version_int):
  187. print("Invalid token version. Only version 2 and 3 are supported.")
  188. sys.exit(1)
  189. if (args.is_third_party is not None and version_int != 3):
  190. print("Only version 3 token supports is_third_party flag.")
  191. sys.exit(1)
  192. if (args.usage_restriction is not None):
  193. if (version_int != 3):
  194. print("Only version 3 token supports alternative usage restriction.")
  195. sys.exit(1)
  196. if (args.usage_restriction not in USAGE_RESTRICTION):
  197. print(
  198. "Only empty string and \"subset\" are supported in alternative usage "
  199. "restriction.")
  200. sys.exit(1)
  201. token_data = GenerateTokenData(version_int, args.origin, args.is_subdomain,
  202. args.is_third_party, args.usage_restriction,
  203. args.trial_name, expiry)
  204. data_to_sign = GenerateDataToSign(version_bytes, token_data)
  205. signature = Sign(private_key, data_to_sign)
  206. # Verify that that the signature is correct before printing it.
  207. try:
  208. ed25519.checkvalid(signature, data_to_sign, private_key[32:])
  209. except Exception as exc:
  210. print("There was an error generating the signature.")
  211. print("(The original error was: %s)" % exc)
  212. sys.exit(1)
  213. token_data = GenerateTokenData(version_int, args.origin, args.is_subdomain,
  214. args.is_third_party, args.usage_restriction,
  215. args.trial_name, expiry)
  216. data_to_sign = GenerateDataToSign(version_bytes, token_data)
  217. signature = Sign(private_key, data_to_sign)
  218. return args, token_data, signature, expiry
  219. def main():
  220. args, token_data, signature, expiry = GenerateTokenAndSignature()
  221. version_int, version_bytes = args.version
  222. # Output the token details
  223. print("Token details:")
  224. print(" Version: %s" % version_int)
  225. print(" Origin: %s" % args.origin)
  226. print(" Is Subdomain: %s" % args.is_subdomain)
  227. if version_int == 3:
  228. print(" Is Third Party: %s" % args.is_third_party)
  229. print(" Usage Restriction: %s" % args.usage_restriction)
  230. print(" Feature: %s" % args.trial_name)
  231. print(" Expiry: %d (%s UTC)" % (expiry, datetime.utcfromtimestamp(expiry)))
  232. print(" Signature: %s" % ", ".join('0x%02x' % x for x in signature))
  233. b64_signature = base64.b64encode(signature).decode("ascii")
  234. print(" Signature (Base64): %s" % b64_signature)
  235. print()
  236. # Output the properly-formatted token.
  237. print(FormatToken(version_bytes, signature, token_data))
  238. if __name__ == "__main__":
  239. main()