Testing cryptography across Go and Kotlin

How known-answer vectors, exact AAD bytes, tamper tests, and rotation proofs keep Prelay's Go server and Android app byte-compatible.

1 min read

Two implementations of the same encryption protocol are a compatibility problem disguised as a security problem.

Prelay has a Go server and a Kotlin Android app. They must agree on every byte: the order of the associated-data fields, the HKDF salt, the base64url decoding, the UUID format, the nonce, and the key version. A single difference means the phone rejects a message the server considers valid.

The primitives are not the hard part. X25519, HKDF-SHA256, and ChaCha20-Poly1305 are standard and audited. The hard part is the transcript around them.

That is what the tests are for.

What can drift

The protocol has several places where two correct-looking implementations can disagree:

  • the exact bytes of the associated data;
  • the order and formatting of the AAD fields;
  • base64url encoding with and without padding;
  • UUIDv7 validation and canonical lowercase text;
  • the HKDF info label and salt construction;
  • the all-zero X25519 output;
  • the rotation-proof transcript;
  • the envelope version and algorithm checks.

Most of these failures do not look like security failures. They look like “the notification did not arrive,” or “the app silently discarded a valid message.” That is why they need pinned vectors, not just round-trip tests.

A round trip is necessary, but not enough

The first test in each implementation is the obvious one: seal a message, open it, compare the plaintext.

That catches a broken local pipeline. It does not catch two implementations that are each self-consistent but incompatible. If Go and Kotlin both use the wrong salt, both round-trip tests pass, and the tests still never exercise compatibility between the two independently written implementations.

The real test is a fixed vector.

The shared envelope vector

Prelay pins a complete envelope generated by the Go implementation and decrypts it in the Android unit tests. The vector includes the device private key, the envelope JSON, and the expected plaintext.

The envelope is a normal v2 envelope:

{
"version": 2,
"message_id": "0193f8e2-6b4a-7b9e-a2c1-000000000001",
"device_id": "0193f8e2-6b4a-7b9e-a2c1-000000000002",
"key_version": 3,
"algorithm": "X25519-ChaCha20-Poly1305",
"sender_key": "jFu77_HV_ciYgbroGfaSncSuHvHnQubJHg4axkAdG1E",
"nonce": "lVuKaGOhzqgjfvt-",
"ciphertext": "<base64url ciphertext>",
"created_at": 1789765999,
"ttl": 3600,
"priority": "high"
}

The Go test TestV2InteropVector parses and decrypts it. The Kotlin test decryptsGoEnvelope does the same with the same private key and expects the same plaintext.

Both tests are deliberately boring. That is the point. If either side changes the derivation, the AAD, the field parsing, or the encoding, one of them stops matching the fixed bytes.

The vector is generated by a separate Go test (TestPrintVector) and pasted into both test suites. It is not regenerated during CI. A fixed vector that changes whenever the code runs is not a fixed vector.

The primitives get their own independent checks too. TestKnownAnswerVector verifies Go’s X25519 shared secret against a value computed with libsodium/PyNaCl, and TestFingerprintKAT checks the public-key fingerprint against an independent SHA-256 result.

Cross-implementation
Two implementations, one transcript
The vector is the contract; the test names are different.
Two implementations, one transcript The Go server and the Kotlin Android app hold the same fixed envelope vector and expect the same plaintext. Their test names differ, but the AAD bytes and envelope format must agree. Go server crypto/ecdh X25519 + HKDF-SHA256 ChaCha20-Poly1305 TestV2InteropVector TestEnvelopeAADCanonical TestRotateProofKAT TestKnownAnswerVector Kotlin app BouncyCastle X25519 + HKDF-SHA256 ChaCha20-Poly1305 decryptsGoEnvelope AAD matches Go RotationProofTest rejectsAllZeroSenderKey envelope v2 same bytes · same text AAD · ciphertext · tag one fixed vector · two implementations
FIG D The Go vector is the source of truth. The Android test decrypts it with the same key and expects the same plaintext.

AAD: the bytes that must not drift

The associated data is a fixed-order, newline-joined string:

prelay-env-v2
2
0193f8e2-6b4a-7b9e-a2c1-000000000001
0193f8e2-6b4a-7b9e-a2c1-000000000002
3
X25519-ChaCha20-Poly1305
1789765999
3600
high

TestEnvelopeAADCanonical in Go and envelopeAADMatchesGoCanonical in Kotlin assert the same bytes. The tests exist because JSON canonicalization is a compatibility trap: field order, whitespace, and number formatting can differ between languages. A newline-joined string is less elegant and much harder to get wrong.

sender_key and nonce are not in the AAD. They already participate in the key derivation and the AEAD. The rest of the header is authenticated, so a relay cannot rewrite message_id, created_at, ttl, priority, or key_version and still produce a message the phone accepts.

Tampering is a test case, not a hope

The tests also prove that the failure modes fail closed.

TestTamperDetected flips a ciphertext byte and flips a byte in the AAD; both must fail. TestEnvelopeHeaderTampering rewrites every authenticated header field in turn:

  • message_id
  • device_id
  • key_version
  • created_at
  • ttl
  • priority
  • version
  • algorithm

The ciphertext is untouched. Every mutation must still fail decryption.

The Android suite mirrors this with rejectsTamperedCiphertext and rejectsTamperedHeader.

There is also the X25519 low-order-point case. BouncyCastle will accept 32 bytes as a public key; the app must not accept an all-zero shared secret. rejectsAllZeroSenderKey locks that behavior, mirroring the Go crypto/ecdh error path.

Rotation proofs get their own vector

Key rotation uses a second transcript:

shared_secret = X25519(current_private_key, challenge_public_key)
proof = HKDF-SHA256(
shared_secret,
salt = SHA-256(new_public_key || device_id),
info = "prelay-rotate-proof-v1",
L = 32
)

This is a separate cryptographic path with its own failure mode. A proof that works on the server but not on the device would make rotation impossible. A proof that is not bound to the new public key could be replayed against another rotation.

TestRotateProofKAT in Go and RotationProofTest.computesGoVector in Kotlin use the same fixed inputs and assert the same proof. The Android test also checks that changing the new public key changes the proof, and that an all-zero challenge key is rejected.

The protocol around the crypto

Not every test is about a primitive. ReplayGuardTest covers the device-side replay layer:

  • freshness follows the envelope TTL;
  • far-future timestamps are rejected;
  • an id is reserved before decryption and committed only after handling;
  • a rolled-back id does not poison the cache;
  • the cache is bounded and survives a restart;
  • expired entries are swept before live ones are evicted.

These are protocol tests, not cryptography tests. They matter because encryption without replay and freshness handling is still an incomplete design.

Test layers
What each side locks down
Different checks, one shared byte-level contract.
What each side locks down A test matrix showing which checks exist in the Go server and the Kotlin app. Both sides cover the shared envelope vector, AAD bytes, tamper cases, and rotation proof. Go also has primitive known-answer tests; Kotlin also has replay-guard tests. check Go Kotlin round trip X25519 KAT — fingerprint KAT — envelope vector AAD bytes tamper cases rotation proof replay guard — byte-level checks must agree · local checks harden each side
FIG E The full envelope vector and AAD checks cross the language boundary. The remaining tests harden each implementation locally.

How it runs

The full check is split between the server and the app:

Terminal window
# Go server
make test
# Android JVM tests
make android-test

The crypto package alone can be run with:

Terminal window
cd server
go test ./internal/crypto

The Android tests run the same Kotlin crypto code used by the app. They are JVM tests; Keystore sealing, notification rendering, and FCM delivery are separate platform concerns.

What the tests do not prove

They do not prove side-channel resistance, formal correctness, or that the Android Keystore behaves correctly on every device. They do not replace audited primitives. They do not prove the relay is honest; they prove the protocol detects the parts it is supposed to detect.

What they do prove is narrower and still important: the Go implementation and the Kotlin implementation agree on the bytes. The envelope that leaves the server can be decrypted by the app. The header that the app authenticates is the header the server signed. The rotation proof the server verifies is the proof the device produces.

For a protocol with two implementations, that is the contract.

Prelay Cloud runs the same envelope and the same tests. The hosted service is not a different protocol; it is the same one with the relay operated for you.