Персональная информация, в том числе пароли и кошельки — это главные секреты каждого человека. Эта информация должна быть максимально зашифрована и надёжно храниться. Раньше проблему решал текстовый файл, где хранились и пароли, и заметки, и который легко было зашифровать. Теперь с появлением кучи устройств проблема усложнилась. Но если с паролями проблема решена благодаря парольным менеджерам, то вот с шифрованием заметок не всё так гладко.

Какой вариант выбрать для безопасного и надёжного шифрования личных заметок, с синхронизацией между устройствами и резервным хранением?

▍ Парольные менеджеры


В некоторых парольных менеджерах реализована функция добавления заметок и даже файлов. Хотя это нарушает принцип не хранить все яйца в одной корзине, но кажется довольно удобным решением.

Например, популярный опенсорсный парольный менеджер 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-приложения кажется интересной. Вызывают вопросы только надёжность шифрования, потому что автор программы не эксперт в этом вопросе. И не самое интуитивное хранение зашифрованных файлов с заметками, которые нужно искать где-то в браузерном хранилище.

Комментарии (6)


  1. Kahelman
    15.09.2024 17:49
    +2

    Joplin - шифрует копии заметой хранящихся на сервере, которые он использует для синхронизации между устройствами.

    На локальных устройствах заметки не шифруется, так как есть шифрование диска средствами ОС.


    1. pkolt
      15.09.2024 17:49

      Все таки было бы надежнее чтобы и локально оно шифровало. Просто положить все в /Users/<user>/.config/joplin-desktop/database.sqlite в открытом виде не очень надежно.


      1. Kahelman
        15.09.2024 17:49
        +2

        Если диск зашифрован - то зачем?


        1. dreams_killer
          15.09.2024 17:49

          Исходя из принципа что система многопользовательская.


    1. saipr
      15.09.2024 17:49

      есть шифрование диска средствами ОС

      Зачем же сразу весь диск, достаточно зашифровать сам файл с заметкой, используя какую-нибудь утилиту типа openssl.


  1. vazir
    15.09.2024 17:49

    Изменения не дифает. Wiki лучше