import type { Thread } from '@wovin/core' import type { ApplogForInsert } from '@wovin/core/applog' import type { IShare, ISubscription, AppAgent as WovinAppAgent } from '@wovin/core/pubsub' import type { GenericObject } from '@wovin/core/types' import type { CryptoKeys } from './AgentCrypto' import { agentToShortHash, filterAndMap, lastWriteWins, rollingFilter } from '@wovin/core' import { action, computed, flow, makeObservable, observable } from '@wovin/core/mobx' import { arrayBufferToBase64, DefaultFalse } from '@wovin/utils' import { fnBrowserDetect } from '@wovin/utils/browser' import { Logger } from 'besonders-logger' import { getHKDFkeyFromHashArray } from 'mnemkey' import stringify from 'safe-stable-stringify' import { Mixin } from 'ts-mixer' import { stopPropagation } from '../../ui/utils-ui' import { getApplogDB, insertApplogsInAppDB } from '../ApplogDB' import { deriveSecretBitsFromECDH, getAESkeyPersonal, getDerivationKeypairFromIDB, getEdDSAfromIDB, initializeCryptoKeypairs, } from './AgentCrypto' import { AgentPubSub } from './AgentPubSub' import { AgentStorage } from './AgentStorage' const { WARN, LOG, DEBUG, VERBOSE, ERROR } = Logger.setup(Logger.INFO) // eslint-disable-line unused-imports/no-unused-vars export const DEMO_USERNAME = 'demouser' export class AgentStateClass extends Mixin(AgentPubSub, AgentStorage) implements WovinAppAgent { app = 'note3' username = localStorage.getItem('agent.username') ?? DEMO_USERNAME devicename = localStorage.getItem('agent.devicename') ?? 'device' agentcode = localStorage.getItem('agent.agentcode') ?? `${import.meta.env.DEV ? 'dev-' : ''}${fnBrowserDetect() ?? 'browser'}` crypto: CryptoKeys = null w3NamePublic: string | null = null // needs to be set before getters will work _cryptoDerivationPublicJWK?: globalThis.JsonWebKey _cryptoDerivationPublicECDH?: string // extracted from the IDB keystore on first request then cached in memory here loadIDB = async () => { // TODO: dexie doesn't support nullable index/sorting - so do it manually this.reloadPubSub() this.loading = false } // TODO figure out how to move these getters to AgentPubSub and make them mobx computed // get publicationsMap() { // return new Map(this.publications.map(pub => [pub.id, pub])) // } // get subscriptionsMap() { // return new Map(this.subscriptions.map(sub => [sub.id, sub])) // } // get mostRecentPublication() { // if (!this.publications.length) return null // return this.publications[this.publications.length - 1] // } getHKDFkey = async (keypair?: CryptoKeyPair) => { if (!this.crypto?.hkdf) { const personalECDH = await this.getDerivationKeypair() this.crypto.hkdf = await getHKDFkeyFromHashArray(await deriveSecretBitsFromECDH(personalECDH)) } return this.crypto?.hkdf } getPersonalEncryptionKey = async () => { if (!this.crypto?.personalAes) this.crypto.personalAes = await getAESkeyPersonal() return this.crypto?.personalAes } getReliableDid = async () => { return (await this.getEdDSAsigningKeypair()).did } get shortDID() { return this.did?.slice(-8) || 'noDIDyet' // (i) first 24 from did "always" seem to be did:key:z4MXj1wBzi9jUsty } get isAgentStringSetup() { return localStorage.getItem('agent.isSetManually') } isAgentStringMatching = computed(() => getAgentString(this.ag) === this.agentString) get expectedAgentHash() { return localStorage.getItem('agent.expectedAgentHash') ?? '' } set expectedAgentHash(hashString) { localStorage.setItem('agent.expectedAgentHash', hashString) } ensureAgentAtoms = flow(function* (this: AgentStateClass) { if (!this.crypto?.ecdh) yield this.getDerivationKeypair() if (!this.crypto?.ecdh) return WARN('[ensureAgentAtoms] not possible without ecdh', this.crypto) if (!this.crypto.eddsa) yield this.getEdDSAsigningKeypair() if (!this.crypto.eddsa) return WARN('[ensureAgentAtoms] not possible without eddsa', this.crypto) if (!this.did) return WARN('[ensureAgentAtoms] not possible without did') const { agentString, ag } = this DEBUG(`[agentAtoms] current agent:`, { ag, agentString }) let ds: Thread try { ds = getApplogDB() } catch (err) { return WARN('[ensureAgentAtoms] not possible without db', err) } const agentAtoms = [] as ApplogForInsert[] // const appAgentResult = db.enatIndex[`${ag}_|_agent/appAgent`]?.[0] // as ag is a unique hash, this should always be the same // const deriverLog = rollingFilter(ds, { en: ag, at: 'agent/jwkd' }) let deriverJWKD = filterAndMap(lastWriteWins(ds), { en: ag, at: 'agent/jwkd' }, 'vl')[0] let deriverECDH64 = filterAndMap(lastWriteWins(ds), { en: ag, at: 'agent/ecdh' }, 'vl')[0] const appAgentFromLogs = getAgentString(ag) if (appAgentFromLogs === agentString) { VERBOSE('[agentAtoms] matching appAgent log exists for current ag, assuming its fine', { appAgentFromLogs }) } else { WARN('[agentAtoms] appAgentLogs missing or non matching', { appAgentFromLogs, deriverECDH64, deriverJWKD, }) agentAtoms.push( { en: ag, at: 'agent/appAgent', vl: agentString, ag }, ) } // array of all atoms for this specific agent - can be more than one if the key has been "rotated" if (deriverJWKD && deriverECDH64) { VERBOSE('[agentAtoms] deriver jwk and 64 both exist, assuming its fine', { deriverECDH64, deriverJWKD }) // TODO stop assuming and start checking // if (deriverJWKD.size > 1) WARN('//TODO deal with multiple jwkd atoms') } else { if (!deriverECDH64) { const newDeriverECDH = deriverECDH64 = yield this.getPublicDerivationECDH() WARN('[agentAtoms] ECDH deriverLog missing or non matching', { newDeriverECDH }) agentAtoms.push({ en: ag, at: 'agent/ecdh', vl: newDeriverECDH, ag }) } if (!deriverJWKD) { const newDeriverJWK = deriverJWKD = yield this.getDerivationJWK() WARN('[agentAtoms] ECDH deriverJWKLog missing or non matching', { newDeriverJWK }) agentAtoms.push({ en: ag, at: 'agent/jwkd', vl: stringify(newDeriverJWK), ag }) } } const nonExistingAgentAtoms = agentAtoms.filter(a => !ds.hasApplogWithDiffTs(a)) if (nonExistingAgentAtoms.length) { VERBOSE('[agentAtoms] adding', ag, { agentAtoms, nonExistingAgentAtoms }) // double check that the above did not produce any duplicates this.expectedAgentHash = ag // update expected if anything changed yield insertApplogsInAppDB(nonExistingAgentAtoms) } else { DEBUG('[agentAtoms] alles klar miene shatzie', { deriverJWKD, deriverECDH64, appAgentFromLogs }) } }) // TODO: mobx-ify those: getDerivationJWK = async () => { if (!this._cryptoDerivationPublicJWK) { this._cryptoDerivationPublicJWK = await globalThis.crypto.subtle.exportKey('jwk', (await this.getDerivationKeypair()).publicKey) } return this._cryptoDerivationPublicJWK } getPublicDerivationECDH = async () => { if (!this._cryptoDerivationPublicECDH) { const exportedKey = await globalThis.crypto.subtle.exportKey('raw', (await this.getDerivationKeypair()).publicKey) const keyBase64 = arrayBufferToBase64(exportedKey) DEBUG('sharedKey', { exportedKey, keyBase64 }) this._cryptoDerivationPublicECDH = keyBase64 } return this._cryptoDerivationPublicECDH } getPublicDerivationECDHshortSync = () => { if (!this._cryptoDerivationPublicECDH) { this.getPublicDerivationECDH() return } return `${this._cryptoDerivationPublicECDH.slice(0, 4)}...${this._cryptoDerivationPublicECDH.slice(-4)}` } getSharedKeyApplogForCurrentAgent = (infoLogs) => { DEBUG('looking for sharedKeyLog for ', this.ag, 'in', { infoLogs }) return infoLogs.find(eachLog => ( (eachLog?.at as string) === 'pub/sharedKey' && (eachLog?.en as string) === this.ag )) } getEdDSAsigningKeypair = async (newMnemonic?: string[]) => { if (newMnemonic) { throw ERROR('//TODO newMnemonic') } else { if (this.crypto.eddsa) return this.crypto.eddsa // -commented to use new lib //? bring back return getEdDSAfromIDB(this) } } getDerivationKeypair = async (newMnemonic?: string[]) => { if (newMnemonic || !this.crypto?.ecdh) { if (!this.crypto) throw new Error('getDerivationKeypair called before agent.crypto init') this.crypto.ecdh = newMnemonic ? await initializeCryptoKeypairs(this, newMnemonic) // reinitialize with newMnem : await getDerivationKeypairFromIDB(this) // try to get from IDB ?? await initializeCryptoKeypairs(this) // create new } return this.crypto.ecdh as CryptoKeyPair } getKnownAgents(thread: Thread = getApplogDB()) { const knownAgentPKlogs = rollingFilter(lastWriteWins(thread), { at: 'agent/jwkd' }) const knownAgentECDHlogs = rollingFilter(lastWriteWins(thread), { at: 'agent/ecdh' }) return computed(() => { const mappedAgentLogs = knownAgentPKlogs.applogs.map( (log, _logIdx) => { const { en: eachAg, vl: jwkd } = log const agString = getAgentString(eachAg) return [eachAg, { log, ag: eachAg, agString, jwkd }] as const }, ) const agentsMap = new Map(mappedAgentLogs) // TODO ts DEBUG('agentMapMemo', { mappedAgentLogs, agentsMap, knownAgentECDHlogs, knownAgentPKlogs }) return agentsMap }) } signWithEdDSA = async (message: Uint8Array) => { return (await this.getEdDSAsigningKeypair()).sign(message) } signWithEdDSAFromIDB = async (message: Uint8Array) => { return (await getEdDSAfromIDB(this)).sign(message) } getProxyfiableSignFx = async () => { return (await getEdDSAfromIDB(this)).sign } /** * satisfying @wovin/core/AppAgent:: * did, ag, agentString, sign */ get did() { if (this.crypto?.eddsa) { return this.crypto?.eddsa?.did } throw ERROR('Missing DID') } get ag() { return agentToShortHash(this.agentString) } get agentString() { return `${this.username}.${this.shortDID}@${this.app}.${this.agentcode}.${this.devicename}` } get agentStringNoDID() { return `${this.username}@${this.app}.${this.agentcode}.${this.devicename}` } sign = this.signWithEdDSA /* constructor() { // ! moved mobx init after constructor to enable code separation via mixin (eg. AgentPubSub) } */ loading = true } const agentState = new AgentStateClass() // not exported to force use of useAgent() export function useAgent() { return agentState } makeObservable(agentState, { loading: observable, app: observable, username: observable, devicename: observable, agentcode: observable, crypto: observable.ref, w3NamePublic: observable, sharesMap: computed, subscriptionsMap: computed, // hasStorageSetup: computed, mostRecentShare: computed, did: computed, shortDID: computed, agentString: computed, ag: computed, // addPub: action, // addSub: action, // publications: observable, - already observable.array // subscriptions: observable, }) export const updateAgentState = action((newState: Partial) => { // TODO persist changes to IDB DEBUG('[AgentState] before', { agentState: JSON.parse(stringify(agentState)), newState }) Object.assign(agentState, newState) DEBUG('[AgentState] after', JSON.parse(stringify(agentState))) }) export function getAgentString(agentEntityHash) { const values = filterAndMap(lastWriteWins(getApplogDB()), { en: agentEntityHash, at: 'agent/appAgent' }, 'vl') if (values.length > 1) WARN(`Multiple appAgent atoms`, values) VERBOSE(`AppAgent from DB`, values[0]) return values[0] } // (getApplogDB().getSortedApplogsFromIndex({ whichID: `${agentEntityHash}_|_agent/appAgent`, whichIndex: 'enatIndex' }))?.[0]?.vl /****** * agentString = ${username}.${pubkey} @ ${agentcode}.${devicename} * agentString = ${w3ui-verifiedEmailAddress}.${fullDID} @ ${agentcode}.${devicename} agentString = ${userhandle}.${shortDID} @ ${app}.${agentcode}.${devicename} agentStringEx = myuser.PVZxrKoX@dev.note3.chromium.laptop ag = hash(agentString)// used for every appLog atoms needed ag=>did mapping ag=>agentstring ag=>signed agentsring // ?? ag=>ucan mapping ag=>exported json public key // only if did extraction ********/ const syncToLocalStorageDefaults = ['username', 'devicename', 'agentcode', 'isSetManually'] export const valuesAsofBinding = new Map() const acceptableExtraAttr: { shareID?: string, pubID?: string, subID?: string, padding?: string, extraClasses?: string } = {} export function boundInput( prop: keyof AgentStateClass, syncToLocalStorage = syncToLocalStorageDefaults, extraAttrs = acceptableExtraAttr, onChange?: (newVal: string) => void, skipBlur = DefaultFalse, ) { const pubOrSubID = extraAttrs.shareID || extraAttrs.pubID || extraAttrs.subID const val = pubOrSubID ? (agentState[prop] as Map)?.get(pubOrSubID as string)?.name ?? 'unknown' : agentState[prop] // VERBOSE('[agent] binding', { [prop]: val }) valuesAsofBinding.set(prop, val) // value="${username}" onkeydown="${handleKeydown}" oninput="${setContents} const onkeydown = (evt: any) => { // const { agentstring: agentStringDirect } = state() DEBUG('[onkeydown]', { val, evt }) const newVal = evt.target.textContent onChange?.(newVal) } const oninput = (evt: any) => { // const newVal = evt.target.textContent // // host[prop] = val - breaks UX // store.set(AgentState, { [prop]: val }) // DEBUG('[oninput]', val, { host, evt }) // if (['username', 'devicename'].includes(prop)) { // localStorage.setItem(`agent.${prop}`, val) // } } const onblur = (evt: any) => { const newVal = evt.target.textContent // VERBOSE('[onblur]', prop, 'newVal:', newVal) if (newVal !== val) { if (pubOrSubID && !skipBlur) { if (!['sharesMap', 'subscriptionsMap'].includes(prop)) throw new Error('invalid prop for pubsub') const currentPubOrSub: ISubscription | IShare = agentState[prop as 'sharesMap' | 'subscriptionsMap'] /* as Map */?.get( pubOrSubID as string, ) DEBUG('[onblur] with subPath', prop, { currentPubOrSub, newVal }) if (!currentPubOrSub) throw ERROR(`Invalid pubOrSubID`, pubOrSubID) if (prop === 'sharesMap') { agentState.updateShare(currentPubOrSub.id, { name: newVal }) } else { agentState.updateSub(currentPubOrSub.id, { name: newVal }) } } else if (!skipBlur) { const newState = updateAgentState({ [prop]: newVal }) VERBOSE('[onblur]', prop, 'newState:', newState) if (syncToLocalStorage.includes(prop)) { localStorage.setItem(`agent.isSetManually`, 'true') localStorage.setItem(`agent.${prop}`, newVal) } } else { VERBOSE('not pub or sub likely skipBlur ', { skipBlur }) } } } // TODO grok how to penetrate shadow dom situation return ( {val} ) }