import { englishPage } from "./language"
const SECRET_KEY = "my secret symmetric key"
async function handleRequest(request) {
if (request.method == "POST") {
return signRequest(request)
} else {
return verifyRequest(request)
}
}
async function verifyRequest(request) {
const url = new URL(request.url)
// Make sure we have the minimum necessary query parameters.
if (!url.searchParams.has("mac") || !url.searchParams.has("expiry")) {
return failPage(request, "Missing query parameter")
}
const encoder = new TextEncoder()
const secretKeyData = encoder.encode(SECRET_KEY)
const key = await crypto.subtle.importKey(
"raw",
secretKeyData,
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"],
)
// Extract the query parameters we need and run the HMAC algorithm on the
// parts of the request we are authenticating: the path and the expiration
// timestamp.
const expiry = Number(url.searchParams.get("expiry"))
const dataToAuthenticate = url.pathname + expiry
// The received MAC is Base64-encoded, so we have to go to some trouble to
// get it into a buffer type that crypto.subtle.verify() can read.
const receivedMacBase64 = url.searchParams.get("mac")
const receivedMac = byteStringToUint8Array(atob(receivedMacBase64))
// Use crypto.subtle.verify() to guard against timing attacks. Since HMACs use
// symmetric keys, we could implement this by calling crypto.subtle.sign() and
// then doing a string comparison -- this is insecure, as string comparisons
// bail out on the first mismatch, which leaks information to potential
// attackers.
const verified = await crypto.subtle.verify(
"HMAC",
key,
receivedMac,
encoder.encode(dataToAuthenticate),
)
if (!verified) {
return failPage(request, "Invalid MAC")
}
if (Date.now() > expiry) {
return failPage(request, `URL expired at ${new Date(expiry)}`)
}
// We have verified the MAC and expiration time; we are good to pass the request
// through.
return fetch(request)
}
// Convert a ByteString (a string whose code units are all in the range
// [0, 255]), to a Uint8Array. If you pass in a string with code units larger
// than 255, their values will overflow!
function byteStringToUint8Array(byteString) {
const ui = new Uint8Array(byteString.length)
for (let i = 0; i < byteString.length; ++i) {
ui[i] = byteString.charCodeAt(i)
}
return ui
}
async function signRequest(reqeust) {
const url = new URL(reqeust.url)
return generateSignedUrl(url)
}
async function generateSignedUrl(url) {
// We"ll need some super-secret data to use as a symmetric key.
const encoder = new TextEncoder()
const secretKeyData = encoder.encode(SECRET_KEY)
const key = await crypto.subtle.importKey(
"raw",
secretKeyData,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
)
// Signed requests expire after one minute. Note that you could choose
// expiration durations dynamically, depending on, e.g. the path or a query
// parameter.
const expirationMs = 18000
const expiry = Date.now() + expirationMs
const dataToAuthenticate = url.pathname + expiry
const mac = await crypto.subtle.sign("HMAC", key, encoder.encode(dataToAuthenticate))
// `mac` is an ArrayBuffer, so we need to jump through a couple of hoops to get
// it into a ByteString, and then a Base64-encoded string.
const base64Mac = btoa(String.fromCharCode(...new Uint8Array(mac)))
url.searchParams.set("mac", base64Mac)
url.searchParams.set("expiry", expiry)
return new Response(url)
}
function failPage(request, msg) {
let page = englishPage(request) ? FAILPAGE_EN : FAILPAGE
page = page.replace('{{error-msg}}', msg)
return new Response(page, {headers: {"Content-Type": "text/html; charset=utf-8"}})
}
const FAILPAGE_EN = `
<!DOCTYPE html>
<html>
<head>
<title>Error Notification Page</title>
<style>
body {
font-family: Arial, sans-serif;
}
h1 {
text-align: center;
}
.container {
max-width: 500px;
margin: auto;
border: 1px solid #ccc;
padding: 20px;
border-radius: 5px;
}
.url-section {
margin-top: 20px;
}
.url-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background-color: #f5f5f5;
border: 1px solid #ccc;
border-radius: 5px;
}
.button-wrapper {
margin-top: 20px;
margin-bottom: 20px;
display: flex;
justify-content: center;
}
.url-button {
background-color: #4CAF50;
color: white;
border: none;
padding: 10px 20px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 16px;
border-radius: 5px;
cursor: pointer;
}
.url-display {
display: inline-block;
max-width: 460px; /* Set maximum width */
word-wrap: break-word; /* Automatically wrap words that exceed the width */
}
</style>
</head>
<body>
<div class="container">
<h1>Error Notification</h1>
<p>The current URL has not been properly signed: {{error-msg}}</p>
<div class="url-section">
<div class="url-wrapper">
<p><span class="url-display" id="current-url" style="display: block;"></span></p>
</div>
<div class="button-wrapper">
<button class="url-button" type="button" onclick="getSignedUrl()">Sign URL</button>
</div>
<div class="url-wrapper" id="signed-url-section">
<p><strong>The signed URL will expire in 18 seconds</strong></p>
<p><span class="url-display" id="signed-url"></span></p>
</div>
</div>
</div>
<script>
var currentUrl = window.location.href;
var currentUrlElement = document.getElementById("current-url");
currentUrlElement.innerText = currentUrl;
function showResult(result) {
var signedUrlElement = document.getElementById("signed-url");
signedUrlElement.innerText = result;
var signedUrlSection = document.getElementById("signed-url-section");
signedUrlSection.style.display = "block";
}
function getSignedUrl() {
fetch("/ECA-test/pet-shop-website-template/about.html", {
method: "POST"
})
.then(response => response.text())
.then(data => {
showResult(data);
})
.catch(error => {
showResult('Error occurred while requesting signed URL: ' + error.toString());
});
}
</script>
</body>
</html>
`
addEventListener("fetch", event => {
return event.respondWith(handleRequest(event.request))
})