Plugin: Encrypted Blocks

name

Encrypted Blocks

icon

enhanced_encryption

description

Allows encrypting individual text blocks within a note with a password (using AES).

instructions

This plugin allows to encrypt individual text blocks, instead of having to turn the whole note into a vault note.

This can be used to store credentials, for example. You may have a note about some project and want to include the credentials for a service that project uses. This allows you to have your general project information easily accessible while still having the credentials protected with a password and displayed only on demand.



Encrypted blocks show up 🔒 like this. (This particular block was encrypted using password hello.)

To create a new encrypted block (without ever entering the secret data into the note itself), type {Encrypted Blocks: New}.

To encrypt an existing block of text, select it and choose "Encrypted Blocks: Encrypt" from the plugin action menu.

Note: On mobile, currently this menu doesn't exist, but you can also cut the text to clipboard and then insert it into a new block instead.

Note: Formatting cannot be preserved.

Be aware that your original text may still exist in the note's version history depending on when the last version snapshot was saved! It's usually better to create a new encrypted block using the first method and entering the secret data there so it never exists in the note in an unencrypted state.

To view or edit an encrypted block, click the "Encrypted Blocks: Open" button within its rich footnote.

The plugin allows you to remember the last password for 24 hours. Closing/reloading the page will also clear the remembered password. When you use this option, a hash of the password is stored only in memory and not saved in settings.

The plugin by default warns you about accidentally using a password that is different from the one you normally use. This behavior can be disabled in settings. The "usual" password is securely salted and hashed before it is stored in a hidden setting.

About the security of this plugin:

To encrypt your data, a secure key is derived from your password using PBKDF2 which is then used for encryption with AES-GCM. The password is not stored anywhere in clear text, and none of the things you enter are ever sent to any server (except in their encrypted form to Amplenote's servers as part of the note, of course).

The encrypted data is stored in form of a link which is written into a rich footnote's description. The link goes to a decryption page which can be used as a fallback decryption method in case decryption through the plugin is not possible. This also allows sharing a note with encrypted blocks - as long as the recipient knows the password, they can access the encrypted contents without the plugin as well, by clicking the "(encrypted contents)" link within the rich footnote and entering the password on the page that opens. Note that the actual encrypted data is appended as a hash and not as query string which means that it is never sent to my server. Decryption also happens entirely within the browser when this method is used. To make sure you will never lose access to your encrypted information, go to the decryption page now and save it to your harddisk using Ctrl+S. This way, even if one day both Amplenote and my server are down, as long as you have a Markdown backup of your note, you can still extract the encrypted URL from there and copy it into your local copy of the decryption page.

☕ If you like my work, you can buy me a coffee!

setting

Warn about unusual passwords? (true/false)

({ // eslint-disable-line
_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) // using PBKDF2
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) // using PBKDF2
 
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) {
// Without the []() part, the original link would stay and the first part of the contents would just replace the link text!
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))
}
}
}
}
})