diff --git a/deno.json b/deno.json index d505ec8..6443181 100644 --- a/deno.json +++ b/deno.json @@ -5,6 +5,7 @@ "imports": { "@db/sqlite": "jsr:@db/sqlite@^0.12.0", "@noble/ciphers": "jsr:@noble/ciphers@^1.2.1", + "@noble/secp256k1": "jsr:@noble/secp256k1@^2.2.3", "@nostr/tools": "jsr:@nostr/tools@^2.10.4", "@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.37.0", "@nostrify/types": "jsr:@nostrify/types@^0.36.0", diff --git a/deno.lock b/deno.lock index 21a17b3..e3ff4d5 100644 --- a/deno.lock +++ b/deno.lock @@ -5,6 +5,7 @@ "jsr:@db/sqlite@0.12": "0.12.0", "jsr:@denosaurs/plug@1": "1.0.6", "jsr:@noble/ciphers@^1.2.1": "1.2.1", + "jsr:@noble/secp256k1@^2.2.3": "2.2.3", "jsr:@nostr/tools@^2.10.4": "2.10.4", "jsr:@nostrify/nostrify@*": "0.37.0", "jsr:@nostrify/nostrify@0.37": "0.37.0", @@ -66,6 +67,9 @@ "@noble/ciphers@1.2.1": { "integrity": "e8eba45a1a6fefa6e522872d2f6b2bcc40d6ff928bdacfb3add5e245c1656819" }, + "@noble/secp256k1@2.2.3": { + "integrity": "830435da513d7d65fa6868061a0048b0f3ade456646c0f79a0675e7f4e965600" + }, "@nostr/tools@2.10.4": { "integrity": "7fda015c96b4f674727843aecb990e2af1989e4724588415ccf6f69066abfd4f", "dependencies": [ @@ -266,6 +270,7 @@ "dependencies": [ "jsr:@db/sqlite@0.12", "jsr:@noble/ciphers@^1.2.1", + "jsr:@noble/secp256k1@^2.2.3", "jsr:@nostr/tools@^2.10.4", "jsr:@nostrify/nostrify@0.37", "jsr:@nostrify/types@0.36", diff --git a/index.ts b/index.ts index 7cfb67a..a25e7b5 100644 --- a/index.ts +++ b/index.ts @@ -7,6 +7,7 @@ import type { import { getCCNPrivateKey, getCCNPubkey, + getMLSPrivateKey, isArray, isLocalhost, isValidJSON, @@ -21,6 +22,7 @@ import { mixQuery, sql, sqlPartial } from "./utils/queries.ts"; import { log, setupLogger } from "./utils/logs.ts"; import { getEveFilePath } from "./utils/files.ts"; import { MIN_POW, POW_TO_MINE } from "./consts.ts"; +import { MLS } from "./mls.ts"; await setupLogger(); @@ -36,6 +38,7 @@ if (!Deno.env.has("ENCRYPTION_KEY")) { } const db = new Database(await getEveFilePath("db")); +const mls = new MLS(await getMLSPrivateKey()); const pool = new nostrTools.SimplePool(); const relays = [ "wss://relay.arx-ccn.com/", @@ -114,10 +117,14 @@ async function createEncryptedEvent( const randomPrivateKey = nostrTools.generateSecretKey(); const randomPrivateKeyPubKey = nostrTools.getPublicKey(randomPrivateKey); const conversationKey = nip44.getConversationKey(randomPrivateKey, ccnPubKey); + const mlsEncryptedEvent = JSON.stringify(mls.encryptMessage( + ccnPubKey, + JSON.stringify(event), + )); const sealTemplate = { kind: 13, created_at: randomTimeUpTo2DaysInThePast(), - content: nip44.encrypt(JSON.stringify(event), conversationKey), + content: nip44.encrypt(mlsEncryptedEvent, conversationKey), tags: [], }; const seal = nostrTools.finalizeEvent(sealTemplate, ccnPrivateKey); @@ -136,25 +143,31 @@ async function createEncryptedEvent( async function decryptEvent( event: nostrTools.Event, ): Promise { - const ccnPrivkey = await getCCNPrivateKey(); - if (event.kind !== 1059) { throw new Error("Cannot decrypt event -- not a gift wrap"); } const pow = nostrTools.nip13.getPow(event.id); - if (pow < MIN_POW) { throw new Error("Cannot decrypt event -- PoW too low"); } - const conversationKey = nip44.getConversationKey(ccnPrivkey, event.pubkey); + const ccnPrivateKey = await getCCNPrivateKey(); + const conversationKey = nip44.getConversationKey(ccnPrivateKey, event.pubkey); const seal = JSON.parse(nip44.decrypt(event.content, conversationKey)); if (!seal) throw new Error("Cannot decrypt event -- no seal"); if (seal.kind !== 13) { throw new Error("Cannot decrypt event subevent -- not a seal"); } - const content = JSON.parse(nip44.decrypt(seal.content, conversationKey)); + const mlsEncryptedContent = JSON.parse( + nip44.decrypt(seal.content, conversationKey), + ); + if (!mlsEncryptedContent) { + throw new Error("Cannot decrypt event -- no mls content"); + } + const content = JSON.parse( + mls.decryptMessage(await getCCNPubkey(), mlsEncryptedContent)!, + ); return content as nostrTools.VerifiedEvent; } diff --git a/mls.ts b/mls.ts new file mode 100644 index 0000000..4aaba46 --- /dev/null +++ b/mls.ts @@ -0,0 +1,674 @@ +import type { NPub, NSec } from "@nostr/tools/nip19"; +import * as nip19 from "@nostr/tools/nip19"; +import * as nostrTools from "@nostr/tools"; +import { hkdf } from "npm:@noble/hashes@1.3.1/hkdf"; +import { sha256 } from "npm:@noble/hashes@1.3.1/sha256"; +import * as secp256k1 from "@noble/secp256k1"; +import { randomBytes } from "@noble/ciphers/webcrypto"; +import { xchacha20poly1305 } from "@noble/ciphers/chacha"; +import { bytesEqual, getCCNPrivateKey } from "./utils.ts"; +import { decodeBase64, encodeBase64 } from "jsr:@std/encoding/base64"; +import { + decryptUint8Array, + encryptionKey, + encryptUint8Array, +} from "./utils/encryption.ts"; + +type CCNId = string; +type Epoch = number; + +interface CCNState { + id: CCNId; + epoch: Epoch; + members: Map; + ccnKey: Uint8Array; + privateKey: NSec; + ratchetTree: RatchetNode[]; +} + +interface RatchetNode { + publicKey: NPub | null; + path: number[]; + parent: number | null; + children: number[]; +} + +interface EncryptedMessage { + sender: NPub; + epoch: Epoch; + ciphertext: Uint8Array; + nonce: Uint8Array; + signature: Uint8Array; +} + +interface Welcome { + ccnId: CCNId; + epoch: Epoch; + encryptedCCNState: Uint8Array; + pathSecret: Uint8Array; + inviterPublicKey: NPub; + nonce: Uint8Array; +} + +interface SerializedCCN { + version: number; + id: CCNId; + epoch: Epoch; + members: [NPub, NPub][]; + ccnKey: string; + privateKey: NSec; + ratchetTree: SerializedRatchetNode[]; +} + +interface SerializedRatchetNode { + publicKey: NPub | null; + path: number[]; + parent: number | null; + children: number[]; +} + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +function generateNonce(): Uint8Array { + return randomBytes(24); +} + +function deriveCCNKey( + secret: Uint8Array, + ccnId: CCNId, + epoch: Epoch, +): Uint8Array { + const info = textEncoder.encode(`MLS CCN Key ${ccnId} ${epoch}`); + return hkdf(sha256, secret, new Uint8Array(0), info, 32); +} + +function derivePathSecret(secret: Uint8Array, path: number[]): Uint8Array { + const pathStr = path.join("/"); + const info = textEncoder.encode(`MLS Path Secret ${pathStr}`); + return hkdf(sha256, secret, new Uint8Array(0), info, 32); +} + +interface UpdateMessage { + sender: NPub; + ccnId: CCNId; + epoch: Epoch; + pathSecrets: Map; + updatedPublicKeys: Map; + signature: Uint8Array; +} + +interface EncryptedPathSecret { + ciphertext: Uint8Array; + nonce: Uint8Array; +} + +export class MLS { + private ccns: Map = new Map(); + private identity: { publicKey: NPub; privateKey: NSec }; + + constructor(privateKey?: NSec) { + const privateKeyBytes = privateKey + ? nip19.decode(privateKey).data + : nostrTools.generateSecretKey(); + if (!privateKey) { + privateKey = nip19.nsecEncode(privateKeyBytes); + } + this.identity = { + privateKey: privateKey, + publicKey: nip19.npubEncode( + nostrTools.getPublicKey(privateKeyBytes), + ), + }; + } + + get memberId(): NPub { + return nip19.npubEncode(nostrTools.getPublicKey( + nip19.decode(this.identity.privateKey).data, + )); + } + + createCCN(ccnId: CCNId): CCNId { + const secret = randomBytes(32); + const epoch = 0; + const ccnKey = deriveCCNKey(secret, ccnId, epoch); + + const ratchetTree: RatchetNode[] = [ + { + publicKey: this.identity.publicKey, + path: [0], + parent: null, + children: [], + }, + ]; + + const ccnState: CCNState = { + id: ccnId, + epoch, + members: new Map([[this.memberId, this.identity.publicKey]]), + ccnKey, + privateKey: this.identity.privateKey, + ratchetTree, + }; + + this.ccns.set(ccnId, ccnState); + return ccnId; + } + + serializeCCN(ccnId: CCNId) { + const CCN = this.ccns.get(ccnId); + if (!CCN) { + throw new Error(`CCN ${ccnId} not found`); + } + const serializedCCN: SerializedCCN = { + version: 1, // For future compatibility + id: CCN.id, + epoch: CCN.epoch, + members: Array.from(CCN.members.entries()), + ccnKey: encodeBase64(CCN.ccnKey), + privateKey: CCN.privateKey, + ratchetTree: CCN.ratchetTree, + }; + const json = JSON.stringify(serializedCCN); + const encrypted = encryptUint8Array( + textEncoder.encode(json), + encryptionKey, + ); + return encodeBase64(encrypted); + } + + deserializeCCN(serialized: string) { + const encrypted = decodeBase64(serialized); + const decrypted = decryptUint8Array(encrypted, encryptionKey); + const json = textDecoder.decode(decrypted); + const serializedCCN = JSON.parse(json) as SerializedCCN; + + if (serializedCCN.version !== 1) { + throw new Error( + `Unknown CCN serialization version: ${serializedCCN.version}`, + ); + } + + this.ccns.set( + serializedCCN.id, + { + id: serializedCCN.id, + epoch: serializedCCN.epoch, + members: new Map(serializedCCN.members), + ccnKey: decodeBase64(serializedCCN.ccnKey), + privateKey: serializedCCN.privateKey, + ratchetTree: serializedCCN.ratchetTree, + }, + ); + } + + addMember( + ccnId: CCNId, + memberId: NPub, + ): Welcome | null { + const CCN = this.ccns.get(ccnId); + if (!CCN) return null; + + CCN.epoch += 1; + CCN.members.set(memberId, memberId); + + const newNodeIndex = CCN.ratchetTree.length; + CCN.ratchetTree.push({ + publicKey: memberId, + path: [newNodeIndex], + parent: null, + children: [], + }); + + const newSecret = randomBytes(32); + CCN.ccnKey = deriveCCNKey(newSecret, ccnId, CCN.epoch); + + const pathSecret = derivePathSecret(newSecret, [newNodeIndex]); + const serializedState = JSON.stringify({ + id: CCN.id, + epoch: CCN.epoch, + members: Array.from(CCN.members.entries()), + ratchetTree: CCN.ratchetTree, + }); + const nonce = generateNonce(); + const sharedSecret = secp256k1.getSharedSecret( + this.identity.privateKey, + memberId, + true, + ).slice(1); // Remove the prefix byte + + const cipher = xchacha20poly1305(sharedSecret, nonce); + const encryptedCCNState = cipher.encrypt( + textEncoder.encode(serializedState), + ); + + const welcome: Welcome = { + ccnId, + epoch: CCN.epoch, + encryptedCCNState, + pathSecret, + inviterPublicKey: this.identity.publicKey, + nonce, + }; + + return welcome; + } + + joinCCN(welcome: Welcome): CCNId | null { + try { + const { + ccnId, + epoch, + encryptedCCNState, + pathSecret, + inviterPublicKey, + nonce, + } = welcome; + + const ccnKey = deriveCCNKey(pathSecret, ccnId, epoch); + + const sharedSecret = secp256k1.getSharedSecret( + this.identity.privateKey, + inviterPublicKey, + true, + ).slice(1); // Remove the prefix byte + + const decipher = xchacha20poly1305(sharedSecret, nonce); + const decryptedStateBytes = decipher.decrypt(encryptedCCNState); + const decryptedState = JSON.parse( + textDecoder.decode(decryptedStateBytes), + ); + + const members = new Map(decryptedState.members); + const ratchetTree = decryptedState.ratchetTree; + + const ccnState: CCNState = { + id: ccnId, + epoch, + members, + ccnKey, + privateKey: this.identity.privateKey, + ratchetTree, + }; + + this.ccns.set(ccnId, ccnState); + return ccnId; + } catch (error) { + console.error("Failed to join ccn:", error); + return null; + } + } + + encryptMessage(ccnId: CCNId, plaintext: string): EncryptedMessage | null { + const CCN = this.ccns.get(ccnId); + if (!CCN) return null; + + const nonce = generateNonce(); + const cipher = xchacha20poly1305(CCN.ccnKey, nonce); + const ciphertext = cipher.encrypt(textEncoder.encode(plaintext)); + + const messageToSign = new Uint8Array([ + ...textEncoder.encode(ccnId), + ...new Uint8Array(new BigUint64Array([BigInt(CCN.epoch)]).buffer), + ...ciphertext, + ...nonce, + ]); + + const messageHash = sha256(messageToSign); + const signature = secp256k1.sign(messageHash, this.identity.privateKey); + + return { + sender: this.memberId, + epoch: CCN.epoch, + ciphertext, + nonce, + signature: signature.toCompactRawBytes(), + }; + } + + decryptMessage(ccnId: CCNId, message: EncryptedMessage): string | null { + const CCN = this.ccns.get(ccnId); + if (!CCN) return null; + + if (message.epoch !== CCN.epoch) { + console.error("Message from wrong epoch"); + return null; + } + + const senderPublicKey = CCN.members.get(message.sender); + if (!senderPublicKey) { + console.error("Message from unknown sender"); + return null; + } + + // Verify signature + const messageToVerify = new Uint8Array([ + ...textEncoder.encode(ccnId), + ...new Uint8Array(new BigUint64Array([BigInt(message.epoch)]).buffer), + ...message.ciphertext, + ...message.nonce, + ]); + + const messageHash = sha256(messageToVerify); + const isValid = secp256k1.verify( + message.signature, + messageHash, + senderPublicKey, + ); + + if (!isValid) { + console.error("Invalid message signature"); + return null; + } + + try { + const decipher = xchacha20poly1305(CCN.ccnKey, message.nonce); + const plaintext = decipher.decrypt(message.ciphertext); + return textDecoder.decode(plaintext); + } catch (error) { + console.error("Failed to decrypt message:", error); + return null; + } + } + + updateCCNKey(ccnId: CCNId): UpdateMessage | null { + const CCN = this.ccns.get(ccnId); + if (!CCN) return null; + + const newEpoch = CCN.epoch + 1; + const newRootSecret = randomBytes(32); + const updatedTree = [...CCN.ratchetTree]; + const myLeafIndex = updatedTree.findIndex((node) => + node.publicKey && + bytesEqual(node.publicKey, this.identity.publicKey) + ); + + if (myLeafIndex === -1) { + console.error("Cannot find self in ratchet tree"); + return null; + } + + const myPath: number[] = [myLeafIndex]; + let currentNode = myLeafIndex; + + while (updatedTree[currentNode].parent !== null) { + currentNode = updatedTree[currentNode].parent!; + myPath.unshift(currentNode); + } + + const pathSecrets: Uint8Array[] = [newRootSecret]; + const updatedPublicKeys = new Map(); + + for (let i = 0; i < myPath.length; i++) { + const nodeIndex = myPath[i]; + + let newPublicKey: NPub; + + if (i === 0) { + newPublicKey = nip19.npubEncode(nostrTools.getPublicKey(newRootSecret)); + } else { + const parentIndex = myPath[i - 1]; + const childSecret = derivePathSecret(pathSecrets[i - 1], [ + parentIndex, + nodeIndex, + ]); + pathSecrets.push(childSecret); + + newPublicKey = nip19.npubEncode(nostrTools.getPublicKey(childSecret)); + } + + updatedTree[nodeIndex].publicKey = newPublicKey; + updatedPublicKeys.set(nodeIndex, newPublicKey); + } + + const encryptedPathSecrets = new Map(); + + for (const [memberId, memberPublicKey] of CCN.members.entries()) { + if (memberId === this.memberId) continue; + + const memberLeafIndex = updatedTree.findIndex((node) => + node.publicKey && + bytesEqual(node.publicKey, memberPublicKey) + ); + + if (memberLeafIndex === -1) continue; + + let memberNode = memberLeafIndex; + const memberPath: number[] = [memberLeafIndex]; + + while (updatedTree[memberNode].parent !== null) { + memberNode = updatedTree[memberNode].parent!; + memberPath.unshift(memberNode); + } + + let commonAncestorIndex = 0; + while ( + commonAncestorIndex < myPath.length && + commonAncestorIndex < memberPath.length && + myPath[commonAncestorIndex] === memberPath[commonAncestorIndex] + ) { + commonAncestorIndex++; + } + + const secretToShare = pathSecrets[commonAncestorIndex - 1]; + + const nonce = generateNonce(); + const sharedSecret = secp256k1.getSharedSecret( + this.identity.privateKey, + memberPublicKey, + true, + ).slice(1); + + const cipher = xchacha20poly1305(sharedSecret, nonce); + const ciphertext = cipher.encrypt(secretToShare); + + encryptedPathSecrets.set(memberId, { ciphertext, nonce }); + } + + const updateData = new Uint8Array([ + ...textEncoder.encode(ccnId), + ...new Uint8Array(new BigUint64Array([BigInt(newEpoch)]).buffer), + ...textEncoder.encode( + JSON.stringify(Array.from(encryptedPathSecrets.entries())), + ), + ...textEncoder.encode( + JSON.stringify(Array.from(updatedPublicKeys.entries())), + ), + ]); + + const updateHash = sha256(updateData); + const signature = secp256k1.sign(updateHash, this.identity.privateKey); + + CCN.epoch = newEpoch; + CCN.ratchetTree = updatedTree; + CCN.ccnKey = deriveCCNKey(newRootSecret, ccnId, newEpoch); + + const updateMessage: UpdateMessage = { + sender: this.memberId, + ccnId, + epoch: newEpoch, + pathSecrets: encryptedPathSecrets, + updatedPublicKeys, + signature: signature.toCompactRawBytes(), + }; + + return updateMessage; + } + + processUpdate(updateMessage: UpdateMessage): boolean { + const { + sender, + ccnId, + epoch, + pathSecrets, + updatedPublicKeys, + signature, + } = updateMessage; + const CCN = this.ccns.get(ccnId); + if (!CCN) { + console.error("Unknown CCN"); + return false; + } + if (epoch !== CCN.epoch + 1) { + console.error("Invalid epoch in update"); + return false; + } + const senderPublicKey = CCN.members.get(sender); + if (!senderPublicKey) { + console.error("Update from unknown sender"); + return false; + } + + const updateData = new Uint8Array([ + ...textEncoder.encode(ccnId), + ...new Uint8Array(new BigUint64Array([BigInt(epoch)]).buffer), + ...textEncoder.encode(JSON.stringify(Array.from(pathSecrets.entries()))), + ...textEncoder.encode( + JSON.stringify(Array.from(updatedPublicKeys.entries())), + ), + ]); + + const updateHash = sha256(updateData); + const isValid = secp256k1.verify( + signature, + updateHash, + senderPublicKey, + ); + + if (!isValid) { + console.error("Invalid update signature"); + return false; + } + + const myPathSecret = pathSecrets.get(this.memberId); + if (!myPathSecret) { + console.error("No path secret for me in update"); + return false; + } + + try { + const sharedSecret = secp256k1.getSharedSecret( + this.identity.privateKey, + senderPublicKey, + true, + ).slice(1); + + const decipher = xchacha20poly1305(sharedSecret, myPathSecret.nonce); + const decryptedSecret = decipher.decrypt(myPathSecret.ciphertext); + + const myLeafIndex = CCN.ratchetTree.findIndex((node) => + node.publicKey && + bytesEqual(node.publicKey, this.identity.publicKey) + ); + + if (myLeafIndex === -1) { + console.error("Cannot find self in ratchet tree"); + return false; + } + + const myPath: number[] = [myLeafIndex]; + let currentNode = myLeafIndex; + + while (CCN.ratchetTree[currentNode].parent !== null) { + currentNode = CCN.ratchetTree[currentNode].parent!; + myPath.unshift(currentNode); + } + + const updatedTree = [...CCN.ratchetTree]; + for (const [nodeIndex, newPublicKey] of updatedPublicKeys.entries()) { + const index = Number(nodeIndex); + if (index >= 0 && index < updatedTree.length) { + updatedTree[index].publicKey = newPublicKey; + } + } + + CCN.epoch = epoch; + CCN.ratchetTree = updatedTree; + CCN.ccnKey = deriveCCNKey(decryptedSecret, ccnId, epoch); + + return true; + } catch (error) { + console.error("Failed to process update:", error); + return false; + } + } + + generateInvite(ccnId: CCNId, recipientPubkey: NPub): string { + const welcome = this.addMember( + ccnId, + recipientPubkey, + ); + + if (!welcome) { + throw new Error("Failed to create welcome message"); + } + + const ccn = this.ccns.get(ccnId); + if (!ccn) { + throw new Error("CCN not found"); + } + + const ccnPrivateKey = ccn.ccnKey; + const conversationKey = nostrTools.nip44.getConversationKey( + ccnPrivateKey, + recipientPubkey, + ); + + const invite = { + type: "ccn-invite", + version: 1, + ccnId, + welcome, + metadata: { + createdAt: Math.floor(Date.now() / 1000), + memberCount: ccn.members.size, + }, + }; + + const encryptedInvite = nostrTools.nip44.encrypt( + JSON.stringify(invite), + conversationKey, + ); + + return `ccn:${ccnId}:${btoa(encryptedInvite)}`; + } + + acceptInvite(inviteString: string): CCNId | null { + try { + // Parse the invite string format: ccn:ccnId:encryptedData + const parts = inviteString.split(":"); + if (parts.length !== 3 || parts[0] !== "ccn") { + throw new Error("Invalid invite format"); + } + + const ccnId = parts[1]; + const encryptedInvite = atob(parts[2]); + + const parsedData = JSON.parse(nostrTools.nip44.decrypt( + encryptedInvite, + nip19.decode<"nsec">(this.identity.privateKey).data, + )); + + if (parsedData.type !== "ccn-invite" || parsedData.version !== 1) { + throw new Error("Invalid invite type or version"); + } + + const welcome = parsedData.welcome; + + const joinResult = this.joinCCN(welcome); + + if (!joinResult) { + throw new Error("Failed to join CCN"); + } + + if (!this.ccns.has(ccnId)) { + throw new Error("CCN was not properly added to the ccns map"); + } + + return ccnId; + } catch (error) { + console.error("Failed to accept invite:", error); + return null; + } + } +} diff --git a/utils.ts b/utils.ts index 1ec0095..58fe189 100644 --- a/utils.ts +++ b/utils.ts @@ -8,6 +8,7 @@ import { encryptionKey, encryptUint8Array, } from "./utils/encryption.ts"; +import { NSec } from "@nostr/tools/nip19"; export function isLocalhost(req: Request): boolean { const url = new URL(req.url); @@ -61,9 +62,52 @@ export async function getCCNPubkey(): Promise { return ccnPublicKey; } +export async function getMLSPrivateKey(): Promise { + const mlsPrivPath = await getEveFilePath("mls.priv"); + const doWeHaveKey = await exists(mlsPrivPath); + if (doWeHaveKey) { + const encryptedPrivateKey = Deno.readTextFileSync(mlsPrivPath); + const decryptedPrivateKey = decryptUint8Array( + decodeBase64(encryptedPrivateKey), + encryptionKey, + ); + return nostrTools.nip19.nsecEncode(decryptedPrivateKey); + } + const mlsPrivateKey = nostrTools.generateSecretKey(); + const encryptedPrivateKey = encryptUint8Array(mlsPrivateKey, encryptionKey); + Deno.writeTextFileSync(mlsPrivPath, encodeBase64(encryptedPrivateKey)); + return nostrTools.nip19.nsecEncode(mlsPrivateKey); +} + export async function getCCNPrivateKey(): Promise { const encryptedPrivateKey = Deno.readTextFileSync( await getEveFilePath("ccn.priv"), ); return decryptUint8Array(decodeBase64(encryptedPrivateKey), encryptionKey); } + +/** + * Compares two byte-like objects in a constant-time manner to prevent timing attacks. + * + * @param a - First byte-like object to compare + * @param b - Second byte-like object to compare + * @returns boolean indicating whether the inputs contain identical bytes + */ +export function bytesEqual< + T extends Uint8Array | number[] | string, +>(a: T, b: T): boolean { + const aLength = a.length; + const bLength = b.length; + let result = aLength !== bLength ? 1 : 0; + const maxLength = Math.max(aLength, bLength); + for (let i = 0; i < maxLength; i++) { + const aVal = i < aLength + ? (typeof a === "string" ? a.charCodeAt(i) : a[i]) + : 0; + const bVal = i < bLength + ? (typeof b === "string" ? b.charCodeAt(i) : b[i]) + : 0; + result |= aVal ^ bVal; + } + return result === 0; +}