signer.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. // Copyright 2020 Google LLC
  2. //
  3. // Licensed under the Apache License, Version 2.0 (the "License");
  4. // you may not use this file except in compliance with the License.
  5. // You may obtain a copy of the License at
  6. //
  7. // http://www.apache.org/licenses/LICENSE-2.0
  8. //
  9. // Unless required by applicable law or agreed to in writing, software
  10. // distributed under the License is distributed on an "AS IS" BASIS,
  11. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. // See the License for the specific language governing permissions and
  13. // limitations under the License.
  14. import * as crypto from 'crypto';
  15. import * as url from 'url';
  16. import { ExceptionMessages, Storage } from './storage.js';
  17. import { encodeURI, qsStringify, objectEntries, formatAsUTCISO } from './util.js';
  18. export var SignerExceptionMessages;
  19. (function (SignerExceptionMessages) {
  20. SignerExceptionMessages["ACCESSIBLE_DATE_INVALID"] = "The accessible at date provided was invalid.";
  21. SignerExceptionMessages["EXPIRATION_BEFORE_ACCESSIBLE_DATE"] = "An expiration date cannot be before accessible date.";
  22. SignerExceptionMessages["X_GOOG_CONTENT_SHA256"] = "The header X-Goog-Content-SHA256 must be a hexadecimal string.";
  23. })(SignerExceptionMessages || (SignerExceptionMessages = {}));
  24. /*
  25. * Default signing version for getSignedUrl is 'v2'.
  26. */
  27. const DEFAULT_SIGNING_VERSION = 'v2';
  28. const SEVEN_DAYS = 7 * 24 * 60 * 60;
  29. /**
  30. * @const {string}
  31. * @deprecated - unused
  32. */
  33. export const PATH_STYLED_HOST = 'https://storage.googleapis.com';
  34. export class URLSigner {
  35. constructor(auth, bucket, file,
  36. /**
  37. * A {@link Storage} object.
  38. *
  39. * @privateRemarks
  40. *
  41. * Technically this is a required field, however it would be a breaking change to
  42. * move it before optional properties. In the next major we should refactor the
  43. * constructor of this class to only accept a config object.
  44. */
  45. storage = new Storage()) {
  46. this.auth = auth;
  47. this.bucket = bucket;
  48. this.file = file;
  49. this.storage = storage;
  50. }
  51. getSignedUrl(cfg) {
  52. const expiresInSeconds = this.parseExpires(cfg.expires);
  53. const method = cfg.method;
  54. const accessibleAtInSeconds = this.parseAccessibleAt(cfg.accessibleAt);
  55. if (expiresInSeconds < accessibleAtInSeconds) {
  56. throw new Error(SignerExceptionMessages.EXPIRATION_BEFORE_ACCESSIBLE_DATE);
  57. }
  58. let customHost;
  59. // Default style is `path`.
  60. const isVirtualHostedStyle = cfg.virtualHostedStyle || false;
  61. if (cfg.cname) {
  62. customHost = cfg.cname;
  63. }
  64. else if (isVirtualHostedStyle) {
  65. customHost = `https://${this.bucket.name}.storage.${this.storage.universeDomain}`;
  66. }
  67. const secondsToMilliseconds = 1000;
  68. const config = Object.assign({}, cfg, {
  69. method,
  70. expiration: expiresInSeconds,
  71. accessibleAt: new Date(secondsToMilliseconds * accessibleAtInSeconds),
  72. bucket: this.bucket.name,
  73. file: this.file ? encodeURI(this.file.name, false) : undefined,
  74. });
  75. if (customHost) {
  76. config.cname = customHost;
  77. }
  78. const version = cfg.version || DEFAULT_SIGNING_VERSION;
  79. let promise;
  80. if (version === 'v2') {
  81. promise = this.getSignedUrlV2(config);
  82. }
  83. else if (version === 'v4') {
  84. promise = this.getSignedUrlV4(config);
  85. }
  86. else {
  87. throw new Error(`Invalid signed URL version: ${version}. Supported versions are 'v2' and 'v4'.`);
  88. }
  89. return promise.then(query => {
  90. var _a;
  91. query = Object.assign(query, cfg.queryParams);
  92. const signedUrl = new url.URL(((_a = cfg.host) === null || _a === void 0 ? void 0 : _a.toString()) || config.cname || this.storage.apiEndpoint);
  93. signedUrl.pathname = this.getResourcePath(!!config.cname, this.bucket.name, config.file);
  94. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  95. signedUrl.search = qsStringify(query);
  96. return signedUrl.href;
  97. });
  98. }
  99. getSignedUrlV2(config) {
  100. const canonicalHeadersString = this.getCanonicalHeaders(config.extensionHeaders || {});
  101. const resourcePath = this.getResourcePath(false, config.bucket, config.file);
  102. const blobToSign = [
  103. config.method,
  104. config.contentMd5 || '',
  105. config.contentType || '',
  106. config.expiration,
  107. canonicalHeadersString + resourcePath,
  108. ].join('\n');
  109. const sign = async () => {
  110. var _a;
  111. const auth = this.auth;
  112. try {
  113. const signature = await auth.sign(blobToSign, (_a = config.signingEndpoint) === null || _a === void 0 ? void 0 : _a.toString());
  114. const credentials = await auth.getCredentials();
  115. return {
  116. GoogleAccessId: credentials.client_email,
  117. Expires: config.expiration,
  118. Signature: signature,
  119. };
  120. }
  121. catch (err) {
  122. const error = err;
  123. const signingErr = new SigningError(error.message);
  124. signingErr.stack = error.stack;
  125. throw signingErr;
  126. }
  127. };
  128. return sign();
  129. }
  130. getSignedUrlV4(config) {
  131. var _a;
  132. config.accessibleAt = config.accessibleAt
  133. ? config.accessibleAt
  134. : new Date();
  135. const millisecondsToSeconds = 1.0 / 1000.0;
  136. const expiresPeriodInSeconds = config.expiration - config.accessibleAt.valueOf() * millisecondsToSeconds;
  137. // v4 limit expiration to be 7 days maximum
  138. if (expiresPeriodInSeconds > SEVEN_DAYS) {
  139. throw new Error(`Max allowed expiration is seven days (${SEVEN_DAYS} seconds).`);
  140. }
  141. const extensionHeaders = Object.assign({}, config.extensionHeaders);
  142. const fqdn = new url.URL(((_a = config.host) === null || _a === void 0 ? void 0 : _a.toString()) || config.cname || this.storage.apiEndpoint);
  143. extensionHeaders.host = fqdn.hostname;
  144. if (config.contentMd5) {
  145. extensionHeaders['content-md5'] = config.contentMd5;
  146. }
  147. if (config.contentType) {
  148. extensionHeaders['content-type'] = config.contentType;
  149. }
  150. let contentSha256;
  151. const sha256Header = extensionHeaders['x-goog-content-sha256'];
  152. if (sha256Header) {
  153. if (typeof sha256Header !== 'string' ||
  154. !/[A-Fa-f0-9]{40}/.test(sha256Header)) {
  155. throw new Error(SignerExceptionMessages.X_GOOG_CONTENT_SHA256);
  156. }
  157. contentSha256 = sha256Header;
  158. }
  159. const signedHeaders = Object.keys(extensionHeaders)
  160. .map(header => header.toLowerCase())
  161. .sort()
  162. .join(';');
  163. const extensionHeadersString = this.getCanonicalHeaders(extensionHeaders);
  164. const datestamp = formatAsUTCISO(config.accessibleAt);
  165. const credentialScope = `${datestamp}/auto/storage/goog4_request`;
  166. const sign = async () => {
  167. var _a;
  168. const credentials = await this.auth.getCredentials();
  169. const credential = `${credentials.client_email}/${credentialScope}`;
  170. const dateISO = formatAsUTCISO(config.accessibleAt ? config.accessibleAt : new Date(), true);
  171. const queryParams = {
  172. 'X-Goog-Algorithm': 'GOOG4-RSA-SHA256',
  173. 'X-Goog-Credential': credential,
  174. 'X-Goog-Date': dateISO,
  175. 'X-Goog-Expires': expiresPeriodInSeconds.toString(10),
  176. 'X-Goog-SignedHeaders': signedHeaders,
  177. ...(config.queryParams || {}),
  178. };
  179. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  180. const canonicalQueryParams = this.getCanonicalQueryParams(queryParams);
  181. const canonicalRequest = this.getCanonicalRequest(config.method, this.getResourcePath(!!config.cname, config.bucket, config.file), canonicalQueryParams, extensionHeadersString, signedHeaders, contentSha256);
  182. const hash = crypto
  183. .createHash('sha256')
  184. .update(canonicalRequest)
  185. .digest('hex');
  186. const blobToSign = [
  187. 'GOOG4-RSA-SHA256',
  188. dateISO,
  189. credentialScope,
  190. hash,
  191. ].join('\n');
  192. try {
  193. const signature = await this.auth.sign(blobToSign, (_a = config.signingEndpoint) === null || _a === void 0 ? void 0 : _a.toString());
  194. const signatureHex = Buffer.from(signature, 'base64').toString('hex');
  195. const signedQuery = Object.assign({}, queryParams, {
  196. 'X-Goog-Signature': signatureHex,
  197. });
  198. return signedQuery;
  199. }
  200. catch (err) {
  201. const error = err;
  202. const signingErr = new SigningError(error.message);
  203. signingErr.stack = error.stack;
  204. throw signingErr;
  205. }
  206. };
  207. return sign();
  208. }
  209. /**
  210. * Create canonical headers for signing v4 url.
  211. *
  212. * The canonical headers for v4-signing a request demands header names are
  213. * first lowercased, followed by sorting the header names.
  214. * Then, construct the canonical headers part of the request:
  215. * <lowercasedHeaderName> + ":" + Trim(<value>) + "\n"
  216. * ..
  217. * <lowercasedHeaderName> + ":" + Trim(<value>) + "\n"
  218. *
  219. * @param headers
  220. * @private
  221. */
  222. getCanonicalHeaders(headers) {
  223. // Sort headers by their lowercased names
  224. const sortedHeaders = objectEntries(headers)
  225. // Convert header names to lowercase
  226. .map(([headerName, value]) => [
  227. headerName.toLowerCase(),
  228. value,
  229. ])
  230. .sort((a, b) => a[0].localeCompare(b[0]));
  231. return sortedHeaders
  232. .filter(([, value]) => value !== undefined)
  233. .map(([headerName, value]) => {
  234. // - Convert Array (multi-valued header) into string, delimited by
  235. // ',' (no space).
  236. // - Trim leading and trailing spaces.
  237. // - Convert sequential (2+) spaces into a single space
  238. const canonicalValue = `${value}`.trim().replace(/\s{2,}/g, ' ');
  239. return `${headerName}:${canonicalValue}\n`;
  240. })
  241. .join('');
  242. }
  243. getCanonicalRequest(method, path, query, headers, signedHeaders, contentSha256) {
  244. return [
  245. method,
  246. path,
  247. query,
  248. headers,
  249. signedHeaders,
  250. contentSha256 || 'UNSIGNED-PAYLOAD',
  251. ].join('\n');
  252. }
  253. getCanonicalQueryParams(query) {
  254. return objectEntries(query)
  255. .map(([key, value]) => [encodeURI(key, true), encodeURI(value, true)])
  256. .sort((a, b) => (a[0] < b[0] ? -1 : 1))
  257. .map(([key, value]) => `${key}=${value}`)
  258. .join('&');
  259. }
  260. getResourcePath(cname, bucket, file) {
  261. if (cname) {
  262. return '/' + (file || '');
  263. }
  264. else if (file) {
  265. return `/${bucket}/${file}`;
  266. }
  267. else {
  268. return `/${bucket}`;
  269. }
  270. }
  271. parseExpires(expires, current = new Date()) {
  272. const expiresInMSeconds = new Date(expires).valueOf();
  273. if (isNaN(expiresInMSeconds)) {
  274. throw new Error(ExceptionMessages.EXPIRATION_DATE_INVALID);
  275. }
  276. if (expiresInMSeconds < current.valueOf()) {
  277. throw new Error(ExceptionMessages.EXPIRATION_DATE_PAST);
  278. }
  279. return Math.floor(expiresInMSeconds / 1000); // The API expects seconds.
  280. }
  281. parseAccessibleAt(accessibleAt) {
  282. const accessibleAtInMSeconds = new Date(accessibleAt || new Date()).valueOf();
  283. if (isNaN(accessibleAtInMSeconds)) {
  284. throw new Error(SignerExceptionMessages.ACCESSIBLE_DATE_INVALID);
  285. }
  286. return Math.floor(accessibleAtInMSeconds / 1000); // The API expects seconds.
  287. }
  288. }
  289. /**
  290. * Custom error type for errors related to getting signed errors and policies.
  291. *
  292. * @private
  293. */
  294. export class SigningError extends Error {
  295. constructor() {
  296. super(...arguments);
  297. this.name = 'SigningError';
  298. }
  299. }