function bufferFromBase64(base64) { const binaryString = atob(base64); const length = binaryString.length; const bytes = new Uint8Array(length); for (let i = 0; i < length; i++) { bytes[i] = binaryString.charCodeAt(i); } return bytes; } function fromString(str, encoding = "utf8") { if (encoding === "utf8") { return new TextEncoder().encode(str); } else if (encoding === "base16") { if (str.length % 2 !== 0) { throw new Error("Invalid hex string length."); } let bytes = new Uint8Array(str.length / 2); for (let i = 0; i < str.length; i += 2) { bytes[i / 2] = parseInt(str.substring(i, i + 2), 16); } return bytes; } else if (encoding === "base64url") { str = str.replace(/-/g, "+").replace(/_/g, "/"); while (str.length % 4) { str += "="; } return new Uint8Array(bufferFromBase64(str)); } else { throw new Error(`Unsupported encoding "${encoding}"`); } } /** * Convert a Uint8Array to a string with the given encoding. * * @param {Uint8Array} byteArray - The Uint8Array to convert. * @param {string} [encoding='utf8'] - The desired encoding ('utf8', 'base16', 'base64url'). * @returns {string} - The encoded string. * @throws {Error} - Throws an error if the encoding is unsupported. */ function toString(byteArray, encoding = "utf8") { switch (encoding) { case "utf8": return decodeUTF8(byteArray); case "base16": return toBase16(byteArray); case "base64url": return toBase64Url(byteArray); default: throw new Error(`Unsupported encoding "${encoding}"`); } } /** * Decode a Uint8Array as a UTF-8 string. * * @param {Uint8Array} byteArray * @returns {string} */ function decodeUTF8(byteArray) { return new TextDecoder().decode(byteArray); } /** * Convert a Uint8Array to a base16 (hex) encoded string. * * @param {Uint8Array} byteArray * @returns {string} */ function toBase16(byteArray) { return Array.from(byteArray) .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); } /** * Convert a Uint8Array to a base64url encoded string. * * @param {Uint8Array} byteArray * @returns {string} */ function toBase64Url(byteArray) { let uint8Array = new Uint8Array(byteArray); let binaryString = ""; for (let i = 0; i < uint8Array.length; i++) { binaryString += String.fromCharCode(uint8Array[i]); } // Encode to base64 let base64 = btoa(binaryString); return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } const u8a = { toString, fromString }; function sha256(payload) { const data = typeof payload === "string" ? u8a.fromString(payload) : payload; return nobleHashes.sha256(data); } async function accessToken(identifier) { const did = identifier["did"]; const privateKeyHex = identifier["keys"][0]["privateKeyHex"]; const signer = await SimpleSigner(privateKeyHex); const nowEpoch = Math.floor(Date.now() / 1000); const endEpoch = nowEpoch + 60; // add one minute const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did }; const alg = undefined; // defaults to 'ES256K', more standardized but harder to verify vs ES256K-R const jwt = await createJWT(tokenPayload, { alg, issuer: did, signer, }); return jwt; } async function createJWT(payload, options, header = {}) { const { issuer, signer, alg, expiresIn, canonicalize } = options; if (!signer) throw new Error( "missing_signer: No Signer functionality has been configured", ); if (!issuer) throw new Error("missing_issuer: No issuing DID has been configured"); if (!header.typ) header.typ = "JWT"; if (!header.alg) header.alg = alg; const timestamps = { iat: Math.floor(Date.now() / 1000), exp: undefined, }; if (expiresIn) { if (typeof expiresIn === "number") { timestamps.exp = (payload.nbf || timestamps.iat) + Math.floor(expiresIn); } else { throw new Error("invalid_argument: JWT expiresIn is not a number"); } } const fullPayload = { ...timestamps, ...payload, iss: issuer }; return createJWS(fullPayload, signer, header, { canonicalize }); } const defaultAlg = "ES256K"; async function createJWS(payload, signer, header = {}, options = {}) { if (!header.alg) header.alg = defaultAlg; const encodedPayload = typeof payload === "string" ? payload : encodeSection(payload, options.canonicalize); const signingInput = [ encodeSection(header, options.canonicalize), encodedPayload, ].join("."); const jwtSigner = ES256KSignerAlg(false); const signature = await jwtSigner(signingInput, signer); // JWS Compact Serialization // https://www.rfc-editor.org/rfc/rfc7515#section-7.1 return [signingInput, signature].join("."); } function canonicalizeData(object) { if (typeof object === "number" && isNaN(object)) { throw new Error("NaN is not allowed"); } if (typeof object === "number" && !isFinite(object)) { throw new Error("Infinity is not allowed"); } if (object === null || typeof object !== "object") { return JSON.stringify(object); } if (object.toJSON instanceof Function) { return serialize(object.toJSON()); } if (Array.isArray(object)) { const values = object.reduce((t, cv, ci) => { const comma = ci === 0 ? "" : ","; const value = cv === undefined || typeof cv === "symbol" ? null : cv; return `${t}${comma}${serialize(value)}`; }, ""); return `[${values}]`; } const values = Object.keys(object) .sort() .reduce((t, cv) => { if (object[cv] === undefined || typeof object[cv] === "symbol") { return t; } const comma = t.length === 0 ? "" : ","; return `${t}${comma}${serialize(cv)}:${serialize(object[cv])}`; }, ""); return `{${values}}`; } function encodeSection(data, shouldCanonicalize = false) { if (shouldCanonicalize) { return encodeBase64url(canonicalizeData(data)); } else { return encodeBase64url(JSON.stringify(data)); } } function encodeBase64url(s) { return bytesToBase64url(u8a.fromString(s)); } function instanceOfEcdsaSignature(object) { return typeof object === "object" && "r" in object && "s" in object; } function ES256KSignerAlg(recoverable) { return async function sign(payload, signer) { const signature = await signer(payload); if (instanceOfEcdsaSignature(signature)) { return toJose(signature, recoverable); } else { if ( recoverable && typeof fromJose(signature).recoveryParam === "undefined" ) { throw new Error( `not_supported: ES256K-R not supported when signer doesn't provide a recovery param`, ); } return signature; } }; } function leftpad(data, size = 64) { if (data.length === size) return data; return "0".repeat(size - data.length) + data; } async function SimpleSigner(hexPrivateKey) { const signer = await ES256KSigner(hexToBytes(hexPrivateKey), true); return async (data) => { const signature = await signer(data); return fromJose(signature); }; } function hexToBytes(s, minLength) { let input = s.startsWith("0x") ? s.substring(2) : s; if (input.length % 2 !== 0) { input = `0${input}`; } if (minLength) { const paddedLength = Math.max(input.length, minLength * 2); input = input.padStart(paddedLength, "00"); } return u8a.fromString(input.toLowerCase(), "base16"); } function ES256KSigner(privateKey, recoverable = false) { const privateKeyBytes = privateKey; if (privateKeyBytes.length !== 32) { throw new Error( `bad_key: Invalid private key format. Expecting 32 bytes, but got ${privateKeyBytes.length}`, ); } return async function (data) { const signature = nobleCurves.secp256k1.sign(sha256(data), privateKeyBytes); return toJose( { r: leftpad(signature.r.toString(16)), s: leftpad(signature.s.toString(16)), recoveryParam: signature.recovery, }, recoverable, ); }; } function toJose(signature, recoverable) { const { r, s, recoveryParam } = signature; const jose = new Uint8Array(recoverable ? 65 : 64); jose.set(u8a.fromString(r, "base16"), 0); jose.set(u8a.fromString(s, "base16"), 32); if (recoverable) { if (typeof recoveryParam === "undefined") { throw new Error("Signer did not return a recoveryParam"); } jose[64] = recoveryParam; } return bytesToBase64url(jose); } function bytesToBase64url(b) { return u8a.toString(b, "base64url"); } function base64ToBytes(s) { const inputBase64Url = s .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=/g, ""); return u8a.fromString(inputBase64Url, "base64url"); } function bytesToHex(b) { return u8a.toString(b, "base16"); } function fromJose(signature) { const signatureBytes = base64ToBytes(signature); if (signatureBytes.length < 64 || signatureBytes.length > 65) { throw new TypeError( `Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`, ); } const r = bytesToHex(signatureBytes.slice(0, 32)); const s = bytesToHex(signatureBytes.slice(32, 64)); const recoveryParam = signatureBytes.length === 65 ? signatureBytes[64] : undefined; return { r, s, recoveryParam }; } function validateBase64(s) { if ( !/^(?:[A-Za-z0-9+\/]{2}[A-Za-z0-9+\/]{2})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/.test( s, ) ) { throw new TypeError("invalid encoding"); } } function decodeBase64(s) { validateBase64(s); var i, d = atob(s), b = new Uint8Array(d.length); for (i = 0; i < d.length; i++) b[i] = d.charCodeAt(i); return b; } async function getSettingById(id) { return new Promise((resolve, reject) => { let openRequest = indexedDB.open("TimeSafari"); openRequest.onupgradeneeded = (event) => { // Handle database setup if necessary let db = event.target.result; if (!db.objectStoreNames.contains("settings")) { db.createObjectStore("settings", { keyPath: "id" }); } }; openRequest.onsuccess = (event) => { let db = event.target.result; let transaction = db.transaction("settings", "readonly"); let objectStore = transaction.objectStore("settings"); let getRequest = objectStore.get(id); getRequest.onsuccess = () => resolve(getRequest.result); getRequest.onerror = () => reject(getRequest.error); }; openRequest.onerror = () => reject(openRequest.error); }); } async function setMostRecentNotified(id) { try { const db = await openIndexedDB("TimeSafari"); const transaction = db.transaction("settings", "readwrite"); const store = transaction.objectStore("settings"); const data = await getRecord(store, 1); if (data) { data["lastNotifiedClaimId"] = id; await updateRecord(store, data); } else { console.error( "safari-notifications setMostRecentNotified IndexedDB settings record not found", ); } transaction.oncomplete = () => db.close(); } catch (error) { console.error( "safari-notifications setMostRecentNotified IndexedDB error", error, ); } } async function appendDailyLog(message) { try { const db = await openIndexedDB("TimeSafari"); const transaction = db.transaction("logs", "readwrite"); const store = transaction.objectStore("logs"); // only keep one day's worth of logs const todayKey = new Date().toDateString(); const previous = await getRecord(store, todayKey); if (!previous) { await store.clear(); // clear out everything previous when this is today's first log } let fullMessage = (previous && previous.message) || ""; if (fullMessage) { fullMessage += "\n"; } fullMessage += message; await updateRecord(store, { date: todayKey, message: fullMessage }); transaction.oncomplete = () => db.close(); return true; } catch (error) { console.error("safari-notifications logMessage IndexedDB error", error); return false; } } function openIndexedDB(dbName) { return new Promise((resolve, reject) => { const request = indexedDB.open(dbName); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); }); } function getRecord(store, key) { return new Promise((resolve, reject) => { const request = store.get(key); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } // Note that this assumes there is only one record in the store. function updateRecord(store, data) { return new Promise((resolve, reject) => { const request = store.put(data); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } async function fetchAllAccounts() { return new Promise((resolve, reject) => { const openRequest = indexedDB.open("TimeSafariAccounts"); openRequest.onupgradeneeded = function (event) { const db = event.target.result; if (!db.objectStoreNames.contains("accounts")) { db.createObjectStore("accounts", { keyPath: "id" }); } }; openRequest.onsuccess = function (event) { const db = event.target.result; const transaction = db.transaction("accounts", "readonly"); const objectStore = transaction.objectStore("accounts"); const getAllRequest = objectStore.getAll(); getAllRequest.onsuccess = function () { resolve(getAllRequest.result); }; getAllRequest.onerror = function () { reject(getAllRequest.error); }; }; openRequest.onerror = function () { reject(openRequest.error); }; }); } async function getNotificationCount() { let accounts = []; let result = null; // 1 is our master settings ID; see MASTER_SETTINGS_KEY const settings = await getSettingById(1); let lastNotifiedClaimId = null; if ("lastNotifiedClaimId" in settings) { lastNotifiedClaimId = settings["lastNotifiedClaimId"]; } const activeDid = settings["activeDid"]; accounts = await fetchAllAccounts(); let activeAccount = null; for (let i = 0; i < accounts.length; i++) { if (accounts[i]["did"] == activeDid) { activeAccount = accounts[i]; break; } } const headers = { "Content-Type": "application/json", }; const identity = activeAccount && activeAccount["identity"]; if (identity && "secret" in self) { const secret = self.secret; const secretUint8Array = self.decodeBase64(secret); const messageWithNonceAsUint8Array = self.decodeBase64(identity); const nonce = messageWithNonceAsUint8Array.slice(0, 24); const message = messageWithNonceAsUint8Array.slice(24, identity.length); const decoder = new TextDecoder("utf-8"); const decrypted = self.secretbox.open(message, nonce, secretUint8Array); const msg = decoder.decode(decrypted); const identifier = JSON.parse(JSON.parse(msg)); headers["Authorization"] = "Bearer " + (await accessToken(identifier)); } const response = await fetch( settings["apiServer"] + "/api/v2/report/claims", { method: "GET", headers: headers, }, ); if (response.status == 200) { const json = await response.json(); const claims = json["data"]; let newClaims = 0; for (let i = 0; i < claims.length; i++) { const claim = claims[i]; if (claim["id"] === lastNotifiedClaimId) { break; } newClaims++; } if (newClaims > 0) { if (newClaims === 1) { result = "There is 1 new activity on Time Safari"; } else { result = `There are ${newClaims} new activities on Time Safari`; } } const most_recent_notified = claims[0]["id"]; await setMostRecentNotified(most_recent_notified); } else { console.error( "safari-notifications getNotificationsCount got a bad response status when fetching claims", response.status, response, ); } return result; } export async function blobToBase64String(blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result); // potential problem if it returns an ArrayBuffer? reader.onerror = reject; reader.readAsDataURL(blob); }); } // Store the image blob and go immediate to a page to upload it. // @param photo - image Blob to store for later retrieval after redirect async function savePhoto(photo) { try { const photoBase64 = await blobToBase64String(photo); const db = await openIndexedDB("TimeSafari"); const transaction = db.transaction("temp", "readwrite"); const store = transaction.objectStore("temp"); await updateRecord(store, { id: "shared-photo-base64", blobB64: photoBase64, }); transaction.oncomplete = () => db.close(); } catch (error) { console.error("safari-notifications logMessage IndexedDB error", error); } } self.appendDailyLog = appendDailyLog; self.getNotificationCount = getNotificationCount; self.decodeBase64 = decodeBase64;