Confidex
Sections

How can a server store and synchronize our secrets without holding the keys to read them? We will follow how Confidex creates its keys, protects them in a lock and uses them to encrypt data and verify changes.

Lock

Only the One can open the door

The Matrix Reloaded

Imagine that we are building a virtual vault to store secret data. It must keep that data on a laptop after the application closes and let us open it on another device. The vault will need its own keys. We must store them so that copying the files gives an outsider no way to read or alter their contents.

A lock protects the keys: a small encrypted file. Opening it takes a master password and a secret code, secret_key; neither is in the file itself. We could print the entire lock in the morning newspaper. Readers would get a copy, but publishing it would not reveal the private keys.

Suppose an outsider gets this file and even learns the master password. They can now repeat every calculation that needs only the password, but those calculations will not produce the key that opens the lock: secret_key is also an input. The file contains no separate check of the password alone. Opening it still requires the unknown secret code.

That code comes from cryptographically random data; a person does not invent it. As long as secret_key is unknown, even a correctly guessed master password does not open the lock: the attacker still lacks a random secret. A weak password alone therefore does not turn the server copy into a practical dictionary attack.

If the attacker also obtains secret_key, they can test master-password guesses offline. This is costly, but the eventual difficulty depends on the strength of the password.

Keys

Gentlemen, what do keys do? Keys… unlock things?

Pirates of the Caribbean – Dead Man’s Chest

The application obtains 32 random bytes from the operating system’s cryptographic generator and prepares them for use as a key. These prepared bytes are the private master key, master_key.

The application always derives the same keys from master_key: repeating the same calculations on the same bytes gives the same result. A laptop and a phone can therefore recover identical keys. To obtain master_key on a new device, the application must first open the lock with the master password and secret_key.

The first pair: encryption

I am the Keymaster.

Ghostbusters

To store encrypted data, the application computes a public key from master_key: secret_key. It can encrypt data for the vault, while decryption requires master_key.

“Public” describes this key’s role in the algorithm, not an obligation to give it to everyone. In Confidex it stays with the user. It is also the second secret needed to open the lock, so it stays local and is not sent over the network. On its own, secret_key lets someone encrypt data for the vault, but not decrypt it or sign changes on its behalf.

X25519 is the algorithm that takes master_key and computes secret_key. Recovering master_key from secret_key is practically infeasible. A classical attack on an imaginary supercomputer performing a billion billion curve operations per second would take about 2.3 trillion years on average (SafeCurves estimate).

public bytes Random bytes32 bytes master_key secret_key public bytes Random bytes32 bytes master_key secret_key
From random bytes to the encryption keys.

The second pair: signatures

The only thing original is the signature

Catch Me If You Can

The first pair helps hide the contents. We now need a way to confirm that the vault’s owner authorized a change. The server must be able to check signatures too, so we need to send it a public verification key. The first pair’s public part is secret_key, which the server must not receive. We therefore create a second, permanent key pair for signatures.

Instead of storing another independent secret, we derive a private signing key from master_key and call it signing_key. The application uses it to sign data and the lock. Other devices and the server use its public part to check signatures without reading the contents.

We can recover this pair from the same master_key. The application derives a starting value from it: 32 bytes that uniquely determine the signing key.

To derive different keys for different jobs, we include a fixed label in the calculation: here, confidex/vault/ed25519/v1. The same master_key and the same label always produce the same key.

The master password has not entered these calculations. Both pairs depend on the random bytes of master_key, not on a string invented by a person. This distinction will matter later: the password will protect access to keys that already exist, so we can change it without changing the keys themselves.

The derivation uses a hash-based key derivation function: HKDF, HMAC-based Extract-and-Expand Key Derivation Function. Confidex uses it with SHA-256, hence the name HKDF-SHA256. The resulting 32 bytes are the seed for the Ed25519 pair. The public part of signing_key does not reveal secret_key, so we can send it to the server for signature verification.

public bytes HKDF-SHA256keymaster_keyinfo"confidex/vault/ed25519/v1" signing_key Signature verification public bytes HKDF-SHA256keymaster_keyinfo"confidex/vault/ed25519/v1" signing_key Signature verification
A separate signing key, derived from the same master key.

What is a lock file, and what does it do?

Open the pod bay doors, HAL.
I’m sorry, Dave. I’m afraid I can’t do that.

A Space Odyssey

The lock stores an encrypted master_key. Opening it requires the master password and secret_key. The public header contains the format version, the vault identifier, the public part of signing_key, a salt and the password-processing parameters. The internal format version is encrypted alongside master_key.

When creating the lock, the application already has master_key, so it computes secret_key before encryption. When opening it, the user supplies secret_key separately: it is absent from the public copy of the lock.

After decryption, the application computes the public part of master_key using X25519 and requires it to match secret_key. It then recovers signing_key and compares its public part with the key in the header. In this way, the lock checks itself.

To slow master-password guessing, each attempt must cost time and memory. Confidex uses Argon2id with 64 MiB of memory and three passes. Password processing also uses a random salt. The salt is not secret: it makes the same password produce different results in different locks.

If secret_key is already known, password length dramatically changes the scale of an attack. There are about 12 million combinations of 5 random lowercase Latin letters, about 8 billion of 7, and about 95 quadrillion of 12. Going from 7 to 12 letters increases the search space almost 12 million times. If searching 7 letters took one day on average on a given machine, 5 would take about two minutes, and 12 about 33,000 years.

To obtain one key from the Argon2id result and secret_key, we use the HKDF-SHA256 function introduced earlier, with the label confidex/lock/wrap/v1. The result is wrapping_key, the key that encrypts the lock.

We must hide master_key and protect it from tampering. XChaCha20-Poly1305 encrypts it with wrapping_key and adds an authentication tag. The tag binds the ciphertext to the public header: a wrong password or a change to the protected bytes makes opening fail. The tag and a random single-use value, the nonce, are stored with the ciphertext.

Every time the lock is created or encrypted again, the application obtains a fresh salt and nonce. The encrypted contents include one version byte and the 32 bytes of master_key; a 24-byte nonce and a 16-byte tag are stored alongside them. The algorithm’s documentation describes the construction.

A mismatch between the recovered keys can result from a deliberately malformed lock or a bug in the program that created it. Random byte corruption is usually detected earlier, when the tag is checked.

The server does not have the secrets needed to check this tag. To transfer the lock, the application therefore adds a signature made with signing_key. It covers the entire finished file, including the public header and encrypted contents. The server checks the signature and, on first upload, pins the full public part of signing_key and the internal vault identifier. It checks subsequent changes against this key. The lock and encrypted data are signed with the same signing_key, recovered from master_key.

The vault

We can now publish the lock. Only someone who knows secret_key and the master password can read master_key, while anyone can take the public signing key from the lock file and check signed data without seeing the contents of either the lock or the data.

Consider a password, SuperPa55worD. For now, we only want to save it: after closing the application, we should be able to send the lock file and the encrypted password anywhere without anyone seeing SuperPa55worD, while still letting them verify our authorship. Encryption must be reversible when we have secret_key and the master password. A symmetric cipher can encrypt and decrypt with the same key. Since we do not store that key, we must be able to recover it using the lock. We will call it message_key.

We will obtain a separate message_key for each new encryption. We still need to arrange its recovery: leaving the key in the open beside the ciphertext would let anyone with a copy read everything.

From fresh random bytes, the application creates a temporary X25519 pair just for this encryption: record_key. It then takes the private part of record_key and the vault’s permanent public key, secret_key, and computes a shared secret. A separate function derives message_key from that secret; this key encrypts our password.

When reading, the application takes another route. It uses master_key and the public part of record_key. These two keys produce the same shared secret. Mathematically, both routes multiply the same initial point on the curve by two private numbers, in a different order. The order does not change the result. We can therefore recover the decryption key without keeping the private part of record_key.

Obtaining this secret from the two public keys is practically impossible: either route needs the corresponding private key. The public points do not let us efficiently recover the private numbers that produced them. This mathematical trick underpins the vault’s security. But it does not stop someone from giving us a different vault with our lock attached to it.

We can now store the ciphertext alongside the public part of record_key. The private part of record_key and the used message_key are no longer needed for future reading. Later, the application recovers the decryption key from master_key and the stored public part of record_key. One permanent vault secret can thus decrypt many separately encrypted values, each with its own message_key recovered when needed.

Hiding the text is not enough: we must also detect tampering when reading it. Encryption therefore adds an authentication tag, a short value checked together with the encrypted data. If the check fails, the application rejects the result. It must not display random or altered text as a successfully decrypted password.

public bytes Fresh random bytes record_key secret_key Shared secret Derive message key message_key Encrypted passwordpacket public bytes Fresh random bytes record_key secret_key Shared secret Derive message key message_key Encrypted passwordpacket
A fresh key pair for every encryption.

We have described a construction in which an asymmetric pair helps establish a shared secret, while message_key encrypts the contents. This is called Hybrid Public Key Encryption, or HPKE, and is specified in RFC 9180. Confidex uses X25519 to agree on the secret, HKDF-SHA256 to derive keys, and ChaCha20-Poly1305 for encryption with integrity checking. Here, HKDF takes the shared secret agreed using record_key and the master_key pair; this is a separate job from deriving the Ed25519 seed. RFC 5869 describes HKDF.

For the next encryption, the application creates a new record_key and a new HPKE context. Reusing a combination of message_key and nonce breaks the cipher’s protection. In Confidex, the key and nonce are derived inside HPKE, used for one message, and then the context is discarded.

ChaCha20 itself uses a 256-bit message_key. To get a sense of a direct attack, imagine a fictional supercomputer testing a billion billion candidates per second. Searching the 2²⁵⁶ possible keys would take about 1.8 × 10⁵¹ years. RFC 8439 describes the algorithm and its analysis.

A record

We happy?

Pulp Fiction

We have saved the password, but logging into a website usually also requires a username and an address. Sometimes we need a note beside them. If we store these values separately, the application must somehow remember that they belong together. Let us put these “papers” into an envelope. We combine the fields into one collection and call it a record.

A record can contain any combination of fields. The application assembles them into one packet, compresses it and encrypts the whole using the construction we have just described. Field values, names, types and tags are all protected. Changing the website address, even while keeping the same password, creates a new version of the entire record, encrypted with a new record_key.

Hash-chain

SHOULD YOUR PROJECT USE A BLOCKCHAIN? → NO

XKCD 2267

Someone who knows secret_key can prepare an encrypted record for our vault. To avoid accepting it as ours, the application signs every record version with signing_key. Without this key, an outsider cannot create a version with a signature that the server and other devices will accept.

The encrypted version needs some accompanying information: which vault and record it belongs to, when it changed, and which versions precede it. This forms the public header. It does not contain the password, but it still needs protection: otherwise someone could change the objects’ ownership and relationships without touching the ciphertext. The application signs the exact header bytes together with the ciphertext. Changing the signed data invalidates the signature.

The server resembles a warehouse of sealed parcels. It sees the header, encrypted contents and signature, but not the password inside, and it knows that the recipient exists in the city. It checks the signature against the pinned public part of signing_key and decides whether to accept the new version. It needs neither the master password nor secret_key nor any private key. When reading, the client checks the same signature, then checks the binding between the contents and the header again during decryption, using the HPKE authentication tag.

The signature also protects the links between versions. A SHA-256 hash of the exact header and ciphertext bytes becomes the version’s identifier. The next version’s header contains the previous version’s identifier. When we change a website password, the application creates and signs a new object with this link. The old object and its signature remain unchanged. Replacing the predecessor while preserving the new version’s signature will therefore fail. If two devices make independent changes, both branches survive; a merged version can refer to both.

Remember blockchain? There it is :-).

The public header lets us check links without decrypting, but some information remains visible. The server knows the identifiers, version links, the change time supplied by the client, the number of fields, and each field value’s encoded size. Encryption hides the record’s contents, not this structural information.

Header andparent IDs Encrypted recordpacket Sign revision signing_key Signed revision Header andparent IDs Encrypted recordpacket Sign revision signing_key Signed revision
The header and ciphertext are signed together.

Sync

“Not a data leak, a free distributed backup.”

Internet meme.

To work on several devices, we need to transfer the lock and record history between them. The object format does not change: we transfer the same encrypted bytes and signatures that are stored locally.

For manual transfer, the application creates a ZIP archive containing the lock, all locally stored encrypted record versions, and a signed file list. Neither the password nor secret_key is included. On another device, the application first opens the lock, then checks signatures, decrypts the records and restores a local vault copy. Import is intended for a device where this vault does not yet exist; it does not merge history with an existing copy.

A sync server exchanges changes between working copies. On first upload, it receives the signed lock, checks the signature and computes the public vault address from the public part of signing_key. It then remembers this key and the vault identifier from the header: this registers the vault on the server. Subsequent changes must match this identifier and carry a signature from the same key.

A new device needs the vault address and secret_key, so the application combines them into a transfer code with a checksum. The user transfers this code to the device and enters the master password separately; only the public address is sent to the server.

After opening the downloaded lock, the application compares the public parts of the recovered keys with secret_key and the header, and checks the computed address against the transfer code. It then saves the verified public part of signing_key to check records.

During synchronization, the application fetches missing versions and uploads its changes. The server checks signatures and links to preceding versions, stores accepted objects and passes them to other devices. Checking received data, decrypting it and resolving conflicts all happen on the user’s devices.

Registration asks for no name, email address or separate password. The server sees the public address, signature verification key, public headers, object sizes and version links. It also sees IP addresses and request times. The master password, secret_key, private keys and plaintext record contents stay on the user’s devices. Anyone who knows the public address can download the encrypted objects, but reading them requires the user’s secrets.

These checks detect forged objects, but do not prove that the server has shown the complete history and its latest state. It can hide a branch the client has never seen or return a consistent older dataset. A signature confirms an individual object’s authenticity, not the completeness of the server’s response. The user may notice missing changes on another device, but a consistent old history will pass hash-chain verification.

Changing the master password

So, would your holiness care to change her password?

Hackers (1995)

Changing the master password re-encrypts only the lock; master_key inside stays the same. Records, their signatures, secret_key and the vault address therefore remain unchanged. The new lock is signed with the same signing_key.

An old lock copy still opens with the old password and the same secret_key; changing the password does not revoke keys that have already been exposed.

The full picture

The lock protects master_key, message_key encrypts records, and signing_key creates signatures. ZIP archives and the server receive encrypted objects with signatures.

public bytes public bytes public bytes master_key Fresh randombytes secret_key record_key Master password wrapping_key signing_key message_key lock Encrypted records Signed lockand records Signatureverification ZIP archive /sync server public bytes public bytes public bytes master_key Fresh randombytes secret_key record_key Master password wrapping_key signing_key message_key lock Encrypted records Signed lockand records Signatureverification ZIP archive /sync server
The keys, what they protect, and what can be shared.