Персональная информация, в том числе пароли и кошельки — это главные секреты каждого человека. Эта информация должна быть максимально зашифрована и надёжно храниться. Раньше проблему решал текстовый файл, где хранились и пароли, и заметки, и который легко было зашифровать. Теперь с появлением кучи устройств проблема усложнилась. Но если с паролями проблема решена благодаря парольным менеджерам, то вот с шифрованием заметок не всё так гладко.
Какой вариант выбрать для безопасного и надёжного шифрования личных заметок, с синхронизацией между устройствами и резервным хранением?
▍ Парольные менеджеры
В некоторых парольных менеджерах реализована функция добавления заметок и даже файлов. Хотя это нарушает принцип не хранить все яйца в одной корзине, но кажется довольно удобным решением.
Например, популярный опенсорсный парольный менеджер Bitwarden поддерживает добавление в зашифрованное хранилище не только паролей, но дополнительно личных заметок и кредитных карт. К сожалению, добавление файлов появляется только в платном аккаунте за доллар в месяц.
1Password тоже поддерживает шифрование заметок и добавление файлов в хранилище.
▍ Специализированный софт
Есть много программ для хранения личных заметок, таких как Google Keep, Apple Notes
или Standard Notes, Evernote, Obsidian и проч., но они не лишены недостатков. Так решил автор новой опенсорсной разработки Unforget, который постарался реализовать в своей программе следующие принципы:
- Офлайновая работа в первую очередь, а онлайновые функции уже как необязательное дополнение
- Приватность как главный принцип
- Прогрессивное веб-приложение. Принцип минимализма, без всякого Electron.js
- Лицензия MIT с открытым исходным кодом
- Синхронизация со сквозным шифрованием
- Десктопная версия, мобильные версии и веб
- Поддержка Markdown
- Варианты самостоятельного размещения (селфхост) и в облаке
- Экспорт данных в JSON одним кликом
- Установка в один клик
- Публичные API с возможностью написания и подключения собственных клиентов
- Импорт из Google Keep
- Импорт из Apple Notes
- Импорт из Standard Notes
- Простая регистрация в облачном сервисе для синхронизации между устройствами и резервного копирования заметок — с надёжным сквозным шифрованием. Опять же такой же сервис для синхронизации устройств можно поднять на своём сервере
Получилось такое минималистичное приложение:
Приложение легко устанавливается на любых устройствах, достаточно просто перетянуть ярлычок URL на главную страницу или на панель закладок.
Чтобы поднять Unforget на своём сервере, нужно положить в рутовую директорию файл .env
следующего содержания:
PORT=3000 NODE_ENV=production DISABLE_CACHE=0 LOG_TO_CONSOLE=0 FORWARD_LOGS_TO_SERVER=0 FORWARD_ERRORS_TO_SERVER=0
а потом запустить софт:
cd unforget/
npm run build
npm run start
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();
Интерфейс минималистичный. Заметки упорядочены в хронологическом порядке, а прикреплённые заметки вверху. Автор пишет, что такая организация оказалась очень эффективной, несмотря на простоту. Поиск очень быстрый (и работает в автономном режиме), что быстро находит нужную заметку. Можно искать по тегам.
Размер заметки не ограничен. Для больших заметок можно вставить ---
(кат) отдельной строкой, чтобы свернуть остальную часть.
Заметки сохраняются сразу после ввода и синхронизируются каждые несколько секунд.
Если вы редактируете заметку на двух устройствах и во время синхронизации возникает конфликт, приоритет будет отдан последней правке.
Что касается облачного сервиса, Unforget не получает и не хранит никаких личных данных. Для регистрации не требуется указывать почту или телефон. Если вы выберете надёжный пароль, ваши заметки будут храниться в облаке в полностью зашифрованном и безопасном виде. Серверы Unforget видят только имя пользователя и даты модификации заметок.
Но конечно, лучше запускать сервис на собственном сервере, чтобы не зависеть от внешнего сайта, который может прекратить существование в любой момент.
Форматирование текста немножко отличается от разметки Github, но в целом тот же Markdown, который одним нажатием кнопки превращается в HTML.
Что ж, идея PWA-приложения кажется интересной. Вызывают вопросы только надёжность шифрования, потому что автор программы не эксперт в этом вопросе. И не самое интуитивное хранение зашифрованных файлов с заметками, которые нужно искать где-то в браузерном хранилище.
Автор: GlobalSign_admin