Личный менеджер паролей на базе 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), но с двумя нюансами:
- Все сейфы сливаются в один. У меня было около десяти сейфов, личных и общих, и импорт вывалил бы их в одну кучу. Мне нужно было разложить каждый сейф в свою организацию/коллекцию.
- Вложения внутри записей теряются. Отдельные документы (категория
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 не зашифрованы и там все пароли лежат открытым текстом. После переноса их стоит удалить (и из корзины тоже), а не оставлять в «Загрузках».