You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
598 lines
17 KiB
598 lines
17 KiB
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;
|
|
|