diff --git a/sw_scripts/safari-notifications.js b/sw_scripts/safari-notifications.js index edca054..9cf8be9 100644 --- a/sw_scripts/safari-notifications.js +++ b/sw_scripts/safari-notifications.js @@ -8,6 +8,337 @@ async function generateSHA256Hash(data) { return hashHex; } +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 generateSHA256Hash(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 privateKey = Secp256k1.uint256(privateKeyBytes, 16); + const hash = await sha256(data); + const digest = Secp256k1.uint256(hash, 16); + console.error(privateKey, digest); + const signature = Secp256k1.ecsign(privateKey, digest); + const sigR = Secp256k1.uint256(signature.r,16); + const sigS = Secp256k1.uint256(signature.s,16); + return toJose({ + r: leftpad(signature.r.toString(16)), + s: leftpad(signature.s.toString(16)), + recoveryParam: signature.v, + }, 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( @@ -107,6 +438,15 @@ async function getNotificationCount() { 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)); + + const headers = { + "Content-Type": "application/json", + }; + + headers["Authorization"] = "Bearer " + await accessToken(identifier); + result = decrypted; break;