import type { Applog, IShare } from '@wovin/core' import type { AgentStateClass } from './AgentState' import { hkdf } from '@noble/hashes/hkdf.js' import { sha256 } from '@noble/hashes/sha2.js' import { utf8ToBytes } from '@noble/hashes/utils.js' import { arrayBufferToBase64, assertCryptoKey, base64ToArray, base64ToArrayBuffer, DefaultFalse, randomBuf } from '@wovin/utils' import { Logger } from 'besonders-logger' import { base64pad } from 'iso-base/rfc4648' import { untag } from 'iso-base/varint' import { EdDSASigner } from 'iso-signatures/signers/eddsa.js' import { createMnemonic, deriveAesViaHKDFKey, EdKeypairInstanceFromSecretKeyBytes, getECDHkeypairFromHashArray, getEdDSAkeypairFromHashArray, getHKDFkeyFromStrings, hashMnemonic, } from 'mnemkey' import { importPublicDerivationKey } from '../../ipfs/share-sync' import { notifyToast, ToastVariant } from '../../ui/utils-ui' import { stateDB } from '../local-state' import { updateAgentState, useAgent } from './AgentState' const { WARN, LOG, DEBUG, VERBOSE, ERROR } = Logger.setup(Logger.DEBUG) // eslint-disable-line unused-imports/no-unused-vars // export const configuration = { // namespace: { creator: 'ztax', name: 'note3' }, // debug: true, // fileSystem: { // loadImmediately: false, // // version?: string // }, // // permissions?: Permissions // // userMessages?: UserMessages // } export const SymmAlg = { AES_CTR: 'AES-CTR', AES_CBC: 'AES-CBC', AES_GCM: 'AES-GCM', } as const type Base64String = string export async function importPublicECDH64(derivationECDH: Base64String) { const publicECDHraw = base64ToArrayBuffer(derivationECDH) DEBUG({ derivationECDH, publicECDHraw }) return await globalThis.crypto.subtle.importKey( 'raw', publicECDHraw, { name: 'ECDH', namedCurve: 'P-256' }, true, [], ) } export const decryptWithAesSharedKey = async function decryptWithAesSharedKey( encByteArray: Uint8Array, aesKey: CryptoKey, as: 'array' | 'parsed' | 'string' = 'array', ) { // const aesKey = currentPubSub.encryptedWith ?? (currentPubSub as IShare).sharedKey if (!aesKey) { throw ERROR('[decrypt] missing aesKey', { encByteArray }) // throw new Error('[decrypt] missing info') // ERROR from logger is now throwable } // @expede advises against fixed iv https://github.com/fission-codes/keystore-idb/issues/70#issuecomment-1624377859 // so i recreated the way @odd / keystoreIDB create random iv and join the iv unencrypted at the front of the encrypted payload let oddDecrypted: Uint8Array const dec = new TextDecoder('utf-8') const { crypto } = useAgent() VERBOSE('decryptWithAesSharedKey', { encByteArray, aesKey, crypto }) try { oddDecrypted = await aesUtils.decrypt(encByteArray, aesKey, SymmAlg.AES_GCM) } catch (error) { ERROR('aes decrypt error', { error }) } const oddDecoded = dec.decode(oddDecrypted) VERBOSE('decryptWithAesSharedKey', { oddDecrypted, oddDecoded }) return as === 'array' ? oddDecrypted as Uint8Array : as === 'parsed' ? JSON.parse(oddDecoded) : oddDecoded } /** * @param privateKey * @param publicKey * @param exportable * @returns a shared AES key derived from the ECDH keys that are provided */ export function deriveSharedEncryptionKey(privateKey: CryptoKey, publicKey: CryptoKey, exportable = DefaultFalse) { if (exportable) ERROR('I hope you know what you be up to', { exportable }) if ( !(privateKey.type === 'private' && privateKey.algorithm.name === 'ECDH' && publicKey.type === 'public' && publicKey.algorithm.name === 'ECDH') ) { throw ERROR('invalid keys (should be ECDH and private/public', { privateKey, publicKey }) } return globalThis.crypto.subtle.deriveKey( { name: 'ECDH', public: publicKey, }, privateKey, { name: 'AES-GCM', length: 256, }, exportable, // ! for testing/comparison ['encrypt', 'decrypt'], ) } export async function deriveSecretBitsFromECDH( { privateKey, publicKey }: CryptoKeyPair, SEEDLENGTH = 32 * 8, ) { const personalSecret = await globalThis.crypto.subtle.deriveBits( { name: 'ECDH', namedCurve: 'P-256', public: publicKey, } as EcdhKeyDeriveParams, privateKey, SEEDLENGTH, ) const secretAsUint8Array = new Uint8Array(personalSecret, 0, SEEDLENGTH / 16) DEBUG({ secretAsUint8Array }) return secretAsUint8Array } /** * @param privateKey CryptoKey * @param publicKey CryptoKey * @param derivationCounter number * @param exportable boolean (default False) * @returns Uint8Array a seed suitable for key creation, derived from the ECDH keys that are provided */ export async function derivePublicationKeySeed( { privateKey, publicKey }: CryptoKeyPair, derivationCounter: number, exportable = DefaultFalse, SEEDLENGTH = 32 * 8, ) { if (exportable) ERROR('I hope you know what you be up to', { exportable }) if ( !(privateKey.type === 'private' && privateKey.algorithm.name === 'ECDH' && publicKey.type === 'public' && publicKey.algorithm.name === 'ECDH') ) { throw ERROR('invalid keys (should be ECDH and private/public', { privateKey, publicKey }) } const secretAsUint8Array = await deriveSecretBitsFromECDH({ privateKey, publicKey }, SEEDLENGTH) DEBUG('derived secret from keys', { privateKey, publicKey, secretAsUint8Array }) // in this case from agent ecdh matching public and private const hashedSecret = hkdf(sha256, secretAsUint8Array, utf8ToBytes(`note3-pubcounter:${derivationCounter}`), utf8ToBytes('note3-pub'), 32) DEBUG({ hashedSecret }) return hashedSecret } // personal means only for the current agent derived using the public and private keys from the same keypair // apparently its fine: https://crypto.stackexchange.com/a/26343/104653 export type ECDHKeyPair = CryptoKeyPair /* TODO strongly type & { privateKey:{type:'ECDH'}} */ export const getAESkeyPersonal = async function getAESkeyForPersonalEncryption(keypair?: ECDHKeyPair) { // TODO review when and where this is appropriate // subsequent derivations for each context may prove more secure const { privateKey, publicKey } = keypair ?? await useAgent().getDerivationKeypair() const aesEncryptionKey = deriveSharedEncryptionKey(privateKey, publicKey, false) VERBOSE({ privateKey, publicKey, aesEncryptionKey }) return aesEncryptionKey } export const getAESkeyForEncryptedApplogs = async function getAESkeyForEncryptedApplogs(nonEncryptedLogs: Applog[]) { const agent = useAgent() const { ag, getDerivationKeypair, getSharedKeyApplogForCurrentAgent } = agent const { privateKey: priDerivationKey } = await getDerivationKeypair() const sharedKeyApplog = getSharedKeyApplogForCurrentAgent(nonEncryptedLogs) if (!sharedKeyApplog) return ERROR('no sharedKey Found for current agent', { nonEncryptedLogs, priDerivationKey, ag }) const { vl: encryptedSharedKey, ag: publisherAg } = sharedKeyApplog as Applog const publicKeyForPublisher = (nonEncryptedLogs as Applog[]).find(eachLog => (eachLog?.at as string) === 'agent/jwkd' && (eachLog?.ag as string) === publisherAg, )?.vl const publicKeyECDHforPublisher = (nonEncryptedLogs as Applog[]).find(eachLog => (eachLog?.at as string) === 'agent/ecdh' && (eachLog?.ag as string) === publisherAg, )?.vl const publicKeyFromApplogs = await importPublicDerivationKey(publicKeyForPublisher as string) // TODO discuss depricate jwkd const derivedKeyDecryptionKey = await deriveSharedEncryptionKey(priDerivationKey, publicKeyFromApplogs) const encryptedKeyUintArr = base64ToArray(encryptedSharedKey as string) DEBUG({ publicKeyFromApplogs, publicKeyECDHforPublisher, derivedKeyDecryptionKey, encryptedSharedKey }) const decryptedSharedKeyBase64 = arrayBufferToBase64(await decryptWithAesSharedKey(encryptedKeyUintArr, derivedKeyDecryptionKey)) const decryptedSharedKeyUintArrForImport = base64ToArrayBuffer(decryptedSharedKeyBase64) DEBUG({ decryptedSharedKeyBase64, decryptedSharedKeyUintArrForImport }) const aesKey = await globalThis.crypto.subtle.importKey( 'raw', decryptedSharedKeyUintArrForImport, // this has been converted from b64 to uint8[] then decrypted then converted again to uint and then imported { name: 'AES-GCM', length: 256, }, false, ['encrypt', 'decrypt'], ) DEBUG('shared enc', { aesKey, decryptedSharedKeyBase64, encryptedSharedKey }) return { aesKey, publisherAg } } export const createAESkeyForShare = async function createAESkeyForShare(share: IShare) { const agent = useAgent() const { ag, did, getHKDFkey } = agent const rootHKDFderivationKey = await getHKDFkey() const salt = share.id const info = `note3share-ag:${ag}-${did}-shareCounter:${share.pubCounter}` const aesKey = await deriveAesViaHKDFKey(rootHKDFderivationKey, info, salt, true) // needs to be extractable for distribution and will only be stored encrypted DEBUG('shared enc', { aesKey, rootHKDFderivationKey, info, salt }) return aesKey } export interface CryptoKeys { personalAes?: CryptoKey hkdf?: CryptoKey ecdh?: Partial ecdsa?: Partial eddsa?: EdDSASigner } export const DEFAULT_CTR_LEN = 64 export const aesUtils = { decrypt: aesDecrypt, encrypt: aesEncrypt, } export async function aesDecrypt(cipherArray, key, alg, iv?: ArrayBufferLike) { assertCryptoKey(key) // the keystore version prefixes the `iv` into the cipher text // : await keystoreAES.decryptBytes(encrypted, key, { alg }); const cipherBufferFull = (new Uint8Array(cipherArray)).buffer // normalizeBase64ToBuf(cipherArray) // const importedKey = typeof key === 'string' ? await keys.importKey(key, opts) : key; type AesParams = AesCtrParams & AesCbcParams & AesGcmParams const decryptOptions: Partial = { name: alg, } // AES-CTR uses a counter, AES-GCM/AES-CBC use an initialization vector if (alg !== SymmAlg.AES_CTR) { decryptOptions.iv = iv ?? cipherBufferFull.slice(0, 16) } else { if (!iv) throw ERROR('iv is needed for AES_CTR') decryptOptions.counter = new Uint8Array(iv) decryptOptions.length = DEFAULT_CTR_LEN } const cipherBuffer = cipherBufferFull.slice(16) DEBUG('aesDecrypt', { cipherBuffer, decryptOptions, key }) try { const msgBuff = await globalThis.crypto.subtle.decrypt( decryptOptions as AesParams, key, cipherBuffer, ) DEBUG('aesDecrypt', { msgBuff }) return new Uint8Array(msgBuff) } catch (error) { throw ERROR('aesDecrypt', { decryptOptions, key, cipherBuffer: cipherBufferFull, error }) } } export function joinBufs(fst, snd) { const view1 = new Uint8Array(fst) const view2 = new Uint8Array(snd) const joined = new Uint8Array(view1.length + view2.length) joined.set(view1) joined.set(view2, view1.length) return joined.buffer } export async function aesEncrypt(data, key, alg, iv?: BufferSource) { assertCryptoKey(key) if (!iv) iv = randomBuf(16) const encrypted = await globalThis.crypto.subtle.encrypt({ name: alg, iv }, key, data) return new Uint8Array(joinBufs(iv, encrypted)) } async function createECDHderivationKeyPair(newMnemonic?: string[]) { // notifyToast(`mnemonic \n${newMnemonic}`, 'success', 300000) // TODO more elegant ux needed const hashedMnemonic = hashMnemonic(newMnemonic) const encHashedMnu = (new TextEncoder()).encode(hashedMnemonic) const keypairFromMnemonic = await getECDHkeypairFromHashArray(encHashedMnu) DEBUG('[createECDHderivationKeyPair]', { newMnemonic, keypairFromMnemonic }) return keypairFromMnemonic } async function createEdDSAkeypair(newMnemonic: string[], agentStringNoDID: string) { DEBUG.force({ agentStringNoDID }) const tweekedHashedMnem = hashMnemonic([...newMnemonic, agentStringNoDID]) const tweekedencHashedMnu = (new TextEncoder()).encode(tweekedHashedMnem) const edDSAKeypairFromMnemonic = await getEdDSAkeypairFromHashArray(tweekedencHashedMnu) // const edDSAKeypairFromNonTweekedMnemonic = await getEdDSAkeypairFromHashArray(encHashedMnu) return edDSAKeypairFromMnemonic } async function createHKDFkey(newMnemonic: string[], entropy = 'base-entropy-for-hkdf') { const tweekedHashedMnem = hashMnemonic([...newMnemonic, entropy]) const HKDFkeyFromMnemonic = await getHKDFkeyFromStrings(tweekedHashedMnem) return HKDFkeyFromMnemonic } export async function getDerivationKeypairFromIDB(agent: AgentStateClass) { const { expectedAgentHash /* , ag */ } = agent // if (expectedAgentHash !== ag) WARN('divergent expected and ag in getDerivationKeypairFromIDB', { expectedAgentHash, ag }) return (await stateDB.cryptokeys.get( [expectedAgentHash, 'derivation'], // ? using expected here so that the indexdb can hold agent info for more than one agent ))?.keys as CryptoKeyPair | undefined } export async function getDecryptedSecretFromIDB(agent: AgentStateClass, crypto?: CryptoKeys) { crypto = crypto ?? agent.crypto const fromIDB = (await stateDB.cryptokeys.get([agent.expectedAgentHash, 'eddsa']))?.keys as { secretKey: Uint8Array } const encryptedfromIDB = fromIDB?.secretKey DEBUG({ fromIDB }) if (encryptedfromIDB) { const personalEncryptionKey = await getAESkeyPersonal(crypto.ecdh as CryptoKeyPair) VERBOSE('Attempting Decryption', { encryptedfromIDB, personalEncryptionKey }) const decryptedFromIDB = await aesUtils.decrypt(encryptedfromIDB, personalEncryptionKey, SymmAlg.AES_GCM) DEBUG({ fromIDB, decryptedFromIDB }) return decryptedFromIDB } else { WARN('decrypted Secret Not Found') } return null } export async function getEdDSAfromIDB(agent: AgentStateClass, crypto?: CryptoKeys) { crypto = crypto ?? agent.crypto const decryptedFromIDB = await getDecryptedSecretFromIDB(agent, crypto) if (decryptedFromIDB) { const edKeypairInstance = EdKeypairInstanceFromSecretKeyBytes(decryptedFromIDB) DEBUG({ edKeypairInstance }) return edKeypairInstance } return null } export async function getMnemonicFromIDB(agent: AgentStateClass, agOveride?: string, crypto?: CryptoKeys) { crypto = crypto ?? agent.crypto const fromIDB = (await stateDB.cryptokeys.get([agOveride ?? agent.expectedAgentHash ?? agent.ag, 'mnemonic']))?.keys // @ts-expect-error const encryptedfromIDB = fromIDB?.encryptedMnemonic DEBUG({ fromIDB }) if (encryptedfromIDB) { const personalEncryptionKey = await getAESkeyPersonal(crypto.ecdh as CryptoKeyPair) const decryptedFromIDB = await aesUtils.decrypt(encryptedfromIDB, personalEncryptionKey, SymmAlg.AES_GCM) return new TextDecoder('utf-8').decode(decryptedFromIDB) } } export const initializeCryptoKeypairs = async function initializeCryptoKeypairs( agent: AgentStateClass, newMnemonic?: string[], skipWarning = DefaultFalse, ) { const crypto: CryptoKeys = agent.crypto ?? {} DEBUG('[CryptoKeys] default', { crypto }) if (newMnemonic?.length > 10) { crypto.ecdh = await createECDHderivationKeyPair(newMnemonic) crypto.eddsa = await createEdDSAkeypair(newMnemonic, agent.agentStringNoDID) VERBOSE('created ecdh and EdDSAkeypair from passed "valid" mneumonic', { crypto }) } else { crypto.ecdh = await getDerivationKeypairFromIDB(agent) DEBUG('[CryptoKeys] ecdh?', { crypto }) if (!crypto.ecdh) { newMnemonic = createMnemonic(24) crypto.ecdh = await createECDHderivationKeyPair(newMnemonic) crypto.eddsa = await createEdDSAkeypair(newMnemonic, agent.agentStringNoDID) // TODO derive from ecdh instead maybe VERBOSE('created ecdh and EdDSAkeypair from new mneumonic', { crypto }) } else { crypto.eddsa = await getEdDSAfromIDB(agent, crypto) VERBOSE('createdEdDSAkeypair from ecdh in IDB', { crypto }) } } const personalEncryptionKey = await getAESkeyPersonal(crypto.ecdh as CryptoKeyPair) // crypto.ecdh could be from idb or fresh // const personalHKDFkey = await createHKDFkey(newMnemonic) // crypto.hkdf = personalHKDFkey let encryptedEdSecretKey if (crypto.eddsa) { // HACK: we need raw private key const eddsaPkEncoded = crypto.eddsa.export() const eddsaPk = untag(EdDSASigner.code, base64pad.decode(eddsaPkEncoded)) encryptedEdSecretKey = await aesUtils.encrypt( eddsaPk, personalEncryptionKey, SymmAlg.AES_GCM, ) } // ready in case its needed: ecdsa // const signingKeypairFromMnemonic = await getECDSAkeypairFromHashArray(encHashedMnu) // crypto.ecdsa = signingKeypairFromMnemonic const updated = { ...crypto } updateAgentState({ crypto: updated }) const didAfter = agent.did const ag = agent.ag const expectedBefor = agent.expectedAgentHash agent.expectedAgentHash = ag DEBUG('init Keys', { ag, didAfter, agExpected: agent.expectedAgentHash }) if (!skipWarning && expectedBefor && expectedBefor !== ag) { notifyToast( `non matching expected and resulting ag \n (ag has changed since last app load from ${expectedBefor} -> ${ag})`, ToastVariant.warning, 90000, ) } // ? check this idempotent put approach if (crypto.eddsa) await stateDB.cryptokeys.put({ ag, type: 'eddsa', keys: { secretKey: encryptedEdSecretKey } }) if (crypto.ecdh) await stateDB.cryptokeys.put({ ag, type: 'derivation', keys: crypto.ecdh }) // await stateDB.cryptokeys.put({ ag, type: 'signing-ecdsa', keys: crypto.eddsa }) // keep around if (newMnemonic?.length) { const encryptedMnemonic = await aesUtils.encrypt( new TextEncoder().encode(newMnemonic.toString()), personalEncryptionKey, SymmAlg.AES_GCM, ) await stateDB.cryptokeys.put({ ag, type: 'mnemonic', keys: { encryptedMnemonic } }) } DEBUG('initialized', { crypto }) await agent.ensureAgentAtoms() return crypto.ecdh }