Kryptor
Search…
Technical details

Password entry

  1. 1.
    Each password character typed or pasted into the console is stored in a char array. This is safer than using strings, which are immutable in C#.
  2. 2.
    The password is converted to a byte array.
  3. 3.
    The char array is zeroed out.

Comparing retyped passwords

  1. 1.
    When entering a new password, the user is asked to re-enter their password.
  2. 2.
    The two char arrays are converted to byte arrays.
  3. 3.
    Both byte arrays are hashed using BLAKE2b-512.
  4. 4.
    These hashes are compared in constant time using the libsodium sodium_compare() function. The sodium_memcmp() function should be being used, but this is not made accessible in libsodium-core.

Passphrase generation

  1. 1.
    The EFF's long word list is read from the resources into a string array.
  2. 2.
    The randombytes_uniform() function in libsodium is used to randomly generate 8 numbers.
  3. 3.
    The word at each index is converted to title case and stored in a string list.
  4. 4.
    Each character in the list is added to a char array, with a '-' symbol in-between each word.

XChaCha20-BLAKE2b

Please read the 'Why are you using XChaCha20-BLAKE2b?' FAQ section for information on why this construction is used instead of XChaCha20-Poly1305.
For information about how XChaCha20-BLAKE2b works, please see the GitHub README and source code.
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. In the future, I may modify the implementation to reuse the same encryption key for each chunk since that is more sensible from a preventing nonce reuse perspective.
A 256-bit tag was chosen because that is sufficiently strong whilst limiting the storage overhead caused by having lots of authentication tags. However, if quantum computers become a practical threat, then a 512-bit tag will likely be used in the future.

Key derivation

Password hashing

  1. 1.
    The password byte array is prehashed using BLAKE2b-512. This is just done for the sake of consistency since keyfiles are hashed in this way.
  2. 2.
    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 second delay on my machine.
  3. 3.
    When the password is no longer required, the byte array is zeroed out.

Keyfile generation

  1. 1.
    64 bytes are randomly generated using the randombytes_buf() function in libsodium.
  2. 2.
    These bytes are written to a .key file.
  3. 3.
    The keyfile is marked as read-only to make accidental modification less likely.

Keyfile hashing

  1. 1.
    The keyfile is hashed using BLAKE2b-512 to get the keyfile bytes.
  2. 2.
    If the keyfile was selected alongside a password, then the keyfile bytes are used as the key when hashing the password with BLAKE2b-512. This is equivalent to using a pepper.
  3. 3.
    If no password was used, then the keyfile bytes are used as the input to Argon2id.

Using a password

  • For individual files, a unique KEK is derived for each file as described in the Password hashing section.
  • 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 read-only kryptor.salt file inside the parent directory as well as being a header in each encrypted file so that files can be decrypted individually.

Using a private key

  1. 1.
    The private key is decrypted using the user's password.
  2. 2.
    The private key and an ephemeral public key are used to calculate a 256-bit ephemeral shared secret per file.
  3. 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.

Using a private and public key

  1. 1.
    The sender's private key is decrypted using the user's password.
  2. 2.
    The sender's private key and the recipient's public key are used to calculate a 256-bit long-term shared secret. This will always be the same for the same sender private key and recipient public key pair.
  3. 3.
    Next, for each file, an ephemeral key pair is generated, and the ephemeral private key and recipient public key are used to calculate a 256-bit ephemeral shared secret. This ephemeral private key is then zeroed out, and the ephemeral public key becomes a file header.
  4. 4.
    The long-term shared secret and ephemeral shared secret are concatenated to form 512-bits of input keying material.
  5. 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, but the key exchange is authenticated.

File encryption

File name obfuscation

  1. 1.
    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.
  2. 2.
    The original file name is then appended to the input file before encryption.
  3. 3.
    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.
  4. 4.
    If the user does not want to overwrite the input file, then the original file name is removed from the input file after encryption.
I am aware that modifying the input file is not ideal, but I was unable to come up with a better solution at the time. This implementation ensures that each encrypted chunk is the same size and that the number of chunks is calculated correctly. However, using an encrypted file header would be a better solution.

Directory name obfuscation

  1. 1.
    When obfuscating directory names, random directory 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.
  2. 2.
    Each original directory name is stored in a text file inside that directory. The file name of the text file is the randomly generated name for that directory.
  3. 3.
    This text file then gets encrypted alongside the other files in the directory.
  4. 4.
    The encrypted file is then used to overwrite the text file.

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 read from the end of the file. The length is stored as 0 if the user did not specify -f|--obfuscate.
  • dataEncryptionKey: a random encryption key per file (32 bytes). This is used to encrypt the file data.

Header encryption

  1. 1.
    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.​
  2. 2.
    The ciphertext length, magic bytes, format version, and ephemeral public key are concatenated and used as additional data (49 bytes).
  3. 3.
    The derived KEK, nonce, and additional data are used to encrypt the last chunk length, file name length, and DEK concatenated together 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

  1. 1.
    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.
  2. 2.
    A random 256-bit DEK is generated per file.
  3. 3.
    The random 192-bit nonce used to encrypt the header is incremented.
  4. 4.
    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. Note that the first chunk uses the authentication tag from the encrypted header as additional data.
  5. 5.
    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.

Overwriting input files

  1. 1.
    After encryption, if the -o|--overwrite option is specified, then the attributes of the input file are set to Normal.
  2. 2.
    File.Copy(), with overwrite set to true, is used to copy the encrypted file to the same file path as the unencrypted input file.
  3. 3.
    The overwritten input file is then deleted.

File decryption

Header decryption

  1. 1.
    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.
  2. 2.
    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.
  3. 3.
    The file size (minus the file headers length), magic bytes, format version, and ephemeral public key are concatenated to form the additional data.
  4. 4.
    The nonce and encrypted header are read from the file.
  5. 5.
    The derived KEK, nonce, and additional data are used to decrypt the encrypted header.
  6. 6.
    If decryption fails, then an error is displayed.
  7. 7.
    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

  1. 1.
    Each chunk is read from the file and decrypted using the DEK, counter nonce, and the previous authentication tag as additional data.
  2. 2.
    If a chunk fails to decrypt midway, then an error is displayed, and the output file is deleted. Otherwise, the plaintext bytes are written to the output file.
  3. 3.
    Once decryption has finished, the file is truncated using the last chunk length to remove padding in the last chunk.
  4. 4.
    If the file name length is not 0, then the file name is read from the end of the output file, removed from the file, and the file is renamed.

Generating key pairs

Kryptor supports hybrid file encryption and file signing, meaning the user is able to generate two different types of key pairs: Curve25519 keys for encryption and Ed25519 keys for digital signatures.
  1. 1.
    Kryptor generates random key pairs using libsodium.
  2. 2.
    The keys are written as strings to .public and .private files. The default key directory is %USERPROFILE%/.kryptor on Windows and /home/.kryptor on Linux/macOS.
  3. 3.
    The .public and .private key files are marked as read-only to make modification less likely.
  4. 4.
    The public key string, public key file path, and private key file path are displayed in the terminal. The private key is not displayed because it should never be shared.

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 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 length of the Base64 encoded private key is 144 characters for Curve25519 and 188 characters for Ed25519.
The private key is encrypted at rest for protection using a key derived from the user's password, as explained in the Password hashing section.

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, then the default comment ("This file has not been tampered with.") is used (37 bytes).
  • globalSignature: the PureEdDSA signature of the rest of the signature file (64 bytes).

File signing

  1. 1.
    Kryptor decrypts the private key using the user's password.
  2. 2.
    The file is either read into a byte array or hashed using BLAKE2b-512, depending on whether -l|--prehash was specified. By default, files are read into memory (PureEdDSA) unless they are equal to or greater than 1 GiB in size (HashedEdDSA).
  3. 3.
    The private key is used with Ed25519 to create a detached signature for the selected file.
  4. 4.
    If the user has specified a comment, then it gets converted from a string to a byte array using UTF8 encoding. If the user does not specify a comment, then the default comment is used.
  5. 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 byte array.
  6. 6.
    The signature file bytes are written to the signature file.
  7. 7.
    The signature file is marked as read-only to make modification less likely.

Signature verification

  1. 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. 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. 3.
    Kryptor uses the entered public key to verify the global signature. If this is invalid, then "Bad signature" is displayed.
  4. 4.
    Otherwise, if the global signature is valid, the prehashed header is read to determine whether to prehash the file using BLAKE2b-512 or load it into memory.
  5. 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. 6.
    If the file signature is valid, then "Good signature" is displayed to the user, followed by the authenticated comment.
Last modified 9d ago