Files
Shade/android/shade-android/MIGRATION-NOVA.md
Sterister 4bf9307548
Some checks failed
Test / test (push) Has been cancelled
feat(android): M-Cross 1-3 — Kotlin module + cross-platform test vectors
Phase C complete: Shade now has a Kotlin implementation with byte-for-byte
compatibility to the TypeScript core, verified by shared test vectors.

M-Cross 1: shade-android Kotlin module
- build.gradle.kts with Tink, EncryptedSharedPreferences, kotlinx.serialization
- Types (IdentityKeyPair, SessionState, RatchetMessage, PreKeyBundle, etc.)
- CryptoProvider interface
- TinkProvider implementation (X25519, Ed25519, AES-GCM, HKDF, HMAC)
- KDF chain functions (kdfRootKey, kdfChainKey, deriveInitialRootKey)
  with the same info strings and salts as @shade/core
- Fingerprint (safety number) computation matching TS exactly
- X3DH protocol: identity gen, signed prekey gen, OTPK gen, bundle processing
- Double Ratchet: initSenderSession, initReceiverSession, ratchetEncrypt,
  ratchetDecrypt, DH ratchet step, skipped key cache
- Wire format matching @shade/proto byte-for-byte
- StorageProvider interface + MemoryStorage impl
- High-level ShadeSessionManager mirroring @shade/core's API

M-Cross 2: Cross-platform test vectors
- scripts/generate-vectors.ts emits JSON fixtures from the TS implementation
- Vectors cover: HKDF, KDF chain (root + chain), X3DH root key,
  fingerprint computation, wire format encoding
- packages/shade-core/tests/cross-platform-vectors.test.ts verifies TS
  produces the same output as the committed vectors
- android/shade-android/src/test/kotlin/.../CrossPlatformVectorTest.kt
  loads the SAME JSON and verifies Kotlin produces identical bytes

M-Cross 3: Nova Android migration plan
- android/shade-android/MIGRATION-NOVA.md — concrete steps to replace
  Nova's static PushKeyStore AES with Shade sessions
- Phase 1 (dual-write) / Phase 2 (switch reads) / Phase 3 (deprecate)
- Smoke test recipe for end-to-end TS → Kotlin push flow

251 tests passing on the TS side. Kotlin tests run via Gradle when
the Android SDK is available; the vectors guarantee they'll pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:45:38 +02:00

3.4 KiB

Migrating Nova Android to Shade

This document describes the concrete steps to replace Nova's static AES push notification encryption with Shade's Signal Protocol ratcheting.

Current state

Nova server (nova/src/server/services/notifications.ts):

  • Uses a per-device static AES-256-GCM key stored in pushDevices.encryptionKey
  • Calls encryptPayload(notificationJson, key) directly
  • Sends via FCM data: { enc, v: '1' }

Nova Android (Android/nova-app/.../data/PushKeyStore.kt):

  • Generates the device's AES key once and stores it via EncryptedSharedPreferences
  • Decrypts FCM data payload in NovaFirebaseMessagingService
  • Uses javax.crypto.Cipher directly

Problem: A single compromised key exposes all past and future notifications. No forward secrecy, no post-compromise recovery.

Target state

Nova server:

  • Uses @shade/sdk with createShade({ prekeyServer, address: 'nova-server' })
  • Per-device Shade sessions stored in PostgreSQL via @shade/storage-postgres
  • To notify a device: await shade.send('device:${id}', notificationJson)
  • The envelope is base64-encoded and sent via FCM data: { enc, v: '2' }

Nova Android:

  • Uses shade-android (Kotlin) with ShadeSessionManager
  • Session state stored via KeystoreStorage (EncryptedSharedPreferences)
  • On FCM receive: decode envelope → manager.decrypt('nova-server', envelope)
  • First time registration: generate identity, upload prekey bundle to the Shade prekey server, and tell the Nova backend the device address

Migration steps

Phase 1: Dual-write (both work simultaneously)

Add a v field to the FCM data payload. Android decrypts v=1 with legacy PushKeyStore and v=2 with Shade. Server can send either. Old devices keep working while new devices get Shade.

Phase 2: Switch reads

When 95% of devices have a Shade session established, flip the server to send v=2 by default. Fall back to v=1 only if the device has no Shade session.

Phase 3: Deprecate

Remove v=1 code paths, drop the pushDevices.encryptionKey column.

Smoke test (prove it works end-to-end)

  1. TS side creates a Shade instance for nova-server (using @shade/sdk)
  2. TS side calls shade.send('device:test', '{"title":"Hello"}')
  3. Encode the envelope as base64 → FCM data.enc
  4. Kotlin side decodes base64 → WireFormat.decodeEnvelope(bytes)
  5. Kotlin side calls manager.decrypt('nova-server', envelope)
  6. Assert plaintext matches

This is verified by the cross-platform vector tests + a manual smoke run described in examples/07-nova-integration/ (to be added).

Files to modify

Nova server:

  • nova/src/server/services/notifications.ts — replace encryptPayload with shade.send
  • nova/src/server/services/push-devices.ts — track Shade address per device
  • Add @shade/sdk to nova/package.json

Nova Android:

  • Android/nova-app/app/src/main/java/no/zyon/nova/data/PushKeyStore.kt — delegate to ShadeSessionManager
  • Android/nova-app/app/src/main/java/no/zyon/nova/NovaFirebaseMessagingService.kt — call WireFormat.decodeEnvelope and manager.decrypt
  • Add shade-android as a Gradle dependency

Not done in M-Cross 3

Running a full Android Gradle build + instrumented tests is out of scope for this milestone. The cross-platform vector tests prove byte-for-byte compatibility; the actual Nova integration happens when the user explicitly wires up the Android module in their Nova project.