Skip to content

SecretStream.State JNA auto-write silently destroys secretstream state on first push/pull #144

@approximated-intelligence

Description

Couldn't decrypt my Backups made on Android, found out lazysodium is encrypting with the nonce being all zero.

Summary

SecretStream.State extends JNA Structure. JNA's auto-sync mechanism writes zero-valued Java fields over native memory before the first push/pull call, silently corrupting the secretstream state initialized by init_push/init_pull. Ciphertext produced this way cannot be decrypted by any standard libsodium implementation.

Reproduction

val state = SecretStream.State()
val header = ByteArray(SecretStream.HEADERBYTES)
sodium.cryptoSecretStreamInitPush(state, header, key)

// State is correct in native memory at this point.
// But state's Java fields (k, nonce, _pad) are still zeros.

val cipher = ByteArray(plaintext.size + SecretStream.ABYTES)
sodium.cryptoSecretStreamPush(state, cipher, plaintext, plaintext.size.toLong(), SecretStream.TAG_MESSAGE)
// JNA auto-writes zero Java fields -> native memory BEFORE this call.
// The init_push state is destroyed. Encryption uses zeroed state.
// After the call, JNA auto-reads native -> Java, so subsequent calls work.

The first push encrypts with a zeroed state. Subsequent pushes work correctly because JNA auto-read syncs the (post-zero, evolved) native state back to Java fields after the first call.

Impact

  • The first secretstream chunk in every stream is encrypted with corrupted state
  • Standard init_pull + pull cannot decrypt it
  • The AEAD authentication tag is computed against the wrong state
  • Data appears irrecoverably corrupted to the user
  • No error is raised - encryption "succeeds" silently
  • This affects anyone using the streaming API for its intended purpose (multiple push/pull calls)

Verified behavior

We confirmed this empirically on production data:

  • init_push -> first push uses zeroed state (JNA clobber)
  • After first push, JNA auto-reads native -> Java, subsequent calls evolve normally
  • Data is recoverable by simulating the zeroed state on the pull side

Suggested fix

Option A (minimal - in documentation): Document that users must call state.setAutoWrite(false) and state.setAutoRead(false) after creating a State instance.

Option B (recommended - in library): Disable auto-sync in the State constructor:

class State extends Structure {
    // ... fields ...
    public State() {
        super();
        setAutoWrite(false);
        setAutoRead(false);
    }
}

Since the state is an opaque blob from the caller's perspective (the Java fields have no useful meaning to users), auto-sync provides no benefit and actively causes corruption.

Option C (safest): Replace State extends Structure with a wrapper around a raw Memory(statebytes) pointer, avoiding JNA Structure auto-sync entirely.

Environment

  • lazysodium-java (also affects lazysodium-android - same codebase)
  • JNA 5.x
  • Any libsodium version

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions