Skip to content

Repository files navigation

kithmoot-android

A native Kotlin implementation of the KithMoot protocol: an open workspace with agents over Nostr, encrypted end to end, where one person can be present on several devices at once and still appear to the room as a single participant, and where calls are a feature of the room rather than the product.

The TypeScript reference client is at forgesworn/kithmoot, live at kithmoot.forgesworn.dev, which is also where the APK is published.

This repository is the second, independent implementation. The first is the TypeScript reference client. That is the entire reason this exists: a protocol implemented once, by one person, in one language, is a product. Implemented twice, from a published wire contract, it is infrastructure. This repository is the proof, and it is only worth something to the extent that it was written against the published vectors rather than against the other implementation's source.

Status

The first production-lineage candidate is 0.6.0 (23) and requires Android 13 or later. It preserves app-private encrypted data from the published preview through an APK Signature Scheme v3 certificate lineage. Publication still requires the owner-held production key, exact-preview upgrade evidence and physical-device acceptance; see the release guide.

The shared project directory follows your signed-in account. Open Projects to create a project, choose people, agents and room invitations, or explicitly join an invitation. Signed updates use the web protocol and an encrypted device cache with exact pending-send recovery. The existing room labels under Chats remain local organisation.

Authority-pinned saved rooms follow signed successor epochs. The app stores the active epoch in a rollback-resistant journal, catches up after missed rekeys, moves roster, chat, work, signalling and quiet traffic together, and keeps all publication blocked when the authority or a paired Bothy cannot prove a safe transition. Removed and closed devices receive terminal states without a successor secret.

It joins rooms. The protocol layer is checked against the published interop vectors; on top of it sit a relay pool, the room state machine, a WebRTC mesh and an Android interface. Two emulators have been in the same room as one person on two devices, over a relay, with chat, microphone handover and screen capture working. The same build has since been run on physical hardware, a Pixel 10 Pro XL, where the screen share, the camera and the foreground-service lifecycle all behave as they do on the emulator. Both sets of captures are in docs/screenshots/.

What is not here:

  • No room descriptor, agent ownership, attachments or approvals. Those vectors are carried in the published set and counted by the coverage guard, but nothing on this side implements them yet.
  • The message layer is partly written. Replies and threads, edits, retractions, mentions on the wire, direct-message invitations and read positions are decoded and resolved exactly as the reference does, checked against every chatThread, chatEdit, chatRetract, chatMention, chatInvite and readPosition vector, and the chat pane shows edits, retractions and threads. A signed-in participant can choose somebody present, create a persistent two-member room, and send its full invitation sealed through the account signer's NIP-44 interface; the recipient deliberately opens and validates it before joining. The composer still sends plain messages: no reply, edit or retract control, and read positions are not published. members on a room policy is enforced.
  • Android cannot start a quiet room yet. It can join one, follow its signed successor epochs and hand a bounded cadence to a paired Bothy. Creating the quiet policy remains a web-client action. Relays hand back two days of quiet history; a phone-owned message waits up to five minutes for its slot.
  • No peer assist. An assist offer on somebody's roster entry is read and dropped, which RosterEventVectorsTest declares rather than hides.
  • No forwarder support and no end-to-end encrypted media.
  • No TURN server configured. The only ICE server is a public STUN, so two devices behind symmetric NATs will not find each other.
  • Accessibility acceptance with TalkBack and physical-device testing of the new saved-room flows remain to do. Emulator tests cover the entry, recovery and destructive-confirmation controls.

Saved rooms and identities

New rooms default to Group: come back any time, matching the web app. A v3 group link can admit someone while the creator and every member are offline. The app fetches a signed, encrypted invitation from relay storage and checks for retirement before entering. Creation and link replacement wait for a relay to acknowledge storage before exposing the new link. Uncheck the group option for a temporary meeting using the existing v2 live handshake.

Group membership is saved without a twelve-hour limit. Members receive no inviter signing key or admission delegation. Creator keys remain in the encrypted local vault, and returning from an old temporary link cannot overwrite saved group authority. Device pairing retains its own credential expiry.

Stored admission depends on relay availability and retention. A group link and its encrypted envelope provide durable access to epoch 0, including retained history. Replacing the link asks cooperative clients to refuse new admission; it cannot revoke copies of the key. Managed member removal, later-epoch recovery and mobile push are separate features. See the published persistent group contract.

The home screen lists rooms saved on this device, with local names, search, rename and a confirmed Forget action. Reopening preserves the participant and device keys. Creators can return alone, and temporary meetings can serve their saved invitation again; recovery does not depend on another member being online. Audio, camera and screen sharing remain off until requested.

Room secrets, identity keys and invitation-host capabilities are encrypted together using AES-256-GCM and an Android Keystore wrapping key, written atomically under noBackupFilesDir. App backups are disabled. The signing keys are decrypted into app memory while used; only the wrapping key is non-exportable through Android Keystore. This is local recovery, not an export or cross-device backup. Clearing app data or uninstalling loses the saved access.

Main-device credentials renew with the same keys. Paired devices keep only their device key and the original bounded credential, never the participant private key; an expired pairing needs a new pairing link. Expired admission delegations are not renewed by recovery. Known retired invitations stay retired, and known room-key changes block reopening with the old secret. Invitation rotation saves the replacement and a signed retirement together before publishing; pending retirement events are replayed on return.

Unreadable or corrupted data blocks room entry and stays intact until an explicit deletion. Forget removes local access and identity for that room; it does not delete other members or relay messages. Room names are local labels, not shared room descriptors.

Signing in as yourself

Without an account every room gets its own participant key, made on this phone, the way the web client's "just a name" works. Sign in with Nostr on the start screen makes the person's real key the participant key for every room started or joined afterwards, exactly as the web client does it: one signature per room, on the device credential, and the key never has to be on the phone.

Where the key can live:

  • A signer app on this phone (NIP-55): My Signet, Amber, Cambium, or anything that answers nostrsigner: intents. The sheet lists them by name. Signing goes through the app's content provider once it has approved KithMoot, and falls back to the intent, which brings the signer up to ask. A person whose key lives in a Heartwood signs in through Cambium this way.
  • Signet in a browser (under Advanced, for a Signet with no app on this phone): the app mints a nostrconnect:// invitation and opens https://mysignet.app/?nostrconnect=…; the person approves there, Signet's bunker pairs with this client over a relay, and the sign-in finishes on the relay. Signet then sends the browser to kithmoot.forgesworn.dev/signet/, a page that only opens the app again. From then on signatures travel over NIP-46 to that Signet, which has to stay open to answer. (My Signet's same-tab redirect hands over no signer for an in-app persona, by design, which is why the invitation goes the other way.)
  • A bunker link (NIP-46), pasted: any remote signer, a Heartwood included.

KithMoot does not accept a pasted nsec or hex private key. Existing debug preview installs that used the former local-key option remain readable while the separate preview-data migration is prepared; the option cannot create a new local account and it is unavailable in release builds.

What is saved is the least that gets the signer back: a package name, or the bunker link and this phone's NIP-46 client key. A room joined as the account records only the public key; it opens only while that account is signed in, and any other account is told whose room it is. The account line shows the person's kind 0 name and picture, looked up on the public profile relays, and both ends of the npub.

What it implements

Piece What it does
Agents, and who may hear you A member that says it is an agent is marked as one, and a control in the room decides whether this device's camera and microphone are sent to it at all - refused means the tracks are never handed to that connection
Room derivation HKDF-SHA256 from the 32-byte room secret to a public roomId and a secret roomKey, under two separate info strings
Join URL V3 group links carry a bearer and pinned inviter in the URL fragment, with the traffic secret in a signed, encrypted kind-1463 relay event. V2 temporary meetings keep the live handshake and bounded delegation. Legacy v1 links remain readable
Device credentials Kind 20460, signed by the participant key, naming one device, one room, and a NIP-40 expiration
Roster events Kind 20461, NIP-44 encrypted to the room key, with the device credential verified on the way in
Signal wrapping Kind 21059 ephemeral gift wrap carrying SDP and ICE, NIP-44 encrypted to the recipient under a throwaway key
Durable chat Kind 1460, matching the TypeScript wire format and fixed interop event; room-key encrypted, credential/proof checked, 2,000-character and 30-per-minute sender bounds, 30-day query horizon and 500-message in-memory cap
The message layer Replies and threads, edits, retractions, mentions, DM invitations and read positions read and resolved as the reference does (session/Messages.kt, Dm.kt, ReadPosition.kt); native signer-sealed creation and deliberate opening of persistent two-member rooms; a two-member members policy enforced at the gate
Kindred access The kin > kith > ken > open tier ladder, proof issuing and verification, and the room gate
TURN credentials coturn's REST convention: <expiry>:<name> with an HMAC-SHA1 password
Dead-drop keys and quiet rooms nostr-deaddrop's derivation, written from its README: a pair's ikm as forgesworn-link's rendezvous material, a room's under its own case byte, one key per epoch, sender and counter (protocol/DeadDrop.kt); room drops and the key table (protocol/RoomDrop.kt, QuietKeys.kt); and the transport that rides a room's chat in them (session/QuietTransport.kt). A room whose policy says quiet beside its members list reads and posts in drops from this client, with the counters spent and any message waiting for a slot kept on the device. Two devices per person post; a third reads. A paired Bothy can take over this device's bounded cadence through the durable ownership flow in the quiet cadence guide. This client cannot start a quiet room yet, only join one from its link
Contact cards The contact card reader, written from the draft: steps 1 to 5 in order, the Link address card inside verified by a strict, cofactorless Ed25519 written out over BigInteger, refresh under the pinned node id (protocol/ContactCard.kt, LinkCard.kt, crypto/Ed25519Strict.kt); and the builder for one's own, a kind 21641 event, ephemeral, signed by whatever holds the identity (protocol/ContactCardBuilder.kt). In the app, Cards in the room: paste a card and the person's tile says one is held for them, their box details are pinned, but transport relay hints grant no sheltered message lane. A keeper-confirmed message relay can be marked separately. Forgetting a card removes its holder badge and saved details. A card opened as a link is offered at the door and kept only on a press. The book lives in its own vault on the phone (storage/ContactBook.kt), never published

Two behaviours in there are load-bearing and easy to get quietly wrong:

  • ken never satisfies a kith gate. Ken is one-way recognition: you pinned somebody's key; they never vouched for you. Kith is a mutual, verified bond. Treating them as interchangeable would silently open a gated room.
  • Roster and signal decoding return null, never throw. Both run inside a relay subscription callback, where a single malformed event from a single hostile publisher would otherwise unwind the subscription and take the whole room down with it.

Running the interop vectors

./gradlew :protocol:test

Requires a JDK 21 and a network connection on first run, to fetch dependencies.

protocol/src/test/resources/kithmoot-vectors.json is a verbatim copy of the published vectors, never an edited one. There are 190 vectors across 26 groups. The suite runs each vector in the groups this implementation covers as its own named test case, so a failure names the vector, and adds three guards that fail the build if a vector goes missing or a group loses its negative cases. The six message-layer groups are run from the :app module, where the chat codec lives, by MessageLayerVectorsTest (./gradlew :app:testDebugUnitTest), reading the same file.

The groups this client does not implement - channelDerivation, roomEpoch beyond the peek, agentOwnership, chatAttachment, approvalControl, roomDescriptor and verificationWords - are counted by VectorCoverageTest without being run, so the day one of them lands the guard already knows how many cases it owes.

deaddrop-vectors.json and contact-card-vectors.json are verbatim copies of nostr-deaddrop's and nostr-contact-card's known-answer files: nine derivations, thirty-three cards with the step each fails at, six refresh cases including a small-order node id and a nonce point carrying torsion. DeadDropVectorsTest and ContactCardVectorsTest run every one; ContactCardBuilderTest rebuilds each passing card's content from its fields and expects the reference's bytes; CardFuzzTest mutates the passing card and address card fifteen hundred ways each and expects a verdict, never an exception. Where this module and those files disagree, the disagreement is the finding. contact-book-web.json in the app's test resources holds cards and fresh Link cards the web library made, for the contact book's serial pin and refresh rules.

persistent-group-web.json is a separate synthetic fixture produced by the TypeScript implementation at 171de0a. Native tests decode its welcome and retirement, then reproduce its encrypted content and event id using the same nonce. The original 95-vector file remains unchanged.

One guard is worth its own paragraph, because it caught something. A roster vector used to be checked by parsing the expected entry through the same model as the decoded one, which meant any field this client did not model was dropped from BOTH sides and the vector passed without the behaviour existing. That is what happened to display names for months: the display-name and display-name-hostile vectors were green while nothing here read a name at all. The test now re-encodes what it decoded and compares the JSON, and holds a declared list of the fields this client knowingly drops - one, assist - so the list can only shrink by somebody doing the work, never grow by somebody not noticing.

The negative vectors are the ones that matter. An implementation that accepts every well-formed structure passes all the positive vectors; only the negatives catch an implementation that accepts everything, including a credential for another room, a roster entry signed by a device it does not name, a gift wrap opened by the wrong person, or a ken proof at a kith door.

Layout

protocol/src/main/kotlin/dev/forgesworn/kithmoot/
├── crypto/     Hex, digests, BIP-340 signing, NIP-44 v2
└── protocol/   Events, rooms, credentials, roster, signalling, access, TURN

app/src/main/kotlin/dev/forgesworn/kithmoot/
├── account/    The Nostr account: signer apps (NIP-55), bunkers (NIP-46), Signet, npub
├── relay/      Relay pool, sockets, filters, de-duplication
├── session/    Room session, presence, roles, chat, identity, pairing links
├── media/      WebRTC engine, negotiation, local capture
├── service/    The foreground service a screen share runs under
├── storage/    Encrypted saved rooms, identities and invitation capabilities
└── ui/         Compose: theme, start screen, room, tiles, chat, controls

:protocol has no Android dependencies by design. It runs on a plain JVM, which keeps the vector suite fast and leaves the protocol reusable outside the app.

Running it

Use JDK 21 and an Android SDK with platform 35 and build tools 35.0.0. Set ANDROID_HOME to the SDK directory, or put sdk.dir=/path/to/sdk in the gitignored local.properties file.

Check the protocol vectors, app unit tests, Android lint and both build variants:

./gradlew :protocol:test :app:testDebugUnitTest :app:lintDebug :app:lintRelease :app:assembleDebug :app:assembleRelease

The CI workflow runs those checks on pull requests and pushes to main, and retains reports for seven days. A separate API 35 emulator job installs the debug app and tests actual Android Keystore storage, corruption and missing-key handling, saved-room controls and identity continuity across a forced process restart. Android 13 and 15 lineage jobs create disposable certificate pairs, update a release package in place, verify its UID and production vault contents survive, and refuse old-signer rollback. The owner-key candidate and physical-device acceptance remain separate requirements. The release guide covers exact signing and acceptance evidence.

To run the recovery checks on a disposable emulator (they replace KithMoot's saved room data on that emulator):

./gradlew :app:assembleDebug :app:assembleDebugAndroidTest
ANDROID_SERIAL=emulator-5554 bash scripts/check-recovery-emulator.sh

The script requires an explicit emulator serial and refuses physical devices. It checks instrumentation summaries because Android can return a successful shell exit code after a test-process crash. Restart preparation and reopening run in separate processes, with participant/device identifiers compared and creator controls retained. Recovery uses an unavailable loopback relay to verify that saved rooms work offline. These checks do not prove live relay admission.

To install the debug build on a connected development device or emulator:

./gradlew :app:installDebug

A room opened in the application uses the relays named on the start screen; a room joined from a link uses the relays the link names. Debug builds allow cleartext to localhost and 10.0.2.2 so a relay on the development machine can be used from an emulator; release builds refuse cleartext outright.

On one person, several devices

This is the whole product, so it is worth saying where it lives. session/ folds the roster into people rather than machines, and ui/room/Tiles.kt turns that into one tile group per participant. A person at a laptop with their phone beside them is one card with one name, two video panes and one microphone. Which of your own devices the room is actually hearing is decided by RoleArbiter, on two rules: most recent claim wins, ties to the lowest pubkey. There is no coordinator and no handover message, and the device that loses the claim releases the microphone rather than sitting on a hot mic nobody can hear.

On the cryptography

NIP-44 v2 is implemented in this repository rather than taken from a Nostr SDK. That is not preference. The roster is encrypted with the raw 32-byte room key used directly as the conversation key, and every Nostr SDK exposes only encrypt(secretKey, publicKey). There is no way to hand one a symmetric key, so the room channel cannot be expressed through them at all.

The primitives are not ours: ChaCha20, HMAC-SHA256, HKDF and the hashes come from BouncyCastle, and all secp256k1 work (BIP-340 signing, verification and the ECDH point multiplication) goes to libsecp256k1 through secp256k1-kmp. What is written here is the NIP-44 construction that arranges them: the key schedule, the padding scheme, the payload framing, and a constant-time MAC comparison. No curve arithmetic, no block cipher and no compression function is implemented in this repository.

Two details are worth recording, because they are the usual places a re-implementation goes wrong. NIP-44 uses the raw ChaCha20 stream cipher, not the AEAD, with the block counter starting at zero. And libsecp's ecdh is not usable here, because it hashes the compressed shared point where NIP-44 wants the bare x coordinate.

Licence

MIT. See LICENSE.

Chat is the first room view. A Call tab opens the existing media controls without starting the microphone, camera or screen share. Drafts, search and the reading position survive switching between Chat and Call. Invite and contact actions are available from Chat. This changes the conversation layout; native assignment and agent-approval controls are still separate work.

Chat shows timestamps above messages, searches loaded messages and people, and offers an emoji picker and encrypted quick reactions. In an ordinary room, a signed-in participant can start a separate private conversation with somebody present. Its persistent room link is sealed by NIP-44 to that account, confirmed on the introduction relay, and opened only after the recipient presses Open; the private room does not expose its raw invitation through the ordinary room-share control. Public kind-0 names and pictures are off by default and can be enabled for the current visit from Chat. Names remain paired with shortened keys; profiles are self-reported. Image requests use HTTPS, bounded downloads and a memory cache cleared when profile lookup is disabled or the room is closed.

Tap Expand screen share on a shared-screen pane for a full-window viewer. Pinch or use +/− to zoom, drag to pan, and use Fit to screen to reset. Pop out opens Android picture-in-picture on supported devices; Android supplies its movement and resizing controls. Closing the viewer keeps the call track alive. These controls have emulator coverage using generated video; physical-device acceptance remains a separate release check.

The emulator script also checks chat/search/emoji/reactions and a live synthetic screen in the fullscreen and picture-in-picture viewers. It saves synthetic UI captures with the recovery reports. No camera, microphone or desktop capture is used for this viewer check.

Persistent-group emulator checks use a local WebSocket relay and the web fixture. They cover group creation, acknowledged link replacement, retirement of the old link, admission with no member online, a forced process restart, and rejected publication. Snapshot queries wait for EOSE from every relay connected when the query begins; a missing EOSE or dropped connection fails the query. Relays that were unavailable at that point are not proof of a complete global history.

M2 protocol compatibility (0.4.1)

New rooms use v3 durable invitations. Existing v1/v2 links and saved rooms retain their readers and recovery paths. Signalling writers retain seal-less 20462/21059 with additive inner profile tags and outer expiry; receivers also verify sealed rumours and bound decrypt attempts before sender attribution. Reserved scoped pass/policy decoders do not enable service enforcement. The independent Kotlin reader consumes the same 190 vectors as the reference implementation.

See the protocol draft and compatibility ledger. The API-35 emulator passed installed recovery/restart, v3 creation/rotation, retired/refused invitation, chat and screen-sharing journeys. This does not replace physical-device acceptance or add the unsupported features listed above.

About

Native Kotlin implementation of the KithMoot protocol: the second, independent implementation and its interop proof

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages