encryptFile method

Future<PqStreamingStats> encryptFile({
  1. required Uint8List recipientPublicKey,
  2. required File input,
  3. required File output,
  4. required PqForgeProfile profile,
  5. Uint8List? recipientKexPublicKey,
  6. List<PqRecipientSpec> additionalRecipients = const [],
  7. String? recipientKeyId,
  8. Uint8List? aad,
  9. Map<String, Object?> metadata = const {},
  10. Uint8List? signerSecretKey,
  11. PqSignatureAlgorithm? signatureAlgorithm,
  12. String? signerKeyId,
  13. int frameSize = PqStreamingEnvelope.defaultFrameSize,
})

Streams input into a .pqfs container at output.

When signerSecretKey is supplied the header (never the payload) is signed with preHash:true, so signing is O(1) in file size. When recipientKexPublicKey (a raw 32-byte X25519 public key) is supplied the container is hybrid: the DEM key combines the ML-KEM shared secret with an ephemeral X25519 exchange (see PqHybridKemDem). Each additionalRecipients entry wraps the same DEM key to one more key (see PqMultiRecipient) — the payload is still sealed exactly once. On failure the partial output is removed.

Implementation

Future<PqStreamingStats> encryptFile({
  required Uint8List recipientPublicKey,
  required File input,
  required File output,
  required PqForgeProfile profile,
  Uint8List? recipientKexPublicKey,
  List<PqRecipientSpec> additionalRecipients = const [],
  String? recipientKeyId,
  Uint8List? aad,
  Map<String, Object?> metadata = const {},
  Uint8List? signerSecretKey,
  PqSignatureAlgorithm? signatureAlgorithm,
  String? signerKeyId,
  int frameSize = PqStreamingEnvelope.defaultFrameSize,
}) async {
  final context = await _prepareEncrypt(
    recipientPublicKey: recipientPublicKey,
    recipientKexPublicKey: recipientKexPublicKey,
    additionalRecipients: additionalRecipients,
    recipientKeyId: recipientKeyId,
    profile: profile,
    aad: aad,
    metadata: metadata,
    signerSecretKey: signerSecretKey,
    signatureAlgorithm: signatureAlgorithm,
    signerKeyId: signerKeyId,
    frameSize: frameSize,
  );
  final source = await input.open();
  try {
    return await _writeContainer(context, output, (writer) async {
      final total = await source.length();
      if (total == 0) {
        // An empty input still gets one (empty) final frame so the reader
        // sees a terminator rather than a truncated stream.
        await writer.add(Uint8List(0), isFinal: true);
        return;
      }
      // Double-buffered read-ahead (R9): the next frame is read from the
      // source while the current one is sealed and written. Input and
      // output are separate file handles, so the disk read genuinely
      // overlaps the AEAD + write; peak memory grows by exactly one frame.
      var current = Uint8List(frameSize);
      var next = Uint8List(frameSize);
      var read = await source.readInto(current);
      var currentLen = read;
      while (currentLen > 0) {
        final isFinal = read >= total;
        final pending = isFinal ? null : source.readInto(next);
        try {
          await writer.add(
            Uint8List.sublistView(current, 0, currentLen),
            isFinal: isFinal,
          );
        } on Object {
          // Never leave the prefetch dangling: an unawaited failing future
          // would surface as an unhandled async error during cleanup.
          await _drainQuietly(pending);
          rethrow;
        }
        if (pending == null) return;
        final n = await pending;
        if (n <= 0) break; // input shrank; the reader detects truncation
        read += n;
        currentLen = n;
        final spare = current;
        current = next;
        next = spare;
      }
    });
  } finally {
    await source.close();
  }
}