6 min read
vault.gluek.info

Личный менеджер паролей на базе Vaultwarden. Это легковесная серверная реализация API Bitwarden на Rust, с которой работают все официальные клиенты Bitwarden: браузерные расширения, десктоп, мобильные приложения и CLI.

Почему ушёл с 1Password

До 1Password я долгое время пользовался KeePass/KeePassX, а на 1Password перешёл, когда понадобились командные хранилища. Но за последнее время накопилось сразу несколько поводов уйти.

Цена. Несмотря на обещание сохранить цену для ранних пользователей, 1Password объявил о повышении цены семейного плана с 27 марта 2026 года: с $59.88 до $71.88 в год. Потом, правда, отыграли назад, и для меня как для «Families Launch Special Plan» цена не изменилась. Но осадочек остался, тем более что по деньгам это сравнимо с арендой целого VPS.

Отказ от локальных сейфов. Поддержку 1Password 7 убрали, а вместе с ней и работу с локальными сейфами, включая синхронизацию «по воздуху» по Wi-Fi и через Dropbox. Теперь синхронизация есть только через их облако и только по подписке.

Российская карта. Когда-то я оплачивал подписку российской картой, и в какой-то момент поддержка начала волноваться: уточняли, нет ли у меня в аккаунте участников из России, Беларуси, Луганска, Донецка или Крыма. Сейчас я живу в Лондоне, но часть российских сервисов доступна только через VPN, а это повышает риск случайной блокировки аккаунта. Физическое местоположение они, надеюсь, не отслеживают… наверное.

В итоге менеджер паролей, самое чувствительное, что у меня есть, оказался в зависимости от чужой ценовой политики и чужих комплаенс-процедур. Как и в случае с git.gluek.info, проще держать это у себя.

Инстанс приватный: открытая регистрация выключена, аккаунты создаются только по приглашениям. Если мы знакомы и вам нужен аккаунт, пишите. Если нет, Vaultwarden несложно поставить самостоятельно или найти публичный сервер, например vaultwarden.com. Но учтите, что это сторонний сервис, который запустился совсем недавно и с самим проектом Vaultwarden не связан, так что доверять ему пароли стоит с осторожностью. Хотя пароли и уходят на сервер только в зашифрованном виде, метаданные (когда, с каких устройств и IP-адресов вы обращаетесь к хранилищу) тоже весьма приватная вещь.

Стек

  • Vaultwarden через Docker Compose с базой SQLite: пользователей будет немного, отдельный Postgres здесь не нужен
  • Caddy в роли reverse proxy (с автоматическим HTTPS от Let’s Encrypt)
  • SMTP через почту на gluek.info: без него пригласить пользователя не получится, отдельной инвайт-ссылки у Vaultwarden нет
  • Админка /admin включена и закрыта стойким паролем, захэшированным через Argon2
  • Бэкапы каждые сутки через rsync, а с них дополнительно копии на внешний диск (подход 3-2-1)
  • Push-уведомления для мобильных приложений через официальный relay Bitwarden (регион EU): бесплатные ключи выдают на bitwarden.com/host, а через relay проходят только сигналы «пора синхронизироваться», без содержимого хранилища
  • Клиенты все те же, что были у 1Password: в основном приложение под Mac и расширение для браузера, приложение под Android и веб-версия на крайний случай

Из приятных бонусов: в Bitwarden встроена функция Send для быстрой отправки текста или файла по ссылке. Для этого у меня, правда, уже есть отдельный сервис Cryptex на drop.gluek.info, про него расскажу отдельно.

Подводный камень: синхронизация в реальном времени

После запуска всё вроде бы работало, но изменения, сделанные на телефоне, не появлялись на Mac, пока не нажмёшь синхронизацию вручную. Оказалось, что в Caddyfile остался блок из старых инструкций, где /notifications/hub проксируется на отдельный порт 3012. Начиная с Vaultwarden 1.29 WebSocket работает на основном порту, а 3012 больше не существует, поэтому Caddy молча отвечал 502 Bad Gateway, и клиенты теряли обновления в реальном времени.

Проверить это можно одной командой. Без токена рабочий сервер должен ответить 401 от Vaultwarden (Server: Rocket), а не 502 от Caddy. Sec-WebSocket-Key здесь не секрет, а обязательный для WebSocket-рукопожатия случайный nonce, взятый как пример прямо из RFC 6455:

curl -i --http1.1 -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  https://vault.example.com/notifications/hub

Лечится удалением отдельного блока: весь хост проксируется на Vaultwarden одним reverse_proxy.

Для Android есть ещё одна тонкость: мобильные приложения получают изменения не по WebSocket, а через push, поэтому без настроенного relay они синхронизируются только при открытии. После включения push на телефоне нужно выйти из аккаунта и войти заново, чтобы устройство зарегистрировалось.

Перенос из 1Password

У Bitwarden есть встроенный импорт формата .1pux (это экспорт 1Password 8), но с двумя нюансами:

  1. Все сейфы сливаются в один. У меня было около десяти сейфов, личных и общих, и импорт вывалил бы их в одну кучу. Мне нужно было разложить каждый сейф в свою организацию/коллекцию.
  2. Вложения внутри записей теряются. Отдельные документы (категория Document) импортируются сами, а вот файлы, прикреплённые к логинам, заметкам и прочим записям, в импорт не попадают.

Для обеих проблем пришлось написать по небольшому скрипту на Python без внешних зависимостей, а позже понадобился и третий, для passkeys.

Всего переехало больше 2000 записей. TOTP-коды перенеслись без проблем, SSH-ключи я храню отдельно, а вот passkeys в .1pux не попадают вообще: их пришлось переносить отдельно, об этом ниже.

1. Нарезка экспорта по сейфам

.1pux — это обычный zip-архив с файлом export.data (JSON со всеми записями) и папкой files/ с вложениями. Скрипт собирает из одного общего экспорта отдельный .1pux на каждый сейф и кладёт туда только нужные ему вложения:

#!/usr/bin/env python3
"""
Разбивает экспорт 1Password (.1pux) на отдельные файлы по сейфам,
чтобы импортировать каждый в свою организацию/коллекцию Bitwarden.

Запуск:
  python3 split_1pux.py 1PasswordExport-XXXX.1pux
"""
import copy
import json
import sys
import zipfile

SRC = sys.argv[1]

with zipfile.ZipFile(SRC) as z:
    full_data = json.loads(z.read("export.data"))
    attrs = z.read("export.attributes")
    all_names = z.namelist()

    vault_names = [v["attrs"]["name"] for v in full_data["accounts"][0]["vaults"]]

    for target_vault_name in vault_names:
        data = copy.deepcopy(full_data)
        for account in data["accounts"]:
            account["vaults"] = [v for v in account["vaults"] if v["attrs"]["name"] == target_vault_name]

        # собрать id вложений, которые нужны именно этому сейфу
        item_blob = json.dumps(data)
        kept_files = set()
        for name in all_names:
            if name.startswith("files/") and name != "files/":
                file_id = name.split("/")[1].split("__")[0].split(".")[0]
                if file_id in item_blob:
                    kept_files.add(name)

        safe_name = target_vault_name.replace(" ", "_").replace("&", "and")
        out_name = f"export_{safe_name}.1pux"
        with zipfile.ZipFile(out_name, "w", zipfile.ZIP_DEFLATED) as out:
            out.writestr("export.data", json.dumps(data))
            out.writestr("export.attributes", attrs)
            for name in kept_files:
                out.writestr(name, z.read(name))

        items = sum(len(v.get("items", [])) for a in data["accounts"] for v in a["vaults"])
        print(f"{target_vault_name} -> {out_name} ({items} items, {len(kept_files)} files)")

Дальше каждый получившийся файл импортируется через веб-интерфейс: Tools → Import data → 1Password (1pux), с выбором нужной организации и коллекции.

2. Докачка вложений

Второй скрипт проходит по записям с вложенными файлами, находит соответствующую запись в Vaultwarden по названию и прикрепляет файлы через Bitwarden CLI (bw). По умолчанию работает в режиме dry-run, а записи, которые не удалось однозначно сопоставить (дубли названий, пропавшие файлы), выводит списком для ручной доработки.

#!/usr/bin/env python3
"""
Прикрепляет вложения из .1pux к уже импортированным записям Vaultwarden
через Bitwarden CLI (bw). Берёт только записи, у которых файлы вложены
внутрь (не Document-категория 006 — те импортируются сами).

Перед запуском:
  bw config server https://vault.example.com
  bw login
  export BW_SESSION=$(bw unlock --raw)
  bw sync

Запуск:
  python3 attach_1pux.py export_Personal.1pux            # dry-run, ничего не меняет
  python3 attach_1pux.py export_Personal.1pux --apply    # реально загрузить
  python3 attach_1pux.py export_Work.1pux --org <organizationId> --apply
"""
import argparse
import json
import os
import shutil
import subprocess
import tempfile
import zipfile

p = argparse.ArgumentParser()
p.add_argument("pux", help="файл .1pux (один сейф)")
p.add_argument("--org", help="organizationId для общих сейфов; без него — личный сейф")
p.add_argument("--apply", action="store_true", help="реально загружать (по умолчанию dry-run)")
a = p.parse_args()


def bw(*args):
    r = subprocess.run(["bw", *args], capture_output=True, text=True)
    if r.returncode:
        raise RuntimeError(r.stderr.strip() or r.stdout.strip())
    return r.stdout


def find_docs(obj, out):
    """Рекурсивно собирает все объекты с documentId (описания файлов)."""
    if isinstance(obj, dict):
        if "documentId" in obj:
            out.append(obj)
        for v in obj.values():
            find_docs(v, out)
    elif isinstance(obj, list):
        for v in obj:
            find_docs(v, out)
    return out


z = zipfile.ZipFile(a.pux)

# documentId -> путь файла внутри архива (files/<id>__<name> или files/<id>.<ext>)
archive_files = {}
for n in z.namelist():
    if n.startswith("files/") and n != "files/":
        doc_id = n.split("/", 1)[1].split("__")[0].split(".")[0]
        archive_files[doc_id] = n

data = json.loads(z.read("export.data"))
items = []
for acc in data["accounts"]:
    for v in acc["vaults"]:
        for i in v.get("items", []):
            if i.get("categoryUuid") == "006":
                continue
            docs = find_docs(i.get("details", {}), [])
            if docs:
                items.append((i, docs))

print(f"Записей с вложениями внутри: {len(items)}")

# Все записи из Vaultwarden одним запросом, фильтр по личному сейфу / организации
vault_items = [
    x for x in json.loads(bw("list", "items"))
    if (x.get("organizationId") or None) == a.org
]
by_name = {}
for x in vault_items:
    by_name.setdefault(x["name"], []).append(x)

tmp = tempfile.mkdtemp(prefix="1pux_att_")
manual = []
uploaded = skipped = 0

try:
    for item, docs in items:
        title = item["overview"].get("title", "")
        matches = by_name.get(title, [])
        if len(matches) != 1:
            manual.append(f"{title}  (совпадений в Vaultwarden: {len(matches)})")
            continue
        cipher = matches[0]
        existing = {att.get("fileName") for att in cipher.get("attachments") or []}

        for d in docs:
            doc_id = d["documentId"]
            src = archive_files.get(doc_id)
            if not src:
                manual.append(f"{title}  (файл {doc_id} не найден в архиве)")
                continue
            fname = d.get("fileName") or os.path.basename(src)
            if fname in existing:
                skipped += 1
                continue

            print(f"{'UPLOAD' if a.apply else 'DRY'}  {title}  <-  {fname}")
            if a.apply:
                dest_dir = os.path.join(tmp, doc_id)
                os.makedirs(dest_dir, exist_ok=True)
                dest = os.path.join(dest_dir, fname)
                with open(dest, "wb") as f:
                    f.write(z.read(src))
                bw("create", "attachment", "--file", dest, "--itemid", cipher["id"])
                os.remove(dest)
            uploaded += 1
finally:
    shutil.rmtree(tmp, ignore_errors=True)  # расшифрованные файлы не оставляем на диске

print(f"\n{'Загружено' if a.apply else 'К загрузке'}: {uploaded}, уже были: {skipped}")
if manual:
    print("\nПрикрепить вручную:")
    for m in manual:
        print("  -", m)

3. Passkeys через CXP и чистка дублей

Passkeys удалось перенести через Credential Exchange Protocol (CXP), новый стандарт FIDO для переноса данных между менеджерами паролей напрямую, без промежуточного файла. Вместе с паролями и TOTP он переносит и сами ключи passkeys. На Android 14+ это делается прямо в приложении Bitwarden: Settings → Vault → Import items → Import from another app, и дальше выбрать 1Password. Подробнее в документации Bitwarden.

Но CXP импортировал всё в личное хранилище, и поверх уже перенесённых записей получилась гора дублей. Встроенной дедупликации в Bitwarden нет, поэтому пришлось написать третий скрипт. Первая версия вызывала bw на каждую запись, и на двух тысячах дублей это шло очень медленно, примерно одна запись в секунду: каждый запуск CLI заново расшифровывает всё хранилище. Поэтому скрипт работает через bw serve, локальный REST API, где хранилище расшифровывается один раз, и это уже 4–5 записей в секунду. Он находит записи, созданные после CXP-импорта, и сравнивает их со старыми по названию, логину, паролю и TOTP. Точные дубли отправляются в корзину, а passkeys переносятся в старые записи, чтобы те остались в своих организациях рядом с вложениями. Новая запись удаляется, только если скрипт перечитал старую и убедился, что passkey в ней сохранился.

В итоге удачно перенеслось больше 30 passkeys. Вручную, по одному сайту, это заняло бы пару часов.

#!/usr/bin/env python3
"""
Чистит дубли после импорта через CXP (Credential Exchange) в Vaultwarden/Bitwarden.

Новыми считаются записи личного хранилища, созданные после --since.
Для каждой новой записи ищется старая с теми же типом, названием,
логином, паролем и TOTP (в личном хранилище или в любой организации):
  - нет passkey  -> новая запись отправляется в корзину (не навсегда);
  - есть passkey -> с --move-passkeys passkey копируется в старую запись,
                    результат перепроверяется, и только тогда новая
                    запись уходит в корзину; иначе — в список «вручную».

Работает через локальный REST API Bitwarden CLI (bw serve): хранилище
расшифровывается один раз, а не на каждый вызов bw, поэтому в разы быстрее.

Перед запуском, в отдельном терминале:
  export BW_SESSION=$(bw unlock --raw)
  bw serve            # слушает только localhost:8087

Запуск:
  python3 dedup_cxp.py --since 2026-09-22T22:00                    # dry-run
  python3 dedup_cxp.py --since 2026-09-22T22:00 --move-passkeys    # dry-run с passkeys
  python3 dedup_cxp.py --since 2026-09-22T22:00 --move-passkeys --apply
"""
import argparse
import json
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime

p = argparse.ArgumentParser()
p.add_argument("--since", required=True, help="начало CXP-импорта, локальное время ISO (2026-09-22T22:00)")
p.add_argument("--move-passkeys", action="store_true", help="переносить passkeys в старые записи")
p.add_argument("--apply", action="store_true", help="реально изменять (по умолчанию dry-run)")
p.add_argument("--api", default="http://localhost:8087", help="адрес bw serve")
a = p.parse_args()


def api(method, path, body=None, retries=5):
    data = json.dumps(body).encode() if body is not None else None
    for attempt in range(retries):
        req = urllib.request.Request(a.api + path, data=data, method=method,
                                     headers={"Content-Type": "application/json"})
        try:
            with urllib.request.urlopen(req, timeout=120) as r:
                res = json.loads(r.read() or b"{}")
        except urllib.error.HTTPError as e:
            # bw serve сообщает об ошибках кодом 400 и JSON с message
            try:
                res = json.loads(e.read() or b"{}")
            except ValueError:
                res = {"message": f"HTTP {e.code}"}
        except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
            res = {"message": f"FetchError: {e}"}
        if res.get("success"):
            return res.get("data")
        msg = str(res.get("message") or res)
        # сетевые сбои (ECONNRESET, таймауты) повторяем с паузой, остальное — сразу ошибка
        if "FetchError" not in msg or attempt == retries - 1:
            raise RuntimeError(msg)
        time.sleep(2 ** attempt)


def parse_ts(s):
    t = datetime.fromisoformat(s.replace("Z", "+00:00"))
    return t if t.tzinfo else t.astimezone()  # без зоны — считаем локальным временем


def key(x):
    login = x.get("login") or {}
    if x.get("type") == 1:
        return (1, x.get("name"), login.get("username"), login.get("password"), login.get("totp"))
    return (x.get("type"), x.get("name"), x.get("notes"))


def passkeys(x):
    return (x.get("login") or {}).get("fido2Credentials") or []


def trash(x):
    """В корзину, не навсегда. Ошибку не пробрасываем — запись уйдёт в «вручную»."""
    if not a.apply:
        return True
    try:
        api("DELETE", f"/object/item/{x['id']}")
        return True
    except RuntimeError as e:
        manual.append(f"{x['name']}  (ошибка: {e})")
        return False


try:
    status = api("GET", "/status")["template"]["status"]
except RuntimeError as e:
    sys.exit(f"bw serve недоступен на {a.api}: {e}")
if status != "unlocked":
    sys.exit(f"Хранилище в статусе «{status}» — запусти bw serve с BW_SESSION от bw unlock")

api("POST", "/sync")  # свежие данные: после прерванного запуска часть записей уже в корзине
items = api("GET", "/list/object/items")["data"]
if items and "creationDate" not in items[0]:
    sys.exit("bw не отдаёт creationDate — обнови Bitwarden CLI")

since = parse_ts(a.since)
new, old = [], {}
for x in items:
    if not x.get("organizationId") and parse_ts(x["creationDate"]) >= since:
        new.append(x)
    else:
        old.setdefault(key(x), []).append(x)

print(f"Новых записей после {since:%Y-%m-%d %H:%M}: {len(new)}, старых: {sum(map(len, old.values()))}\n")

trashed = moved = 0
unique, manual = [], []

for x in new:
    matches = old.get(key(x), [])
    pk = passkeys(x)
    if not matches:
        unique.append(x["name"])
        continue

    if pk:
        ids = {c.get("credentialId") for c in pk}
        if any(ids <= {c.get("credentialId") for c in passkeys(m)} for m in matches):
            # passkey уже перенесён прошлым запуском, осталось убрать новую запись
            print(f"{'TRASH' if a.apply else 'DRY'}  {x['name']}  (passkey уже в старой записи)")
            trashed += trash(x)
            continue
        if not a.move_passkeys:
            manual.append(f"{x['name']}  (есть passkey, запусти с --move-passkeys)")
            continue
        targets = [m for m in matches if not passkeys(m)]
        if len(targets) != 1:
            manual.append(f"{x['name']}  (passkey; подходящих старых записей: {len(targets)})")
            continue
        target = targets[0]
        print(f"{'MOVE' if a.apply else 'DRY'}   passkey  {x['name']}")
        if a.apply:
            try:
                full = api("GET", f"/object/item/{target['id']}")
                full["login"]["fido2Credentials"] = pk
                api("PUT", f"/object/item/{target['id']}", full)
                check = api("GET", f"/object/item/{target['id']}")
            except RuntimeError as e:
                manual.append(f"{x['name']}  (ошибка: {e})")
                continue
            if not ids <= {c.get("credentialId") for c in passkeys(check)}:
                manual.append(f"{x['name']}  (CLI не сохранил passkey в старую запись, новая оставлена)")
                continue
            if not trash(x):
                continue
        moved += 1
        continue

    print(f"{'TRASH' if a.apply else 'DRY'}  {x['name']}")
    trashed += trash(x)

print(f"\n{'В корзине' if a.apply else 'К удалению'}: {trashed}, passkeys перенесено: {moved}")
if unique:
    print(f"\nБез пары среди старых (оставлены, стоит глянуть глазами): {len(unique)}")
    for n in unique:
        print("  -", n)
if manual:
    print("\nРазобрать вручную:")
    for m in manual:
        print("  -", m)

P.S. ⚠️ Файлы .1pux не зашифрованы и там все пароли лежат открытым текстом. После переноса их стоит удалить (и из корзины тоже), а не оставлять в «Загрузках».