Man
Professional
- Messages
- 2,963
- Reaction score
- 486
- Points
- 83
Personal information, including passwords and wallets, are the main secrets of every person. This information should be encrypted as much as possible and stored securely. Previously, the problem was solved by a text file, where both passwords and notes were stored, and which was easy to encrypt. Now, with the advent of a bunch of devices, the problem has become more complicated. But if the problem with passwords has been solved thanks to password managers, then with encryption of notes, not everything is so smooth.
Which option should you choose for safe and reliable encryption of personal notes, with synchronization between devices and backup storage?
Some password managers have a feature for adding notes and even files. Although this violates the principle of not putting all your eggs in one basket, it seems like a pretty convenient solution.
For example, the popular open-source password manager Bitwarden supports adding not only passwords to the encrypted storage, but also personal notes and credit cards. Unfortunately, adding files only appears in a paid account for a dollar per month.
1Password also supports encrypting notes and adding files to the storage.
There are many programs for storing personal notes, such as Google Keep, Apple Notes
or Standard Notes, Evernote, Obsidian, etc., but they are not without drawbacks. This is what the author of the new open source development Unforget decided, who tried to implement the following principles in his program:
The result is a minimalist application:
Demo version
The application is easy to install on any device, just drag the URL shortcut to the main page or bookmarks bar.
To run Unforget on your server, you need to put .env the following file in the root directory:
and then run the software:
Encryption module code
The interface is minimalistic. Notes are organized chronologically, with pinned notes at the top. The author writes that this organization turned out to be very effective despite its simplicity. The search is very fast (and works offline), which quickly finds the note you need. You can search by tags.
The size of the note is not limited. For large notes, you can insert ---(cut) as a separate line to collapse the rest.
Notes are saved immediately after entering and synced every few seconds.
If you edit a note on two devices and a conflict occurs during syncing, the latest edit will take precedence.
As for the cloud service, Unforget does not receive or store any personal data. Registration does not require specifying an email or phone number. If you choose a strong password, your notes will be stored in the cloud in a fully encrypted and secure form. Unforget servers only see the username and modification dates of notes.
But of course, it is better to run the service on your own server, so as not to depend on an external site that can cease to exist at any moment.
The text formatting is slightly different from Github markup, but in general it is the same Markdown, which is converted into HTML with one click of a button.
Well, the idea of a PWA application seems interesting. The only questions are about the reliability of encryption, because the author of the program is not an expert in this matter. And not the most intuitive storage of encrypted files with notes, which need to be looked for somewhere in the browser storage.
Source
Which option should you choose for safe and reliable encryption of personal notes, with synchronization between devices and backup storage?
Password managers
Some password managers have a feature for adding notes and even files. Although this violates the principle of not putting all your eggs in one basket, it seems like a pretty convenient solution.
For example, the popular open-source password manager Bitwarden supports adding not only passwords to the encrypted storage, but also personal notes and credit cards. Unfortunately, adding files only appears in a paid account for a dollar per month.
1Password also supports encrypting notes and adding files to the storage.
Specialized software
There are many programs for storing personal notes, such as Google Keep, Apple Notes
or Standard Notes, Evernote, Obsidian, etc., but they are not without drawbacks. This is what the author of the new open source development Unforget decided, who tried to implement the following principles in his program:
- Offline work first, online functions as an optional add-on
- Privacy as a main principle
- Progressive Web App. Minimalism principle, without any Electron.js
- MIT Open Source License
- Sync with end-to-end encryption
- Desktop version, mobile versions and web
- Markdown support
- Self-hosted and cloud options
- Export data to JSON with one click
- One-click installation
- Public APIs with the ability to write and connect your own clients
- Import from Google Keep
- Import from Apple Notes
- Import from Standard Notes
- Simple registration in the cloud service for synchronization between devices and backup of notes - with reliable end-to-end encryption. Again, the same service for synchronization of devices can be set up on your server
The result is a minimalist application:

Demo version
The application is easy to install on any device, just drag the URL shortcut to the main page or bookmarks bar.
To run Unforget on your server, you need to put .env the following file in the root directory:
Code:
PORT=3000
NODE_ENV=production
DISABLE_CACHE=0
LOG_TO_CONSOLE=0
FORWARD_LOGS_TO_SERVER=0
FORWARD_ERRORS_TO_SERVER=0
and then run the software:
Code:
cd unforget/
npm run build
npm run start
Encryption module code
Code:
import { webcrypto } from 'node:crypto';
import fs from 'node:fs';
type Note = {
// UUID version 4
id: string;
// Deleted notes have null text
text: string | null;
// ISO 8601 format
creation_date: string;
// ISO 8601 format
modification_date: string;
// 0 means deleted, 1 means not deleted
not_deleted: number;
// 0 means archived, 1 means not archived
not_archived: number;
// 0 means not pinned, 1 means pinned
pinned: number;
// A higher number means higher on the list
// Usually, by default it's milliseconds since the epoch
order: number;
};
type EncryptedNote = {
// UUID version 4
id: string;
// ISO 8601 format
modification_date: string;
// The encrypted Note in base64 format
encrypted_base64: string;
// Initial vector, a random number, that was used for encrypting this specific note
iv: string;
};
type LoginData = {
username: string;
password_client_hash: string;
};
type SignupData = {
username: string;
password_client_hash: string;
encryption_salt: string;
};
type LoginResponse = {
username: string;
token: string;
encryption_salt: string;
};
// In addition to LoginResponse, we want to locally store the CryptoKey which is derived from
// the encryption salt and the raw password during login/signup and used for encryption/decryption.
// However, since CryptoKey is not directly serializable, we convert it to JsonWebKey and use
// importKey() to convert back later.
type Credentials = LoginResponse & { jwk: webcrypto.JsonWebKey };
const BASE_URL = 'https://unforget.computing-den.com';
async function main() {
switch (process.argv[2]) {
case 'signup': {
const username = process.argv[3];
const password = process.argv[4];
if (!username || !password) usageAndExit();
await signup(username, password);
break;
}
case 'login': {
const username = process.argv[3];
const password = process.argv[4];
if (!username || !password) usageAndExit();
await login(username, password);
break;
}
case 'create': {
const text = process.argv[3];
if (!text) usageAndExit();
await createNote(text);
break;
}
case 'get': {
const id = process.argv[3];
await getNote(id);
break;
}
default:
usageAndExit();
}
console.log('Success.');
}
function usageAndExit() {
console.error(`
Usage: npx tsx example.ts COMMAND
Available commands:
singup USERNAME PASSWORD
login USERNAME PASSWORD
create TEXT
get [ID]
`);
process.exit(1);
}
async function signup(username: string, password: string) {
const salt = bytesToHexString(webcrypto.getRandomValues(new Uint8Array(16)));
const hash = await calcPasswordHash(username, password);
const data: SignupData = { username, password_client_hash: hash, encryption_salt: salt };
const res = await post<LoginResponse>('/api/signup', data);
const credentials = await createCredentials(res, password);
writeCredentials(credentials);
}
async function login(username: string, password: string) {
const hash = await calcPasswordHash(username, password);
const data: LoginData = { username, password_client_hash: hash };
const res = await post<LoginResponse>('/api/login', data);
const credentials = await createCredentials(res, password);
writeCredentials(credentials);
}
async function createNote(text: string) {
const note: Note = {
id: webcrypto.randomUUID(),
text,
creation_date: new Date().toISOString(),
modification_date: new Date().toISOString(),
not_deleted: 1,
not_archived: 1,
pinned: 0,
order: Date.now(),
};
// Read the credentials and convert the key from JsonWebKey back to CryptoKey.
const credentials = readCredentials();
const key = await importKey(credentials);
const encryptedNote = await encryptNote(note, key);
await post(`/api/merge-notes`, { notes: [encryptedNote] }, credentials);
console.log(`Created note with ID ${note.id}`);
}
async function getNote(id?: string) {
// Read the credentials and convert the key from JsonWebKey back to CryptoKey.
const credentials = readCredentials();
const key = await importKey(credentials);
// ids: [] would return no notes. ids: undefined or null would return everything.
const ids = id ? [id] : null;
const encryptedNotes = await post<EncryptedNote[]>(`/api/get-notes`, { ids }, credentials);
if (encryptedNotes.length === 0) {
console.log('Not found');
} else {
// Decrypt the received notes using the key.
const notes = await Promise.all(encryptedNotes.map(x => decryptNote(x, key)));
// Log to console.
for (const note of notes) console.log(JSON.stringify(note, null, 2) + '\n');
}
}
async function encryptNote(note: Note, key: webcrypto.CryptoKey): Promise<EncryptedNote> {
// Encode the string to bytes.
const data = new TextEncoder().encode(JSON.stringify(note));
// Generate the initial vector (iv).
const iv = webcrypto.getRandomValues(new Uint8Array(12));
// Encrypt the bytes using the iv and the given key.
const encrypted = await webcrypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data);
// Encode as base64 to easily store in JSON.
const encryptedBase64 = Buffer.from(encrypted).toString('base64');
// Create the EncryptedNote object.
return {
id: note.id,
modification_date: note.modification_date,
encrypted_base64: encryptedBase64,
iv: bytesToHexString(iv),
};
}
async function decryptNote(encryptedNote: EncryptedNote, key: webcrypto.CryptoKey): Promise<Note> {
// Decode the base64 string to bytes.
const encryptedBytes = Buffer.from(encryptedNote.encrypted_base64, 'base64');
// Decrypt the bytes using note's initial vector (iv) and the given key.
const iv = hexStringToBytes(encryptedNote.iv);
const decryptedBytes = await webcrypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encryptedBytes);
// Decode the decrypted bytes into string.
const noteString = new TextDecoder().decode(decryptedBytes);
// Parse the string to get the note JSON.
return JSON.parse(noteString);
}
/**
* Read the credentials from ./credentials.json
*/
function readCredentials(): Credentials {
return JSON.parse(fs.readFileSync('./credentials.json', 'utf8'));
}
/**
* Write the credentials to ./credentials.json.
*/
function writeCredentials(credentials: Credentials) {
fs.writeFileSync('credentials.json', JSON.stringify(credentials, null, 2));
console.log('Wrote credentials to ./credentials.json');
}
/**
* Converts the JsonWebKey (credentials.jwk) which was exported from CryptoKey back to CryptoKey so
* that it can be used for encrypting and decrypting notes.
*/
async function importKey(credentials: Credentials): Promise<CryptoKey> {
return webcrypto.subtle.importKey('jwk', credentials.jwk, 'AES-GCM', true, ['encrypt', 'decrypt']);
}
/**
* It derives a PBKDF2 CryptoKey from the password and the res.encryption_salt for encrypting and decrypting notes.
* The CryptoKey is then exported to JsonWebKey so that we can serialize it and store it in credentials.json.
* Use importKey() to convert back to CryptoKey.
*/
async function createCredentials(res: LoginResponse, password: string): Promise<Credentials> {
const keyData = new TextEncoder().encode(password);
const keyMaterial = await webcrypto.subtle.importKey('raw', keyData, 'PBKDF2', false, ['deriveBits', 'deriveKey']);
const saltBuf = hexStringToBytes(res.encryption_salt);
const key = await webcrypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: saltBuf,
iterations: 100000,
hash: 'SHA-256',
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt'],
);
const jwk = await webcrypto.subtle.exportKey('jwk', key);
return { ...res, jwk };
}
/**
* Send a POST request to BASE_URL and parse the resopnse as JSON.
*/
async function post<T>(pathname: string, body?: any, credentials?: Credentials): Promise<T> {
const query = credentials ? `?token=${credentials.token}` : '';
const url = `${BASE_URL}${pathname}${query}`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body && JSON.stringify(body),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
/**
* The password hash is derived from the username, password, and a specific static random number.
* It is important to use the exact same method for calculating the hash if you wish the
* credentials to work with the official unforget app.
*/
async function calcPasswordHash(username: string, password: string): Promise<string> {
const text = username + password + '32261572990560219427182644435912532';
const encoder = new TextEncoder();
const textBuf = encoder.encode(text);
const hashBuf = await webcrypto.subtle.digest('SHA-256', textBuf);
return bytesToHexString(new Uint8Array(hashBuf));
}
/**
* bytesToHexString(Uint8Array.from([1, 2, 3, 10, 11, 12])) //=> '0102030a0b0c'
*/
function bytesToHexString(bytes: Uint8Array): string {
return Array.from(bytes)
.map(byte => byte.toString(16).padStart(2, '0'))
.join('');
}
/**
* hexStringToBytes('0102030a0b0c') //=> Uint8Array(6) [ 1, 2, 3, 10, 11, 12 ]
*/
function hexStringToBytes(str: string): Uint8Array {
if (str.length % 2) throw new Error('hexStringToBytes invalid string');
const bytes = new Uint8Array(str.length / 2);
for (let i = 0; i < str.length; i += 2) {
bytes[i / 2] = parseInt(str.substring(i, i + 2), 16);
}
return bytes;
}
main();
The interface is minimalistic. Notes are organized chronologically, with pinned notes at the top. The author writes that this organization turned out to be very effective despite its simplicity. The search is very fast (and works offline), which quickly finds the note you need. You can search by tags.
The size of the note is not limited. For large notes, you can insert ---(cut) as a separate line to collapse the rest.
Notes are saved immediately after entering and synced every few seconds.
If you edit a note on two devices and a conflict occurs during syncing, the latest edit will take precedence.
As for the cloud service, Unforget does not receive or store any personal data. Registration does not require specifying an email or phone number. If you choose a strong password, your notes will be stored in the cloud in a fully encrypted and secure form. Unforget servers only see the username and modification dates of notes.
But of course, it is better to run the service on your own server, so as not to depend on an external site that can cease to exist at any moment.
The text formatting is slightly different from Github markup, but in general it is the same Markdown, which is converted into HTML with one click of a button.
Well, the idea of a PWA application seems interesting. The only questions are about the reliability of encryption, because the author of the program is not an expert in this matter. And not the most intuitive storage of encrypted files with notes, which need to be looked for somewhere in the browser storage.
Source