miniLock

File Format

Version 1
Version 2

Discuss this proposal at miniLock HQ

This info is available when you inspect a miniLock file without a key:

File Size:
Header Size:
Ciphertext Size:
miniLock Version:
?
Ephemeral Key:
?
Decrypt Info:
?

A secret key is required to see more details and decrypt the file.

Lets attempt to open Secret.minilock with one of these keys:

Now imagine you are a computer. Still here? OK, proceed:

Magic Bytes

The first eight slots of the file are the miniLock magic bytes. Please use this unique sequence of bytes to identify miniLock encrypted files in your own computer programs.

Magic Bytes in Base10:
Magic Bytes in Base16:

The bytes spell miniLock in UTF-8. They are magic!

Size of Header

The next four slots specify the size of the header in bytes. Interpret this four-byte little-endian word to get the size as a decimal number. The word in this file indicates the header is ? bytes long.

Header

The header of a miniLock file is a UTF-8 encoded JSON string. The header bytes always start at slot 12. In this file the header bytes end at slot 645. Read, encode and parse the header bytes like this:

headerBytes = file.read(12, 645)
serializedHeader = NaCl.util.encodeUTF8(headerBytes)
header = JSON.parse(serializedHeader)

The parsed header of this file looks like this:


version specifies the miniLock file format version number. Usually it is 1 right now. Someday soon it might be 2 more often.

ephemeral is a Base64 encoded 32-byte public key. It is one of two keys that is required to decrypt a permit.

decryptInfo contains a nonce:encryptedPermit pair for each recipient. Each nonce is a Base64 encoded 16-byte unique nonce that is required to decrypt the encryptedPermit. Each encryptedPermit is a Base64 encoded string of encrypted and serialized JSON bytes.

Decoding the Header

Since most members of header are Base64 encoded we will need to decode them before we go any further. First lets decode the ephemeral key:

ephemeral = NaCl.util.decodeBase64(header.ephemeral)

OK, ephemeral key is . We will use it later when we attempt to decrypt a permit.

Next we’ll decode each nonce and encryptedPermit in header.decryptInfo:

encryptedPermits = {}
for encodedNonce, encodedEncryptedPermit of header.decryptInfo
  nonce = NaCl.util.decodeBase64(encodedNonce)
  encryptedPermit = NaCl.util.decodeBase64(encodedEncryptedPermit)
  encryptedPermits[nonce] = encryptedPermit

Now we have encrypted permits that we can attempt to unlock with our keys.

Decrypt a Permit

miniLock permits are encrypted with the public-key authenticated encryption scheme defined in the Networking and Cryptography library. Ports of this popular library are available for many programming languages. This document features TweetNaCl.js for ECMAScript.

Two keys and a nonce are required to decrypt a permit. The first key is the ephemeral key. The second key is the permit recipient’s secretKey. Loop through the encryptedPermits until you find one that matches your keys:

for nonce, encryptedPermit of encryptedPermits
  decryptedPermit = NaCl.box.open(encryptedPermit, nonce, ephemeral, secretKey)
  if decryptedPermit
    permit = JSON.parse(decryptedPermit)

Here is the permit that was decrypted with ?’s secretKey:


permit.senderID identifies the person who created the miniLock file.

permit.recipientID identifies the recipient of the permit. Readers can use this to verify the recipientID matches the miniLock ID bound to their secretKey.

permit.fileInfo consists of three members: fileKey, fileNonce and fileHash. All are Base64 encoded strings. Lets decode them to get a look at their bytes:


fileKey is the 32-byte secret key for the ciphertext. fileNonce is the 16-byte nonce for the ciphertext. We will use these in a moment when we prepare to decrypt the ciphertext stream.

fileHash is a 32-byte BLAKE2 hash digest of the original cleartext. Use it to verify the integrity of the decrypted bytes when you have finished decrypting.

Ciphertext

The ciphertext in a miniLock file starts at the end of the header and it continues until the end of the file. Calculate the ciphertext size with:

Take file size in bytes       + 
Subtract magic bytes          - 8
Subtract size of header bytes - 4
Subtract header bytes         - 
Ciphertext size in bytes      = 

The ciphertext in this file starts at slot # and ends at slot #.

miniLock relies on a streaming encryption scheme based on TweetNaCl to encrypt and decrypt its ciphertext. The fileKey and fileNonce from ?’s permit are required to setup the decryption scheme: We don’t have the fileKey or the fileNonce because ?’s keys didn’t unlock any permits.

fileKey:   
fileNonce: 

With these keys on hand we are ready to construct a stream decryptor. Configure it to process the ciphertext with a maximum chunk size of 1MB:

maxChunkSize = 1024 * 1024
decryptor = NaCl.stream.createDecryptor(fileKey, fileNonce, maxChunkSize)

Also construct a 32-byte BLAKE2 hash to record the decrypted bytestream so that it can be compared with fileHash during integrity verification.

hash = new BLAKE2(32)

The First Chunk 152-bytes longer in Version 2

In version 2, the first chunk of ciphertext is reserved for the file name, type and time attributes. The decrypted chunk is always 408 bytes long. The encrypted chunk is 428 bytes because the encryption scheme adds 20 bytes.

Read and decrypt the first chunk of ciphertext like this:

startOfCiphertext = 
encryptedChunk = file.read(startOfCiphertext, startOfCiphertext+428)
decryptedChunk = decryptor.decryptChunk(encryptedChunk, false)
hash.update(decryptedChunk)

File Name

The first 256 slots in the chunk are reserved for the name of the file. To get it, slice off the first 256 bytes, filter out any null bytes and then encode the remaing bytes in UTF-8. Don’t be surprised if the name is blank because that is a possibility.

sliceOfBytes   = decryptedChunk.slice(0, 256)
filteredBytes  = (b for b in sliceOfBytes when b isnt 0)
name           = NaCl.util.encodeUTF8(filteredBytes)

is the decrypted name of this file.

Media Type Introduced in Version 2

The next 128 slots in the chunk are reserved to record the file type. If present, type is expected to be a registered media type such as text/plain or image/jpeg. It may also be blank. The fixed size of 128 slots was informed by the media type naming requirements defined in RFC6838. Slice, filter and then encode the bytes like so:

sliceOfBytes   = decryptedChunk.slice(256, 384)
filteredBytes  = (b for b in sliceOfBytes when b isnt 0)
type           = NaCl.util.encodeUTF8(filteredBytes)

is the decrypted type of this file.

Encrypt Time Introduced in Version 2

The last 24 slots in the chunk are reserved to record the time when the file was encrypted. time is expected to be a ISO 8601 extended format timestamp or a blank string. The time parsing routine is the same as the name and type routines: slice, filter and then encode:

sliceOfBytes   = decryptedChunk.slice(384, 408)
filteredBytes  = (b for b in sliceOfBytes when b isnt 0)
time           = NaCl.util.encodeUTF8(filteredBytes)

The time says this file was encrypted on .

Decrypt Data Chunks

The ciphertext chunks after the first chunk contain the complete byte stream of Simple.txt. Each chunk is decrypted in squential order so that it can be reassembled into a coherent stream of cleartext at the end of the routine.

First, construct an empty array to collect the decryptedChunks:

decryptedChunks = []

The encrypted data chunks in this file start at 428 and end at slot ?.

startOfChunk   = 1620
endOfChunk     = Math.min(startOfChunk + maxChunkSize + 20, file.size)
isLastChunk    = endOfChunk is file.size
encryptedChunk = file.read(startOfChunk, endOfChunk)
decryptedChunk = decryptor.decryptChunk(encryptedChunk, isLastChunk)
decryptedChunks.push(decryptedChunk)
hash.update(decryptedChunk)
# Repeat from endOfChunk if isLastChunk is false

Cleartext Integrity

Create a digest of hash and compare it with the fileHash that was received in Alice’s permit after the last chunk has been processed. If the digests are not identical the integrity of the decrypted bytes has been compromised.

if isLastChunk
  if hash.digest() is fileHash
    # The integrity of the name, type, time and data were verified.
  else
    # The integrity of this file has been compromised.
data = new Blob(decryptedChunks)
if type is "text/plain"
  text = NaCl.util.encodeUTF8(data)
Permits:
3 2 1