summaryrefslogtreecommitdiffstats
path: root/toolkit/components/normandy/lib/NormandyApi.sys.mjs
blob: 4b3387130f3d88ecd21626531a24dfaca93ebe37 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  CanonicalJSON: "resource://gre/modules/CanonicalJSON.sys.mjs",
});

const prefs = Services.prefs.getBranch("app.normandy.");

let indexPromise = null;

function getChainRootIdentifier() {
  const normandy_url = Services.prefs.getCharPref("app.normandy.api_url");
  if (normandy_url == "https://normandy.cdn.mozilla.net/api/v1") {
    return Ci.nsIContentSignatureVerifier.ContentSignatureProdRoot;
  }
  if (normandy_url.includes("stage.")) {
    return Ci.nsIContentSignatureVerifier.ContentSignatureStageRoot;
  }
  if (normandy_url.includes("dev.")) {
    return Ci.nsIContentSignatureVerifier.ContentSignatureDevRoot;
  }
  if (Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) {
    return Ci.nsIX509CertDB.AppXPCShellRoot;
  }
  return Ci.nsIContentSignatureVerifier.ContentSignatureLocalRoot;
}

export var NormandyApi = {
  InvalidSignatureError: class InvalidSignatureError extends Error {},

  clearIndexCache() {
    indexPromise = null;
  },

  get(endpoint, data) {
    const url = new URL(endpoint);
    if (data) {
      for (const key of Object.keys(data)) {
        url.searchParams.set(key, data[key]);
      }
    }
    return fetch(url.href, {
      method: "get",
      headers: { Accept: "application/json" },
      credentials: "omit",
    });
  },

  absolutify(url) {
    if (url.startsWith("http")) {
      return url;
    }
    const apiBase = prefs.getCharPref("api_url");
    const server = new URL(apiBase).origin;
    if (url.startsWith("/")) {
      return server + url;
    }
    throw new Error("Can't use relative urls");
  },

  async getApiUrl(name) {
    if (!indexPromise) {
      const apiBase = new URL(prefs.getCharPref("api_url"));
      if (!apiBase.pathname.endsWith("/")) {
        apiBase.pathname += "/";
      }
      indexPromise = this.get(apiBase.toString()).then(res => res.json());
    }
    const index = await indexPromise;
    if (!(name in index)) {
      throw new Error(`API endpoint with name "${name}" not found.`);
    }
    const url = index[name];
    return this.absolutify(url);
  },

  /**
   * Verify content signature, by serializing the specified `object` as
   * canonical JSON, and using the Normandy signer verifier to check that
   * it matches the signature specified in `signaturePayload`.
   *
   * If the the signature is not valid, an error is thrown. Otherwise this
   * function returns undefined.
   *
   * @param {object|String} data The object (or string) to be checked
   * @param {object} signaturePayload The signature information
   * @param {String} signaturePayload.x5u The certificate chain URL
   * @param {String} signaturePayload.signature base64 signature bytes
   * @param {String} type The object type (eg. `"recipe"`, `"action"`)
   * @returns {Promise<undefined>} If the signature is valid, this function returns without error
   * @throws {NormandyApi.InvalidSignatureError} if signature is invalid.
   */
  async verifyObjectSignature(data, signaturePayload, type) {
    const { signature, x5u } = signaturePayload;
    const certChainResponse = await this.get(this.absolutify(x5u));
    const certChain = await certChainResponse.text();
    const builtSignature = `p384ecdsa=${signature}`;

    const serialized =
      typeof data == "string" ? data : lazy.CanonicalJSON.stringify(data);

    const verifier = Cc[
      "@mozilla.org/security/contentsignatureverifier;1"
    ].createInstance(Ci.nsIContentSignatureVerifier);

    let valid;
    try {
      valid = await verifier.asyncVerifyContentSignature(
        serialized,
        builtSignature,
        certChain,
        "normandy.content-signature.mozilla.org",
        getChainRootIdentifier()
      );
    } catch (err) {
      throw new NormandyApi.InvalidSignatureError(
        `${type} signature validation failed: ${err}`
      );
    }

    if (!valid) {
      throw new NormandyApi.InvalidSignatureError(
        `${type} signature is not valid`
      );
    }
  },

  /**
   * Fetch metadata about this client determined by the server.
   * @return {object} Metadata specified by the server
   */
  async classifyClient() {
    const classifyClientUrl = await this.getApiUrl("classify-client");
    const response = await this.get(classifyClientUrl);
    const clientData = await response.json();
    clientData.request_time = new Date(clientData.request_time);
    return clientData;
  },

  /**
   * Fetch details for an extension from the server.
   * @param extensionId {integer} The ID of the extension to look up
   * @resolves {Object}
   */
  async fetchExtensionDetails(extensionId) {
    const baseUrl = await this.getApiUrl("extension-list");
    const extensionDetailsUrl = `${baseUrl}${extensionId}/`;
    const response = await this.get(extensionDetailsUrl);
    return response.json();
  },
};