Każdy homelab prędzej czy później obrasta w usługi, które po cichu obsługują inne usługi. Vaultwarden to ta, do której najmniej chętnie się przyznaję, bo kiedy pada, nie mogę się zalogować nawet do poczty. Działa jednak wystarczająco długo i wystarczająco stabilnie, żebym chciał spisać, dlaczego mu ufam i co było potrzebne, żeby czuć się komfortowo z wystawieniem go na świat.

Czym właściwie jest Vaultwarden

Vaultwarden zaczynał jako bitwarden_rs, napisana od zera w Ruście reimplementacja API serwera Bitwarden. To nie jest oficjalny serwer Bitwarden i projekt nie jest powiązany z firmą Bitwarden. To, co robi, to mówienie tym samym językiem API, którego oczekują oficjalne klienty, na tyle dokładnie, że zwykła aplikacja Bitwarden, rozszerzenie do przeglądarki i CLI działają z nim bez żadnych modyfikacji. Instalujesz zwykłą aplikację Bitwarden, zmieniasz jedno ustawienie (adres własnego serwera) i logujesz się tak, jakby nic się nie zmieniło.

Powód, dla którego Vaultwarden w ogóle powstał, to zużycie zasobów. Oficjalny serwer Bitwarden to stos .NET złożony z kilku usług i zależny od SQL Server, sensowny, gdy firma uruchamia go na dużą skalę, ale przesada dla jednej osoby i jednego telefonu. Vaultwarden to pojedynczy binarny plik w Ruście z SQLite jako domyślnym backendem. Całość działa bez problemu w kontenerze, zużywając ułamek pamięci.

Konfiguracja

Sam kontener jest niczym niezwykłym:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
services:
  vaultwarden:
    image: vaultwarden/server:latest
    restart: unless-stopped
    environment:
      - SIGNUPS_ALLOWED=false
      - DOMAIN=https://pass.example.dev
      - DATABASE_URL=/data/vaultwarden.db
    volumes:
      - ./vaultwarden-data:/data
    ports:
      - "127.0.0.1:8081:80"

Dwa szczegóły robią tu większość roboty. Po pierwsze, SIGNUPS_ALLOWED=false. Skoro moje konto już istnieje, nie ma powodu, żeby endpoint rejestracji nadal działał. Ktoś, kto znajdzie adres i spróbuje się zarejestrować, dostaje odmowę zamiast działającego konta na moim serwerze. Po drugie, port jest zmapowany jako 127.0.0.1:8081:80, nie 0.0.0.0. Kontener w ogóle nie nasłuchuje w sieci lokalnej. Jest osiągalny wyłącznie z tego samego hosta, co ma znaczenie w kolejnym kroku.

Dostęp bez otwierania portu

Nie przekierowuję portów na routerze pod ten serwis i nie chcę tego robić. Drogą do środka jest Cloudflare Tunnel: mały demon (cloudflared) działający na tym samym serwerze, utrzymujący wyłącznie wychodzące połączenie z siecią brzegową Cloudflare. Jest plik konfiguracyjny mapujący nazwę hosta na lokalny adres, w tym przypadku pass.example.dev na localhost:8081. Cloudflare terminuje TLS i proxuje żądanie do środka tym tunelem. Z perspektywy internetu mój router nie ma pod ten serwis żadnego otwartego portu przychodzącego, bo żadnego nie potrzebuje.

To połączenie, nasłuch tylko na loopback plus tunel odpowiedzialny za faktyczne wystawienie usługi, sprawia, że powierzchnia ataku na “dostań się bezpośrednio do kontenera” jest bliska zeru. Jedyna droga do środka wiedzie przez brzeg Cloudflare, który ma własną ochronę przed DDoS i kontrolę dostępu stojącą przed usługą, której jedynym zadaniem jest trzymanie kluczy do wszystkiego innego.

Prawdziwy powód, dla którego temu ufam, to model szyfrowania

Nasłuch na loopback i schowanie się za tunelem to dobra higiena, ale to nie jest powód, dla którego jestem skłonny trzymać swój magazyn haseł na tej samej maszynie, na której działają też zadania crona bloga. Powodem jest to, że Vaultwarden, podobnie jak Bitwarden, ma architekturę zero-knowledge na poziomie klienta. Wpisy w skarbcu są szyfrowane i odszyfrowywane wyłącznie na urządzeniu, kluczem wyprowadzonym z hasła głównego przez PBKDF2 lub Argon2. Serwer dostaje wyłącznie zaszyfrowane bloki danych. Nie dostaje też hasła głównego, tylko osobny hash używany do uwierzytelniania, który nie jest tym samym sekretem co klucz szyfrujący.

To oznacza, że uczciwa odpowiedź na pytanie “co się stanie, jeśli ktoś włamie się na twój serwer” brzmi: dostanie zaszyfrowany plik SQLite i nic więcej. Nie jest to świetny wynik, ale da się z nim żyć, pod warunkiem że hasło główne jest naprawdę silne i że zaszyfrowany blok nie zostanie później skojarzony ze słabym hasłem. Skompromitowanie serwera to nie to samo co skompromitowanie skarbca, i o to właśnie chodzi w takim projektowaniu systemu, zamiast ufać serwerowi, że upilnuje sekretów, których nigdy nie posiadał.

Życie jako własny pojedynczy punkt awarii

Niewygodna część to nie kryptografia, tylko operacje. Jeśli serwer nie działa, tunel się urwie albo NAS trzymający wolumin danych zaczyna dziwnie się zachowywać, nie mogę pobrać nowego wpisu i nie mogę zresetować niczego, co zależy od tego skarbca, czyli z założenia niemal wszystkiego. Dwie rzeczy sprawiają, że da się to znieść w praktyce. Klienty Bitwarden trzymają lokalną zaszyfrowaną kopię skarbca, więc klient, który już raz zsynchronizował się z serwerem, dalej działa offline, tylko nie można wypchnąć ani pobrać zmian, dopóki połączenie nie wróci. A plik SQLite jest objęty zwykłym, nocnym backupem całej maszyny razem z resztą danych, więc “dysk padł” i “straciłem skarbiec” to nie ten sam scenariusz awarii.

Nadal trzymam w głowie dwa albo trzy hasła po staremu, konkretnie te, które byłyby potrzebne, żeby wrócić do maszyny i do konta, na które przychodzą alerty mailowe. Nazwijmy to planem awaryjnym. Nie jest elegancki, ale też nieelegancko byłoby zablokować sobie dostęp do własnej ścieżki odzyskiwania, bo rzecz, która miałaby pomóc w odzyskaniu dostępu, sama jest zablokowana.

Czy było warto

Dla jednej osoby, w większości tak. Migracja z chmurowego skarbca była naprawdę tylko kwestią “zmień adres serwera i zaloguj się”, bez tańca z importem i eksportem, bo to to samo API. Koszt zasobów jest znikomy, a konfiguracja tunelu sprawiła, że w ogóle nie musiałem myśleć o regułach firewalla dla tego serwisu. Kompromis, który świadomie przyjąłem, jest operacyjny: biorę na siebie odpowiedzialność za dostępność czegoś, co kiedyś traktowałem jako cudzy problem. Biorąc pod uwagę, jak faktycznie działa model szyfrowania, ten kompromis wydaje mi się po dobrej stronie.