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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions