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 sodium_compare() function.

XChaCha20-BLAKE2b

Kryptor v3.0.0-beta was originally going to use XChaCha20-Poly1305, but I decided to switch to my XChaCha20-BLAKE2b AEAD implementation because Poly1305 is not designed for large messages and long-term storage, and the lack of key commitment in popular AEAD schemes allows a ciphertext message to be decrypted using multiple keys. This could theoretically lead to data loss and partitioning oracle attacks.​ You can read about how XChaCha20-BLAKE2b 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 is 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. This is just done for the sake of consistency since keyfiles are hashed in this way.

  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 generation

  • 64 random bytes are generated using the randombytes_buf() function in libsodium and stored in a .key file.

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

Keyfile hashing

  1. If the specified keyfile is equal to or 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, then 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 and as input to Argon2id.

Password

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

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

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-256 is used to derive a 256-bit KEK, with an empty byte array as the message, the ephemeral shared secret as the key, a random 128-bit salt, and Encoding.UTF8.GetBytes("Kryptor.Personal") as the personalisation bytes.

Private and public keys

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

  2. The sender's private key and the 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 ScalarMult.Mult(ephemeralPrivateKey, recipientPublicKey). 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-256 is used to derive a 256-bit KEK, with an empty byte array as the message, the input keying material as the key, a random 128-bit salt, and Encoding.UTF8.GetBytes("Kryptor.Personal") as the personalisation bytes.

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.

File name obfuscation

When the user specifies the -f|--obfuscate option, random output file names are generated by calling Path.GetRandomFileName() twice and concatenating the names after removing the random file extensions. This function uses RNGCryptoServiceProvider internally to generate cryptographically secure random numbers.

The original file name is then appended to the input file before encryption. The length of the original file name is stored as an encrypted file header so the file name can be read from the end of the decrypted file. If the user does not want to overwrite the input file, then the original file name is removed from the input file after encryption.

When obfuscating directory names, the original directory name is stored in a text file inside the directory that gets encrypted alongside the other files in the directory. The encrypted file is then used to overwrite the text file.

I am aware that modifying the input file is not ideal, but I was unable to come up with a better solution. This implementation ensures that each encrypted chunk is the same size and that the number of chunks is calculated correctly.

Header structure

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

  • magicBytes: Encoding.UTF8.GetBytes("KRYPTOR") (7 bytes). This identifies an encrypted file.

  • encryptionVersion: the file format version (2 bytes). This will only be incremented when the file structure/cryptographic algorithms change.

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

  • salt: a random salt used for key derivation per file (16 bytes).

  • nonce: a random nonce per file (24 bytes).

  • encryptedHeader: contains the last chunk length, file name 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). This allows the original file name to be restored. The length is stored as 0 if the user did not specify -f|--obfuscate.

    • dataEncryptionKey: a random encryption key for the file data (32 bytes).

Header encryption

  • The ciphertext length, magic bytes, format version, and ephemeral public key are concatenated and used as additional data (49 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 ephemeral public key is included in the additional data because it is an unused header when encrypting files with a password. This ensures that any tampering of the file is detected.

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

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

Chunk structure

ciphertext || authenticationTag

  • ciphertext: the XChaCha20 encrypted chunk (16,384 bytes).

  • authenticationTag: the BLAKE2b tag (32 bytes).

Chunked file encryption

  • If -f|--obfuscate has been specified, then the file name of the input file is converted into bytes using UTF8 encoding and appended to the end of the input file before encryption. This means that the file name is part of the last encrypted chunk.

  • 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 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.

  • 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 file size (minus the file headers length), magic bytes, format version, and ephemeral public key are concatenated to form 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.

  • If decryption is successful, then 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 DEK, counter nonce, and the previous authentication tag as additional data.

  • If a chunk fails to decrypt midway, then an error is displayed, decryption stops, and the output file is deleted. Otherwise, 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.

  • If the file name length is not 0, then the file name is read from the end of the file, removed from the file, and then the file is renamed.

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 .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 Encoding.UTF8.GetBytes("Cu") (for Curve25519) or Encoding.UTF8.GetBytes("Ed") (for Ed25519).

  • publicKey: the randomly generated public key (32 bytes).

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 Encoding.UTF8.GetBytes("Cu") (for Curve25519) or Encoding.UTF8.GetBytes("Ed") (for Ed25519).

  • privateKeyVersion: the private key version (2 bytes). This will only be incremented when the file structure/cryptographic algorithms change.

  • salt: a random salt used for key derivation (16 bytes).

  • nonce: a random nonce (24 bytes).

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

    • privateKey: the random private key (32 or 64 bytes).

The total length of the Base64 encoded private key is 144 (for Curve25519) or 188 (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

Signature format

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

  • magicBytes: Encoding.UTF8.GetBytes("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 change.

  • 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 no comment is specified, the default comment is used.

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

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 greater than 1 GiB in size (HashedEdDSA).

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

  4. The comment gets converted to a byte array using UTF8 encoding. 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 using BLAKE2b-512 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 authenticated comment.