Technical details

Password entry

  • Char arrays are used to temporarily store passwords. After the password is converted to a byte array, the char array is zeroed out. This is safer than using strings, which are immutable in C#.

  • When entering a new password, the user is asked to re-enter their password. To compare these passwords in constant time, the two char arrays are converted to byte arrays, hashed using BLAKE2b-512, and then passed to the libsodium compare function.

Keyfile generation

  • 64 random bytes are generated using libsodium and stored in a .key file.

  • Generated keyfiles are marked as read-only to make accidental modification less likely.

XChaCha20-BLAKE2b

Kryptor v3.0.0 was originally using XChaCha20-Poly1305, but I decided to switch to my own XChaCha20-BLAKE2b AEAD implementation due to potential key commitment issues and stronger security guarantees. You can read about how it works here.

Using XChaCha20-BLAKE2b means that each chunk is encrypted with a unique 256-bit encryption key and authenticated with a unique 512-bit MAC key.

A 256-bit tag was chosen because that's the same length as a 128-bit Poly1305 tag plus the 128-bit padding fix that adds key commitment.

File encryption

Password hashing

  1. The password is converted from a char array to a byte array.

  2. The password char array is zeroed out.

  3. The password bytes are hashed using BLAKE2b-512.

  4. Argon2id is then used to derive a 256-bit KEK using the password bytes, a random 128-bit salt, a memory size of 256 MiB, and an iteration count of 12. This is equivalent to a 1-1.2 second delay, depending on the machine.

  5. When the password is no longer required, the password bytes are zeroed out.

Keyfile hashing

  1. If the specified keyfile is equal to/greater than 64 bytes, then the file is hashed using BLAKE2b-512 to get the keyfile bytes. Otherwise, an error is returned and the keyfile is not used.

  2. If a keyfile has been selected alongside a password, the keyfile bytes are used as the key when hashing the password with BLAKE2b-512.

  3. If no password was used, the keyfile bytes are used as the password bytes.

Password

  • For individual files, a unique KEK per file is derived as described in Password hashing.

  • However, if a directory is selected, then Argon2id is only called once to derive one KEK for the entire directory. The random 128-bit salt is stored in a .salt file inside the parent directory as well as being a header in each encrypted file (so each file can be decrypted individually).

Private and public keys

  1. The sender's private key is decrypted using the user's password.

  2. The sender's private key and recipient's public key are used to calculate a 256-bit shared secret. This will always be the same for the same sender private key and recipient public key pair.

  3. Next, for each file, an ephemeral key pair is generated and used to calculate a 256-bit ephemeral shared secret (ephemeral private key, recipient public key). This ephemeral private key is then zeroed out.

  4. The long-term shared secret and ephemeral shared secret are concatenated to form 512-bits of input keying material.

  5. BLAKE2b with an empty byte array as the message, the input keying material as the key, a random 128-bit salt, and Kryptor.Personal as the personalisation bytes is used to derive a 256-bit KEK.

The sender cannot decrypt the encrypted files, meaning if their private key is compromised, no files can be decrypted. Furthermore, the identity of the sender and recipient is not known from looking at an encrypted file.

Private key

  1. The private key is decrypted using the user's password.

  2. The private key and an ephemeral public key are used to calculate a unique 256-bit shared secret per file.

  3. BLAKE2b with an empty byte array as the message, the ephemeral shared secret as the key, a random 128-bit salt, and Kryptor.Personal as the personalisation bytes is used to derive a 256-bit KEK.

Header structure

magicBytes || encryptionVersion || ephemeralPublicKey || salt || nonce || encryptedHeader​

  • magicBytes: "KRYPTOR" (7 bytes). This identifies the file as a Kryptor file.

  • encryptionVersion: the file format version (2 bytes). This is only incremented when the file structure/cryptographic algorithms need to be changed (breaking changes).

  • ephemeralPublicKey: a random ephemeral public key per file (32 bytes).​

  • salt: 16 random bytes used for key derivation.

  • nonce: 24 random bytes used as the nonce.

  • encryptedHeader: contains the file name length, last chunk length, and DEK (72 bytes).XChaCha20-BLAKE2b(lastChunkLength || fileNameLength || dataEncryptionKey)

  • lastChunkLength: the length of the last chunk (4 bytes). This allows the padding to be removed.

  • fileNameLength: the length of the input file name (4 bytes). The length is stored as 0 if the user does not want file name obfuscation.

  • dataEncryptionKey: 32 random bytes used as the file encryption key.

Header encryption

  • The magic bytes, format version, and ciphertext length are concatenated and used as additional data (17 bytes).

  • The ciphertext length is calculated by rounding up the number of 16 KiB chunks required to encrypt the file.​ This value does not include the length of the file headers.

  • The derived KEK, nonce, and additional data are used to encrypt the last chunk length, file name length, and DEK using XChaCha20-BLAKE2b.

The magic bytes and format version are authenticated to prevent tampering, and the ciphertext length is authenticated to prevent truncation of the ciphertext.

Chunked file encryption

  • If file name obfuscation is being used, then the file name of the input file is converted into bytes and appended to the input file.

  • A random 256-bit DEK is generated per file.

  • The random 192-bit nonce used to encrypt the header is incremented for each chunk.

  • The file bytes are encrypted in chunks of 16 KiB.

  • Each chunk is encrypted using XChaCha20-BLAKE2b with the random DEK, the counter nonce, and the previous authentication tag as additional data.

  • The first chunk uses the authentication tag from the encrypted header as additional data.

  • The total length of each encrypted chunk is 16,384 bytes (plaintext chunk) + 32 bytes (authentication tag).

  • Once the file has been encrypted, the DEK is zeroed out, and the output file is marked as read-only to make modification less likely.

The counter nonce and additional data prevent chunk truncation, reordering, removal, and duplication.

File decryption

Header decryption

  • The magic bytes are compared in constant time to the encryption magic bytes constant. If the two values do not match, then an error is displayed.

  • The encryption format version is compared in constant time to the encryption format version constant for that version of the program. If the two values do not match, then an error is displayed.

  • The magic bytes, format version, and file size (minus the file headers length) are used to calculate the additional data.

  • The nonce and encrypted header are read from the file.

  • Then the derived KEK, nonce, and additional data are used to decrypt the encrypted header.

  • If decryption fails, then an error is displayed and decryption stops.

  • If decryption is successful, then the key commitment block is compared in constant time to 16 bytes of zeroes. If this comparison returns not equal, then an error is displayed and decryption stops.

  • Otherwise, the last chunk length, file name length, and DEK are read from the decrypted header and decryption continues.

Chunked file decryption

  • Each chunk is decrypted using the decrypted DEK, counter nonce, and the previous Poly1305 authentication tag as additional data.

  • If a chunk fails to decrypt, then an error is displayed, decryption stops, and the output file is deleted.

  • After each chunk is decrypted, the key commitment block is compared in constant time to 16 bytes of zeroes. If they do not match, then an error is displayed, decryption stops, and the output file is deleted.

  • Otherwise, the key commitment block is removed from the decrypted chunk and the plaintext bytes are written to the output file.

  • This process repeats for each chunk until the entire file has been decrypted.

  • The file is then truncated using the last chunk length to remove padding in the last chunk.

Generating key pairs

Because Kryptor supports hybrid file encryption and file signing, the user is able to generate two different types of key pairs - Curve25519 keys (for encryption) and Ed25519 keys (for signing).

Kryptor generates random key pairs using libsodium. Keys are exported to .public and .private files. The default location for generated key pairs is ~/.kryptor. The created .public and .private key files are marked as read-only to make modification less likely.

Public key format

Base64(keyAlgorithm || publicKey)

  • keyAlgorithm: the public key algorithm (2 bytes). Either Cu (for Curve25519) or Ed (for Ed25519).

  • publicKey: the 32 byte random public key.

The public key is written as text to a .public file as well as being displayed in the terminal. The length of the Base64 encoded public key is 48 characters.

Private key format

Base64(keyAlgorithm || privateKeyVersion || salt || nonce || encryptedPrivateKey)

  • keyAlgorithm: the private key algorithm (2 bytes). Either Cu (for Curve25519) or Ed (for Ed25519).

  • privateKeyVersion: the private key version (2 bytes). This is only incremented when the file structure/cryptographic algorithms need to be changed (breaking changes).

  • salt: 16 random bytes for key derivation.

  • nonce: 24 random bytes.

  • encryptedPrivateKey: XChaCha20-BLAKE2b(privateKey)(96 bytes). The key algorithm and private key version are used as additional data.

  • privateKey: the 32 or 64 byte random private key.

The total length of the Base64 encoded private key is 144 (for Curve25519) or 184 (for Ed25519) characters.

The private key is encrypted at rest for protection. Argon2id is used for password-based key derivation with a memory size of 256 MiB and an iteration count of 12 - a delay of between 1-1.2 seconds, depending on the machine.

The private key is not displayed in the terminal because it should never be shared. Instead, it is exported to a .private file as text.

Digital signatures

File signing

  1. Kryptor decrypts the private key using the user's password.

  2. The file is either read into a byte array or hashed using BLAKE2b-512 depending on whether the prehashing option was selected. By default, files are read into memory (PureEdDSA) unless they are equal to or above 1 GiB in size (HashedEdDSA).

  3. The private key is used with Ed25519 in libsodium to create a detached signature for the selected file.

  4. The comment gets converted to a byte array. If the user does not specify a comment, then the default comment is used ('This file has not been tampered with.').

  5. The entire signature file (the headers, file signature, and comment) is then signed using Ed25519 to get a global signature, which is appended to the signature file.

  6. The signature file is marked as read-only to make modification less likely.

Signature verification

  1. The magic bytes are compared in constant time to the signature file magic bytes constant. If the two values do not match, then an error is displayed.

  2. The signature format version is compared in constant time to the signature format version constant for that version of the program. If the two values do not match, then an error is displayed. ​

  3. Kryptor uses the entered public key to verify the global signature. If this is invalid, then Bad signature is displayed.

  4. Otherwise, if the global signature is valid, the prehashed header is read to determine whether or not to prehash the file or load it into memory.

  5. The file is read, and the file signature is verified using the public key. If this is invalid, then Bad signature is displayed.

  6. If the file signature is valid, then Good signature is displayed to the user followed by the comment.

Signature format

magicBytes || signatureVersion || preHashed || fileSignature || comment || globalSignature

  • magicBytes: "SIGNATURE" (9 bytes). This identifies the file as a Kryptor signature.

  • signatureVersion: the file format version (2 bytes). This is only incremented when the file structure/cryptographic algorithms need to be changed (breaking changes).

  • preHashed: whether or not the file was prehashed (1 byte).

  • fileSignature: either the PureEdDSA or HashedEdDSA file signature (64 bytes).

  • comment: an authenticated comment (limited to 500 characters). If the user never specified a comment, the default comment will be used.

  • globalSignature: the PureEdDSA signature of the rest of the signature file (64 bytes).