Specification

Author: Samuel Lucas

Revision: 5

Date: 25/03/2023

Status: Official/Unstable

Introduction

This page describes the file and key formats used by Kryptor v4, providing a high-level overview of the innerworkings and implementation guidance.

Unlike age, backwards compatibility is not a goal. This is because it prevents making certain improvements. Instead, the focus is on developing the most sensible, long-lasting protocol. Unfortunately, due to various constraints, v4 is not that protocol. Therefore, breaking changes can be expected in the distant future, although the aim is to eventually have a stable format. This is unlikely to occur anytime soon due to issues such as post-quantum security and AEAD committing security.

If anything is unclear, please share your feedback here.

Conventions and definitions

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC 2119] [RFC 8174] when, and only when, they appear in all capitals, as shown here.

|| denotes concatenation. = denotes assignment. , denotes separate parameters. 0x followed by two hexadecimal characters denotes a byte value in the 0-255 range. Finally, ++ denotes incremented by one in little-endian.

Variable/parameter names may contain spaces for readability. Additionally, the parameter order follows the Geralt cryptographic library API. This means the key usually does not come first. See the bullet points below the pseudocode and Geralt documentation for clarification.

Base64 refers to the original Base64 encoding with padding, as specified in RFC 4648. Encoders MUST generate canonical Base64 according to Section 3.5, and decoders MUST reject non-canonical encodings and other Base64 variants, such as Base64URL and Base64 without padding.

ChaCha20-Poly1305 is the AEAD from RFC 8439. kcChaCha20-Poly1305 is ChaCha20-Poly1305 with a variant of the key commitment padding fix, which involves prepending the latter 32 bytes of block 0 (after the Poly1305 key) to the ciphertext as a commitment. Verification of the commitment MUST be done in constant time at the same time as verification of the authentication tag.

ChaCha20 is the unauthenticated stream cipher from RFC 8439, with the block counter set to 0.

BLAKE2b is the cryptographic hash function and message authentication code from RFC 7693. However, for key derivation, BLAKE2b uses the personalisation and salt parameters, which are instead specified in the BLAKE2 paper. BLAKE2b-256/BLAKE2b-512 refers to the output length in bits.

Argon2id is the password-based key derivation function from RFC 9106, with a parallelism of 1, a memory size of 256 MiB, and 3 passes.

X25519 is outlined in RFC 7748. Implementations MUST reject all-zero outputs as outlined in Section 6.

Ed25519 is from RFC 8032. For prehashing, BLAKE2b-512 is used to hash the message before regular Ed25519 is used on the hash. Ed25519 keys MUST NOT be converted to X25519 keys.

Elligator 2 is described in Elligator: Elliptic-curve points indistinguishable from uniform random strings. The Monocypher cryptographic library MUST be used to generate 'dirty' ephemeral public keys and decode said ephemeral public keys. Other known implementations are incompatible.

ISO/IEC 7816-4 padding is from ISO/IEC 7816-4:2005.

A CSPRNG MUST be used whenever the word 'random' is used.

Sensitive bytes SHOULD be zeroed from memory once they are no longer required.

Zero-length passphrases MUST be rejected.

It is RECOMMENDED that all cryptography except Elligator 2 is implemented using the libsodium cryptographic library.

Encrypted file format

A Kryptor file is treated as binary and consists of four parts: an unencrypted header, an encrypted key wrap header, an encrypted file metadata header, and an encrypted payload.

When file names are not being encrypted, Kryptor files SHOULD use the extension .bin. In contrast, with file name encryption, Kryptor files SHOULD NOT use an extension. In both cases, the extension .kryptor MUST NOT be used as this would defeat the purpose of the encrypted file being indistinguishable from random data. Similarly, encrypted files SHOULD NOT be marked as read-only.

Unencrypted header

salt || hidden ephemeral public key
  • salt: a random salt per file (16 bytes).

  • hidden ephemeral public key: random bytes per file if only a passphrase and/or symmetric key is used; otherwise, a random X25519 ephemeral public key per file made indistinguishable from random using Elligator 2 (32 bytes).

Key wrap header

wrapped file key [1] || ... || wrapped file key [20]

This header contains space to wrap the same 256-bit symmetric key for up to 20 different recipients, making it 640 bytes long. It MUST be fixed in length. This was done to keep parsing simple and to avoid leaking the number of recipients, which padding may not protect sufficiently.

If a symmetric encryption method is used (e.g. a passphrase) or less than 20 recipients are specified, random bytes MUST be generated to fill the rest of the header.

wrapped file key = ChaCha20(file key, nonce, header key)
  • file key: a random symmetric key per file (32 bytes).

  • nonce: an all-zero nonce (12 bytes).

  • header key: the output keying material from a KDF per recipient (32 bytes).

Header key derivation

The way the header key is derived depends on the encryption method used. The header key MUST be unique per recipient.

Passphrase

hashed passphrase = Argon2id(passphrase, salt)
header key = BLAKE2b-256(hashed passphrase, personalisation, empty salt, info)
  • passphrase: the UTF8 encoding of the user's passphrase (1+ bytes).

  • salt: the random salt from the unencrypted header (16 bytes).

  • hashed passphrase: the key for BLAKE2b (32 bytes).

  • personalisation: the UTF8 encoding of "Kryptor.Personal" (16 bytes).

  • empty salt: an all-zero salt (16 bytes).

  • info: the hidden ephemeral public key from the unencrypted header, which is actually just random bytes when a passphrase is used (32 bytes).

Symmetric key

header key = BLAKE2b-256(symmetric key, personalisation, salt, info) 
  • symmetric key: the key for BLAKE2b (32 bytes).

  • personalisation: the UTF8 encoding of "Kryptor.Personal" (16 bytes).

  • salt: the random salt from the unencrypted header (16 bytes).

  • info: the hidden ephemeral public key from the unencrypted header, which is actually just random bytes when a symmetric key is used (32 bytes).

Passphrase and symmetric key

hashed passphrase = Argon2id(passphrase, salt)
header key = BLAKE2b-256(hashed passphrase || symmetric key, personalisation, empty salt, info)
  • passphrase: the UTF8 encoding of the user's passphrase (1+ bytes).

  • salt: the random salt from the unencrypted header (16 bytes).

  • hashed passphrase: the first half of the key for BLAKE2b (32 bytes).

  • symmetric key: the second half of the key for BLAKE2b (32 bytes).

  • personalisation: the UTF8 encoding of "Kryptor.Personal" (16 bytes).

  • empty salt: an all-zero salt (16 bytes).

  • info: the hidden ephemeral public key from the unencrypted header, which is actually just random bytes when a passphrase and symmetric key is used (32 bytes).

Private key

shared secret = X25519(private key, unhidden ephemeral public key)
hashed shared secret = BLAKE2b-256(shared secret || public key || unhidden ephemeral public key, pre-shared key)
header key = BLAKE2b-256(hashed shared secret, personalisation, salt, info)
  • private key: the user's private key (32 bytes).

  • unhidden ephemeral public key: the hidden ephemeral public key from the unencrypted header decoded to a curve point on Curve25519 (32 bytes).

  • shared secret: the result of a key exchange between the above parameters (32 bytes).

  • public key: the user's public key computed from their private key (32 bytes).

  • pre-shared key: an optional symmetric key in the BLAKE2b key slot for post-quantum security (0 or 32 bytes).

  • hashed shared secret: the key for BLAKE2b (32 bytes).

  • personalisation: the UTF8 encoding of "Kryptor.Personal" (16 bytes).

  • salt: the random salt from the unencrypted header (16 bytes).

  • info: the hidden ephemeral public key from the unencrypted header (32 bytes).

Private and public key(s)

ephemeral shared secret = X25519(ephemeral private key, recipient public key)
hashed ephemeral shared secret = BLAKE2b-256(ephemeral shared secret || unhidden ephemeral public key || recipient public key, pre-shared key)

shared secret = X25519(sender private key, recipient public key)
hashed shared secret = BLAKE2b-256(shared secret || sender public key || recipient public key, pre-shared key)

input keying material = hashed ephemeral shared secret || hashed shared secret
header key = BLAKE2b-256(input keying material, personalisation, salt, info)
  • ephemeral private key: a random ephemeral private key used for all recipients (32 bytes).

  • recipient public key: the public key for a specified recipient (32 bytes).

  • ephemeral shared secret: the result of a key exchange between the above parameters (32 bytes).

  • unhidden ephemeral public key: the hidden ephemeral public key from the unencrypted header decoded to a curve point on Curve25519 (32 bytes).

  • pre-shared key: an optional symmetric key in the BLAKE2b key slot for post-quantum security (0 or 32 bytes).

  • sender private key: the user's private key (32 bytes).

  • shared secret: the result of a key exchange between the user's static private key and the recipient's static public key (32 bytes).

  • sender public key: the user's public key computed from their private key (32 bytes).

  • input keying material: the concatenation of the hashed ephemeral and static shared secrets used as the key for BLAKE2b, following the K one-way handshake pattern from the Noise Protocol Framework (64 bytes).

  • personalisation: the UTF8 encoding of "Kryptor.Personal" (16 bytes).

  • salt: the random salt from the unencrypted header (16 bytes).

  • info: the hidden ephemeral public key from the unencrypted header (32 bytes).

File metadata header

file length || padded file name || free space || directory flag
  • file length: the plaintext file length as a signed 64-bit integer converted to bytes in little-endian (8 bytes).

  • padded file name: the UTF8 encoding of the file name padded using ISO/IEC 7816-4 padding (256 bytes). If file name encryption is not used, these bytes are filled with padding.

  • free space: zeros that can be replaced by something in the future (27 bytes).

  • directory flag: whether a directory is being encrypted as a Boolean converted to a single 0x01 or 0x00 byte, with 0x01 representing true (1 byte).

Encryption of the above is done using a key-committing version of ChaCha20-Poly1305. This commits to the key and nonce, preventing partitioning oracle attacks.

commitment || ciphertext || tag = kcChaCha20-Poly1305(metadata header, nonce, file key, associated data)
  • metadata header: the header above as the plaintext (292 bytes).

  • nonce: a counter with all bytes starting at 0x00, except the last byte is fixed at 0x00 until the final chunk when it becomes 0x01 (12 bytes).

  • file key: the random file key (32 bytes).

  • associated data: the key wrap header (640 bytes).

For decryption, if authentication fails, the file key SHOULD be zeroed and an error MUST be thrown, causing decryption of the file to stop.

Payload

If a directory is specified, the contents MUST be converted to a ZIP file with no compression and treated like any other file. The ZIP file name MUST be the directory name plus the .zip extension.

If file name encryption is specified, the encrypted file MUST be given a randomly generated name consisting of lower/uppercase letters and numbers, and an extension SHOULD NOT be used. A file name length of 16 characters is RECOMMENDED. Otherwise, the encrypted file name SHOULD be the original file name plus .bin.

First, the randomised padding scheme from Covert Encryption is used to determine the amount of padding to encrypt given the plaintext file length. The proportion SHOULD be set to 10%, which ensures very small messages are padded to at least 50 bytes.

The plaintext file is then read and encrypted in 16 KiB chunks. Once the end of the file has been reached, whatever was previously read into the plaintext buffer is encrypted in chunks as padding until the padded length is reached. The final chunk can be less than 16 KiB.

Each chunk is encrypted using regular ChaCha20-Poly1305 with the file key, the counter nonce from the metadata header incremented in little-endian by one, and no associated data. For the final chunk, the last reserved byte of the nonce MUST be set to 0x01, following the STREAM construction.

ciphertext || tag = ChaCha20-Poly1305(chunk, nonce++, file key)

After encryption, if overwriting is specified, the encrypted file SHOULD be copied to the location of the plaintext file in an attempt to overwrite its contents.

For decryption, if authentication fails at any point, decryption MUST stop, the file key SHOULD be zeroed, and the plaintext output file SHOULD be deleted. Otherwise, if decryption succeeds and overwriting is specified, the encrypted file SHOULD be deleted.

Signature file format

A signature file is treated as binary and MUST use the extension .signature. Signature files SHOULD be marked as read-only.

magic bytes || version || prehashed flag || file signature || comment || global signature
  • magic bytes: the UTF8 encoding of "SIGNATURE" (9 bytes).

  • version: a signed 16-bit integer converted to bytes in little-endian, which currently equals { 0x01, 0x00 } (2 bytes).

  • prehashed flag: whether the file is being prehashed as a Boolean converted to a single 0x01 or 0x00 byte, with 0x01 representing true (1 byte).

  • file signature: the Ed25519 signature calculated over the file bytes or the BLAKE2b-512 hash of the file bytes (64 bytes).

  • comment: the UTF8 encoding of a message to accompany the signature. By default, the comment is "This file has not been tampered with." (37 bytes).

  • global signature: the Ed25519 signature calculated over all of the above concatenated together in the above order (64 bytes).

If a file is equal to or greater than 1 GiB in size, prehashing SHOULD be used automatically. Otherwise, the default MUST be no prehashing.

Before verifying a signature, incorrect magic bytes or an incorrect version MUST be rejected. Next, the global signature MUST be verified first. If invalid, "Bad signature" MUST be displayed and verification MUST stop.

If the global signature is valid, the file to verify should be read into memory or prehashed incrementally, and the file signature should be verified. If invalid, "Bad signature" MUST be displayed and no comment should be shown. Otherwise, "Good signature" MUST be shown, followed by the authenticated comment unless it only consists of whitespace.

Asymmetric key format

Public key

public key string = Base64(key algorithm || public key)
  • key algorithm: either { 10, 239, 255 } for Curve25519 or { 17, 223, 255 } for Ed25519 so the Base64 string starts with "Cu//" or "Ed//" (3 bytes).

  • public key: the public key for the random private key (32 bytes).

Strings that are not 48 characters in length or missing the correct key algorithm for the specified command-line option MUST be rejected.

Public key files MUST use the .public extension and contain the Base64 public key string on the first line. However, whitespace before/after or a space and comment after the public key string MAY be present and ignored.

Private key

private key string = Base64(key algorithm || version || salt || encrypted private key)
  • key algorithm: either { 10, 239, 255 } for Curve25519 or { 17, 223, 255 } for Ed25519 so the Base64 string starts with "Cu//" or "Ed//" (3 bytes).

  • version: a signed 16-bit integer converted to bytes in little-endian, which currently equals { 0x02, 0x00 } (2 bytes).

  • salt: a random salt per private key (16 bytes).

  • encrypted private key: a random private key (and the associated public key with Ed25519) encrypted using the key-committing version of ChaCha20-Poly1305 (80 or 112 bytes):

key = Argon2id(passphrase, salt)
commitment || ciphertext || tag = kcChaCha20-Poly1305(private key, nonce, key, associated data)
  • passphrase: the UTF8 encoding of the user's passphrase (1+ bytes).

  • salt: the random salt from the unencrypted header (16 bytes).

  • private key: the random Curve25519 private key or the random Ed25519 seed concatenated with the associated public key (32 or 64 bytes).

  • nonce: an all-zero nonce (12 bytes).

  • key: the output from Argon2id (32 bytes).

  • associated data: the key algorithm concatenated with the version (5 bytes).

Strings that are not 136 or 180 characters in length or missing the correct key algorithm for the specified command-line option MUST be rejected.

Private key files MUST use the .private extension and contain the Base64 private key string on the first line. However, whitespace before/after or a space and comment after the private key string MAY be present and ignored.

To be detected as a default private key, the file MUST be named either encryption.private or signing.private and stored in the default directory:

  • Windows: %USERPROFILE%/.kryptor

  • Linux: /home/.kryptor

  • macOS: /Users/USERNAME/.kryptor

Symmetric key format

Pre-shared key

pre-shared key string = Base64(header || key)
  • header: the bytes { 61, 34, 191 } so the Base64 string starts with "PSK/" (3 bytes).

  • key: a random symmetric key (32 bytes).

Strings that are not 48 characters in length or missing the correct header MUST be rejected.

Keyfile

symmetric key = BLAKE2b-256(keyfile)
  • keyfile: the entire file as bytes.

Keyfiles MUST be at least 32 bytes long. A random keyfile is just a random symmetric key, which is 32 bytes long, written to a file as bytes. Random keyfiles MUST be marked as read-only.

Last updated