Персональная информация, в том числе пароли и кошельки — это главные секреты каждого человека. Эта информация должна быть максимально зашифрована и надёжно храниться. Раньше проблему решал текстовый файл, где хранились и пароли, и заметки, и который легко было зашифровать. Теперь с появлением кучи устройств проблема усложнилась. Но если с паролями проблема решена благодаря парольным менеджерам, то вот с шифрованием заметок не всё так гладко.
Какой вариант выбрать для безопасного и надёжного шифрования личных заметок, с синхронизацией между устройствами и резервным хранением?
▍ Парольные менеджеры
В некоторых парольных менеджерах реализована функция добавления заметок и даже файлов. Хотя это нарушает принцип не хранить все яйца в одной корзине, но кажется довольно удобным решением.
Например, популярный опенсорсный парольный менеджер 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-приложения кажется интересной. Вызывают вопросы только надёжность шифрования, потому что автор программы не эксперт в этом вопросе. И не самое интуитивное хранение зашифрованных файлов с заметками, которые нужно искать где-то в браузерном хранилище.
ссылка на оригинал статьи https://habr.com/ru/articles/843518/
Добавить комментарий