Pretty damn secure self hosted Bitwarden
Every year I spend an afternoon reading through my credit card statement to see whether I've accidentally forgotten to unsubscribe from something.
This year was no different, and on my travels through the statement, I stumbled upon my LastPass subscription.
While there are two certainties for everyone in life (death and taxes), for me there are also two Sisyphean tasks that I continue to work on:
- Unsubscribing for emails
- Ceasing subscriptions
Figuring I could save some money here, and the inertia to leave a password manager is possibly even higher than leaving your bank, I felt up to the challenge.
Why not LastPass?
This would have been a good question 5-10 years ago, but honestly I feel like the more relevant question now is Why LastPass?
Since their acquisition by LogMeIn in 2015, the only new feature I've seen is an increase in cost. I've been a premium subscriber for a few years in order to share password folders, but it still irks me that they use predatory practices to stymie usage of their free account.
The most egregious of which is the recent limitation of one device per free user. Honestly, which person nowadays only has one device? I find it to be an extremely security hindering limitation for a company that states:
Security is our highest priority at LastPass
But it's not just price gouging that's given me the ick. It's also the aggregation of security incidents in 2015, 2016, 2017, 2019, and 2021. I want my most secure data to remain secure and my trust, the most thing important (currently) in an online world has been shaken. I say currently because zero-trust is neat.
What are the alternatives?
LastPass isn't the boy without a date at prom. There're a handful of password managers which have gained prevalence over the last few years to choose from from 1Password to KeyPassX to Dashlane. Each has their own benefits, drawbacks, and pricing.
Ultimately though, I chose to go with Bitwarden and it was down to three reasons:
- Open Source (Despite leaving the FOSS world I'm still a FOSS boy at heart)
- A free account that hasn't been hamstrung into impracticality
- Strong emphasis on security both in system architecture and level of audit
While I could have signed up for a free account on the Bitwarden website, I decided to go full neckbeard and host my own password manager. This was 50:50 living my open source tenets as well as just seeing whether I could.
How did I install Bitwarden?
In a word: Ansible.
In a few more words, I created a new 1GB/1CPU Digital Ocean droplet (referral link) using Ubuntu 18.04 LTS because I wanted to ensure complete separation between where my passwords are stored and other servers. I also enabled automatic backups because why not right?
Once the server was provisioned, I SSH'd in and ran the following commands to install Ansible and the packages I'd require.
apt-get update
apt-get upgrade
add-apt-repository --yes --update ppa:ansible/ansible
apt install ansible
ansible-galaxy install geerlingguy.swap
ansible-galaxy install ahuffman.resolv
ansible-galaxy install geerlingguy.security
ansible-galaxy install geerlingguy.firewall
ansible-galaxy install geerlingguy.ntp
ansible-galaxy install geerlingguy.certbot
ansible-galaxy install geerlingguy.nginx
ansible-galaxy install geerlingguy.postgresql
ansible-galaxy install geerlingguy.postfix
ansible-galaxy install jenstimmerman.vaultwarden
ansible-galaxy install adamruzicka.wireguard
I did end up having to use the version of jenstimmerman.vaultwarden
from GitHub rather than Ansible Galaxy because 0.5 hadn't been pushed. The author has since fixed that though!
After this, I created a custom role and placed it the following configuration in /etc/ansible/roles/yphonius.servername/tasks/main.yml
for some of the tweaks I'd need in the server:
# Create required users and ensure periodic running of Ansible
- name: Ensure typhonius group exists
group:
name: typhonius
state: present
- name: Add the user typhonius
user:
name: typhonius
groups: typhonius,sudo
create_home: true
shell: '/bin/bash'
- name: Installing ssh key for typhonius
authorized_key:
user: typhonius
key: "{{ lookup('file', './files/authorized_keys.typhonius.pub') }}"
- name: Add the user bitwarden
user:
name: bitwarden
create_home: false
shell: '/bin/nologin'
- name: Runs Ansible on cron
cron:
name: "Ansible cron"
state: "present"
user: "root"
hour: "15"
minute: "0"
job: '/usr/bin/ansible-playbook /etc/ansible/servername.yml'
# Required packages for Certbot
- name: install unzip
package:
name: unzip
state: present
- name: install openresolv
package:
name: openresolv
state: present
# For Wireguard
- sysctl:
name: net.ipv4.ip_forward
value: '1'
state: present
reload: yes
- sysctl:
name: net.ipv6.conf.all.forwarding
value: '1'
state: present
reload: yes
- sysctl:
name: net.ipv4.conf.wg0.route_localnet
value: '1'
state: present
reload: yes
I then created a servername.yml
in /etc/ansible
and filled it with the following
- hosts: localhost
vars_files:
- vars/main.yml
roles:
- { role: typhonius.servername }
- { role: geerlingguy.swap }
- { role: ahuffman.resolv }
- { role: geerlingguy.security }
- { role: geerlingguy.firewall }
- { role: geerlingguy.ntp }
- { role: geerlingguy.certbot }
- { role: geerlingguy.nginx }
- { role: geerlingguy.postgresql }
- { role: geerlingguy.postfix }
- { role: jenstimmerman.vaultwarden }
- { role: adamruzicka.wireguard }
ansible_python_interpreter: /usr/bin/python3
# geerlingguy.nginx
nginx_extra_http_options: |
resolver 1.1.1.1;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
nginx_remove_default_vhost: true
nginx_server_tokens: "off"
nginx_multi_accept: "on"
nginx_listen_ipv6: false
nginx_vhosts:
- server_name: "servername.adammalone.net"
listen: "127.0.0.1:443 ssl http2"
state: "present"
template: "{{ nginx_vhost_template }}"
filename: "servername.adammalone.net-https.conf"
extra_parameters: |
location / { deny all; }
ssl_certificate /etc/letsencrypt/live/servername.adammalone.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/servername.adammalone.net/privkey.pem;
- server_name: "bitwarden.adammalone.net"
listen: "127.0.0.1:443 ssl http2"
filename: "bitwarden.adammalone.net-https.conf"
extra_parameters: |
ssl_certificate /etc/letsencrypt/live/bitwarden.adammalone.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/bitwarden.adammalone.net/privkey.pem;
location / {
proxy_pass http://localhost:8008/;
proxy_set_header Host $server_name;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /notifications/hub {
proxy_pass http://localhost:3003;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /notifications/hub/negotiate {
proxy_pass http://localhost:8008;
}
add_header X-Frame-Options SAMEORIGIN;
add_header Referrer-Policy "strict-origin-when-cross-origin";
add_header Content-Security-Policy "default-src 'self'; prefetch-src 'self'; connect-src 'self' adammalone.report-uri.com; font-src 'self' data:; frame-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline' ; style-src 'self' 'unsafe-inline'; media-src 'self'; base-uri 'self'; report-to csp-endpoint";
add_header Report-To '{"group":"csp-endpoint","max_age":31536000,"endpoints":[{"url":"https://adammalone.report-uri.com/r/d/csp/enforce"}]},{"group":"default","max_age":31536000,"endpoints":[{"url":"https://adammalone.report-uri.com/a/d/g"}],"include_subdomains":true}';
add_header X-Content-Type-Options "nosniff";
add_header Feature-Policy "accelerometer 'none'; camera 'none'; geolocation 'none'; gyroscope 'none'; magnetometer 'none'; microphone 'none'; payment 'none'; usb 'none'";
# geerlingguy.ntp
ntp_manage_config: true
- "127.0.0.1"
- "::1"
# geerlingguy.security
security_ssh_port: 38387
security_autoupdate_mail_to: "[email protected]"
security_sudoers_passwordless:
- typhonius
# geerlingguy.firewall
firewall_allowed_tcp_ports:
- "38387" # SSH
- "80" # Certbot
firewall_allowed_udp_ports:
- "53" # Wireguard
- "55290" # Wireguard
firewall_additional_rules:
- "iptables -t nat -A PREROUTING -p tcp -i wg0 --dport 443 -d REDACTED -j DNAT --to-destination 127.0.0.1"
- "iptables -t nat -A PREROUTING -p udp --dport 53 -j REDIRECT --to-ports 51820 -i eth0"
- "iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE"
- "iptables -A INPUT -p tcp -i wg0 --dport 443 -j ACCEPT"
# geerlingguy.postgres
postgresql_users:
- name: bitwarden
password: REDACTED
postgresql_databases:
- name: bitwarden
owner: bitwarden
# jenstimmerman.vaultwarden
vaultwarden_version: 1.23.1
vaultwarden_webvault_version: 2.25.0
vaultwarden_config:
DOMAIN: "https://bitwarden.adammalone.net"
DOMAIN_PATH: "" # results in a domain of https://example.com/vaultwarden/, needs to start with a '/'
DATABASE_URL: "postgresql://bitwarden:REDACTED@/bitwarden?host=/run/postgresql/"
ROCKET_ADDRESS: 127.0.0.1
ROCKET_PORT: 8008
SIGNUPS_ALLOWED: false
SIGNUPS_VERIFY: true
SIGNUPS_DOMAINS_WHITELIST: 'adammalone.net'
INVITATIONS_ALLOWED: 'false'
SMTP_FROM: '[email protected]'
SMTP_FROM_NAME: 'bitwarden'
SMTP_HOST: smtp.sendgrid.net
SMTP_PORT: 587
SMTP_SSL: true
SMTP_EXPLICIT_TLS: false
SMTP_USERNAME: apikey
SMTP_PASSWORD: REDACTED
SMTP_AUTH_MECHANISM: "Login"
WEBSOCKET_ENABLED: true
WEBSOCKET_ADDRESS: 127.0.0.1
WEBSOCKET_PORT: 3003
#ADMIN_TOKEN: "REDACTED"
# adamruzicka.wireguard
wireguard_networks:
- wg0
wireguard_wg0_interface:
address: 10.10.0.0/16
private_key: REDACTED
listen_port: 51820
post_up: 'iptables -A FORWARD -i %i -j wireguard; iptables -A FORWARD -o %i -j wireguard; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE'
post_down: 'iptables -D FORWARD -i %i -j wireguard; iptables -D FORWARD -o %i -j wireguard; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE'
dns: 1.1.1.1
wireguard_wg0_peers:
laptop:
public_key: REDACTED
allowed_ips: 10.10.10.96/32
mobile:
public_key: REDACTED
allowed_ips: 10.10.10.97/32
# ahuffman.ansible-resolv
resolv_nameservers:
- "1.1.1.1"
- "1.0.0.1"
resolv_options:
- "timeout:2"
- "rotate"
# geerlingguy.certbot
certbot_create_if_missing: true
certbot_admin_email: [email protected]
certbot_certs:
- domains:
- servername.adammalone.net
- domains:
- bitwarden.adammalone.net
Is it more secure?
As a result of the firewalling, the server is for all intents and purposes as locked down as is possible with a single box. Yes, I could have added in jump servers/bastion hosts and further increased complexity but the following was Good Enough™ for my needs.
The only publicly available TCP port is my SSH port which greatly limits the attack surface. In order for me to get access to any of the passwords within Bitwarden, I need to authenticate via WireGuard which will then give me access to NGINX.
Without authenticating, anyone trying to access the server won't be able to access anything and if they navigate to the Bitwarden URL then the page simply won't load and instead will error out.
I decided to architect the configuration in this way to provide me with a little extra protection in the event of a Bitwarden vulnerability. If a bad actor isn't able to access the Bitwarden instance, then they won't be able to attack it. This is my way of attempting to use defence in depth.
Am I going to keep it?
Honestly, probably not. But maybe.
As a proof of concept super fun, and I've been using it successfully on all my devices for over six months. That being said, I'm probably going to switch over to one of the Bitwarden hosted plans reasonably shortly for two reasons:
- I don't really have a backup strategy for Bitwarden aside from block level server backups and that's scary. Some exist, but I would want to write my own as another fun project (which I can then of course open source). The main issue here is lack of pgSQL support in existing repos
- Having to activate Wireguard every time I want to save a new password is actually a massive pain – even if it's only one click