({
_baseUrl: 'https://www.david-trapp.com/misc/amplenote-encrypted/',
_settingNames: {
warnAboutUnusualPasswords: 'Warn about unusual passwords? (true/false, default=false)'
},
_rememberedPasswordHash: null,
_rememberedPasswordExpirationTimeout: null,
_getWarnAboutUnusualPasswordsSetting (app) {
return app.settings[this._settingNames.warnAboutUnusualPasswords] !== 'false'
},
async _bytesToBase64 (bytes) {
const dataUri = await new Promise((resolve, reject) => {
const reader = Object.assign(new FileReader(), {
onload: () => resolve(reader.result),
onerror: () => reject(reader.error)
})
reader.readAsDataURL(new File([bytes], '', { type: 'application/octet-stream' }))
})
return dataUri.split(',')[1]
},
async _base64ToBytes (base64) {
return new Uint8Array(atob(base64).split('').map(x => x.charCodeAt(0)))
},
_regexpEscape (s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
},
async _sha512 (s) {
const hashBuffer = await crypto.subtle.digest('SHA-512', new TextEncoder().encode(s))
return [...new Uint8Array(hashBuffer)].map(v => v.toString(16).padStart(2, '0')).join('')
},
async _hashAndSaltPassword (password) {
const salt = crypto.getRandomValues(new Uint8Array(16))
const encodedPassword = new TextEncoder().encode(password)
const combined = new Uint8Array(salt.byteLength + encodedPassword.byteLength)
combined.set(salt, 0)
combined.set(encodedPassword, salt.byteLength)
const hash = await crypto.subtle.digest('SHA-512', combined)
return [await this._bytesToBase64(salt), await this._bytesToBase64(hash)].join('.')
},
async _verifySaltedPasswordHash (password, saltedHash) {
try {
const [storedSaltB64, storedHashB64] = saltedHash.split('.')
const storedSalt = await this._base64ToBytes(storedSaltB64)
const encodedPassword = new TextEncoder().encode(password)
const combined = new Uint8Array(storedSalt.byteLength + encodedPassword.byteLength)
combined.set(storedSalt, 0)
combined.set(encodedPassword, storedSalt.byteLength)
const newHash = await crypto.subtle.digest('SHA-512', combined)
return (await this._bytesToBase64(newHash)) === storedHashB64
} catch (e) {
console.error('Failed to compare passwords', e)
return false
}
},
async _getKeyMaterial (passwordHash, salt) {
return await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(passwordHash),
{ name: 'PBKDF2' },
false,
['deriveKey']
)
},
async _deriveKey (keyMaterial, salt) {
return await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations: 100000,
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
)
},
async _encryptRaw (data, passwordHash) {
const salt = crypto.getRandomValues(new Uint8Array(16))
const keyMaterial = await this._getKeyMaterial(passwordHash, salt)
const key = await this._deriveKey(keyMaterial, salt)
const iv = crypto.getRandomValues(new Uint8Array(12))
const encryptedData = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(data)
)
const combinedData = new Uint8Array(salt.byteLength + iv.byteLength + encryptedData.byteLength)
combinedData.set(salt, 0)
combinedData.set(iv, salt.byteLength)
combinedData.set(new Uint8Array(encryptedData), salt.byteLength + iv.byteLength)
return combinedData
},
async _decryptRaw (data, passwordHash) {
const salt = data.slice(0, 16)
const iv = data.slice(16, 28)
const encryptedData = data.slice(28)
const keyMaterial = await this._getKeyMaterial(passwordHash, salt)
const key = await this._deriveKey(keyMaterial, salt)
const decryptedData = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
encryptedData
)
return new TextDecoder().decode(decryptedData)
},
async _queryPassword (app, createNew = false) {
let passwordHash = createNew ? this._rememberedPasswordHash : null
if (createNew && passwordHash) {
if (await app.alert('The remembered password you entered before will be used to encrypt this block, unless you set a new one.', {
actions: [{ label: 'Enter new password', icon: 'vpn_key' }]
}) === 0) {
passwordHash = null
}
}
while (!passwordHash) {
const [password, doRemember] = await app.prompt('Enter password', {
inputs: [{
type: 'secureText'
}, {
type: 'checkbox',
label: 'Remember password (for 24h or until page reload)'
}]
}) ?? []
if (password === undefined) return null
if (password === '') {
await app.alert({ message: 'Password is required!' })
continue
}
if (createNew && this._getWarnAboutUnusualPasswordsSetting(app)) {
if (!app.settings._defaultPassword) {
await app.setSetting('_defaultPassword', await this._hashAndSaltPassword(password))
} else if (!await this._verifySaltedPasswordHash(password, app.settings._defaultPassword)) {
const result = await app.prompt('Warning: The password you entered is different from your usual password!\n\nWhat do you want to do?', {
inputs: [{
type: 'radio',
options: [{
value: 'retry',
label: 'Enter another password'
}, {
value: 'continue',
label: 'Use this password anyway, this one time'
}, {
value: 'save',
label: 'Use this password and remember it as my new usual password'
}]
}]
})
if (!result) return null
if (result === 'retry') continue
if (result === 'save') {
await app.setSetting('_defaultPassword', await this._hashAndSaltPassword(password))
}
}
}
passwordHash = await this._sha512(password)
if (doRemember) {
this._rememberedPasswordHash = passwordHash
if (this._rememberedPasswordExpirationTimeout) clearTimeout(this._rememberedPasswordExpirationTimeout)
this._rememberedPasswordExpirationTimeout = setTimeout(() => {
this._rememberedPasswordHash = null
this._rememberedPasswordExpirationTimeout = null
}, 86400000)
}
}
return passwordHash
},
async _editData (app, data = '', showLinkTextField = true, showDecrypt = false) {
const fields = ['data']
if (showLinkTextField) fields.push('linkText')
if (showDecrypt) fields.push('decrypt')
const resultArray = [await app.prompt('Edit encrypted text below:', {
inputs: fields.map(f => ({
data: { type: 'text', label: 'Contents', value: data },
linkText: { type: 'text', label: 'Text of link to be inserted into note', value: '🔒 ' },
decrypt: { type: 'checkbox', label: 'Insert decrypted contents into note, replacing encrypted block' }
}[f]))
}) ?? []].flat()
if (!resultArray.length) return null
return Object.fromEntries([...fields.entries()].map(([index, f]) => [f, resultArray[index]]))
},
async _encryptString (s, passwordHash) {
const encryptedBuffer = await this._encryptRaw(s, passwordHash)
return `${this._baseUrl}##${encodeURIComponent(await this._bytesToBase64(encryptedBuffer))}##`
},
async _decryptString (s, passwordHash) {
const rawString = s.split('##')[1]
if (!rawString) throw new Error('No encrypted data found!')
const encryptedBuffer = await this._base64ToBytes(decodeURIComponent(rawString))
return await this._decryptRaw(encryptedBuffer, passwordHash)
},
async _createEncryptedBlock (app, data = '') {
const passwordHash = await this._queryPassword(app, true)
if (!passwordHash) return false
const { data: newData, linkText } = await this._editData(app, data) ?? {}
if (newData === undefined) return false
const encryptedUrl = await this._encryptString(newData, passwordHash)
const escapedLinkText = linkText.trim().replace(/[\[\]\^]/g, '\\%&')
await app.context.replaceSelection(`[${escapedLinkText}][^1]\n\n[^1]: [${escapedLinkText}]()\n\n _[(encrypted contents)](${encryptedUrl})_`)
return true
},
async _editEncryptedBlock (app, link) {
const regexp = new RegExp(`${this._regexpEscape(this._baseUrl)}##[^#]+##`)
const [encryptedUrl] = link.description.match(regexp) ?? []
if (!encryptedUrl) throw new Error('No encrypted data found in footnote description!')
let passwordHash
let data
if (this._rememberedPasswordHash) {
try {
data = await this._decryptString(encryptedUrl, this._rememberedPasswordHash)
passwordHash = this._rememberedPasswordHash
} catch (e) {
console.warn(e)
}
}
while (data === undefined) {
passwordHash = await this._queryPassword(app)
if (!passwordHash) return false
try {
data = await this._decryptString(encryptedUrl, passwordHash)
} catch (e) {
if (e.name === 'OperationError') {
console.warn(e)
await app.alert('Decryption failed - please make sure the password is correct!')
} else {
throw e
}
}
}
const { data: newData, decrypt } = await this._editData(app, data, false, true) ?? {}
if (newData === undefined) return false
if (decrypt) {
await app.context.replaceSelection('[]()' + newData.replaceAll('\n', '\n\n'))
} else {
const newUrl = await this._encryptString(newData, passwordHash)
link.description = link.description.replace(regexp, newUrl)
await app.context.updateLink(link)
}
return true
},
insertText: {
'New': {
async run (app) {
try {
if (!await this._createEncryptedBlock(app)) {
await app.context.replaceSelection('')
}
} catch (e) {
console.error(e)
await app.alert(String(e))
}
}
}
},
replaceText: {
'Encrypt': {
async run (app, text) {
try {
await this._createEncryptedBlock(app, text)
} catch (e) {
console.error(e)
await app.alert(String(e))
}
}
}
},
linkOption: {
'Open': {
check (app, link) {
try {
const regexp = new RegExp(`${this._regexpEscape(this._baseUrl)}##[^#]+##`)
return !!link.description?.match(regexp)
} catch (e) {
console.error(e)
return false
}
},
async run (app, link) {
try {
await this._editEncryptedBlock(app, link)
} catch (e) {
console.error(e)
await app.alert(String(e))
}
}
}
}
})