The canonical encoded request to register a Vaultwarden root account | Lisandro Fernández Rocha

The canonical encoded request to register a Vaultwarden root account

Published: April 25, 2026
bashopensslcryptographyzero-knowledgeenvelope-encryptionvaultwardenself-hostedinfrastructure-as-code

What goes in the body payload when zero-knowledge protocol design rules out long-lived plaintext passwords.

An HTTP POST with a JSON body, a 200 response, the master root account created. The body itself is the engineering exercise: three of its seven fields are cryptographic material the client has to derive locally before the first network call, because the server is never trusted with anything decryptable. This post reconstructs that derivation against a published test vector, in pure shell, in a form a CD pipeline can run unattended.

What the server receives

Bitwarden is zero-knowledge by design. ¹ The server never sees the master password, never sees the vault keys. What it stores is material the client derived locally. The endpoint for this is POST /identity/accounts/register -note identity, not api. A large number of posts and forum threads from 2021 still indexed by search engines point to /api/accounts/register, which returns 404 on Vaultwarden 1.35.7. Bitwarden moved the endpoint during a 2022 client restructuring. A 404 on registration is not a payload problem.

The registration payload has seven fields. Five are trivial. Three are not:

  • masterPasswordHash -the authentication credential, derived from the password via two rounds of PBKDF2. Not the password. Not a bcrypt hash of the password. A PBKDF2 output used as input to a second PBKDF2.
  • key -a random 64-byte symmetric key, AES-CBC encrypted and HMAC-authenticated, wrapped in a format Bitwarden calls a CipherString.
  • keys.encryptedPrivateKey -an RSA-2048 private key in PKCS#8 DER, wrapped in the same format, using the random key above as the wrapping key.

Reproducing those three fields in shell is the problem.

The five derivations

The rubywarden API notes publish a test vector. ¹ Every derivation below verifies against it before touching any server.

password       = "p4ssw0rd"
email          = "nobody@example.com"
kdf_iterations = 5000

1. masterPasswordHash

Two PBKDF2-HMAC-SHA256 calls in sequence. ¹ The first derives a 32-byte master key from (password, email, iterations). The second derives the authentication hash from (master_key, password, 1). The server applies its own 100 000 iterations on top before comparing -the client’s PBKDF2 iteration count protects against offline attacks on the transmitted hash; the server’s count protects the stored value. ¹

MK=$(openssl kdf -keylen 32 \
    -kdfopt digest:SHA256 \
    -kdfopt pass:p4ssw0rd \
    -kdfopt salt:nobody@example.com \
    -kdfopt iter:5000 \
    PBKDF2 | tr -d ':[:space:]')

openssl kdf -keylen 32 \
    -kdfopt digest:SHA256 \
    -kdfopt hexpass:$MK \
    -kdfopt salt:p4ssw0rd \
    -kdfopt iter:1 \
    PBKDF2 | tr -d ':[:space:]' | xxd -r -p | base64

Expected output: r5CFRR+n9NQI8a525FY+0BPR0HGOjVJX0cR1KEMnIOo=

openssl kdf requires OpenSSL 3.0 or later. The hexpass: option signals that the password argument is hex-encoded bytes, not a literal string. Without it, the second call hashes the ASCII characters of the hex string -64 printable characters instead of 32 binary bytes- and the hash is wrong in a way that is silent until login fails.

2. encKey and macKey

Two subkeys derived from the master key via HKDF-Expand with distinct info labels. ¹ The same IKM with different labels produces cryptographically independent outputs. Using the same key for both encryption and MAC would allow the MAC to leak information about the ciphertext; separate derivation closes that.

ENCKEY=$(openssl kdf -keylen 32 \
    -kdfopt digest:SHA256 \
    -kdfopt mode:EXPAND_ONLY \
    -kdfopt hexkey:$MK \
    -kdfopt info:enc \
    HKDF | tr -d ':[:space:]')

MACKEY=$(openssl kdf -keylen 32 \
    -kdfopt digest:SHA256 \
    -kdfopt mode:EXPAND_ONLY \
    -kdfopt hexkey:$MK \
    -kdfopt info:mac \
    HKDF | tr -d ':[:space:]')

3. CipherString

Bitwarden’s wire format for encrypted data: "2." + base64(iv) + "|" + base64(ct) + "|" + base64(mac). The 2. prefix identifies the algorithm: AES-256-CBC + HMAC-SHA256.

The MAC is computed over IV || ciphertext, not over the plaintext. That ordering -encrypt-then-MAC- is the only composition of symmetric encryption and MAC that is generically secure against chosen-ciphertext attacks. ¹ MAC-then-encrypt is the construction behind POODLE and Lucky13.

IV=$(openssl rand -hex 16)

CT=$(printf '%s' "$PLAINTEXT_HEX" | xxd -r -p | \
    openssl enc -aes-256-cbc -K $ENCKEY -iv $IV | \
    xxd -p | tr -d '\n')

MAC=$(printf '%s%s' "$IV" "$CT" | xxd -r -p | \
    openssl dgst -sha256 -mac HMAC -macopt hexkey:$MACKEY -binary | \
    xxd -p | tr -d '\n')

b64() { base64 | tr -d '\n'; }

CS="2.$(printf '%s' $IV | xxd -r -p | b64)|$(printf '%s' $CT | xxd -r -p | b64)|$(printf '%s' $MAC | xxd -r -p | b64)"

base64 on GNU coreutils wraps output at 76 characters by default. A CipherString enclosing 64 bytes of payload has a 108-character base64 block in the middle. Without tr -d '\n' that block carries a literal newline into the JSON field, the server returns 422, and the error body says nothing useful about where the problem is.

4. The generated symmetric key

64 random bytes. This is the user’s data encryption key -the DEK- that encrypts every vault entry. It never travels in the clear. Before registration it is wrapped in a CipherString using encKey and macKey from step 2.

GSK_HEX=$(openssl rand -hex 64)

# Shell variables cannot hold arbitrary binary safely.
# Write to a temp file and pass the path.
printf '%s' "$GSK_HEX" | xxd -r -p > "$TMPDIR/gsk.bin"

KEY_CS=$(bw_cipherstring_encrypt "$TMPDIR/gsk.bin" "$ENCKEY" "$MACKEY")

5. RSA-2048 keypair

Required by the protocol regardless of whether the account ever shares anything. When items are shared between users, the sender encrypts the symmetric key of the shared collection with the recipient’s public key. The server needs the public key at registration time.

The private key goes in PKCS#8 DER format, wrapped in a CipherString. The wrapping key is the GSK split in half: first 32 bytes as the AES key, last 32 as the HMAC key.

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
    -outform DER -out "$TMPDIR/priv.der"

PUBLIC_KEY=$(openssl pkey -inform DER -in "$TMPDIR/priv.der" \
    -pubout -outform DER | base64 | tr -d '\n')

GSK_ENC=${GSK_HEX:0:64}
GSK_MAC=${GSK_HEX:64:64}

ENCRYPTED_PRIVATE_KEY=$(bw_cipherstring_encrypt \
    "$TMPDIR/priv.der" "$GSK_ENC" "$GSK_MAC")

The public key in base64 is consistently 392 characters for RSA-2048 in SPKI DER. The encrypted private key lands around 1672 characters. Those numbers are useful sanity checks: if they are off, something earlier in the derivation chain is wrong.

The pattern underneath

What just happened is envelope encryption. ¹ The master password never leaves the client. From it a KEK is derived. The KEK wraps the DEK -the GSK. The DEK encrypts the data. When the master password changes, only the wrapped DEK is re-encrypted. The vault entries are untouched. The same structure appears in AWS KMS, LUKS, SOPS+age, and every serious key management system built in the last twenty years.

That last point matters operationally: password rotation is cheap. The vault does not get re-encrypted. Only the CipherString that wraps the GSK changes.

Provision then rotate

The master password used for registration is a bootstrap credential, not a long-lived secret. The pattern is older than IaC and shows up everywhere credentials need to enter a system that has none yet:

  • cloud-init injects an initial password on first boot, then expires it.
  • kubeadm issues bootstrap tokens valid for 24 hours.
  • Terraform commonly generates admin passwords inside an apply, stores them in a secrets manager, and rotates them in the same run.

For Vaultwarden the sequence is:

  1. Generate a strong password with openssl rand -base64 32.
  2. POST to /identity/accounts/register with the derived material.
  3. POST to /identity/connect/token to obtain an access token.
  4. POST to /api/accounts/api-key to obtain a personal API key.
  5. Store the API key (client_id + client_secret) in an encrypted secrets store.
  6. Optionally rotate the master password via POST /api/accounts/password, replacing the wrapped DEK with a fresh KEK derivation. Vault entries are not re-encrypted, only the wrapper changes.

Steps 1 to 5 happen inside a single pipeline run. Step 6 is independent and can be triggered any time the operator wants to reduce the lifetime of the bootstrap password. The window during which a leaked bootstrap password would compromise anything is bounded by the operator’s choice, not by the protocol.

The regression test

The rubywarden vector is a fixed point. Given those three inputs, the masterPasswordHash output is deterministic. If anything in the derivation chain changes, the hash changes.

bash vaultwarden/tests/test-vectors.sh
ts=2026-04-25T04:38:37.430Z host=workstation service=test-vectors section=setup event=start
ts=2026-04-25T04:38:37.446Z host=workstation service=test-vectors section=test_master_key event=pass name=master_key_matches_vector
ts=2026-04-25T04:38:37.454Z host=workstation service=test-vectors section=test_master_password_hash event=pass name=mph_matches_vector
ts=2026-04-25T04:38:37.508Z host=workstation service=test-vectors section=test_cipherstring_roundtrip event=pass name=mac_verifies
ts=2026-04-25T04:38:37.514Z host=workstation service=test-vectors section=test_cipherstring_roundtrip event=pass name=plaintext_recovered
ts=2026-04-25T04:38:37.521Z host=workstation service=test-vectors section=test_gsk event=pass name=gsk_128_hex_chars len=128
ts=2026-04-25T04:38:37.522Z host=workstation service=test-vectors section=summary event=summary total=8 passed=8 failed=0

Eight assertions, 92 milliseconds. Full test file. The test lives alongside the registration script. If an OpenSSL upgrade changes kdf output behavior -it has happened- the test fails before any server sees the payload.

Operational notes

No shebang lines, no execute bit. Every script in the repo is invoked as bash register.sh or sourced with . lib/bitwarden-crypto.sh. The interpreter is explicit at the call site, not encoded in the file. This eliminates the #!/usr/bin/env bash portability question across Alpine, Arch, and whatever the CI runner image happens to be.

The master password enters via stdin only. Never argv -it appears in ps and in /proc/$PID/cmdline for the process lifetime. Never an environment variable -readable from /proc/$PID/environ by any process running as the same UID. IFS= read -r on a pipe, read -rs on a TTY.

Exit codes are differentiated: 1 for input errors, 2 for network, 3 for server rejection, 4 for internal failures. A CD pipeline can distinguish between “the bastion is unreachable” and “the binary you just built returns an unexpected status from the registration endpoint.”

The full registration script, the crypto library, and the test suite are at wererootops/zero-touch-trust-none.