Encryption

This specification is still a work-in-progress draft. If you spot any issues or have suggestions on how it could be improved, please create an issue here: https://github.com/s5-dev/docs/issues

S5 supports different types of encryption, used in the file-system.md and other parts of the spec.

It ensures secure end-to-end-encryption when users need it, like in their private file system or when sending messages over a stream.

Supported algorithms

IDCipher
2AES-GCM (AES-256-GCM)
4XChaCha20-Poly1305

S5 implementations must support both AES-GCM and XChaCha20-Poly1305 for immutable and mutable encrypted blobs.

Immutable Encrypted Blobs

Immutable Encrypted Blobs are used for file versions in the S5 file-system.md.

They have some parameters:

  • cipher: The cipher used by the blob
  • chunk size: The chunk size used for encrypting the blob (must be a power of 2 and larger than 1024 bytes). By default, S5 uses 256 KiB chunks.
  • encrypted blob hash: The BLAKE3 hash of the encrypted blob
  • key: The encryption key used for the blob
  • padding: How much padding is added to the end of the blob (before encryption).
  • plaintext blob hash: The BLAKE3 hash of the blob, before encryption and padding
  • plaintext blob size: The size of the blob, before encryption and padding

The implementation itself is pretty simple: Every chunk is encrypted with the key, using the chunk index in the blob as a nonce (little-endian encoded). This is secure, because a new encryption key is randomly generated for every blob (and thus file version).

There's no spec (or need) for a way to encode immutable blobs as CIDs yet, because they only appear in the S5 file system file version objects, which are specifically designed to support the metadata parameters needed for encrypted blobs.

Mutable Encryption

Mutable Encryption can be used when a key needs to be re-used for multiple revisions of a metadata file (like a directory) or messages (in a stream).

Payloads are currently limited to 1 MiB in size and padding is used by default to obfuscate the true data size.

XChaCha20-Poly1305 is the default cipher for mutable encrypted blobs.

Directory CIDs with an encryption key

If you share a directory with someone using a CID, it usually consists of the bytes 5d ed 32_BYTE_PUBKEY, which indicates that it points to a S5 Directory metadata file (0x5d, see file-system.md), that it's mutable by using an ed25519 pubkey (0xed) that points to a registry entry.

But if the directory is encrypted, you also need a key. In that case, a CID is encoded like this:

5d 5e e4 32_BYTE_ENCRYPTION_KEY ed 32_BYTE_PUBKEY
0x5d   S5 Directory CID magic byte
0x5e   Directory uses mutable encryption
0xe4   Mutable encryption uses the XChaCha20-Poly1305 cipher (see "Supported algorithms")
       In the case of AES-256-GCM, it would instead be 0xe2

32_BYTE_ENCRYPTION_KEY   This is the 32-byte key needed to decrypt the directory metadata with XChaCha20-Poly1305

ed 32_BYTE_PUBKEY   The ed25519 pubkey pointing to the registry entry is the same as with non-encrypted directories.

Encryption (in Dart)

TODO: Does it make sense to use a magic byte prefix for encrypted mutable files, or would that be a risk?

const encryptionNonceLength = 24;
const encryptionOverheadLength = 16;

Future<Uint8List> encryptMutableBytes(
  Uint8List data,
  Uint8List key, {
  required CryptoImplementation crypto,
}) async {
  final lengthInBytes = encodeEndian(data.length, 4); // 4 bytes

  final totalOverhead =
      encryptionOverheadLength + lengthInBytes.length + encryptionNonceLength;

  final finalSize =
      padFileSizeDefault(data.length + totalOverhead) - totalOverhead;

  // Prepend the data size and append the padding bytes
  data = Uint8List.fromList(
    lengthInBytes + data + Uint8List(finalSize - data.length),
  );

  // Generate a random nonce.
  final nonce = crypto.generateRandomBytes(encryptionNonceLength);

  // Encrypt the data.
  final encryptedBytes = await crypto.encryptXChaCha20Poly1305(
    key: key,
    plaintext: data,
    nonce: nonce,
  );

  // Prepend the nonce to the final data.
  return Uint8List.fromList(nonce + encryptedBytes);
}

Decryption (in Dart)

const encryptionKeyLength = 32;

Future<Uint8List> decryptMutableBytes(
  Uint8List data,
  Uint8List key, {
  required CryptoImplementation crypto,
}) async {
  if (key.length != encryptionKeyLength) {
    throw 'wrong encryptionKeyLength (${key.length} != $encryptionKeyLength)';
  }

  // Validate that the size of the data corresponds to a padded block.
  if (!checkPaddedBlock(data.length)) {
    throw "Expected parameter 'data' to be padded encrypted data, length was '${data.length}', nearest padded block is '${padFileSizeDefault(data.length)}'";
  }

  // Extract the nonce.
  final nonce = data.sublist(0, encryptionNonceLength);

  final decryptedBytes = await crypto.decryptXChaCha20Poly1305(
    key: key,
    nonce: nonce,
    ciphertext: data.sublist(encryptionNonceLength),
  );

  final lengthBytes = decryptedBytes.sublist(0, 4);
  final length = decodeEndian(lengthBytes);

  return decryptedBytes.sublist(4, length + 4);
}

Padding

S5 uses "pad blocks" for padding, with the algorithm taken from the Sia Skynet project.

/// MIT License
/// Copyright (c) 2020 Nebulous

/// To prevent analysis that can occur by looking at the sizes of files, all
/// encrypted files will be padded to the nearest "pad block" (after encryption).
/// A pad block is minimally 4 kib in size, is always a power of 2, and is always
/// at least 5% of the size of the file.
///
/// For example, a 1 kib encrypted file would be padded to 4 kib, a 5 kib file
/// would be padded to 8 kib, and a 105 kib file would be padded to 112 kib.
/// Below is a short table of valid file sizes:
///
/// ```
///   4 KiB      8 KiB     12 KiB     16 KiB     20 KiB
///  24 KiB     28 KiB     32 KiB     36 KiB     40 KiB
///  44 KiB     48 KiB     52 KiB     56 KiB     60 KiB
///  64 KiB     68 KiB     72 KiB     76 KiB     80 KiB
///
///  88 KiB     96 KiB    104 KiB    112 KiB    120 KiB
/// 128 KiB    136 KiB    144 KiB    152 KiB    160 KiB
///
/// 176 KiB    192 Kib    208 KiB    224 KiB    240 KiB
/// 256 KiB    272 KiB    288 KiB    304 KiB    320 KiB
///
/// 352 KiB    ... etc
/// ```
///
/// Note that the first 20 valid sizes are all a multiple of 4 KiB, the next 10
/// are a multiple of 8 KiB, and each 10 after that the multiple doubles. We use
/// this method of padding files to prevent an adversary from guessing the
/// contents or structure of the file based on its size.
///
/// @param initialSize - The size of the file.
/// @returns - The final size, padded to a pad block.

int padFileSizeDefault(int initialSize) {
  final kib = 1 << 10;
  // Only iterate to 53 (the maximum safe power of 2).
  for (var n = 0; n < 53; n++) {
    if (initialSize <= (1 << n) * 80 * kib) {
      final paddingBlock = (1 << n) * 4 * kib;
      var finalSize = initialSize;
      if (finalSize % paddingBlock != 0) {
        finalSize = initialSize - (initialSize % paddingBlock) + paddingBlock;
      }
      return finalSize;
    }
  }
  // Prevent overflow.
  throw "Could not pad file size, overflow detected.";
}

bool checkPaddedBlock(int size) {
  final kib = 1024;
  // Only iterate to 53 (the maximum safe power of 2).
  for (int n = 0; n < 53; n++) {
    if (size <= (1 << n) * 80 * kib) {
      final paddingBlock = (1 << n) * 4 * kib;
      return size % paddingBlock == 0;
    }
  }
  throw "Could not check padded file size, overflow detected.";
}