Streaming Encryption
SkySend uses a custom Encrypted Content-Encoding (ECE) scheme based on AES-256-GCM for streaming file encryption.
Stream Format
[baseNonce (12 bytes)] [record_0] [record_1] ... [record_N]The first 12 bytes of the encrypted stream are the randomly generated base nonce. The remaining bytes are encrypted records.
Record Format
Each record consists of:
[ciphertext (up to 65,536 bytes)] [GCM auth tag (16 bytes)]| Component | Size |
|---|---|
| Plaintext chunk | Up to 65,536 bytes (64 KB) |
| GCM auth tag | 16 bytes |
| Encrypted record | Up to 65,552 bytes |
The last record may be smaller than 65,536 bytes.
Constants
| Constant | Value | Description |
|---|---|---|
RECORD_SIZE | 65,536 | Plaintext chunk size (64 KB) |
TAG_LENGTH | 16 | GCM authentication tag size |
NONCE_LENGTH | 12 | AES-GCM nonce size |
ENCRYPTED_RECORD_SIZE | 65,552 | RECORD_SIZE + TAG_LENGTH |
MAX_RECORDS | 2^32 - 1 | Maximum records per stream |
Nonce Construction
Each record uses a unique nonce derived from the base nonce via XOR with a 32-bit counter:
nonce_i = baseNonce XOR counter_iThe counter is encoded as big-endian 32-bit integer, XOR'd into the last 4 bytes of the nonce:
function nonceXorCounter(baseNonce: Uint8Array, counter: number): Uint8Array {
const nonce = new Uint8Array(baseNonce)
const view = new DataView(nonce.buffer)
const offset = nonce.byteLength - 4 // last 4 bytes
view.setUint32(offset, view.getUint32(offset) ^ counter)
return nonce
}This guarantees unique nonces for up to 2^32 - 1 records, which allows encrypting files up to approximately 256 TB.
Encryption
const encryptStream = createEncryptStream(fileKey)
const encryptedStream = plaintextStream.pipeThrough(encryptStream)The createEncryptStream function returns a TransformStream that:
- Outputs a 12-byte random base nonce as the first chunk
- Buffers input into 64 KB plaintext chunks
- Encrypts each chunk with AES-256-GCM using
nonce_i = baseNonce XOR i - Outputs ciphertext + auth tag for each record
- Flushes any remaining buffered data as the final (possibly smaller) record
Decryption
const decryptStream = createDecryptStream(fileKey, expectedPlaintextSize)
const plaintextStream = encryptedStream.pipeThrough(decryptStream)The createDecryptStream function returns a TransformStream that:
- Reads the first 12 bytes as the base nonce
- Buffers input into 65,552-byte encrypted records
- Decrypts each record with AES-256-GCM, verifying the auth tag
- Outputs plaintext chunks
- Throws an error if any record fails authentication
- If
expectedPlaintextSizeis provided, throws"Stream truncation detected"if the total decrypted byte count does not match - this prevents a malicious server from delivering fewer records than were encrypted
The expectedPlaintextSize should be sourced from the authenticated, encrypted metadata (which contains the original file size). Passing it is optional but strongly recommended.
Size Calculation
// Plaintext -> Encrypted size
const encryptedSize = calculateEncryptedSize(plaintextSize)
// Includes: 12-byte nonce header + (number of records * TAG_LENGTH)
// Encrypted -> Plaintext size
const plaintextSize = calculatePlaintextSize(encryptedSize)Security Properties
- Confidentiality - AES-256-GCM encryption
- Integrity - GCM auth tags on every record (16 bytes each)
- No nonce reuse - Counter-based XOR guarantees unique nonces
- Random base nonce - New random nonce per encryption operation
- Streaming - Constant memory usage regardless of file size
- Record-level authentication - Tampering with any individual record is detected immediately