|
| 1 | +<!DOCTYPE html> |
| 2 | +<head> |
| 3 | +<meta charset="utf-8"> |
| 4 | +<title>VAPID verification page</title> |
| 5 | +<link href="https://developer.cdn.mozilla.net/static/build/styles/mdn.6ff34abfc698.css" rel="stylesheet" /> |
| 6 | +<link href="style.css" rel="stylesheet" /> |
| 7 | +</head> |
| 8 | +<body class="document"> |
| 9 | +<main class="document"> |
| 10 | +<div class="center"> |
| 11 | +<div id="document-main"> |
| 12 | +<h1>VAPID verification</h1> |
| 13 | +<div id="intro" class="section"> |
| 14 | + <p>This page helps construct or validate <a href="https://datatracker.ietf.org/doc/draft-thomson-webpush-vapid/">VAPID</a> header data.</p> |
| 15 | +</div> |
| 16 | +<div id="inputs" class="section"> |
| 17 | +<h2>Headers</h2> |
| 18 | + <p>The headers are sent with subscription updates. They provide the site information to associate with |
| 19 | + this feed.</p> |
| 20 | + <label for="auth">Authorization Header:</label> |
| 21 | + <textarea name="auth" placeholder="Bearer abCDef..."></textarea> |
| 22 | + <label for="crypt">Crypto-Key Header:</label> |
| 23 | + <p>The public key expressed after "p256ecdsa=" can associate this feed with the dashboard."</p> |
| 24 | + <textarea name="crypt" placeholder="p256ecdsa=abCDef.."></textarea> |
| 25 | + <div class="control"> |
| 26 | + <button id="check">Check headers</button> |
| 27 | +</div> |
| 28 | +</div> |
| 29 | +<div id="result" class="section"> |
| 30 | +<h2>Claims</h2> |
| 31 | + <p>Claims are the information a site uses to identify itself. |
| 32 | + <div class="row"> |
| 33 | + <label for="aud" title="The full URL to your site."><b>Aud</b>ience:</label> |
| 34 | + <p>The full URL to your site.</p> |
| 35 | + <input name="aud" placeholder="https://push.example.com"> |
| 36 | + </div> |
| 37 | + <div class="row"> |
| 38 | + <label for="sub" ><b>Sub</b>scriber:</label> |
| 39 | + <p>The administrative email address that can be contacted if there's an issue</p> |
| 40 | + <input name=" sub" placeholder=" mailto:[email protected]" > |
| 41 | + </div> |
| 42 | + <div class="row"> |
| 43 | + <label for="exp"><b>Exp</b>iration:</label> |
| 44 | + <p>Time in seconds for this claim to live. (Max: 24 hours from now)</p> |
| 45 | + <input name="exp" placeholder="Time in seconds"> |
| 46 | + </div> |
| 47 | + <div class="control"> |
| 48 | + <button id="gen">Generate VAPID</button> |
| 49 | + </div> |
| 50 | + <h3>Claims JSON object:</h3> |
| 51 | + <pre class="brush: js line-numbers lanuage-js"> |
| 52 | + <code id="raw_claims" >None</code> |
| 53 | + </pre> |
| 54 | + <div id="ignored" class="hidden"> |
| 55 | + <div class="title">The following were ignored. |
| 56 | + <div class="items"></div> |
| 57 | + </div> |
| 58 | + </div> |
| 59 | +</div> |
| 60 | +<div id="keys" class="section"> |
| 61 | + <h2>Exported Keys</h2> |
| 62 | +<b>Auto-generated keys:</b> |
| 63 | +<p>These are ASN.1 DER formatted version of the public and private keys used to generate |
| 64 | +the VAPID headers. These can be useful for languages that use DER for key import.</p> |
| 65 | + <label for="priv">Private Key:</label><textarea name="priv"></textarea> |
| 66 | + <label for="pub">Public Key:</label><textarea name="pub"></textarea> |
| 67 | +</div> |
| 68 | +<div id="err" class="hidden section"></div> |
| 69 | +</div> |
| 70 | +</div> |
| 71 | +</main> |
| 72 | +<script src="vapid.js"></script> |
| 73 | +<script> |
| 74 | + |
| 75 | +let err_strs = { |
| 76 | + enus: { |
| 77 | + INVALID_EXP: "Invalid Expiration", |
| 78 | + CLAIMS_FAIL: "Claims Failed", |
| 79 | + HEADER_NOPE: "Could not generate headers", |
| 80 | + BAD_AUTH_HE: "Missing Authorization Header", |
| 81 | + BAD_CRYP_HE: "Missing Crypto-Key Header", |
| 82 | + BAD_HEADERS: "Header check failed", |
| 83 | + } |
| 84 | +} |
| 85 | + |
| 86 | +function error(ex=null, msg=null, clear=false) { |
| 87 | + let er = document.getElementById("err"); |
| 88 | + if (clear) { |
| 89 | + er.innerHTML = ""; |
| 90 | + } |
| 91 | + if (msg) { |
| 92 | + er.innerHTML += msg + "<br>"; |
| 93 | + } |
| 94 | + if (ex) { |
| 95 | + er.innerHTML += `${ex.name}: ${ex.message}</br>`; |
| 96 | + } |
| 97 | + er.classList.remove("hidden"); |
| 98 | +} |
| 99 | + |
| 100 | +function success(claims) { |
| 101 | + for (let n of ["aud", "sub", "exp"]) { |
| 102 | + let item = document.getElementsByName(n)[0]; |
| 103 | + item.value = claims[n]; |
| 104 | + item.classList.add("updated"); |
| 105 | + delete (claims[n]); |
| 106 | + } |
| 107 | + let err = document.getElementById("err"); |
| 108 | + err.innerHTML = ""; |
| 109 | + err.classList.add("hidden"); |
| 110 | + let extra = JSON.stringify(claims, " "); |
| 111 | + if (extra.length > 2) { |
| 112 | + let ignored = document.getElementsById("ignored"); |
| 113 | + ignored.getElementsByClassName("items") |
| 114 | + .innerHTML = extra; |
| 115 | + ignored.classList.remove("hidden"); |
| 116 | + } |
| 117 | +} |
| 118 | + |
| 119 | + |
| 120 | +function fetchAuth(){ |
| 121 | + let auth = document.getElementsByName("auth")[0]; |
| 122 | + if (!auth) { |
| 123 | + return null |
| 124 | + } |
| 125 | + if (auth.value.split('.').length != 3) { |
| 126 | + throw new Error("Malformed Header"); |
| 127 | + } |
| 128 | + return auth.value; |
| 129 | +} |
| 130 | + |
| 131 | +function fetchCrypt(){ |
| 132 | + let crypt = document.getElementsByName("crypt")[0]; |
| 133 | + if (! crypt) { |
| 134 | + return null |
| 135 | + } |
| 136 | + return crypt.value; |
| 137 | +} |
| 138 | + |
| 139 | +function fetchClaims(){ |
| 140 | + let claims = document.getElementById("result").getElementsByTagName("input"); |
| 141 | + let reply = {}; |
| 142 | + let err = false; |
| 143 | + error(null, null, true); |
| 144 | + for (item of claims) { |
| 145 | + reply[item.name] = item.value; |
| 146 | + } |
| 147 | + |
| 148 | + // verify aud |
| 149 | + if (! /^https?:\/\//.test(reply['aud'])) { |
| 150 | + error(null, |
| 151 | + `Invalid Audience: Use the full URL of your site e.g. "http://example.com"`); |
| 152 | + document.getElementsByName("aud")[0].classList.add("err"); |
| 153 | + err = true; |
| 154 | + } else { |
| 155 | + document.getElementsByName("aud")[0].classList.remove("err"); |
| 156 | + } |
| 157 | + // verify sub |
| 158 | + if (! /^mailto:.+@.+/.test(reply['sub'])) { |
| 159 | + error(null, |
| 160 | + `Invalid Subscriber: Use the email address of your site's ` + |
| 161 | + `administrative contact as a link (e.g. "mailto:[email protected]"`); |
| 162 | + document.getElementsByName("sub")[0].classList.add("err"); |
| 163 | + err = true; |
| 164 | + } else { |
| 165 | + document.getElementsByName("sub")[0].classList.remove("err"); |
| 166 | + } |
| 167 | + |
| 168 | + // verify exp |
| 169 | + try { |
| 170 | + let expry = parseInt(reply['exp']); |
| 171 | + let now = parseInt(Date.now() * .001); |
| 172 | + if (! expry) { |
| 173 | + document.getElementsByName("exp")[0].value = now + 86400; |
| 174 | + reply['exp'] = now + 86400; |
| 175 | + } |
| 176 | + if (expry < now) { |
| 177 | + error(null, |
| 178 | + `Invalid Expiration: Already expired.`); |
| 179 | + err = true; |
| 180 | + |
| 181 | + } |
| 182 | + } catch (ex) { |
| 183 | + error(ex, err_strs.enus.INVAPID_EXP); |
| 184 | + err = true; |
| 185 | + } |
| 186 | + if (err) { |
| 187 | + return null; |
| 188 | + } |
| 189 | + return reply |
| 190 | +} |
| 191 | + |
| 192 | +function gen(){ |
| 193 | + // clear the headers |
| 194 | + for (h of document.getElementById("inputs").getElementsByTagName("textarea")) { |
| 195 | + h.value = ""; |
| 196 | + h.classList.remove("updated"); |
| 197 | + } |
| 198 | + let claims = fetchClaims(); |
| 199 | + if (! claims) { |
| 200 | + return |
| 201 | + } |
| 202 | + try { |
| 203 | + let rclaims = document.getElementById("raw_claims"); |
| 204 | + rclaims.innerHTML = JSON.stringify(claims, null, " "); |
| 205 | + rclaims.classList.add("updated"); |
| 206 | + vapid.generate_keys().then(x => { |
| 207 | + vapid.export_private_der() |
| 208 | + .then(k => document.getElementsByName("priv")[0].value = k) |
| 209 | + .catch(er => error(er, "Private Key export failed")); |
| 210 | + vapid.export_public_der() |
| 211 | + .then(k => document.getElementsByName("pub")[0].value = k) |
| 212 | + .catch(er => error(er, "Public key export failed" )); |
| 213 | + vapid.sign(claims) |
| 214 | + .then(k => { |
| 215 | + let auth = document.getElementsByName("auth")[0] |
| 216 | + auth.value = k.authorization; |
| 217 | + auth.classList.add('updated'); |
| 218 | + let crypt = document.getElementsByName("crypt")[0] |
| 219 | + crypt.value = k["crypto-key"]; |
| 220 | + crypt.classList.add('updated'); |
| 221 | + }) |
| 222 | + .catch(err => error(err, err_strs.enus.CLAIMS_FAIL)); |
| 223 | + }); |
| 224 | + } catch (ex) { |
| 225 | + error(ex, err_strs.enus.HEADER_NOPE); |
| 226 | + } |
| 227 | +} |
| 228 | + |
| 229 | +function check(){ |
| 230 | + try { |
| 231 | + // clear claims |
| 232 | + for (let item of document |
| 233 | + .getElementById("result").getElementsByTagName("input")) { |
| 234 | + item.value = ""; |
| 235 | + item.classList.remove("updated"); |
| 236 | + } |
| 237 | + // clear keys |
| 238 | + for (let item of document.getElementById("keys") |
| 239 | + .getElementsByTagName("textarea")) { |
| 240 | + item.value = ""; |
| 241 | + item.classList.remove("updated"); |
| 242 | + } |
| 243 | + let token = fetchAuth(); |
| 244 | + let public_key = fetchCrypt(); |
| 245 | + if ((token == null) && (pubic_key == null)) { |
| 246 | + if (token == null){ |
| 247 | + error(null, err_strs.enus.BAD_AUTH_HE); |
| 248 | + return |
| 249 | + } |
| 250 | + failure(null, err_strs.enus.BAD_CRYP_HE); |
| 251 | + return |
| 252 | + } |
| 253 | + vapid.verify(token, public_key) |
| 254 | + .then(k => success(k)) |
| 255 | + .catch(err => error(err, err_strs.enus.BAD_HEADERS)); |
| 256 | + } catch (e) { |
| 257 | + error(e, "Header check failed"); |
| 258 | + } |
| 259 | +} |
| 260 | + |
| 261 | +document.getElementById("check").addEventListener("click", check); |
| 262 | +document.getElementById("gen").addEventListener("click", gen); |
| 263 | + |
| 264 | + |
| 265 | +</script> |
| 266 | +</body> |
| 267 | +</html> |
0 commit comments