![](https://habrastorage.org/webt/h_/nv/9b/h_nv9bbjpgthvyiipii0v0jymvm.jpeg)
Персональная информация, в том числе пароли и кошельки — это главные секреты каждого человека. Эта информация должна быть максимально зашифрована и надёжно храниться. Раньше проблему решал текстовый файл, где хранились и пароли, и заметки, и который легко было зашифровать. Теперь с появлением кучи устройств проблема усложнилась. Но если с паролями проблема решена благодаря парольным менеджерам, то вот с шифрованием заметок не всё так гладко.
Какой вариант выбрать для безопасного и надёжного шифрования личных заметок, с синхронизацией между устройствами и резервным хранением?
▍ Парольные менеджеры
В некоторых парольных менеджерах реализована функция добавления заметок и даже файлов. Хотя это нарушает принцип не хранить все яйца в одной корзине, но кажется довольно удобным решением.
Например, популярный опенсорсный парольный менеджер Bitwarden поддерживает добавление в зашифрованное хранилище не только паролей, но дополнительно личных заметок и кредитных карт. К сожалению, добавление файлов появляется только в платном аккаунте за доллар в месяц.
1Password тоже поддерживает шифрование заметок и добавление файлов в хранилище.
▍ Специализированный софт
Есть много программ для хранения личных заметок, таких как Google Keep, Apple Notes
или Standard Notes, Evernote, Obsidian и проч., но они не лишены недостатков. Так решил автор новой опенсорсной разработки Unforget, который постарался реализовать в своей программе следующие принципы:
- Офлайновая работа в первую очередь, а онлайновые функции уже как необязательное дополнение
- Приватность как главный принцип
- Прогрессивное веб-приложение. Принцип минимализма, без всякого Electron.js
- Лицензия MIT с открытым исходным кодом
- Синхронизация со сквозным шифрованием
- Десктопная версия, мобильные версии и веб
- Поддержка Markdown
- Варианты самостоятельного размещения (селфхост) и в облаке
- Экспорт данных в JSON одним кликом
- Установка в один клик
- Публичные API с возможностью написания и подключения собственных клиентов
- Импорт из Google Keep
- Импорт из Apple Notes
- Импорт из Standard Notes
- Простая регистрация в облачном сервисе для синхронизации между устройствами и резервного копирования заметок — с надёжным сквозным шифрованием. Опять же такой же сервис для синхронизации устройств можно поднять на своём сервере
Получилось такое минималистичное приложение:
![](https://habrastorage.org/webt/ca/1c/wz/ca1cwzgx6hsehkrdt2qe2fyo02m.png)
Демо-версия
Приложение легко устанавливается на любых устройствах, достаточно просто перетянуть ярлычок 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();
Интерфейс минималистичный. Заметки упорядочены в хронологическом порядке, а прикреплённые заметки вверху. Автор пишет, что такая организация оказалась очень эффективной, несмотря на простоту. Поиск очень быстрый (и работает в автономном режиме), что быстро находит нужную заметку. Можно искать по тегам.
![](https://habrastorage.org/webt/oy/ir/zb/oyirzbg2dcvc6fq_6qvrl8vxncs.png)
Размер заметки не ограничен. Для больших заметок можно вставить
---
(кат) отдельной строкой, чтобы свернуть остальную часть.Заметки сохраняются сразу после ввода и синхронизируются каждые несколько секунд.
Если вы редактируете заметку на двух устройствах и во время синхронизации возникает конфликт, приоритет будет отдан последней правке.
Что касается облачного сервиса, Unforget не получает и не хранит никаких личных данных. Для регистрации не требуется указывать почту или телефон. Если вы выберете надёжный пароль, ваши заметки будут храниться в облаке в полностью зашифрованном и безопасном виде. Серверы Unforget видят только имя пользователя и даты модификации заметок.
Но конечно, лучше запускать сервис на собственном сервере, чтобы не зависеть от внешнего сайта, который может прекратить существование в любой момент.
Форматирование текста немножко отличается от разметки Github, но в целом тот же Markdown, который одним нажатием кнопки превращается в HTML.
Что ж, идея PWA-приложения кажется интересной. Вызывают вопросы только надёжность шифрования, потому что автор программы не эксперт в этом вопросе. И не самое интуитивное хранение зашифрованных файлов с заметками, которые нужно искать где-то в браузерном хранилище.
Комментарии (17)
okhsunrog
15.09.2024 17:49Попробуйте Logseq. Похоже на Obsidian, но Open Source. Есть на все платформы. Заметки при синхронизации шифрует. Сам уже почти два года использую
meettya
15.09.2024 17:49Как будто бы проще поднять у себя Outline или Bookstack. Да, они web, но есть привязка к KeyCloak и Miro, так что настраиваются для хоумлаба или собственного хостинга довольно просто. Ну и пилит их не 1 энтузиаст.
jackchickadee
15.09.2024 17:49зашифровать:
gpg -c ~/.secret/my_secret_note_001.txt
srm ~/.secret/my_secret_note_001.txtрасшифровать:
gpg -d ~/.secret/my_secret_note_001.txt
на этом все.
Kahelman
15.09.2024 17:49Кстати, я не эксперт в криптографии но хранить ключ шифрования в файле credentials.json на серваке не совсем правильный. Да и симметричное шифрование при этом использовать - дыра ещё та.
Если мы про безопасность и у вас несколько клиентов откуда может утечь ключи то надо использовать ассиметричное шифрование и предусмотреть возможность отзыва ключей.
Чтобы в случае потери девайса можно было отключить его.
Но тут проблема что делать с локальной копией заметок…..
Speik777
15.09.2024 17:49Получается нет надёжных по для шифрования? А как-же биткоин? В нем куча новых технологий и алгоритмов для шифрования. Или проблема в хранении или вводе ключа? Биткоин и это может решить за 1 блок! Используйте новые технологии для 100% шифровки любой информации. Биткоин как инструкция.
413x
15.09.2024 17:49Над дизайном приложения стоит поработать, лучше всего скопировать интерфейс популярных заметок
Kahelman
Joplin - шифрует копии заметой хранящихся на сервере, которые он использует для синхронизации между устройствами.
На локальных устройствах заметки не шифруется, так как есть шифрование диска средствами ОС.
pkolt
Все таки было бы надежнее чтобы и локально оно шифровало. Просто положить все в
/Users/<user>/.config/joplin-desktop/database.sqlite
в открытом виде не очень надежно.Kahelman
Если диск зашифрован - то зачем?
dreams_killer
Исходя из принципа что система многопользовательская.
Kahelman
Это вам не поможет. На Linux администратор может делать все что хочет. Например заменить ваш файл с шифрованием на свой с key logger-ом.
И ваш пароль утечёт куда надо.
Если у потенциального злоумышленника есть доступ к машине да ещё и с правами root, то шифрование бесполезно.
Если доступа root нет, то как правило разграничение прав пользователей будет достаточно.
Плюс современные системы предлагают возможность зашифровать отдельную папку.
jackchickadee
рассмотрите угрозу вида "ноутбук или десктоп украден физически".
Kahelman
Ноутбук украден если диск зашифрован то это кирпич. Если ноутбук включён в сеть и на нёс что-либо запущено то возможны варианты …
Опять таки - товарищ предлагал шифровать файлы в многопользовательской системе…
saipr
Зачем же сразу весь диск, достаточно зашифровать сам файл с заметкой, используя какую-нибудь утилиту типа openssl.
uranik
>На локальных устройствах заметки не шифруется
А зря, записал пароль в заметку, а другая программа считала файл и слила куда-то в сеть..