<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Generate signed URL</title> <link rel=stylesheet href="https://rsms.me/inter/inter.css"> </head> <body> <main> <div class="pad-x"> <h2>OvenMediaEngine utility (unofficial)</h2> <h1>Generate signed URL</h1> <p> This webpage saves you from needing to run the official <code>signed_policy_url_generator.sh</code> shell script by using web browser's built-in Crypto APIs. If you want a more in-depth breakdown on how to generate signed URLs, check the <a href="https://airensoft.gitbook.io/ovenmediaengine/access-control/signedpolicy">official documentation</a> on SignedPolicy. </p> <p> <b>Note:</b> This page works completely in your browser (check the source, it's not minimized or anything!) so your keys should never reach my server, not even in logs! </p> <section class="input"> <fieldset> <legend>URL to sign</legend> <input type="url" name="url" required placeholder="wss://your.ome.endpoint:3334/app/stream/webrtc" /> </fieldset> <fieldset> <legend>Secret key</legend> <input type="password" name="secret" required placeholder="your secret key" /> </fieldset> <fieldset> <legend>Policy</legend> <div> <input type="radio" id="policy-unlimited" name="policy-preset" value="unlimited" checked /> <label for="policy-unlimited">Unlimited (very high <code>url_expire</code>)</label> </div> <div> <input type="radio" id="policy-custom" name="policy-preset" value="custom" /> <label for="policy-custom">Custom (specify below as JSON object)</label> </div> <textarea name="custom-policy" disabled rows="5">{ "url_expire" : 0 }</textarea> </fieldset> <button id="generate">Generate signed URL</button> <div id="error" class="output-box hidden"></div> <div id="result" class="output-box hidden"></div> </section> </div> </main> <script> // Using a form element would be really nice but I don't wanna risk javascript shenanigans // sending the data to my server by adding a form tag that might be faulty, so // I'll do it the boring way and re-implement form stuff in Javascript /** @type {HTMLInputElement} */ const urlElement = document.querySelector("input[name=url]"); /** @type {HTMLInputElement} */ const secretElement = document.querySelector("input[name=secret]"); /** @type {HTMLInputElement} */ const policyElements = [...document.querySelectorAll("input[name=policy-preset]")]; /** @type {HTMLTextAreaElement} */ const customPolicyElement = document.querySelector("textarea[name=custom-policy]"); /** @type {HTMLButtonElement} */ const buttonElement = document.querySelector("button#generate"); /** @type {HTMLDivElement} */ const errorElement = document.querySelector("div#error"); /** @type {HTMLDivElement} */ const resultElement = document.querySelector("div#result"); function showError(message) { errorElement.innerHTML = message; errorElement.classList.remove("hidden"); } function hideError() { errorElement.classList.add("hidden"); } function showResult(message) { resultElement.innerHTML = message; resultElement.classList.remove("hidden"); } function hideResult() { resultElement.classList.add("hidden"); } /** * Runs a function and catches any errors it might throw, * then shows an error message if one is thrown. * @template T * @param {() => T} fn - The function to run * @param {string} message - The error message to show * @returns {T} - The return value of the function */ function must(fn, message) { try { return fn(); } catch (e) { showError(`<b>${message}:</b> ${e.message}`); } } const policyPresets = { "unlimited": { "url_expire": 6289318800000 // remember to update this before 2169-4-20 }, }; async function generateURL() { hideError(); if (!urlElement.reportValidity()) return; if (!secretElement.reportValidity()) return; if (!customPolicyElement.reportValidity()) return; const url = must(() => new URL(urlElement.value), "URL is not valid"); let toSign = new URL(url.href); // Check if URL is SRT, if so, extract the sub-URL (streamid) and use that instead if (url.protocol === "srt:") { const streamid = url.searchParams.get("streamid"); if (!streamid) { showError("Missing streamid in SRT url"); return; } toSign = must(() => new URL(streamid), "streamid is not a valid URL"); } // If custom policy, parse and validate it, otherwise use the chosen preset const policyPreset = policyElements.find((el) => el.checked).value; const policy = policyPreset === "custom" ? must(() => JSON.parse(customPolicyElement.value), "Custom policy is not valid JSON") : policyPresets[policyPreset]; // Convert policy to Base64 const binaryPolicy = new TextEncoder().encode(JSON.stringify(policy)); const encodedPolicy = new Base64UrlSafe(false).encode(binaryPolicy); // Append policy to URL toSign.searchParams.append("policy", encodedPolicy); // Get secret as bytes const secret = new TextEncoder().encode(secretElement.value); // Create HMAC-SHA1 const key = await crypto.subtle.importKey("raw", secret, { name: "HMAC", hash: "SHA-1" }, false, ["sign"]); const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(toSign.href)); // Append policy and signature to original URL url.searchParams.append("policy", encodedPolicy); debugger; url.searchParams.append("signature", new Base64UrlSafe(false).encode(new Uint8Array(signature))); // Copy URL to clipboard showResult(`URL generated: <code>${url.href}</code>`); } // Run URL generation on button click buttonElement.addEventListener("click", () => generateURL()); // Toggle custom policy box (and requirement) on policy preset change policyElements.forEach((el) => { el.addEventListener("change", () => { const isCustom = el.value === "custom"; customPolicyElement.required = isCustom; customPolicyElement.disabled = !isCustom; }); }); </script> <script> // https://github.com/jedisct1/js-base64-ct // Copyright (c) 2021 Frank Denis // License: MIT, full text: https://github.com/jedisct1/js-base64-ct/blob/master/LICENSE function eq(x, y) { return (((0 - (x ^ y)) >> 16) & 0xffff) ^ 0xffff; } function gt(x, y) { return ((y - x) >> 8) & 0xffff; } function lt(x, y) { return gt(y, x); } function ge(x, y) { return gt(y, x) ^ 0xffff; } function le(x, y) { return ge(y, x); } function byteToCharOriginal(x) { const c = (lt(x, 26) & (x + 'A'.charCodeAt(0))) | (ge(x, 26) & lt(x, 52) & (x + ('a'.charCodeAt(0) - 26))) | (ge(x, 52) & lt(x, 62) & (x + ('0'.charCodeAt(0) - 52))) | (eq(x, 62) & '+'.charCodeAt(0)) | (eq(x, 63) & '/'.charCodeAt(0)); return String.fromCharCode(c); } function charToByteOriginal(c) { const x = (ge(c, 'A'.charCodeAt(0)) & le(c, 'Z'.charCodeAt(0)) & (c - 'A'.charCodeAt(0))) | (ge(c, 'a'.charCodeAt(0)) & le(c, 'z'.charCodeAt(0)) & (c - ('a'.charCodeAt(0) - 26))) | (ge(c, '0'.charCodeAt(0)) & le(c, '9'.charCodeAt(0)) & (c - ('0'.charCodeAt(0) - 52))) | (eq(c, '+'.charCodeAt(0)) & 62) | (eq(c, '/'.charCodeAt(0)) & 63); return x | (eq(x, 0) & (eq(c, 'A'.charCodeAt(0)) ^ 0xffff)); } function byteToCharUrlSafe(x) { const c = (lt(x, 26) & (x + 'A'.charCodeAt(0))) | (ge(x, 26) & lt(x, 52) & (x + ('a'.charCodeAt(0) - 26))) | (ge(x, 52) & lt(x, 62) & (x + ('0'.charCodeAt(0) - 52))) | (eq(x, 62) & '-'.charCodeAt(0)) | (eq(x, 63) & '_'.charCodeAt(0)); return String.fromCharCode(c); } function charToByteUrlSafe(c) { const x = (ge(c, 'A'.charCodeAt(0)) & le(c, 'Z'.charCodeAt(0)) & (c - 'A'.charCodeAt(0))) | (ge(c, 'a'.charCodeAt(0)) & le(c, 'z'.charCodeAt(0)) & (c - ('a'.charCodeAt(0) - 26))) | (ge(c, '0'.charCodeAt(0)) & le(c, '9'.charCodeAt(0)) & (c - ('0'.charCodeAt(0) - 52))) | (eq(c, '-'.charCodeAt(0)) & 62) | (eq(c, '_'.charCodeAt(0)) & 63); return x | (eq(x, 0) & (eq(c, 'A'.charCodeAt(0)) ^ 0xffff)); } function bin2Base64(bin, padding, byteToChar) { let bin_len = bin.length; let nibbles = Math.floor(bin_len / 3); let remainder = bin_len - 3 * nibbles; let b64_len = nibbles * 4; if (remainder) { if (padding) { b64_len += 4; } else { b64_len += 2 + (remainder >> 1); } } let b64 = ""; let acc = 0, acc_len = 0, bin_pos = 0; while (bin_pos < bin_len) { acc = ((acc << 8) + bin[bin_pos++]) & 0xfff; acc_len += 8; while (acc_len >= 6) { acc_len -= 6; b64 += byteToChar((acc >> acc_len) & 0x3F); } } if (acc_len > 0) { b64 += byteToChar((acc << (6 - acc_len)) & 0x3F); } while (b64.length < b64_len) { b64 += '='; } return b64; } function skipPadding(b64, ignore, padding_len) { let i = 0; while (padding_len > 0) { let c = b64[i++]; if (c == '=') { padding_len--; } else if (!ignore || ignore.indexOf(c) < 0) { throw new Error("Invalid base64 padding"); } } if (i !== b64.length) { throw new Error("Invalid base64 padding length"); } } function base642Bin(b64, padding, ignore, charToByte) { let b64_len = b64.length; let bin = new Uint8Array(Math.ceil(b64_len * 3 / 4)); let acc = 0, acc_len = 0, bin_len = 0, b64_pos = 0; while (b64_pos < b64_len) { const c = b64[b64_pos]; const d = charToByte(c.charCodeAt(0)); if (d == 0xffff) { if (ignore && ignore.indexOf(c) >= 0) { b64_pos++; continue; } break; } acc = ((acc << 6) + d) & 0xfff; acc_len += 6; if (acc_len >= 8) { acc_len -= 8; bin[bin_len++] = (acc >> acc_len) & 0xff; } b64_pos++; } if (acc_len > 4 || (acc & ((1 << acc_len) - 1)) != 0) { throw new Error("Non-canonical base64 encoding"); } if (padding) { skipPadding(b64.slice(b64_pos), ignore, acc_len / 2); } return new Uint8Array(bin.buffer, 0, bin_len); } /** * Custom Base64 encoding/decoding, with support for arbitrary char<->maps. */ class Base64Codec { /** * Custom Base64 encoding/decoding. * * @param padding - whether padding is used. * @param ignore - string of characters to ignore. * @param charToByte - function to convert Base64 char to a byte. * @param byteToChar - function to convert byte to a Base64 char. */ constructor(padding = false, ignore = null, charToByte, byteToChar) { this._ignore = null; this._padding = false; this._padding = padding; this._ignore = ignore; this._charToByte = charToByte; this._byteToChar = byteToChar; } /** * Encode a buffer to Base64. * * @param data - a buffer to encode. * @returns a Base64 encoded string. */ encode(data) { return bin2Base64(data, this._padding, this._byteToChar); } /** * Decode a Base64 string to a buffer. * * @param data - a Base64 string to decode. * @returns a buffer. * @throws an error if the string is not a valid Base64 string. */ decode(data) { return base642Bin(data, this._padding, this._ignore, this._charToByte); } } /** * URL-safe Base64 encoding/decoding. */ class Base64UrlSafe extends Base64Codec { /** * URL-safe Base64 encoding/decoding. * * @param padding - whether padding is used. * @param ignore - string of characters to ignore. */ constructor(padding = false, ignore = null) { super(padding, ignore, charToByteUrlSafe, byteToCharUrlSafe); } } </script> <style> :root { background-color: #111; color: #f3f3f3; --copy-font: inter, gill sans, gill sans mt, segoe ui, sans-serif; --monospace-font: monospace; --link-color: #e9d; --link-color-border: #848; --link-color-bg: #414; --header-color: #ffc; --bold-text: #dcf; font-family: var(--copy-font); } section { display: flex; flex-direction: column; gap: 1rem; } fieldset { display: flex; flex-direction: column; border-radius: 8px; border-color: #666; gap: 0.5rem; } input, textarea, button { font-size: 1.25rem; background-color: #333; color: #eee; border: 0; border-radius: 5px; padding: 8px 10px; } textarea { font-size: 1rem; font-family: var(--monospace-font); } input:disabled, textarea:disabled { opacity: 0.3; } input[type="radio"], label { cursor: pointer; } button { border: 1px solid var(--link-color-border); background-color: var(--link-color-bg); color: var(--link-color); cursor: pointer; margin: 0 1rem; } main { margin: 0 auto; width: 100%; max-width: 960px; .pad-x { padding: 0 1rem; } } b { color: var(--bold-text); } .hidden { display: none; } .output-box { position: relative; border-radius: 5px; padding: 5px 8px; margin-top: 1rem; &::before { text-transform: uppercase; font-weight: bold; font-size: 10pt; position: absolute; left: 0px; top: -20px; } & b { color: #fff; } code { user-select: all; } } #error { background-color: #512; border: 1px solid #b23; color: #fdd; &::before { content: "Error"; color: #f46; } } #result { background-color: #154; border: 1px solid #afe; color: #dff; &::before { content: "Result"; color: #bfe9e0; } } h2 { color: var(--header-color); } code { background-color: #333; display: inline-block; padding: 2px 4px; font-size: 1rem; border-radius: 4px; } a, a:visited { color: var(--link-color); } </style> </body> </html>