Secure, end-to-end-encrypted notification mirroring across your trusted devices. A device can capture its notifications and forward them to your other devices, and display notifications mirrored from them. A lightweight broker server only relays opaque ciphertext and coordinates push delivery — it can never read your notifications.
A single Gradle build with shared protocol/crypto code consumed by Android, iOS, and the Kotlin/Ktor broker, so the wire format and signature verification can never drift between them.
:protocol Kotlin Multiplatform (JVM + Apple). @Serializable CBOR DTOs (cards, route
claims, envelope, captured notification, dismissal), the versioned cipher-suite
tag, the transport-neutral Transport interface, JSON control-plane DTOs, and the
iOS XCFramework codec facade.
:protocol-crypto Pure-Kotlin/JVM. Tink-based envelope sealing/opening (random DEK → AES-256-GCM
body + HPKE per-recipient DEK), ECDSA-P256 signing/verification, client-id
derivation. Shared verbatim by client and server.
:server Ktor CIO broker. Verifies signed cards/routes (never decrypts), store-and-forward
relay, authenticated WebSocket transport, FCM HTTP v1 adapter, Exposed/SQLite
recoverable cache. Containerized (distroless JRE 21).
:app Jetpack Compose + Material 3 Expressive client. Hardware-backed identity
(Keystore P-256, StrongBox→TEE), HPKE keys, NotificationListenerService capture,
mirror rendering + dismissal, Ktor WebSocket transport, FCM, QR pairing.
Android A (provider+consumer) ──► NotiSync broker ──► FCM / WebSocket ──► Android B
▲ (broker is blind to content) │
└──── signed cards, route claims, encrypted envelopes, dismissals ──────┘
- End-to-end encryption. Each notification body is sealed once with a random data key
(AES-256-GCM); that key is HPKE-sealed (
DHKEM_X25519_HKDF_SHA256via Google Tink) once per recipient. The broker fans out by recipient id and sees only ciphertext + routing metadata. - Hardware-backed identity. A non-exportable EC P-256 key in the Android Keystore (StrongBox
when available, else TEE) signs cards, route claims, and envelope authenticators. The
clientIdisbase32(SHA-256(public key))— a reproducible fingerprint that doubles as the safety number. - Client authority. Clients own identity, group membership, keys, and recoverable state. The broker is a disposable cache: if it loses all data, clients rebuild it through normal use.
- Algorithm agility. Every signed/encrypted structure carries the cipher-suite id (
NS1), so algorithms can be upgraded (NS2, …) without breaking old data.
Prerequisites: JDK 21, Android SDK (platform 37), Gradle wrapper (bundled).
# Run locally. Signed/JWT enforcement is on by default; for local-only protocol tests set
# NOTISYNC_SECURITY_ENABLED=false — the master switch turns off signed/JWT auth (and attestation)
# together, and the client tolerates it and works without a token.
./gradlew :server:run
# Or build a deployable fat jar and container:
./gradlew :server:buildFatJar
docker compose up --build # serves on :8080, SQLite cache on a named volume
curl http://localhost:8080/healthz # {"status":"ok","version":"0.1.0"}Configuration (environment variables): NOTISYNC_DB_PATH, NOTISYNC_FCM_ENABLED,
NOTISYNC_FCM_PROJECT_ID, NOTISYNC_APNS_ENABLED, NOTISYNC_APNS_TEAM_ID,
NOTISYNC_APNS_KEY_ID, NOTISYNC_APNS_PRIVATE_KEY_PATH, NOTISYNC_APNS_TOPIC,
NOTISYNC_INLINE_BUDGET, NOTISYNC_RELAY_TTL_MS, NOTISYNC_ASSET_TTL_MS,
NOTISYNC_SECURITY_ENABLED (master switch: enforce signed + JWT auth; default on),
NOTISYNC_INTEGRITY_REQUIRED (require a passing client-integrity attestation — App Check today — to
mint a bearer; default off, so a validly-signed client is still issued a bearer while a method is
rolled out), NOTISYNC_APPCHECK_ENABLED, NOTISYNC_APPCHECK_PROJECT_NUMBER,
NOTISYNC_APPCHECK_APP_IDS, NOTISYNC_JWT_PRIVATE_KEY_PATH, NOTISYNC_JWT_TTL_MS (default 7 days),
and NOTISYNC_POW_DIFFICULTY (leading-hex-zero proof-of-work on /v2/integrity/verify, default 4).
The broker exposes its JWT verification key at /.well-known/jwks.json, and an unauthenticated
/v2/status for clients to discover whether the broker is secured / requires integrity and whether
their token is still valid. The security-sensitive switches (NOTISYNC_SECURITY_ENABLED,
NOTISYNC_INTEGRITY_REQUIRED, NOTISYNC_APPCHECK_ENABLED) are read from the environment / system
properties only — never from local.properties.
Client integrity is verified via Firebase App Check (the broker validates the App Check token locally
against the App Check JWKS — no Google API credentials needed for it). For real FCM, give the server
Application Default Credentials: gcloud auth application-default login (local), or mount a
service-account key and set GOOGLE_APPLICATION_CREDENTIALS + NOTISYNC_FCM_ENABLED=true.
To enable APNs delivery for the iOS client, mount the Apple Auth Key .p8 file and set
NOTISYNC_APNS_ENABLED=true, NOTISYNC_APNS_TEAM_ID, NOTISYNC_APNS_KEY_ID,
NOTISYNC_APNS_PRIVATE_KEY_PATH, and NOTISYNC_APNS_TOPIC to the iOS app bundle identifier.
./gradlew :app:assembleDebug # APK at app/build/outputs/apk/debug/app/google-services.json (for the extrawdw-notifly Firebase project) is already in place.
In Settings → Broker URL, point the app at your broker. From the Android emulator, use
ws://10.0.2.2:8080 (host loopback); on a device, use your machine's LAN address.
Pairing is mutual QR exchange of self-signed client cards. The QR carries only public key
material, so the optical channel is the trust anchor (no relay can substitute keys) and the
clientId fingerprint is the human-verifiable safety number. The QR encodes a verified Android App
Link (https://notisync.apps.extrawdw.net/pair?...), so Camera/QR scanner apps can open NotiSync's
pairing screen directly and show a trust prompt with the device details. On each device:
Devices → Pair a device, show your code, then trust the other device's signed card. Both add each
other as trusted peers.