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)