Native clients (Swift & Kotlin)
The engine and React bindings are TypeScript, but the protocol isn't — it's a
few hundred lines of standard crypto over a tiny HTTP wire format. So a fully
native app can join the same sync network without a JavaScript runtime. Two
clean-room ports live in the repo and are byte-verified against the canonical
@simplysync/protocol:
| Port | Path | Shape |
|---|---|---|
Swift (SimplySyncKit + engine) |
ports/swift |
Foundation + CryptoKit + SQLite3. Ships a near-complete @simplysync/engine port (schema, SQLite, reactive queries) on top of the Kit/ protocol core; also runs on macOS. |
| Kotlin / JVM | ports/kotlin |
Pure JVM (no Android framework deps). The protocol core that powers the Simply Finance Android build. |
This page is the cross-language reference: why interop works, what each port covers, the exact API surface, and — importantly — the one place identity derivation diverges, so you know which secrets sync across which clients.
Why a native client can interoperate
Everything rests on the same guarantee the rest of these docs are built on: the relay only ever sees ciphertext. It never holds keys or plaintext, so a native client doesn't have to reimplement the engine — it only has to speak the same bytes on the wire. Concretely, three things must match the TypeScript implementation exactly:
- Identity — derive
ownerId,relayAuth(the bearer token), the AES data key, and thestreamKeyfrom one secret. Auto-detected from ansf1_recovery key (HKDF-SHA256) or a 24-word BIP39 phrase (SLIP-21), exactly likederiveIdentity. - Envelope — AES-256-GCM with the canonical-JSON header as additional authenticated data, base64url on the wire. See the encrypted envelope.
- Transport —
POST/GET /v1/events,Authorization: Bearer <relayAuth>, an integer-free keyset cursor. See the relay wire protocol.
Get those three byte-identical and a native client and a TypeScript client that share a secret become the same owner and converge — through any of the wire-identical relays.
What's ported, what stays app-owned
Both ports include the protocol core (identity, envelope, HLC, …) and a port of the engine layer — the local SQLite store, the reactive query cache, the sync loop — so each gives the same schema/mutations/queries DX as the React version. (Re-read the three layers for the split.)
| Layer | TypeScript | Swift | Kotlin |
|---|---|---|---|
| Protocol (identity, envelope, HLC, canonical JSON, BIP39, base64url, relay client) | @simplysync/protocol |
Ported — Kit/ |
Ported — com.simplysync.kotlin |
| Engine (schema, SQLite store, outbox, sync loop, reactive queries) | @simplysync/engine |
Ported — Engine/ (guide) |
Ported — same package (guide) |
| Relay | relays/* |
Unchanged | Unchanged |
So on the server you port nothing, and both native clients get the full engine
DX. The reactive layer is idiomatic per platform — SwiftUI LiveQuery
(ObservableObject) in Swift, a StateFlow in Kotlin — over the same SQLite store
and sync loop.
The Swift port — SimplySyncKit
Kit/ is a clean-room Swift port of @simplysync/protocol built on **Foundation
- CryptoKit only** — no UIKit, no third-party packages — so it also compiles and
runs on macOS and is unit-testable without a simulator. It ships inside the
SimplySyncDemoSwiftUI app (onboarding + a synced todo list).
For the step-by-step API — derive identity → build a payload → encrypt → push / pull → apply — see the Client API → Swift guide.
| File | Role |
|---|---|
Base64URL.swift |
RFC 4648 §5 base64url (no padding) — the only wire encoding. |
Crypto.swift |
HMAC-SHA256/512, HKDF (deriveBytes), SLIP-21, AES-256-GCM, SHA-256, random bytes. |
CanonicalJSON.swift |
JSON.stringify-compatible canonical form — the AES-GCM AAD and plaintext bytes. |
BIP39.swift / BIP39Wordlist.swift |
Mnemonic ⇄ entropy + the 2048-word English list. |
Identity.swift |
IdentityDeriver — sf1_ (HKDF) and BIP39 (SLIP-21) derivation. |
Envelope.swift |
SyncEnvelope ⇄ SyncPayload (encrypt / decrypt). |
HLC.swift |
The Hybrid Logical Clock string + a monotonic HLCClock. |
RelayClient.swift |
push / pull against /v1/events over URLSession (async/await). |
// Derive once from a recovery key or a 24-word phrase (auto-detected).
let identity = try IdentityDeriver.derive(from: recoveryKeyOrPhrase)
let clock = HLCClock(deviceId: deviceId)
let relay = RelayClient(baseURL: url, ownerId: identity.ownerId, relayAuth: identity.relayAuth)
// Encrypt a row mutation and push it.
let payload = SyncPayload(
schemaVersion: 1, table: "todo", op: "upsert",
rowId: rowId, hlc: clock.next(), value: ["title": .string("Buy milk")]
)
let envelope = try Envelope.encrypt(
identity: identity, eventId: payload.hlc,
deviceId: deviceId, createdAt: isoNow, payload: payload
)
_ = try await relay.push([envelope])
// Pull + decrypt; apply last-writer-wins with HLC.compare.
let pulled = try await relay.pull(after: cursor)
for env in pulled.events {
let row = try Envelope.decrypt(identity: identity, envelope: env)
// overwrite local row only if HLC.compare(localUpdatedAt, row.hlc) <= 0
}
Above Kit/, Engine/ ports @simplysync/engine: a typed schema, a real SQLite
store, typed mutations, and reactive LiveQuery results — so the example above is
the low-level path, and most app code uses the engine instead (see the
Client API → Swift guide). Run the demo from Xcode against a
local relay (bun run relay:go), then ⋯ → Show recovery key and paste the
same key into a second simulator, the web playground, or the Expo app to watch them
converge. Full run/verify steps are in the
Swift README.
Not ported (by design): binary blobs, the encrypted DB export/import (snapshot)
format, and legacy import. Conflict resolution is row-level last-writer-wins by
HLC; delete is a soft delete (isDeleted).
The Kotlin / JVM port
The Kotlin port is pure JVM — no Android framework dependencies — so the crypto unit-tests without an emulator and the same artifact is reusable from any Android app. It's the protocol core behind the Simply Finance Android build.
For the step-by-step API — including snapshot crypto and the phrase-interop caveat — see the Client API → Kotlin guide.
| File | Role |
|---|---|
Base64Url.kt |
RFC 4648 §5 base64url (no padding). |
CanonicalJson.kt |
Deterministic, JSON.stringify-compatible canonical JSON (the AAD/plaintext bytes). |
Crypto.kt |
HMAC-SHA256/512, HKDF deriveBytes, SLIP-21, AES-256-GCM, PBKDF2 (minSdk-24 safe). |
Bip39.kt / Bip39Wordlist.kt |
BIP39 decode/validate and mnemonic generation. |
Identity.kt |
IdentityDeriver — see the interop boundary. |
Hlc.kt |
Hlc string + a thread-safe HlcClock (next / seed / observe). |
Envelope.kt |
SyncEnvelope (AES-256-GCM, ct‖tag) ⇄ SyncPayload. |
RelayClient.kt |
POST / GET /v1/events over OkHttp; suspend (coroutines). |
SnapshotCrypto.kt |
Encrypted DB-snapshot envelope (recovery-phrase and passphrase keyings). |
val identity = IdentityDeriver.derive(recoveryPhraseOrKey) // 24-word BIP39 or sf1_ key
val clock = HlcClock(deviceId)
val relay = RelayClient(baseUrl, identity.ownerId, identity.relayAuth)
// Encrypt a row mutation and push it.
val payload = SyncPayload(1, "todos", "upsert", rowId, clock.next(), columns)
val envelope = Envelope.encrypt(identity, payload.hlc, deviceId, createdAtIso, payload)
relay.push(listOf(envelope))
// Pull + decrypt; last-writer-wins by Hlc.compare.
val pulled = relay.pull(after = cursor)
pulled.events.forEach { Envelope.decrypt(identity, it) }
This module is library-only — no UI. Above the protocol core it ships a port
of @simplysync/engine: a typed schema, a SQLite store, typed mutations, and
reactive StateFlow queries (the example above is the low-level path; most app
code uses the engine — see the Client API → Kotlin guide).
The default JdbcSqliteDriver keeps the module pure-JVM and emulator-free for
tests; an Android app injects its own android.database-backed driver. Treat the
protocol core as pinned: update it wholesale, don't fork it, or clients stop
being able to sync with each other.
Extra over Swift: SnapshotCrypto ports the encrypted DB-snapshot format for
relay-free device switches — AES-256-GCM keyed either by the recovery phrase
(deriveBackupKeyBytes, SLIP-21 ["SimplySync","BackupKey"]) or by a passphrase
(PBKDF2-HMAC-SHA256, default 600k iterations). Not ported: binary blobs.
Identity derivation & the interop boundary
This is the one subtlety to get right. Two secret encodings derive an identity, and they interoperate differently:
| Secret | Scheme | TypeScript | Swift | Kotlin |
|---|---|---|---|---|
sf1_ recovery key |
HKDF-SHA256, labels owner-id · data-key · relay-auth · stream-key |
✓ | ✓ | ✓ |
| 24-word BIP39 phrase | SLIP-21 path prefix | SimplySync |
SimplySync |
Evolu |
sf1_recovery keys interoperate everywhere. All three deriveownerId/dataKey/relayAuth/streamKeywith byte-identical HKDF labels, so a key generated on any client resolves to the same owner on every other.- BIP39 phrases are where Kotlin diverges. TypeScript and Swift both expand a
phrase under the SLIP-21 prefix
"SimplySync". The Kotlin port deliberately pins"Evolu"instead, because its consumer (Simply Finance Android) migrated from Evolu and a phrase must keep resolving to the owner the existing data and relay already use. A regression test (SyncKitTest.identityUsesEvoluPaths…) locks this — rebranding the path would orphan every existing user's synced data.
Consequence: the same 24-word phrase derives the same owner on TypeScript + Swift, but a different owner on Kotlin. To sync a Kotlin client with a TS/Swift client, share an
sf1_recovery key (identical on all three), not a phrase — or reconcile the SLIP-21 prefix first. The envelope, relay protocol, andsf1_derivation are otherwise byte-identical across all three.
Everything below the secret — the AES-GCM envelope, canonical JSON, the
streamId grouping key, the HLC encoding, the wire protocol — is shared verbatim,
so once two clients agree on an owner they converge regardless of language.
Cross-language primitive map
The same concept, three names. Useful when reading one port against another or against the protocol source.
| Concept | TypeScript | Swift | Kotlin |
|---|---|---|---|
| Derive identity | deriveIdentity(secret) |
IdentityDeriver.derive(from:) |
IdentityDeriver.derive(...) |
| New recovery key | createRecoveryKey() |
IdentityDeriver.createRecoveryKey() |
IdentityDeriver.createRecoveryKey() |
| New mnemonic | createMnemonic() |
— | Bip39.generate() |
| Encrypt event | encryptPayload(...) |
Envelope.encrypt(identity:…) |
Envelope.encrypt(identity, …) |
| Decrypt event | decryptEnvelope(...) |
Envelope.decrypt(identity:envelope:) |
Envelope.decrypt(identity, envelope) |
| HLC make / compare | createHlc / compareHlc |
HLC.make / HLC.compare |
Hlc.make / Hlc.compare |
| Monotonic clock | engine-internal | HLCClock.next() |
HlcClock.next() |
| Push / pull | engine sync loop | RelayClient.push / .pull |
RelayClient.push / .pull |
| Encrypted DB snapshot | snapshot-crypto.ts |
— (not ported) | SnapshotCrypto |
| Define a schema | { table: { col: Type } } |
Schema([...]) |
Schema(mapOf(...)) |
| Create the engine | createSync(deps)(schema, cfg) |
createSync(deps)(schema, cfg) |
createSync(deps)(schema, cfg) |
| Write a row | engine.insert(...) |
engine.insert(...) |
engine.insert(...) |
| Reactive query | useQuery(q) |
engine.liveQuery(q) → LiveQuery |
engine.liveQuery(q) → StateFlow |
Verification — these aren't "should match," they're checked
Both ports are tested against vectors generated by the real TypeScript protocol, so drift is caught, not hoped against.
Swift — host-side, no simulator needed:
cd ports/swift
# 1) crypto byte-compatibility vs @simplysync/protocol (verify/vectors.json)
swiftc SimplySyncDemo/Kit/*.swift verify/main.swift -o /tmp/ssverify && /tmp/ssverify
# 2) live end-to-end against a running relay (push → pull → decrypt)
swiftc SimplySyncDemo/Kit/*.swift verify/itest/main.swift -o /tmp/ssintegration
/tmp/ssintegration http://localhost:4100
Kotlin — the JUnit suite covers the canonical BIP39 zero-entropy vector, the
Evolu identity-path regression guard, envelope round-trips, SnapshotCrypto,
HLC ordering, canonical JSON, and PBKDF2:
cd ports/kotlin
./gradlew test
Parity rules — what must stay byte-identical
If you change a port (or add a third language), these are the load-bearing contracts. Break any one and decryption fails or owners drift:
- Canonical JSON. Keys sorted to match JS
localeCompare; it's both the AES-GCM plaintext and the AAD. Keep row/column names lowercase-ASCII so the ordering matches across runtimes. - The AAD is the header.
{version, ownerId, eventId, deviceId, createdAt, streamId?}as canonical JSON. A relay can't rebind a ciphertext to a different event/owner without GCM failing on decrypt. - Derivation labels & paths. The HKDF labels and SLIP-21 paths are part of the contract — see the interop boundary. Add a new vector, never edit an existing one.
- Fixed-width HLC.
base36(millis,10)-base36(counter,4)-deviceId, so plain string order is causal order andeventId = hlckeeps relay compaction sound. - AES-256-GCM, 12-byte random nonce per event, ciphertext stored as
ct‖tag, everything base64url (no padding) on the wire.
Read next
- Sync & the relay — the envelope, the wire protocol, and the threat model these ports implement.
- How it works — the on-device store + clock the engine layer (the part you write natively) owns.
- Security — the end-to-end-encryption claim, walked through the source.