Commit Graph
6 Commits
Author SHA1 Message Date
Paul HittandClaude Fable 5.1 4dd544083c Remove machine-writing tells from comments and docs
Emoji markers, markdown bold inside code comments, and em-dashes are
gone from source comments, string literals, scripts, Gradle files and
the markdown docs. Counted before and after:

  emoji markers   2680 -> 1 (the emoji picker's own list, which is the feature)
  em-dashes       2490 -> 9 (table placeholders and fenced code)

Two warning glyphs in user-facing strings in ChatScreens.kt are removed
as well; the sentences say the same thing without them. Two backtick
test names carried an em-dash and now carry a comma.

Nothing else changed. 1208 tests pass, counted from the result XML.
The three obfs4 interop tests are opted out with OBFS4_INTEROP_OPTOUT=1
for this run, as their own message instructs when no bridge is set up.

CONTRIBUTING gets a four-line rule so it does not grow back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 18:52:12 -04:00
Paul HittandClaude Opus 5 db35c7ce55 obfs4 for Android, part 1: Elligator2, against the same vectors as Swift
A new `:obfs4` module, a plain JVM library like `:engine` so its tests run
without an emulator -- which matters here because the only tests that mean
anything are vector comparisons against lyrebird.

Far smaller than the Swift port, for a reason worth recording: the Swift
implementation hand-writes SipHash-2-4, XSalsa20, Poly1305 and its own
field arithmetic because CryptoKit exposes none of them. The JVM does not
have that hole. BouncyCastle -- already a dependency of :engine, for the
same class of reason -- has X25519Field, XSalsa20Engine, Poly1305 and
SipHash, vetted and constant-time where it matters.

So the two clients do NOT share an implementation. They share the vectors
and the live interop harness, which is what actually keeps them honest:
two independent implementations agreeing with the same reference is a
stronger statement than one agreeing with itself twice.

Two things BouncyCastle would not do, both found by vectors rather than
by reading.

X25519Field.sqrtRatioVar is not a drop-in. Elligator needs the root even
when the ratio is NOT square -- it is sqrt(i*u/v) then, and the algorithm
uses it to pick a branch rather than to detect an error. BC only promises
a usable root in the square case, and handing its output to the map
produced zeros for 120 of 256 vectors. The exponent chain and branch
selection are written out here; the field arithmetic underneath is still
BouncyCastle's.

X25519Field.cmov did nothing at all with a condition of 1. Both tweak
parities produced the SAME representative -- a map that still
round-trips, still looks uniform, and only ever emits one of the two
valid answers. The failing assertion said "wrong bytes"; it took a
diagnostic printing both candidates to see they were identical. Replaced
with an explicit mask so the convention is stated rather than assumed.

Behind no setting yet because nothing calls it: this is the crypto
foundation only. The transport, the OkHttp socket factory and the
user-facing switch come next, and the switch defaults to off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:46:25 -04:00
Paul HittandClaude Opus 5 5f983c1455 Android: the app, and the two things only a phone could tell us
There is now a Flamenet Messenger app that installs, launches, creates an
account on a self-hosted relay, registers a device, publishes prekeys, and
stores all of it in an Android Keystore-backed vault. Verified on an emulator
through the actual screens, not by unit test.

92 JVM tests, 37 cross-engine, 9 on-device, 3 against live relays.

Two findings, both of which the JVM suite was structurally incapable of
producing.

**Android has no ordinary Ed25519 provider.** `KeyPairGenerator.Ed25519` and
`KeyFactory.Ed25519` come from `AndroidKeyStore` and from nowhere else -- probed
on API 35 rather than assumed -- and Keystore only makes keys it keeps. Every
Ed25519 key in this engine is one we must hold: a device identity in our own
vault, an account key derived from a passphrase. So the JCA route could never
have worked. It compiled, passed 81 tests on a laptop, and threw "Not
initialized" on the first launch on a phone. Worse, it was LATENT: a wrong
password failed before any key was minted, so the error path worked perfectly
while the happy path was broken.

Ed25519 now uses BouncyCastle's `math.ec.rfc8032` core directly -- the engine's
one real dependency, present because the platform leaves a hole, and a strictly
better answer than the non-constant-time keygen it replaces. ProviderProbeTest
records the measurement so nobody guesses again.

**The prekey field is `prekeys`, not `opks`.** I guessed. The relay accepted the
registration, returned 200, and stored nothing -- so the device was published
and could not be started with, a failure with no error anywhere. Caught by a
live test against the throwaway fleet, which exists because the same path
through the emulator takes four minutes to say "timed out" and nothing about
where.

Two testing traps, recorded where they bite:

An indeterminate progress spinner animates for ever, so Compose's test clock
never goes idle and `waitUntil` never evaluates its condition -- it fails on the
full timeout, reading as "sign-up is broken" when sign-up works. Auto-advance is
now disabled around the wait only; disabling it for the whole test is the next
mistake, because the app opens on a spinner while it reads the vault.

Wiping the Keystore in `@Before` deletes the key the already-launched activity
is holding, and every later write fails against a key that no longer exists. The
test signs out through the UI instead, which is both safe and what a person does.

Failure messages now include what the screen was showing. A bare timeout sends
you to the network, the relay and the crypto in turn while the app has been
displaying the reason the whole time.

Also corrects a note that said this Mac had no Android SDK. It does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 17:02:16 -04:00
Paul HittandClaude Opus 5 b1ba9cdc58 Kotlin: the relay transport
The engine can hold a conversation but had no way to reach a relay. This is
that: the §8 routes, both authentication modes, and the transparency log.

13 tests against a real HTTP server rather than a hand-written fake. That
distinction is the point -- a fake written alongside the client agrees with it
about everything including the field name it got wrong, which is exactly how
this protocol's one shipped bug survived until a live round trip.

Two more tests run against the deployed relay, opt-in via FN_LIVE_RELAY and
silent when unset. A test that reaches the network cannot sit in a hermetic
suite; it fails on an aeroplane and a suite that is red for unrelated reasons
stops being read. What they check is the thing MockWebServer structurally
cannot: that the URL this client builds is one relay.flamenet.io actually
serves. It does, on both mounts, and the Kotlin engine verified an inclusion
proof from the production log against a head fetched separately -- not against
the root the proof response carried, because a server that supplies both halves
of a comparison has proven nothing.

Details that are load-bearing:

The per-request signature covers the LOGICAL section-8 path, not the URL's. The
relay mounts these routes twice and signs against the former, so signing the
REST-prefixed path yields a valid signature over the wrong string and a refusal
with no hint as to why. Tested by asserting the other one does NOT verify.

The nonce is not decoration. Two inbox polls in the same second with the same
empty body would otherwise produce identical signatures, and the relay's replay
cache cannot tell an honest repeat from a captured one -- so it refuses the
honest client and the app quietly stops receiving mail.

Token minting is lazy, and a deployment with no token route is asked exactly
once. Laziness cost an hour here: the tests hung, because takeRequest() blocks
forever and I was waiting for a mint that had not happened yet. They now use a
bounded read, so that mistake fails instead of looking like a hung build.

Application passwords are stripped of spaces. The account service prints them
in readable groups and users paste what they were shown; sending the spaces is
a 401 nobody can diagnose, because the password on screen looks right.

Kotlin nests block comments, so `/e2e/` followed by a star inside a doc comment
opens one that never closes and the compiler blames the last line of the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:37:59 -04:00
Paul HittandClaude Opus 5 fc93df24e6 Kotlin: Ed25519, and cross-engine interop against the web engine
The seventeen Kotlin tests that landed with X3DH and the ratchet prove the
engine agrees with itself. That is not the property anyone needs. Two engines
written by the same person agree about the same misreadings of the spec, and
every one of those shows up months later as "the message will not decrypt" on
someone else's phone. So this wires the Kotlin engine up against the web
engine, which is normative, in both directions.

23 assertions, all passing: v1 X3DH both ways, identical associated data,
alternating ratchet turns past the handshake, out-of-order and permanently
lost messages, a drained one-time prekey pool, tamper rejection, safety
numbers, and the canonical transparency-log leaf.

Three things worth recording.

Ed25519 was missing, and the engine could derive an account address but never
sign a claim for one. The JCA offers no route from a private EdDSA key to its
public half, so the RFC 8032 keygen is done here -- and verified against the
RFC 8032 test vectors, not against a round-trip through our own code. A
round-trip proves only that the file agrees with itself, and every wrong clamp,
endianness and sign convention agrees with itself perfectly. The scalar
multiplication uses BigInteger and is therefore not constant-time; it runs once
at sign-in, in the user's own process, and signing deliberately stays with the
platform provider.

The harness reports a build failure as a failure, never a skip. The Swift
harness beside it once printed "skipped" -- which reads as green -- while
silently building a different checkout. Only a missing JDK skips here, and the
resolved path is printed either way.

Each section is wrapped so a divergence is a named failure rather than a crash.
That was not theoretical: breaking the Kotlin message-key derivation on purpose
crashed the process with a stack trace and skipped every later section. It now
reports four failing sections and finishes the run. The negative control is how
this was checked at all -- a suite that passes on its first run and has never
been seen to fail is not evidence of anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:04:35 -04:00
Paul HittandClaude Opus 5 06b97ca69b Start Android with the engine, held to the published vectors
Android does not exist, and the first thing it needs is not a screen. It
needs the protocol implemented a third time — which is only worth
anything if the third implementation is checked against the spec and the
published vectors rather than against a reading of our own Swift.

So `android/engine` is a plain Kotlin/JVM library with no Android in it:
no SDK, no emulator, no device, and therefore gateable in CI on Linux
like everything else. It reads
engine/test/vectors/protocol-v1.json from its one location rather than
carrying a copy, because a vector transcribed into the test that checks
it is a vector that agrees with itself.

Covered so far: safety numbers (§7) and key transparency verification
(§19) — RFC 6962 leaf and node hashing, the unbalanced split, and
bottom-up audit paths. Nine tests, all green against the same file the
JavaScript and Swift engines answer to.

The §19 tests are the ones worth having. A top-down reading of a
bottom-up audit path yields the correct root for every left-spine index,
so it passes any suite that only checks index 0 — the vectors pin a
right-descending index, and the tests assert both that the published
path verifies and that the same path reversed does not.

android/README.md lists what is missing — X25519, X3DH, the ratchet,
sealed sender, both post-quantum halves, the relay client, the app — as
a table rather than as an omission. Android stays honestly marked "not
started" on the site until that list is shorter.

Bytecode targets 17 without demanding a JDK 17 be installed: a
jvmToolchain(17) fails on any newer JDK, which is a poor trade for a
library with no JDK 17-specific behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:00:13 -04:00