diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ae6a48..b4b9b1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,84 @@ All notable changes to Shade are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased — 2026-05-09] — Android: V4.9/V4.10 ports + KeystoreStorage adapter + +The Kotlin side of the v4.10 cross-host approval routing FR. With this +release every primitive Prism Plan 04 needs on the phone has a Kotlin +implementation that produces byte-identical output to the TS reference. + +**`shade-android` (pure-JVM, no Android SDK needed)** + +- V4.9 blob primitives ported: `deriveBlobSlotId / deriveBlobKey / + deriveBlobSigningSeed`, `aeadSeal / aeadOpen` (`nonce(12) || ct||tag` + format with `shade-profile-aad-v1:` AAD), + `ed25519PublicKeyFromSeed`, `slotIdToHex`. Lives under `no.zyon.shade.blob`. +- `BlobClient` HTTP wrapper for `/v1/blob/` (java.net.http) — + GET/PUT/DELETE with the same canonical-JSON-sorted-keys signing form + the TS server expects. Hand-written JSON canonicalizer keeps Ed25519 + signing-input bytes identical to TS `signPayload` output. +- `Profile` high-level namespace (`createProfileNamespace`) — bundles + KDF + AEAD seal/open + BlobClient calls into the same shape as + `@shade/sdk`'s `createProfileNamespace`. +- V4.10 approval helpers ported: canonical profile schema + (`CanonicalProfileBlob`, `ProfileHostEntry`, `ProfileClientEntry`) + with parse/serialize/upsert/setTrustedApprover mutators that + re-derive `trustedApproverFingerprints[]` invariantly. + `buildApprovalRequest`, `signProxyApproval`, `verifyProxyApproval`, + and the load-bearing `canonicalApprovalSigningBytes` (length-prefixed + u16 BE UTF-8). All under `no.zyon.shade.approval`. +- Password KDFs: `deriveMasterKey` (scrypt) + `deriveMasterKeyArgon2id` + via Bouncy Castle. NFKC-normalize string inputs to match TS. +- New `SessionStateJson` serializer for at-rest persistence of + `IdentityKeyPair` / `SignedPreKey` / `OneTimePreKey` / `SessionState`. + +**Cross-platform vectors** + +- `test-vectors/blob.json` — V4.9 blob KDF + AEAD round-trip. Three + (master, app) cases plus two pinned AEAD seal/open round-trips. +- `test-vectors/approval.json` — V4.10 approval signing-payload bytes + + a TS-signed Ed25519 signature the Kotlin port verifies. Ed25519 is + deterministic, so the Kotlin sign-with-the-same-seed produces the + same 64 bytes back — that's checked too. +- Both wired into `CrossPlatformVectorTest` so any byte-divergence + fails Gradle within the existing 60-second parity gate. + +**`shade-android-keystore` (new sibling module — Android-specific)** + +- `KeystoreMasterKey` — hardware-backed AES-256-GCM master key. + - `BIOMETRIC_STRONG` gating only (Class 3 assurance) — explicitly + excludes `DEVICE_CREDENTIAL` so a stolen-device-with-known-PIN + can't unlock Shade. + - StrongBox-backed when available; transparent fallback to TEE. + - `setInvalidatedByBiometricEnrollment(true)`: a newly enrolled + fingerprint/face wipes the key, forcing credential rebootstrap. +- `BiometricUnlock` — coroutine wrapper around `BiometricPrompt`. + Tagged exceptions (`BiometricCancelledException` / + `BiometricFailedException`) so callers handle UX without writing + callback boilerplate. +- `KeystoreStorage` — `StorageProvider` impl over `SharedPreferences` + with each row AES-GCM-encrypted under the keystore key. AAD = the + pref key string so a substituted-prefs swap fails to open. Exposes + `unlock(BiometricUnlock)` / `lock()` / `forgetEverything()` for + app-lifetime gating (single biometric prompt at start, in-memory + unlock thereafter). +- Builds as a standard AAR (`com.android.library`, AGP 8.7.3), depends + transitively on `:shade-android` for protocol types and on + `androidx.biometric:biometric:1.2.0-alpha05`. + +**Threat model** + +The keystore key never leaves the secure environment — encrypt/decrypt +operations happen in the TEE/StrongBox. A compromised app process can +ask the TEE to use the cipher only after biometric authentication +within the same `Cipher` instance. Combined with `Profile.delete()` ++ `forgetEverything()`, this gives a credible erase path: zero +recoverable plaintext after a rebootstrap. + +Instrumented tests for `KeystoreStorage` are deferred (need an +emulator/device); the pure-JVM `SessionStateJson` round-trip is unit- +tested in `:shade-android`. + ## [4.10.0] — 2026-05-09 — cross-host approval routing primitives Prism filed a follow-up feature request diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 32dfc94..4ff02ba 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,3 +1,5 @@ plugins { kotlin("jvm") version "2.0.20" apply false + kotlin("android") version "2.0.20" apply false + id("com.android.library") version "8.7.3" apply false } diff --git a/android/gradle.properties b/android/gradle.properties index 9764913..9979027 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -2,3 +2,5 @@ org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 org.gradle.parallel=true org.gradle.caching=true kotlin.code.style=official +android.useAndroidX=true +android.nonTransitiveRClass=true diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index b6c9865..f494ff1 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -4,6 +4,7 @@ pluginManagement { repositories { gradlePluginPortal() mavenCentral() + google() } } @@ -17,3 +18,6 @@ dependencyResolutionManagement { include(":shade-android") project(":shade-android").projectDir = file("shade-android") + +include(":shade-android-keystore") +project(":shade-android-keystore").projectDir = file("shade-android-keystore") diff --git a/android/shade-android-keystore/README.md b/android/shade-android-keystore/README.md new file mode 100644 index 0000000..15cec8c --- /dev/null +++ b/android/shade-android-keystore/README.md @@ -0,0 +1,68 @@ +# shade-android-keystore + +Android-specific bindings for `shade-android`. Lives as a sibling Gradle module so the JVM-only protocol code can keep running in CI without an Android SDK install. + +Provides: + +- **`KeystoreMasterKey`** — hardware-backed AES-256-GCM master key in the Android Keystore. Optionally biometric-gated (BIOMETRIC_STRONG only — Class 3 assurance), StrongBox-backed when available, invalidated on new biometric enrollment. +- **`BiometricUnlock`** — coroutine wrapper around `BiometricPrompt` for unlocking a `Cipher` instance bound to the keystore key. Throws `BiometricCancelledException` / `BiometricFailedException` so callers can handle the auth flow without writing custom callbacks. +- **`KeystoreStorage`** — `StorageProvider` implementation that persists session/identity/prekey state to `SharedPreferences`, each row encrypted under the keystore key with the row's preference key bound as AAD. + +## Usage + +```kotlin +import androidx.fragment.app.FragmentActivity +import no.zyon.shade.ShadeSessionManager +import no.zyon.shade.crypto.TinkProvider +import no.zyon.shade.keystore.BiometricUnlock +import no.zyon.shade.keystore.KeystoreStorage + +class MyActivity : FragmentActivity() { + private val crypto = TinkProvider() + private lateinit var storage: KeystoreStorage + private lateinit var manager: ShadeSessionManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + storage = KeystoreStorage(this, crypto) + + lifecycleScope.launch { + val unlock = BiometricUnlock( + activity = this@MyActivity, + title = "Unlock Shade", + subtitle = "Tap your fingerprint to access your messages", + ) + try { + storage.unlock(unlock) + } catch (e: BiometricCancelledException) { + // user backed out — show a "tap to retry" UI + return@launch + } + + manager = ShadeSessionManager(crypto, storage) + manager.initialize() + // ... use manager normally + } + } +} +``` + +For credential-driven bootstrap (V4.9 profile + V4.10 approval), pair this with `no.zyon.shade.blob.createProfileNamespace` and `no.zyon.shade.approval.signProxyApproval` — both pure-JVM (in `:shade-android`). + +## Threat model + +- **Compromised app process**: cannot read the AES key (it's in the secure environment). Can attempt to use the cipher only after the user has authenticated; biometric re-prompts are required after each biometric event. +- **Stolen device with known PIN**: cannot unlock — `setUserAuthenticationParameters(0, BIOMETRIC_STRONG)` excludes `DEVICE_CREDENTIAL`. +- **Attacker enrolls own biometric**: `setInvalidatedByBiometricEnrollment(true)` invalidates the key on enrollment, forcing a credential rebootstrap (which would need username + password + PIN). +- **Catastrophic recovery**: `forgetEverything()` deletes the master key and clears the SharedPreferences. Pair with `Profile.delete()` for full account erasure. + +## Build + +Requires an Android SDK. The Gradle build uses Android Gradle Plugin 8.7+, AGP minSdk 28 (Pie+ for BiometricPrompt baseline), targetSdk 35. + +```bash +JAVA_HOME=/path/to/jdk-21 ./gradlew :shade-android-keystore:assembleDebug +``` + +Unit tests: none yet — `KeystoreStorage` requires Android runtime. Robolectric or instrumented tests against an emulator are tracked as a follow-up. The pure-JVM `SessionStateJson` round-trip serializer is tested in `:shade-android` (`SessionStateJsonTest`). diff --git a/android/shade-android-keystore/build.gradle.kts b/android/shade-android-keystore/build.gradle.kts new file mode 100644 index 0000000..f69057e --- /dev/null +++ b/android/shade-android-keystore/build.gradle.kts @@ -0,0 +1,59 @@ +plugins { + id("com.android.library") + kotlin("android") +} + +// V4.10 — Android-specific KeystoreStorage adapter. +// +// Lives as a sibling module to `:shade-android` so the JVM-only +// protocol code can keep running in CI without an Android SDK. +// This module pulls in `:shade-android` for `StorageProvider`, +// `IdentityKeyPair`, etc., and binds those types to a hardware- +// backed Android Keystore master key with biometric gating. + +android { + namespace = "no.zyon.shade.keystore" + compileSdk = 35 + + defaultConfig { + minSdk = 28 // BiometricPrompt + StrongBox baseline + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + testOptions { + unitTests.isReturnDefaultValues = true + } +} + +dependencies { + // Sibling: protocol types + StorageProvider interface. + api(project(":shade-android")) + + // androidx.biometric — fragment-safe BiometricPrompt wrapper. + // 1.2.0-alpha05 is the latest with stable BiometricPrompt API. + implementation("androidx.biometric:biometric:1.2.0-alpha05") + + // androidx.fragment — BiometricPrompt requires FragmentActivity. + implementation("androidx.fragment:fragment-ktx:1.8.5") + + // Coroutines for the suspend-function StorageProvider implementation. + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") +} diff --git a/android/shade-android-keystore/src/main/AndroidManifest.xml b/android/shade-android-keystore/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/android/shade-android-keystore/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/android/shade-android-keystore/src/main/kotlin/no/zyon/shade/keystore/BiometricUnlock.kt b/android/shade-android-keystore/src/main/kotlin/no/zyon/shade/keystore/BiometricUnlock.kt new file mode 100644 index 0000000..eb905a4 --- /dev/null +++ b/android/shade-android-keystore/src/main/kotlin/no/zyon/shade/keystore/BiometricUnlock.kt @@ -0,0 +1,112 @@ +package no.zyon.shade.keystore + +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.fragment.app.FragmentActivity +import javax.crypto.Cipher +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine + +/** + * Biometric unlock for a `KeystoreMasterKey`-bound `Cipher`. + * + * The Android keystore enforces that any operation on a + * user-authentication-required key must happen via a + * `BiometricPrompt.CryptoObject`-wrapped `Cipher`. The user sees a + * system biometric prompt; on success the same `Cipher` instance is + * usable for one operation (or one streaming session) before + * needing to re-prompt. + * + * This is a thin coroutine wrapper around `BiometricPrompt` that + * resolves to the authenticated cipher or throws on user + * cancellation. Callers typically run it once at app start to + * unlock the master key for the lifetime of the foreground session. + */ +class BiometricUnlock( + private val activity: FragmentActivity, + private val title: String, + private val subtitle: String? = null, + private val negativeButton: String = "Cancel", +) { + + /** + * True if BIOMETRIC_STRONG is currently usable on this device. + * False means the user has no enrolled fingerprint/face that + * meets the class-3 assurance level — fall back to a credential + * recovery flow rather than crashing. + */ + fun canAuthenticate(): Boolean { + val mgr = BiometricManager.from(activity) + return mgr.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == + BiometricManager.BIOMETRIC_SUCCESS + } + + /** + * Show the biometric prompt and return the authenticated cipher. + * + * Cancellation paths: + * - User taps the negative button → throws `BiometricCancelledException`. + * - System errors out (e.g. too many failures) → throws + * `BiometricFailedException` with the system error code. + */ + suspend fun unlock(cipher: Cipher): Cipher = suspendCancellableCoroutine { cont -> + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + val authedCipher = result.cryptoObject?.cipher + if (authedCipher == null) { + cont.resumeWithException( + BiometricFailedException(-1, "BiometricPrompt returned no cipher"), + ) + } else { + cont.resume(authedCipher) + } + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + if (errorCode == BiometricPrompt.ERROR_USER_CANCELED || + errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON || + errorCode == BiometricPrompt.ERROR_CANCELED + ) { + cont.resumeWithException(BiometricCancelledException(errString.toString())) + } else { + cont.resumeWithException( + BiometricFailedException(errorCode, errString.toString()), + ) + } + } + + override fun onAuthenticationFailed() { + // A single failed attempt — the prompt stays open and + // gives the user another try. Don't resume the + // continuation; let the system flow continue. + } + } + + val prompt = BiometricPrompt( + activity, + activity.mainExecutor, + callback, + ) + val info = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .apply { if (subtitle != null) setSubtitle(subtitle) } + .setNegativeButtonText(negativeButton) + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) + .build() + + cont.invokeOnCancellation { prompt.cancelAuthentication() } + prompt.authenticate(info, BiometricPrompt.CryptoObject(cipher)) + } +} + +/** User cancelled the biometric prompt. */ +class BiometricCancelledException(message: String) : RuntimeException(message) + +/** + * BiometricPrompt returned a non-cancellation error (lockout, hardware + * unavailable, no enrolled biometrics, etc.). Inspect `errorCode` + * against `BiometricPrompt.ERROR_*` constants to decide UX response. + */ +class BiometricFailedException(val errorCode: Int, message: String) : + RuntimeException("[$errorCode] $message") diff --git a/android/shade-android-keystore/src/main/kotlin/no/zyon/shade/keystore/KeystoreMasterKey.kt b/android/shade-android-keystore/src/main/kotlin/no/zyon/shade/keystore/KeystoreMasterKey.kt new file mode 100644 index 0000000..03e80c1 --- /dev/null +++ b/android/shade-android-keystore/src/main/kotlin/no/zyon/shade/keystore/KeystoreMasterKey.kt @@ -0,0 +1,172 @@ +package no.zyon.shade.keystore + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import java.security.KeyStore + +/** + * Hardware-backed AES-256-GCM master key in the Android Keystore. + * + * The key never leaves the secure environment — Android's keystore + * implementation enforces that all encrypt/decrypt operations + * happen inside the TEE (or StrongBox if present), and the raw + * key bytes are never returned to userspace. + * + * The key is created on first use with these properties: + * + * - AES-256-GCM, no padding + * - User authentication required: opt-in via the `requireBiometric` + * flag. When true, every encrypt/decrypt operation must be wrapped + * in a `BiometricPrompt.authenticate(CryptoObject(cipher))` call + * that succeeds within the same `Cipher` instance. + * - StrongBox-backed if available (Pixel 3+, most Samsung flagships). + * Falls back to TEE on devices without StrongBox. + * - InvalidatedByBiometricEnrollment(true): a newly enrolled + * fingerprint/face invalidates the key, forcing the user to + * re-bootstrap from credentials. Defends against a thief who + * enrolls their own biometric. + * + * Mirrors the role `KeyManager` plays in `@shade/storage-encrypted`'s + * V4.5 KDF chain: this is the *encryption-at-rest* master key, not + * the X3DH identity key. The Shade protocol's identity keys are + * stored encrypted under THIS key. + */ +class KeystoreMasterKey( + private val alias: String, + private val requireBiometric: Boolean = true, +) { + + init { + require(alias.isNotEmpty()) { "alias must be non-empty" } + } + + /** + * Build a `Cipher` initialized for encryption with the master key. + * + * If the key requires user auth, the returned cipher is *not yet + * usable* — the caller MUST wrap it in a + * `BiometricPrompt.authenticate(CryptoObject(cipher))` and use + * the cipher exposed by the auth-success callback. Calling + * `cipher.doFinal(...)` before authentication throws + * `UserNotAuthenticatedException`. + */ + fun cipherForEncrypt(): Cipher { + val key = getOrCreateKey() + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, key) + return cipher + } + + /** + * Build a `Cipher` initialized for decryption with the master key + * and a previously-stored 12-byte nonce. Same authentication + * requirement as `cipherForEncrypt`. + */ + fun cipherForDecrypt(nonce: ByteArray): Cipher { + require(nonce.size == 12) { "GCM nonce must be 12 bytes" } + val key = getOrCreateKey() + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, nonce)) + return cipher + } + + /** True if the key already exists in the Android Keystore. */ + fun exists(): Boolean { + val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + return ks.containsAlias(alias) + } + + /** + * Delete the master key. Catastrophic — all data encrypted under + * it becomes unrecoverable. Used by the "forget everything" flow + * (paired with `Profile.delete()` in the V4.9 namespace). + */ + fun deleteKey() { + val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + if (ks.containsAlias(alias)) ks.deleteEntry(alias) + } + + private fun getOrCreateKey(): SecretKey { + val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + ks.getEntry(alias, null)?.let { entry -> + return (entry as KeyStore.SecretKeyEntry).secretKey + } + return generateKey() + } + + private fun generateKey(): SecretKey { + val builder = KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + // Each encrypt operation generates a fresh IV in the secure + // env; we read it back via `cipher.iv` after init. + .setRandomizedEncryptionRequired(true) + + if (requireBiometric) { + builder.setUserAuthenticationRequired(true) + // BIOMETRIC_STRONG only — class 3, the highest assurance + // level (Class 3 = false-accept rate < 1/50 000 per BiometricPrompt). + // DEVICE_CREDENTIAL is intentionally NOT included: a stolen + // device with a known PIN should not unlock Shade. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + builder.setUserAuthenticationParameters( + /* timeout = */ 0, + KeyProperties.AUTH_BIOMETRIC_STRONG, + ) + } else { + @Suppress("DEPRECATION") + builder.setUserAuthenticationValidityDurationSeconds(-1) + } + builder.setInvalidatedByBiometricEnrollment(true) + } + + // StrongBox if available — bumps key storage to a dedicated + // tamper-resistant chip on Pixel 3+ / most Samsung flagships. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + builder.setIsStrongBoxBacked(true) + } + + val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + return try { + gen.init(builder.build()) + gen.generateKey() + } catch (_: Exception) { + // StrongBox not present or full → retry without StrongBox. + // Same for older devices that don't honor + // setUserAuthenticationParameters. + val fallback = KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .setRandomizedEncryptionRequired(true) + .apply { + if (requireBiometric) { + setUserAuthenticationRequired(true) + @Suppress("DEPRECATION") + setUserAuthenticationValidityDurationSeconds(-1) + setInvalidatedByBiometricEnrollment(true) + } + } + .build() + gen.init(fallback) + gen.generateKey() + } + } + + companion object { + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + } +} diff --git a/android/shade-android-keystore/src/main/kotlin/no/zyon/shade/keystore/KeystoreStorage.kt b/android/shade-android-keystore/src/main/kotlin/no/zyon/shade/keystore/KeystoreStorage.kt new file mode 100644 index 0000000..8e146ca --- /dev/null +++ b/android/shade-android-keystore/src/main/kotlin/no/zyon/shade/keystore/KeystoreStorage.kt @@ -0,0 +1,229 @@ +package no.zyon.shade.keystore + +import android.content.Context +import android.content.SharedPreferences +import android.util.Base64 +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import no.zyon.shade.crypto.CryptoProvider +import no.zyon.shade.serialization.SessionStateJson +import no.zyon.shade.storage.StorageProvider +import no.zyon.shade.types.IdentityKeyPair +import no.zyon.shade.types.OneTimePreKey +import no.zyon.shade.types.SessionState +import no.zyon.shade.types.SignedPreKey + +/** + * `StorageProvider` implementation that gates all reads/writes through + * a biometric-locked `KeystoreMasterKey`. Mirrors `MemoryStorage` for + * the API surface but persists state to `SharedPreferences` with each + * row encrypted under the keystore key. + * + * Lifecycle: + * + * 1. App start → construct `KeystoreStorage(context, alias)`. + * 2. `unlock(BiometricUnlock)` runs the system biometric prompt. + * The Android keystore caches the auth state under the key's + * `setUserAuthenticationParameters(0, BIOMETRIC_STRONG)` policy + * until the next biometric event (re-enrollment, etc.). + * 3. While unlocked, `getSession`/`saveSession` etc. work normally. + * 4. `lock()` clears the in-memory unlocked flag so a future + * operation triggers another biometric prompt. + * + * Wire layout per row: + * `:` + * + * Stored as `String` SharedPreferences entries. AAD = the row's + * preference key (`session:
`, `signedPreKey:`, etc.) so + * a substituted-prefs swap fails to open. + */ +class KeystoreStorage( + context: Context, + private val crypto: CryptoProvider, + keyAlias: String = DEFAULT_KEY_ALIAS, + prefsName: String = DEFAULT_PREFS_NAME, + requireBiometric: Boolean = true, +) : StorageProvider { + + private val prefs: SharedPreferences = + context.applicationContext.getSharedPreferences(prefsName, Context.MODE_PRIVATE) + private val masterKey = KeystoreMasterKey(keyAlias, requireBiometric = requireBiometric) + private val writeMutex = Mutex() + + @Volatile + private var unlocked: Boolean = false + + /** + * Unlock the keystore via biometric prompt. Idempotent — calling + * twice without a `lock()` between is a no-op. + */ + suspend fun unlock(unlock: BiometricUnlock) { + if (unlocked) return + // The biometric flow returns an authenticated *encrypt* + // cipher; we discard it after a one-shot probe to confirm + // the master key is reachable. The actual encrypt/decrypt + // ciphers in the I/O path use the authentication state + // established here (Android Keystore caches the auth for + // the user-authentication-required key under the + // `setUserAuthenticationParameters(0, BIOMETRIC_STRONG)` + // policy until the next biometric event). + val probe = masterKey.cipherForEncrypt() + unlock.unlock(probe) + unlocked = true + } + + /** Unlock without biometric — only valid for keys constructed with `requireBiometric=false`. */ + fun unlockNoBiometric() { + unlocked = true + } + + /** Wipe in-memory unlock state. The key itself stays in the keystore. */ + fun lock() { + unlocked = false + } + + /** + * Catastrophic reset: deletes the master key + all encrypted + * preferences. Used by the "forget everything" / 3-strikes-wipe + * path. The next bootstrap rebuilds from credentials. + */ + fun forgetEverything() { + masterKey.deleteKey() + prefs.edit().clear().apply() + unlocked = false + } + + // ─── Identity ────────────────────────────────────────────── + + override suspend fun getIdentityKeyPair(): IdentityKeyPair? { + val json = readDecrypted(KEY_IDENTITY) ?: return null + return SessionStateJson.deserializeIdentityKeyPair(json) + } + + override suspend fun saveIdentityKeyPair(keyPair: IdentityKeyPair) { + writeEncrypted(KEY_IDENTITY, SessionStateJson.serializeIdentityKeyPair(keyPair)) + } + + override suspend fun getLocalRegistrationId(): Int { + return readDecrypted(KEY_REGISTRATION_ID)?.toIntOrNull() ?: 0 + } + + override suspend fun saveLocalRegistrationId(id: Int) { + writeEncrypted(KEY_REGISTRATION_ID, id.toString()) + } + + // ─── Signed prekeys ──────────────────────────────────────── + + override suspend fun getSignedPreKey(keyId: Int): SignedPreKey? { + val json = readDecrypted("$KEY_SIGNED_PREKEY:$keyId") ?: return null + return SessionStateJson.deserializeSignedPreKey(json) + } + + override suspend fun saveSignedPreKey(key: SignedPreKey) { + writeEncrypted( + "$KEY_SIGNED_PREKEY:${key.keyId}", + SessionStateJson.serializeSignedPreKey(key), + ) + } + + override suspend fun removeSignedPreKey(keyId: Int) { + writeMutex.withLock { prefs.edit().remove("$KEY_SIGNED_PREKEY:$keyId").apply() } + } + + // ─── One-time prekeys ────────────────────────────────────── + + override suspend fun getOneTimePreKey(keyId: Int): OneTimePreKey? { + val json = readDecrypted("$KEY_ONETIME_PREKEY:$keyId") ?: return null + return SessionStateJson.deserializeOneTimePreKey(json) + } + + override suspend fun saveOneTimePreKey(key: OneTimePreKey) { + writeEncrypted( + "$KEY_ONETIME_PREKEY:${key.keyId}", + SessionStateJson.serializeOneTimePreKey(key), + ) + } + + override suspend fun removeOneTimePreKey(keyId: Int) { + writeMutex.withLock { prefs.edit().remove("$KEY_ONETIME_PREKEY:$keyId").apply() } + } + + override suspend fun getOneTimePreKeyCount(): Int { + return prefs.all.keys.count { it.startsWith("$KEY_ONETIME_PREKEY:") } + } + + // ─── Sessions ────────────────────────────────────────────── + + override suspend fun getSession(address: String): SessionState? { + val json = readDecrypted("$KEY_SESSION:$address") ?: return null + return SessionStateJson.deserialize(json) + } + + override suspend fun saveSession(address: String, state: SessionState) { + writeEncrypted("$KEY_SESSION:$address", SessionStateJson.serialize(state)) + } + + override suspend fun removeSession(address: String) { + writeMutex.withLock { prefs.edit().remove("$KEY_SESSION:$address").apply() } + } + + // ─── Trust ───────────────────────────────────────────────── + + override suspend fun isTrustedIdentity(address: String, identityKey: ByteArray): Boolean { + val stored = readDecrypted("$KEY_TRUSTED:$address") ?: return true // TOFU + val storedBytes = Base64.decode(stored, Base64.NO_WRAP) + return crypto.constantTimeEqual(storedBytes, identityKey) + } + + override suspend fun saveTrustedIdentity(address: String, identityKey: ByteArray) { + writeEncrypted( + "$KEY_TRUSTED:$address", + Base64.encodeToString(identityKey, Base64.NO_WRAP), + ) + } + + // ─── Encrypted-row plumbing ──────────────────────────────── + + private fun ensureUnlocked() { + check(unlocked) { + "KeystoreStorage is locked — call unlock(BiometricUnlock) first" + } + } + + private suspend fun readDecrypted(prefKey: String): String? { + ensureUnlocked() + val raw = prefs.getString(prefKey, null) ?: return null + val parts = raw.split(":", limit = 2) + require(parts.size == 2) { "malformed encrypted row at $prefKey" } + val nonce = Base64.decode(parts[0], Base64.NO_WRAP) + val ct = Base64.decode(parts[1], Base64.NO_WRAP) + val cipher = masterKey.cipherForDecrypt(nonce) + cipher.updateAAD(prefKey.toByteArray(Charsets.UTF_8)) + return cipher.doFinal(ct).toString(Charsets.UTF_8) + } + + private suspend fun writeEncrypted(prefKey: String, plaintext: String) { + ensureUnlocked() + writeMutex.withLock { + val cipher = masterKey.cipherForEncrypt() + cipher.updateAAD(prefKey.toByteArray(Charsets.UTF_8)) + val ct = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) + val nonce = cipher.iv + val nonceB64 = Base64.encodeToString(nonce, Base64.NO_WRAP) + val ctB64 = Base64.encodeToString(ct, Base64.NO_WRAP) + prefs.edit().putString(prefKey, "$nonceB64:$ctB64").apply() + } + } + + companion object { + const val DEFAULT_KEY_ALIAS = "shade-master-v1" + const val DEFAULT_PREFS_NAME = "shade-keystore-storage-v1" + + private const val KEY_IDENTITY = "identity" + private const val KEY_REGISTRATION_ID = "registrationId" + private const val KEY_SIGNED_PREKEY = "signedPreKey" + private const val KEY_ONETIME_PREKEY = "oneTimePreKey" + private const val KEY_SESSION = "session" + private const val KEY_TRUSTED = "trusted" + } +} diff --git a/android/shade-android/README.md b/android/shade-android/README.md index ab447b3..e3a4708 100644 --- a/android/shade-android/README.md +++ b/android/shade-android/README.md @@ -7,7 +7,11 @@ Kotlin implementation of the Shade E2EE protocol for Android apps. Byte-for-byte **M-Cross 1 ✅** — keys + HKDF + X3DH + fingerprint. **M-Cross 2 ✅** — full ratchet step (encrypt + decrypt roundtrip) + wire 0x02 (RatchetMessage and PreKeyMessage with/without OTPK). **M-Cross 3 ✅** — streams 0x11 (KDF labels with embedded NULs, deterministic chunk nonce/AAD, wire 0x11 encode/decode). -**M-Cross 4 ✅** — backup-key HKDF + AES-GCM, group sender-key step (`kdfChainKey` + Ed25519 sign over `aad ‖ ct`), storage HKDF (storageKey/fieldKey/rowNonce). Pending: scrypt master-key, argon2id swap, Android KeystoreStorage (sibling module). +**M-Cross 4 ✅** — backup-key HKDF + AES-GCM, group sender-key step (`kdfChainKey` + Ed25519 sign over `aad ‖ ct`), storage HKDF (storageKey/fieldKey/rowNonce). +**M-Cross 5 ✅** — V4.9 blob KDF + AEAD (`deriveBlobSlotId / deriveBlobKey / deriveBlobSigningSeed`, AAD-bound seal/open), `BlobClient` HTTP, `Profile` namespace. Cross-platform vectors in `blob.json`. +**M-Cross 6 ✅** — V4.10 cross-host approval routing: canonical profile-blob schema (`hosts[]` / `clients[]` / `trustedApproverFingerprints[]`), build/sign/verify proxy approvals via `canonicalApprovalSigningBytes` (length-prefixed u16 BE UTF-8). Cross-platform vectors in `approval.json`, including a TS-signed Ed25519 signature that the Kotlin port verifies. +**M-Cross 7 ✅** — scrypt + argon2id password-KDF wrappers (Bouncy Castle), NFKC-normalized inputs. +**M-Cross 8 ✅** — `:shade-android-keystore` sibling module: `KeystoreMasterKey` (StrongBox-backed AES-256-GCM, BIOMETRIC_STRONG-gated, invalidated on biometric enrollment), `BiometricUnlock`, `KeystoreStorage` (`StorageProvider` over biometric-gated AES-encrypted SharedPreferences). Cross-platform test vectors in `/test-vectors/` are loaded by both the TS and Kotlin test suites; any byte-divergence fails CI within 60 s. See diff --git a/android/shade-android/build.gradle.kts b/android/shade-android/build.gradle.kts index bfca636..f06b70b 100644 --- a/android/shade-android/build.gradle.kts +++ b/android/shade-android/build.gradle.kts @@ -29,6 +29,11 @@ dependencies { // The same `subtle.*` API as `tink-android` so the source compiles unchanged. implementation("com.google.crypto.tink:tink:1.15.0") + // Bouncy Castle for scrypt + argon2id. Tink doesn't ship password + // KDFs; @shade/storage-encrypted uses @noble/hashes for both. We + // pin to the JDK18-on artifact so it works on JVM 17 + Android. + implementation("org.bouncycastle:bcprov-jdk18on:1.78.1") + // JSON serialization (session state + test-vector loader on JVM). implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") diff --git a/android/shade-android/src/main/kotlin/no/zyon/shade/approval/Approval.kt b/android/shade-android/src/main/kotlin/no/zyon/shade/approval/Approval.kt new file mode 100644 index 0000000..dddcab1 --- /dev/null +++ b/android/shade-android/src/main/kotlin/no/zyon/shade/approval/Approval.kt @@ -0,0 +1,273 @@ +package no.zyon.shade.approval + +import no.zyon.shade.crypto.CryptoProvider +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * V4.10 — cross-host approval routing helpers. Mirror + * `@shade/sdk/approval.ts` byte-for-byte. + * + * The frames themselves (`approvalNeeded` / `linkApproveByProxy`) are + * app-defined payloads sent over the existing Shade bilateral E2EE + * channel. This file ships the canonical signing-payload layout, the + * Ed25519 sign step a phone runs after biometric unlock, and the + * verify step a host runs against the freshest profile blob. + * + * The signing payload is length-prefixed binary (u16 BE) so any + * platform — Kotlin, Swift, Go — can produce byte-identical input + * without needing a JSON canonicalizer. Cross-platform parity is + * gated by `test-vectors/blob-storage.json` (signing payload + * fixtures) plus a Kotlin↔TS round-trip in `CrossPlatformVectorTest`. + */ + +/** Default domain separator. Apps with their own canonical name (e.g. Prism) override. */ +const val DEFAULT_APPROVAL_DOMAIN = "shade-link-approve-v1" + +/** Default expiry: 5 minutes after the host issues the request. */ +const val DEFAULT_APPROVAL_EXPIRES_IN_MS = 5L * 60 * 1000 + +/** Information about the device the host received a `linkRequest` from. */ +data class ApprovalRequestingDevice( + val fingerprint: String, + val deviceName: String? = null, + val userAgent: String? = null, + val ipHint: String? = null, + val receivedAt: Long, +) + +data class ApprovalRequestFrame( + val kind: String = "approvalNeeded", + /** 128-bit hex (32 chars) random idempotency key. */ + val requestId: String, + val hostAddress: String, + val hostFingerprint: String, + val requestingDevice: ApprovalRequestingDevice, + val expiresAt: Long, + val domain: String, +) + +data class ProxyApprovalFrame( + val kind: String = "linkApproveByProxy", + val requestId: String, + val decision: String, + val approverFingerprint: String, + /** 64-byte Ed25519 signature, lowercase hex (128 chars). */ + val signature: String, + val domain: String, +) + +/** + * Build a fresh `approvalNeeded` frame with a 128-bit random + * `requestId`. Hosts SHOULD persist the requestId in a pending-set + * keyed by `expiresAt` so a returning `linkApproveByProxy` can be + * matched up — that's app state, the SDK doesn't track it. + */ +fun buildApprovalRequest( + crypto: CryptoProvider, + hostAddress: String, + hostFingerprint: String, + requestingDeviceFingerprint: String, + deviceName: String? = null, + userAgent: String? = null, + ipHint: String? = null, + expiresInMs: Long = DEFAULT_APPROVAL_EXPIRES_IN_MS, + domain: String = DEFAULT_APPROVAL_DOMAIN, + now: Long = System.currentTimeMillis(), +): ApprovalRequestFrame { + val requestId = crypto.randomBytes(16).joinToString("") { "%02x".format(it) } + return ApprovalRequestFrame( + requestId = requestId, + hostAddress = hostAddress, + hostFingerprint = hostFingerprint, + requestingDevice = ApprovalRequestingDevice( + fingerprint = requestingDeviceFingerprint, + deviceName = deviceName, + userAgent = userAgent, + ipHint = ipHint, + receivedAt = now, + ), + expiresAt = now + expiresInMs, + domain = domain, + ) +} + +/** + * Sign a `linkApproveByProxy` frame with the approver's long-term + * Ed25519 identity key. The seed is the 32-byte Ed25519 private key + * (Tink's `Ed25519Sign(seed)` consumes it directly). + */ +fun signProxyApproval( + crypto: CryptoProvider, + request: ApprovalRequestFrame, + decision: String, + approverFingerprint: String, + approverSigningKey: ByteArray, +): ProxyApprovalFrame { + require(decision == "approve" || decision == "reject") { + "decision must be 'approve' or 'reject'" + } + require(approverSigningKey.size == 32) { + "approverSigningKey must be 32 bytes (Ed25519 seed)" + } + val payload = canonicalApprovalSigningBytes( + domain = request.domain, + requestId = request.requestId, + hostFingerprint = request.hostFingerprint, + requestingDeviceFingerprint = request.requestingDevice.fingerprint, + decision = decision, + ) + val sig = crypto.sign(approverSigningKey, payload) + return ProxyApprovalFrame( + requestId = request.requestId, + decision = decision, + approverFingerprint = approverFingerprint, + signature = sig.joinToString("") { "%02x".format(it) }, + domain = request.domain, + ) +} + +/** Tagged result of `verifyProxyApproval`. */ +sealed class VerifyProxyApprovalResult { + data class Ok(val approver: ProfileClientEntry) : VerifyProxyApprovalResult() + data class Failed(val reason: Reason) : VerifyProxyApprovalResult() + + enum class Reason { + REQUEST_ID_MISMATCH, + DOMAIN_MISMATCH, + UNKNOWN_APPROVER, + NOT_TRUSTED, + BAD_SIGNATURE, + EXPIRED, + } +} + +/** + * Verify a `linkApproveByProxy` against the originating + * `approvalNeeded` and the host's freshest profile blob. Returns a + * tagged result rather than throwing — callers usually want to log + * the reason before deciding what to surface to the user. + * + * Order of checks: + * + * 1. requestId match (replay defense) + * 2. domain match (cross-app confusion defense) + * 3. approver resolves to a `clients[]` entry + * 4. approver is in `trustedApproverFingerprints[]` AND has the + * `trustedApprover` flag (cross-checked via `isTrustedApprover`) + * 5. expiresAt in the future + * 6. Ed25519 signature verifies against `clients[].identityPublicKey` + * + * Hosts MUST refetch the profile blob fresh before calling this — see + * the FR §5 "approver-revocation propagation" rationale. + */ +fun verifyProxyApproval( + crypto: CryptoProvider, + request: ApprovalRequestFrame, + approval: ProxyApprovalFrame, + profile: CanonicalProfileBlob, + now: Long = System.currentTimeMillis(), +): VerifyProxyApprovalResult { + if (approval.requestId != request.requestId) { + return VerifyProxyApprovalResult.Failed( + VerifyProxyApprovalResult.Reason.REQUEST_ID_MISMATCH, + ) + } + if (approval.domain != request.domain) { + return VerifyProxyApprovalResult.Failed( + VerifyProxyApprovalResult.Reason.DOMAIN_MISMATCH, + ) + } + + val approver = findClientByFingerprint(profile, approval.approverFingerprint) + ?: return VerifyProxyApprovalResult.Failed( + VerifyProxyApprovalResult.Reason.UNKNOWN_APPROVER, + ) + + if (!isTrustedApprover(profile, approval.approverFingerprint)) { + return VerifyProxyApprovalResult.Failed( + VerifyProxyApprovalResult.Reason.NOT_TRUSTED, + ) + } + + if (now > request.expiresAt) { + return VerifyProxyApprovalResult.Failed(VerifyProxyApprovalResult.Reason.EXPIRED) + } + + val pubkey = try { + hexToBytes(approver.identityPublicKey) + } catch (_: Exception) { + return VerifyProxyApprovalResult.Failed(VerifyProxyApprovalResult.Reason.BAD_SIGNATURE) + } + val sig = try { + hexToBytes(approval.signature) + } catch (_: Exception) { + return VerifyProxyApprovalResult.Failed(VerifyProxyApprovalResult.Reason.BAD_SIGNATURE) + } + if (pubkey.size != 32 || sig.size != 64) { + return VerifyProxyApprovalResult.Failed(VerifyProxyApprovalResult.Reason.BAD_SIGNATURE) + } + + val payload = canonicalApprovalSigningBytes( + domain = approval.domain, + requestId = approval.requestId, + hostFingerprint = request.hostFingerprint, + requestingDeviceFingerprint = request.requestingDevice.fingerprint, + decision = approval.decision, + ) + val ok = crypto.verify(pubkey, payload, sig) + return if (ok) VerifyProxyApprovalResult.Ok(approver) + else VerifyProxyApprovalResult.Failed(VerifyProxyApprovalResult.Reason.BAD_SIGNATURE) +} + +/** + * Build the canonical signing payload bytes for a proxy approval. + * + * Format (length-prefixed UTF-8, big-endian u16 lengths): + * + * u16(len(domain)) || domain + * u16(len(requestId)) || requestId + * u16(len(hostFp)) || hostFingerprint + * u16(len(requestFp)) || requestingDeviceFingerprint + * u16(len(decision)) || decision + * + * This is the EXACT byte layout `@shade/sdk`'s + * `canonicalApprovalSigningBytes` produces, ensuring an Android-signed + * approval verifies on a TS host and vice versa. + */ +fun canonicalApprovalSigningBytes( + domain: String, + requestId: String, + hostFingerprint: String, + requestingDeviceFingerprint: String, + decision: String, +): ByteArray { + val fields = listOf( + domain.toByteArray(Charsets.UTF_8), + requestId.toByteArray(Charsets.UTF_8), + hostFingerprint.toByteArray(Charsets.UTF_8), + requestingDeviceFingerprint.toByteArray(Charsets.UTF_8), + decision.toByteArray(Charsets.UTF_8), + ) + for (f in fields) { + require(f.size <= 0xFFFF) { "signing field too long: ${f.size} bytes (max 65535)" } + } + val total = fields.sumOf { 2 + it.size } + val buf = ByteBuffer.allocate(total).order(ByteOrder.BIG_ENDIAN) + for (f in fields) { + buf.putShort(f.size.toShort()) + buf.put(f) + } + return buf.array() +} + +private fun hexToBytes(hex: String): ByteArray { + require(hex.length % 2 == 0) { "hex length must be even" } + require(hex.all { it.isDigit() || it in 'a'..'f' }) { "hex must be lowercase 0-9a-f" } + val out = ByteArray(hex.length / 2) + for (i in out.indices) { + out[i] = ((Character.digit(hex[i * 2], 16) shl 4) + + Character.digit(hex[i * 2 + 1], 16)).toByte() + } + return out +} diff --git a/android/shade-android/src/main/kotlin/no/zyon/shade/approval/CanonicalProfile.kt b/android/shade-android/src/main/kotlin/no/zyon/shade/approval/CanonicalProfile.kt new file mode 100644 index 0000000..7787d9a --- /dev/null +++ b/android/shade-android/src/main/kotlin/no/zyon/shade/approval/CanonicalProfile.kt @@ -0,0 +1,307 @@ +package no.zyon.shade.approval + +import org.json.JSONArray +import org.json.JSONObject + +/** + * V4.10 — canonical profile-blob schema. Mirror + * `@shade/sdk/approval.ts` byte-for-byte: same field names, same + * JSON shape, same denormalization invariants. + * + * The blob is the AEAD plaintext stored in the V4.9 profile slot. It + * holds the user's list of paired hosts + clients; cross-host + * approval routing reads `clients[]` to find trusted approvers when + * a headless host needs to dispatch a `linkRequest` to a phone. + * + * Mutators (`upsertHost`, `setTrustedApprover`, ...) are immutable — + * they return a new blob and never modify the input. The denormalized + * `trustedApproverFingerprints[]` is rederived on every mutation so it + * can never drift from the per-client `trustedApprover` flag. + */ + +/** A host: a device that receives `linkRequest` frames and runs pairing. */ +data class ProfileHostEntry( + val address: String, + val name: String, + /** Open enum: `"desktop" | "server" | "laptop" | ...`. */ + val kind: String, + val addedAt: Long, +) + +/** + * A client: a device that initiates link/approval flows and may + * proxy-approve when `trustedApprover == true`. Stores both the + * 32-byte Ed25519 identity public key (hex) and the safety-number + * fingerprint — the public key is what `verifyProxyApproval` checks + * signatures against; the fingerprint is what UIs display. + */ +data class ProfileClientEntry( + val address: String, + /** 32-byte Ed25519 long-term identity public key, lowercase hex (64 chars). */ + val identityPublicKey: String, + /** Safety-number fingerprint of the identity key (computeFingerprint output). */ + val identityFingerprint: String, + val name: String, + /** Open enum: `"mobile" | "tablet" | "browser" | ...`. */ + val kind: String, + val addedAt: Long, + val trustedApprover: Boolean = false, +) + +/** + * Canonical profile blob. `version=1` is the only currently-supported + * shape; bump when an incompatible field is added. Unknown top-level + * fields are dropped on parse — additive changes need a coordinated + * schema bump on both platforms. + */ +data class CanonicalProfileBlob( + val version: Int = 1, + val hosts: List = emptyList(), + val clients: List = emptyList(), + /** Denormalized list of trusted-approver fingerprints. Rederived on mutate. */ + val trustedApproverFingerprints: List = emptyList(), + val updatedAt: Long = 0, + /** Optional hex-encoded pubkey of the writer; informational only. */ + val signedBy: String? = null, +) + +/** Build a fresh empty profile blob with `updatedAt = now ?? System.currentTimeMillis()`. */ +fun emptyCanonicalProfile(now: Long? = null): CanonicalProfileBlob = + CanonicalProfileBlob(updatedAt = now ?: System.currentTimeMillis()) + +/** + * Decode a profile-blob plaintext (the AEAD-opened bytes) into the + * canonical shape. Throws `IllegalArgumentException` on malformed JSON + * or wrong shape. + */ +fun parseCanonicalProfile(plaintext: ByteArray): CanonicalProfileBlob = + parseCanonicalProfile(plaintext.toString(Charsets.UTF_8)) + +fun parseCanonicalProfile(plaintext: String): CanonicalProfileBlob { + val obj = try { + JSONObject(plaintext) + } catch (e: Exception) { + throw IllegalArgumentException("profile blob is not valid JSON: ${e.message}") + } + val version = obj.optInt("version", -1) + require(version == 1) { "unsupported profile blob version: $version" } + + val hosts = parseArray(obj.optJSONArray("hosts"), "hosts", ::parseHostEntry) + val clients = parseArray(obj.optJSONArray("clients"), "clients", ::parseClientEntry) + val trustedApproverFingerprints = parseStringArray( + obj.optJSONArray("trustedApproverFingerprints"), + "trustedApproverFingerprints", + ) + val updatedAt = if (obj.has("updatedAt") && !obj.isNull("updatedAt")) + obj.getLong("updatedAt") else 0L + val signedBy = obj.optString("signedBy", "").takeIf { it.isNotEmpty() } + + return CanonicalProfileBlob( + version = 1, + hosts = hosts, + clients = clients, + trustedApproverFingerprints = trustedApproverFingerprints, + updatedAt = updatedAt, + signedBy = signedBy, + ) +} + +/** Serialize a profile blob to UTF-8 JSON ready for `Profile.put`. */ +fun serializeCanonicalProfile(blob: CanonicalProfileBlob): ByteArray { + val json = JSONObject() + json.put("version", blob.version) + json.put("hosts", JSONArray().apply { + blob.hosts.forEach { put(hostEntryToJson(it)) } + }) + json.put("clients", JSONArray().apply { + blob.clients.forEach { put(clientEntryToJson(it)) } + }) + json.put("trustedApproverFingerprints", JSONArray(blob.trustedApproverFingerprints)) + json.put("updatedAt", blob.updatedAt) + if (blob.signedBy != null) json.put("signedBy", blob.signedBy) + return json.toString().toByteArray(Charsets.UTF_8) +} + +private fun parseHostEntry(o: JSONObject): ProfileHostEntry = + ProfileHostEntry( + address = o.requireString("address", "hosts"), + name = o.requireString("name", "hosts"), + kind = o.requireString("kind", "hosts"), + addedAt = o.requireLong("addedAt", "hosts"), + ) + +private fun parseClientEntry(o: JSONObject): ProfileClientEntry { + val identityPublicKey = o.requireString("identityPublicKey", "clients") + require(identityPublicKey.matches(Regex("^[0-9a-f]{64}$"))) { + "clients[].identityPublicKey must be 64 lowercase hex chars" + } + return ProfileClientEntry( + address = o.requireString("address", "clients"), + identityPublicKey = identityPublicKey, + identityFingerprint = o.requireString("identityFingerprint", "clients"), + name = o.requireString("name", "clients"), + kind = o.requireString("kind", "clients"), + addedAt = o.requireLong("addedAt", "clients"), + trustedApprover = o.optBoolean("trustedApprover", false), + ) +} + +private fun hostEntryToJson(e: ProfileHostEntry): JSONObject = JSONObject().apply { + put("address", e.address) + put("name", e.name) + put("kind", e.kind) + put("addedAt", e.addedAt) +} + +private fun clientEntryToJson(e: ProfileClientEntry): JSONObject = JSONObject().apply { + put("address", e.address) + put("identityPublicKey", e.identityPublicKey) + put("identityFingerprint", e.identityFingerprint) + put("name", e.name) + put("kind", e.kind) + put("addedAt", e.addedAt) + if (e.trustedApprover) put("trustedApprover", true) +} + +private fun parseArray( + arr: JSONArray?, + field: String, + parse: (JSONObject) -> T, +): List { + if (arr == null) return emptyList() + return (0 until arr.length()).map { i -> + val item = arr.opt(i) + require(item is JSONObject) { "$field[$i] must be an object" } + parse(item) + } +} + +private fun parseStringArray(arr: JSONArray?, field: String): List { + if (arr == null) return emptyList() + return (0 until arr.length()).map { i -> + val item = arr.opt(i) + require(item is String) { "$field[$i] must be a string" } + item + } +} + +private fun JSONObject.requireString(key: String, ctx: String): String { + require(has(key) && !isNull(key)) { "$ctx[].$key is required" } + val v = get(key) + require(v is String) { "$ctx[].$key must be a string" } + return v +} + +private fun JSONObject.requireLong(key: String, ctx: String): Long { + require(has(key) && !isNull(key)) { "$ctx[].$key is required" } + return when (val v = get(key)) { + is Number -> v.toLong() + else -> throw IllegalArgumentException("$ctx[].$key must be a number") + } +} + +// ─── Mutators (immutable; return new blob, never mutate input) ── + +/** Insert or replace a host entry by address. Bumps `updatedAt`. */ +fun upsertHost( + blob: CanonicalProfileBlob, + host: ProfileHostEntry, + now: Long? = null, +): CanonicalProfileBlob { + val hosts = blob.hosts.filter { it.address != host.address } + host + return blob.copy(hosts = hosts, updatedAt = now ?: System.currentTimeMillis()) +} + +/** Remove the host with the given address, if any. */ +fun removeHost( + blob: CanonicalProfileBlob, + address: String, + now: Long? = null, +): CanonicalProfileBlob { + val hosts = blob.hosts.filter { it.address != address } + if (hosts.size == blob.hosts.size) return blob + return blob.copy(hosts = hosts, updatedAt = now ?: System.currentTimeMillis()) +} + +/** + * Insert or replace a client entry by `identityFingerprint`. Re-derives + * `trustedApproverFingerprints` from the resulting `clients[]` so the + * denormalized list never drifts. + */ +fun upsertClient( + blob: CanonicalProfileBlob, + client: ProfileClientEntry, + now: Long? = null, +): CanonicalProfileBlob { + val clients = blob.clients + .filter { it.identityFingerprint != client.identityFingerprint } + client + return blob.copy( + clients = clients, + trustedApproverFingerprints = deriveTrustedApprovers(clients), + updatedAt = now ?: System.currentTimeMillis(), + ) +} + +/** Remove the client with the given identityFingerprint, if any. */ +fun removeClient( + blob: CanonicalProfileBlob, + identityFingerprint: String, + now: Long? = null, +): CanonicalProfileBlob { + val clients = blob.clients.filter { it.identityFingerprint != identityFingerprint } + if (clients.size == blob.clients.size) return blob + return blob.copy( + clients = clients, + trustedApproverFingerprints = deriveTrustedApprovers(clients), + updatedAt = now ?: System.currentTimeMillis(), + ) +} + +/** + * Toggle the `trustedApprover` flag on a client by fingerprint. + * Returns the input unchanged if fingerprint isn't found OR the + * desired state already matches (no spurious updatedAt bump). + */ +fun setTrustedApprover( + blob: CanonicalProfileBlob, + identityFingerprint: String, + trusted: Boolean, + now: Long? = null, +): CanonicalProfileBlob { + var touched = false + val clients = blob.clients.map { c -> + if (c.identityFingerprint != identityFingerprint) c + else if (c.trustedApprover == trusted) c + else { + touched = true + c.copy(trustedApprover = trusted) + } + } + if (!touched) return blob + return blob.copy( + clients = clients, + trustedApproverFingerprints = deriveTrustedApprovers(clients), + updatedAt = now ?: System.currentTimeMillis(), + ) +} + +/** + * True iff the given fingerprint resolves to a client with both + * `trustedApprover == true` AND an entry in `trustedApproverFingerprints[]`. + */ +fun isTrustedApprover(blob: CanonicalProfileBlob, identityFingerprint: String): Boolean { + if (!blob.trustedApproverFingerprints.contains(identityFingerprint)) return false + val c = findClientByFingerprint(blob, identityFingerprint) ?: return false + return c.trustedApprover +} + +fun findClientByFingerprint( + blob: CanonicalProfileBlob, + identityFingerprint: String, +): ProfileClientEntry? = blob.clients.firstOrNull { it.identityFingerprint == identityFingerprint } + +fun findClientByAddress(blob: CanonicalProfileBlob, address: String): ProfileClientEntry? = + blob.clients.firstOrNull { it.address == address } + +private fun deriveTrustedApprovers(clients: List): List = + clients.filter { it.trustedApprover }.map { it.identityFingerprint } diff --git a/android/shade-android/src/main/kotlin/no/zyon/shade/blob/BlobAead.kt b/android/shade-android/src/main/kotlin/no/zyon/shade/blob/BlobAead.kt new file mode 100644 index 0000000..ee95407 --- /dev/null +++ b/android/shade-android/src/main/kotlin/no/zyon/shade/blob/BlobAead.kt @@ -0,0 +1,94 @@ +package no.zyon.shade.blob + +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * AEAD wrapper for the V4.9 profile blob. + * + * Wire format for one ciphertext blob: + * `nonce(12) || ciphertext(N) || tag(16)` + * + * Mirror `@shade/storage-encrypted/crypto/aead.ts` byte-for-byte. The + * relay stores this as a single opaque BLOB column; AAD is reconstructed + * at read-time as `"shade-profile-aad-v1:" + slotIdHex` and is NOT + * stored on the relay. + */ + +const val BLOB_AEAD_NONCE_LEN = 12 +const val BLOB_AEAD_TAG_LEN = 16 + +private const val MIN_CIPHERTEXT_LEN = BLOB_AEAD_NONCE_LEN + BLOB_AEAD_TAG_LEN + +/** + * Seal a plaintext blob. Returns `nonce || ct||tag` ready for direct + * blob storage. The caller supplies the nonce so this function is + * deterministic — the high-level Profile namespace generates a fresh + * 12-byte random nonce per write to keep (key, nonce, plaintext) + * unique across re-uploads. + */ +fun aeadSeal( + key: ByteArray, + nonce: ByteArray, + plaintext: ByteArray, + aad: ByteArray, +): ByteArray { + require(key.size == 32) { "AES-256-GCM key must be 32 bytes" } + require(nonce.size == BLOB_AEAD_NONCE_LEN) { + "nonce must be $BLOB_AEAD_NONCE_LEN bytes" + } + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val spec = GCMParameterSpec(128, nonce) + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), spec) + cipher.updateAAD(aad) + val ctTag = cipher.doFinal(plaintext) + val out = ByteArray(BLOB_AEAD_NONCE_LEN + ctTag.size) + System.arraycopy(nonce, 0, out, 0, BLOB_AEAD_NONCE_LEN) + System.arraycopy(ctTag, 0, out, BLOB_AEAD_NONCE_LEN, ctTag.size) + return out +} + +/** + * Open a `nonce || ct||tag` blob and return the plaintext. Throws on + * tamper (AEAD tag mismatch) or short input. The caller may pass an + * `expectedNonce` to enforce a deterministic nonce — mismatch throws + * before the AEAD even runs (defense-in-depth against a relay returning + * the wrong slot's blob). + */ +fun aeadOpen( + key: ByteArray, + blob: ByteArray, + aad: ByteArray, + expectedNonce: ByteArray? = null, +): ByteArray { + require(key.size == 32) { "AES-256-GCM key must be 32 bytes" } + require(blob.size >= MIN_CIPHERTEXT_LEN) { "ciphertext blob too short" } + val nonce = blob.copyOfRange(0, BLOB_AEAD_NONCE_LEN) + if (expectedNonce != null && !ctEqual(nonce, expectedNonce)) { + throw IllegalArgumentException( + "nonce mismatch — ciphertext blob has been tampered or row identity changed", + ) + } + val ctTag = blob.copyOfRange(BLOB_AEAD_NONCE_LEN, blob.size) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val spec = GCMParameterSpec(128, nonce) + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), spec) + cipher.updateAAD(aad) + return cipher.doFinal(ctTag) +} + +private fun ctEqual(a: ByteArray, b: ByteArray): Boolean { + if (a.size != b.size) return false + var diff = 0 + for (i in a.indices) { + diff = diff or (a[i].toInt() xor b[i].toInt()) + } + return diff == 0 +} + +/** Build the AAD for a given slotId hex string. */ +fun blobAadForSlot(slotIdHex: String): ByteArray { + require(slotIdHex.length == 64) { "slotIdHex must be 64 hex chars" } + return "shade-profile-aad-v1:$slotIdHex".toByteArray(Charsets.UTF_8) +} diff --git a/android/shade-android/src/main/kotlin/no/zyon/shade/blob/BlobClient.kt b/android/shade-android/src/main/kotlin/no/zyon/shade/blob/BlobClient.kt new file mode 100644 index 0000000..f79aa4d --- /dev/null +++ b/android/shade-android/src/main/kotlin/no/zyon/shade/blob/BlobClient.kt @@ -0,0 +1,250 @@ +package no.zyon.shade.blob + +import no.zyon.shade.crypto.CryptoProvider +import org.json.JSONObject +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration +import java.util.Base64 + +/** + * Low-level HTTP client for the V4.9 encrypted-blob primitive + * (`/v1/blob/`). Mirror `@shade/inbox`'s `BlobClient` — + * stateless, reusable, and protocol-compatible with the TypeScript + * relay endpoints. + * + * The client doesn't care what the blob bytes mean — it just + * transports them. Higher-level wrappers (e.g. `Profile`) compose + * this client with AEAD-sealing of the actual payload. + * + * Auth model: every PUT/DELETE carries a detached Ed25519 signature + * (base64) over a canonical-JSON form of the request body. The + * canonicalization is deterministic — sorted keys, compact JSON, no + * trailing whitespace — so signatures generated on Kotlin verify on + * the TS server. + */ +class BlobClient( + private val baseUrl: String, + private val crypto: CryptoProvider, + private val httpClient: HttpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(15)) + .build(), +) { + + data class GetResult( + val blob: ByteArray, + val etag: String, + val updatedAt: Long, + ) + + data class PutResult( + val created: Boolean, + val etag: String, + val updatedAt: Long, + ) + + /** + * Read a slot. Returns null if no blob has ever been written there + * (or if it was DELETE'd). GET is unauthenticated by design — the + * slotId is itself a 256-bit secret derived from the master key. + */ + fun get(slotIdHex: String): GetResult? { + validateSlotIdHex(slotIdHex) + val req = HttpRequest.newBuilder(URI.create(joinUrl(baseUrl, "/v1/blob/$slotIdHex"))) + .GET() + .build() + val res = httpClient.send(req, HttpResponse.BodyHandlers.ofString()) + if (res.statusCode() == 404) return null + val json = parseJson(res, "GET") + val blob = Base64.getDecoder().decode(json.getString("blob")) + return GetResult( + blob = blob, + etag = json.getString("etag"), + updatedAt = json.getLong("updatedAt"), + ) + } + + /** + * Create or update a slot. + * + * `ifMatch` semantics: + * - `null`: create-only. Slot must be empty (else 409). + * - ``: compare-and-swap. Must match (else 412). + * - `"*"`: unconditional overwrite. Slot must already exist (else 412). + */ + fun put( + slotIdHex: String, + blob: ByteArray, + signingSeed: ByteArray, + ownerPubkey: ByteArray, + ifMatch: String? = null, + ): PutResult { + validateSlotIdHex(slotIdHex) + require(blob.isNotEmpty()) { "Empty blob" } + require(ownerPubkey.size == 32) { "ownerPubkey must be 32 bytes (Ed25519)" } + require(signingSeed.size == 32) { "signingSeed must be 32 bytes (Ed25519)" } + + // Canonical form for signing: sorted keys, slotId included, + // signature field absent. The wire body strips slotId (it's + // in the URL) but the signature is computed over the + // slotId-bearing form. + val signedAt = System.currentTimeMillis() + val canonical = sortedMapOf().apply { + put("blob", Base64.getEncoder().encodeToString(blob)) + if (ifMatch != null) put("ifMatch", ifMatch) + put("ownerPubkey", Base64.getEncoder().encodeToString(ownerPubkey)) + put("signedAt", signedAt) + put("slotId", slotIdHex) + } + val canonicalBytes = canonicalJson(canonical).toByteArray(Charsets.UTF_8) + val sig = crypto.sign(signingSeed, canonicalBytes) + + // Wire body: same as canonical minus slotId, plus signature. + val wire = JSONObject() + wire.put("blob", Base64.getEncoder().encodeToString(blob)) + if (ifMatch != null) wire.put("ifMatch", ifMatch) + wire.put("ownerPubkey", Base64.getEncoder().encodeToString(ownerPubkey)) + wire.put("signedAt", signedAt) + wire.put("signature", Base64.getEncoder().encodeToString(sig)) + + val req = HttpRequest.newBuilder(URI.create(joinUrl(baseUrl, "/v1/blob/$slotIdHex"))) + .header("content-type", "application/json") + .PUT(HttpRequest.BodyPublishers.ofString(wire.toString())) + .build() + val res = httpClient.send(req, HttpResponse.BodyHandlers.ofString()) + val json = parseJson(res, "PUT") + return PutResult( + created = json.optBoolean("created"), + etag = json.getString("etag"), + updatedAt = json.getLong("updatedAt"), + ) + } + + /** + * Delete a slot. The next PUT TOFU-claims it again, possibly under + * a fresh signing key (e.g. after rotation). Used by "forget + * everything" flows. + */ + fun delete(slotIdHex: String, signingSeed: ByteArray): Boolean { + validateSlotIdHex(slotIdHex) + require(signingSeed.size == 32) { "signingSeed must be 32 bytes (Ed25519)" } + + val signedAt = System.currentTimeMillis() + val canonical = sortedMapOf().apply { + put("signedAt", signedAt) + put("slotId", slotIdHex) + } + val canonicalBytes = canonicalJson(canonical).toByteArray(Charsets.UTF_8) + val sig = crypto.sign(signingSeed, canonicalBytes) + + val wire = JSONObject() + wire.put("signedAt", signedAt) + wire.put("signature", Base64.getEncoder().encodeToString(sig)) + + val req = HttpRequest.newBuilder(URI.create(joinUrl(baseUrl, "/v1/blob/$slotIdHex"))) + .header("content-type", "application/json") + .method("DELETE", HttpRequest.BodyPublishers.ofString(wire.toString())) + .build() + val res = httpClient.send(req, HttpResponse.BodyHandlers.ofString()) + val json = parseJson(res, "DELETE") + return json.optBoolean("ok", false) + } + + private fun parseJson(res: HttpResponse, op: String): JSONObject { + val text = res.body() ?: "" + val json = if (text.isEmpty()) JSONObject() else try { + JSONObject(text) + } catch (e: Exception) { + throw BlobClientException( + code = "SHADE_NETWORK", + statusCode = res.statusCode(), + message = "Blob $op response not JSON: ${text.take(200)}", + ) + } + if (res.statusCode() !in 200..299) { + throw BlobClientException( + code = json.optString("code", "SHADE_NETWORK"), + statusCode = res.statusCode(), + message = json.optString("message", text), + ) + } + return json + } + + private fun validateSlotIdHex(s: String) { + require(s.matches(Regex("^[0-9a-f]{64}$"))) { + "slotIdHex must be 64 lowercase hex chars (32 bytes)" + } + } + + private fun joinUrl(base: String, path: String): String = + when { + base.endsWith("/") && path.startsWith("/") -> base + path.substring(1) + !base.endsWith("/") && !path.startsWith("/") -> "$base/$path" + else -> base + path + } +} + +/** + * Mirror of TS `signPayload`'s canonicalization: sorted keys, compact + * JSON, signature field absent. Only handles the subset of types we + * need (strings + longs + base64 strings) — keeping the implementation + * narrow so it can't accidentally diverge from `JSON.stringify` on + * structurally-different inputs. + */ +internal fun canonicalJson(map: Map): String { + val sb = StringBuilder() + sb.append('{') + var first = true + for ((key, value) in map.toSortedMap()) { + if (!first) sb.append(',') + first = false + appendJsonString(sb, key) + sb.append(':') + appendJsonValue(sb, value) + } + sb.append('}') + return sb.toString() +} + +private fun appendJsonValue(sb: StringBuilder, value: Any) { + when (value) { + is String -> appendJsonString(sb, value) + is Long, is Int -> sb.append(value.toString()) + is Boolean -> sb.append(value.toString()) + else -> throw IllegalArgumentException( + "canonicalJson: unsupported value type ${value::class.java}", + ) + } +} + +private fun appendJsonString(sb: StringBuilder, s: String) { + sb.append('"') + for (c in s) { + when (c) { + '\\' -> sb.append("\\\\") + '"' -> sb.append("\\\"") + '\b' -> sb.append("\\b") + '\u000C' -> sb.append("\\f") + '\n' -> sb.append("\\n") + '\r' -> sb.append("\\r") + '\t' -> sb.append("\\t") + else -> { + if (c.code < 0x20) { + sb.append("\\u").append("%04x".format(c.code)) + } else { + sb.append(c) + } + } + } + } + sb.append('"') +} + +class BlobClientException( + val code: String, + val statusCode: Int, + message: String, +) : RuntimeException("[$code @ $statusCode] $message") diff --git a/android/shade-android/src/main/kotlin/no/zyon/shade/blob/BlobKdf.kt b/android/shade-android/src/main/kotlin/no/zyon/shade/blob/BlobKdf.kt new file mode 100644 index 0000000..b43ffed --- /dev/null +++ b/android/shade-android/src/main/kotlin/no/zyon/shade/blob/BlobKdf.kt @@ -0,0 +1,88 @@ +package no.zyon.shade.blob + +import com.google.crypto.tink.subtle.Ed25519Sign +import no.zyon.shade.crypto.CryptoProvider + +/** + * V4.9 — relay-side encrypted blob primitive: deterministic + * derivations from a 32-byte master key + per-app namespace string. + * + * Mirror `@shade/storage-encrypted/crypto/kdf.ts` byte-for-byte. + * Reference vectors: `test-vectors/blob-storage.json`. + * + * Three independent 32-byte derivations: + * + * slotId = HKDF(masterKey, info=`shade-blob-slot-v1:`) // relay-visible opaque ID + * blobKey = HKDF(masterKey, info=`shade-blob-key-v1:`) // AEAD key for the blob + * sigSeed = HKDF(masterKey, info=`shade-blob-sig-v1:`) // Ed25519 owner signing seed + * + * `app` is a caller-supplied namespace string — distinct apps under + * the same master MUST pass different values (e.g. `prism-profile-v1`) + * so they don't collide on the same slot. + * + * The signing seed is an Ed25519 *seed* in the @noble/curves convention: + * `pubkey = Ed25519.publicFromSeed(seed)` is what the relay TOFU-stores + * on the first PUT and verifies subsequent writes against. + */ + +private const val SLOT_INFO_PREFIX = "shade-blob-slot-v1:" +private const val BLOB_KEY_INFO_PREFIX = "shade-blob-key-v1:" +private const val SIG_SEED_INFO_PREFIX = "shade-blob-sig-v1:" + +private const val DERIVED_LEN = 32 + +/** Lower-hex 64-char slotId derived deterministically from the master key. */ +fun deriveBlobSlotId(crypto: CryptoProvider, masterKey: ByteArray, app: String): ByteArray { + require(masterKey.size == 32) { "masterKey must be 32 bytes" } + require(app.isNotEmpty()) { "app namespace must be non-empty" } + return crypto.hkdf( + masterKey, + ByteArray(0), + (SLOT_INFO_PREFIX + app).toByteArray(Charsets.UTF_8), + DERIVED_LEN, + ) +} + +/** AEAD key for sealing/opening the blob. The slotId hex is bound as AAD. */ +fun deriveBlobKey(crypto: CryptoProvider, masterKey: ByteArray, app: String): ByteArray { + require(masterKey.size == 32) { "masterKey must be 32 bytes" } + require(app.isNotEmpty()) { "app namespace must be non-empty" } + return crypto.hkdf( + masterKey, + ByteArray(0), + (BLOB_KEY_INFO_PREFIX + app).toByteArray(Charsets.UTF_8), + DERIVED_LEN, + ) +} + +/** + * 32-byte Ed25519 signing seed. The pubkey, derived deterministically + * from the seed, is what the relay TOFU-stores on the first PUT. + */ +fun deriveBlobSigningSeed(crypto: CryptoProvider, masterKey: ByteArray, app: String): ByteArray { + require(masterKey.size == 32) { "masterKey must be 32 bytes" } + require(app.isNotEmpty()) { "app namespace must be non-empty" } + return crypto.hkdf( + masterKey, + ByteArray(0), + (SIG_SEED_INFO_PREFIX + app).toByteArray(Charsets.UTF_8), + DERIVED_LEN, + ) +} + +/** + * Recover the Ed25519 public key from a 32-byte seed. Mirrors + * `@shade/crypto-web`'s `ed25519PublicKeyFromSeed`. Tink's + * `Ed25519Sign.KeyPair.newKeyPairFromSeed(seed)` exposes both halves; + * we discard the private half here. + */ +fun ed25519PublicKeyFromSeed(seed: ByteArray): ByteArray { + require(seed.size == 32) { "Ed25519 seed must be 32 bytes" } + return Ed25519Sign.KeyPair.newKeyPairFromSeed(seed).publicKey +} + +/** Convert a 32-byte slotId into the lowercase-hex wire form (64 chars). */ +fun slotIdToHex(slotId: ByteArray): String { + require(slotId.size == 32) { "slotId must be 32 bytes" } + return slotId.joinToString("") { "%02x".format(it) } +} diff --git a/android/shade-android/src/main/kotlin/no/zyon/shade/blob/Profile.kt b/android/shade-android/src/main/kotlin/no/zyon/shade/blob/Profile.kt new file mode 100644 index 0000000..51edda1 --- /dev/null +++ b/android/shade-android/src/main/kotlin/no/zyon/shade/blob/Profile.kt @@ -0,0 +1,118 @@ +package no.zyon.shade.blob + +import no.zyon.shade.crypto.CryptoProvider + +/** + * V4.9 — high-level profile namespace. Mirror + * `@shade/sdk`'s `createProfileNamespace`. The relay never sees + * plaintext; AAD binds the slotId so a relay returning the wrong + * slot's blob fails to open. + * + * Usage: + * + * val crypto = TinkProvider() + * val masterKey = deriveMasterKey("password", salt) // V4.5 KDF chain + * val profile = createProfileNamespace( + * baseUrl = "https://shade.example/", + * crypto = crypto, + * masterKey = masterKey, + * app = "prism-profile-v1", + * ) + * + * val current = profile.get() // null if no blob yet + * profile.put(serializeCanonicalProfile(...), ifMatch = current?.etag) + * profile.delete() + * + * Apps with the same master key + app namespace converge on the same + * slot — that's the whole point: a brand new device with the right + * credentials can locate, decrypt, and update the blob. + */ +class ProfileNamespace internal constructor( + /** Lower-hex 64-char slotId. Stable per (master, app). */ + val slotIdHex: String, + private val blobKey: ByteArray, + private val signingSeed: ByteArray, + private val ownerPubkey: ByteArray, + private val aad: ByteArray, + private val client: BlobClient, + private val crypto: CryptoProvider, +) { + + data class GetResult( + val plaintext: ByteArray, + val etag: String, + val updatedAt: Long, + ) + + data class PutResult( + val created: Boolean, + val etag: String, + val updatedAt: Long, + ) + + /** Returns null when the slot has never been written (or was deleted). */ + fun get(): GetResult? { + val raw = client.get(slotIdHex) ?: return null + val plaintext = aeadOpen(blobKey, raw.blob, aad) + return GetResult(plaintext = plaintext, etag = raw.etag, updatedAt = raw.updatedAt) + } + + /** + * Create or update. `ifMatch`: + * - null: create-only (fails with 409 if slot populated). + * - "": CAS (fails with 412 on stale). + * - "*": unconditional overwrite. + */ + fun put(plaintext: ByteArray, ifMatch: String? = null): PutResult { + // Fresh random nonce per write — see `BlobAead`. Re-uploading + // the same plaintext after a transient error reuses neither + // (key, nonce, plaintext) nor (key, nonce). + val nonce = crypto.randomBytes(BLOB_AEAD_NONCE_LEN) + val sealed = aeadSeal(blobKey, nonce, plaintext, aad) + val r = client.put( + slotIdHex = slotIdHex, + blob = sealed, + signingSeed = signingSeed, + ownerPubkey = ownerPubkey, + ifMatch = ifMatch, + ) + return PutResult(created = r.created, etag = r.etag, updatedAt = r.updatedAt) + } + + /** "Forget everything" path — the next PUT TOFU-claims it again. */ + fun delete(): Boolean = client.delete(slotIdHex, signingSeed) +} + +/** + * Build a Profile namespace bound to a (master key, app) pair. The + * derivations are deterministic: any device with the same master + * key + app namespace produces the same slot, so a fresh device + * after credential entry can locate the existing profile blob. + */ +fun createProfileNamespace( + baseUrl: String, + crypto: CryptoProvider, + masterKey: ByteArray, + app: String, +): ProfileNamespace { + require(masterKey.size == 32) { "masterKey must be 32 bytes" } + require(app.isNotEmpty()) { "app namespace must be non-empty" } + + val slotIdBytes = deriveBlobSlotId(crypto, masterKey, app) + val slotIdHex = slotIdToHex(slotIdBytes) + val blobKey = deriveBlobKey(crypto, masterKey, app) + val signingSeed = deriveBlobSigningSeed(crypto, masterKey, app) + val ownerPubkey = ed25519PublicKeyFromSeed(signingSeed) + val aad = blobAadForSlot(slotIdHex) + + val client = BlobClient(baseUrl = baseUrl, crypto = crypto) + return ProfileNamespace( + slotIdHex = slotIdHex, + blobKey = blobKey, + signingSeed = signingSeed, + ownerPubkey = ownerPubkey, + aad = aad, + client = client, + crypto = crypto, + ) +} diff --git a/android/shade-android/src/main/kotlin/no/zyon/shade/serialization/SessionStateJson.kt b/android/shade-android/src/main/kotlin/no/zyon/shade/serialization/SessionStateJson.kt new file mode 100644 index 0000000..e8d51ba --- /dev/null +++ b/android/shade-android/src/main/kotlin/no/zyon/shade/serialization/SessionStateJson.kt @@ -0,0 +1,133 @@ +package no.zyon.shade.serialization + +import no.zyon.shade.types.ChainState +import no.zyon.shade.types.IdentityKeyPair +import no.zyon.shade.types.KeyPair +import no.zyon.shade.types.OneTimePreKey +import no.zyon.shade.types.SessionState +import no.zyon.shade.types.SignedPreKey +import org.json.JSONObject +import java.util.Base64 as JdkBase64 + +/** + * Plain-JSON serialization for the persisted protocol state types + * (`IdentityKeyPair`, `SignedPreKey`, `OneTimePreKey`, `SessionState`). + * + * The on-disk shape is for at-rest storage only — it does NOT need + * to round-trip across platforms (TS uses its own JSON shape via + * `@shade/core/serialization`). What matters is that the Kotlin + * round-trip (`serialize` then `deserialize`) preserves every byte. + * + * Both Android-targeted (`shade-android-keystore`) and pure-JVM + * (`shade-android` tests) callers use this — the function works + * without any `android.*` imports so it compiles in both. + */ +object SessionStateJson { + + fun serialize(state: SessionState): String { + val o = JSONObject() + o.put("remoteIdentityKey", b64(state.remoteIdentityKey)) + o.put("rootKey", b64(state.rootKey)) + o.put("sendChain", chainToJson(state.sendChain)) + if (state.receiveChain != null) o.put("receiveChain", chainToJson(state.receiveChain!!)) + o.put("dhSend", keyPairToJson(state.dhSend)) + if (state.dhReceive != null) o.put("dhReceive", b64(state.dhReceive!!)) + o.put("previousSendCounter", state.previousSendCounter) + val skipped = JSONObject() + for ((k, v) in state.skippedKeys) skipped.put(k, b64(v)) + o.put("skippedKeys", skipped) + return o.toString() + } + + fun deserialize(s: String): SessionState { + val o = JSONObject(s) + val skipped = mutableMapOf() + val skJson = o.optJSONObject("skippedKeys") + if (skJson != null) { + val it = skJson.keys() + while (it.hasNext()) { + val k = it.next() + skipped[k] = fb64(skJson.getString(k)) + } + } + return SessionState( + remoteIdentityKey = fb64(o.getString("remoteIdentityKey")), + rootKey = fb64(o.getString("rootKey")), + sendChain = chainFromJson(o.getJSONObject("sendChain")), + receiveChain = if (o.has("receiveChain")) + chainFromJson(o.getJSONObject("receiveChain")) else null, + dhSend = keyPairFromJson(o.getJSONObject("dhSend")), + dhReceive = if (o.has("dhReceive")) fb64(o.getString("dhReceive")) else null, + previousSendCounter = o.getInt("previousSendCounter"), + skippedKeys = skipped, + ) + } + + fun serializeIdentityKeyPair(k: IdentityKeyPair): String = JSONObject().apply { + put("signingPublicKey", b64(k.signingPublicKey)) + put("signingPrivateKey", b64(k.signingPrivateKey)) + put("dhPublicKey", b64(k.dhPublicKey)) + put("dhPrivateKey", b64(k.dhPrivateKey)) + }.toString() + + fun deserializeIdentityKeyPair(s: String): IdentityKeyPair = JSONObject(s).run { + IdentityKeyPair( + signingPublicKey = fb64(getString("signingPublicKey")), + signingPrivateKey = fb64(getString("signingPrivateKey")), + dhPublicKey = fb64(getString("dhPublicKey")), + dhPrivateKey = fb64(getString("dhPrivateKey")), + ) + } + + fun serializeSignedPreKey(k: SignedPreKey): String = JSONObject().apply { + put("keyId", k.keyId) + put("keyPair", keyPairToJson(k.keyPair)) + put("signature", b64(k.signature)) + put("timestamp", k.timestamp) + }.toString() + + fun deserializeSignedPreKey(s: String): SignedPreKey = JSONObject(s).run { + SignedPreKey( + keyId = getInt("keyId"), + keyPair = keyPairFromJson(getJSONObject("keyPair")), + signature = fb64(getString("signature")), + timestamp = getLong("timestamp"), + ) + } + + fun serializeOneTimePreKey(k: OneTimePreKey): String = JSONObject().apply { + put("keyId", k.keyId) + put("keyPair", keyPairToJson(k.keyPair)) + }.toString() + + fun deserializeOneTimePreKey(s: String): OneTimePreKey = JSONObject(s).run { + OneTimePreKey( + keyId = getInt("keyId"), + keyPair = keyPairFromJson(getJSONObject("keyPair")), + ) + } + + private fun chainToJson(c: ChainState): JSONObject = JSONObject().apply { + put("chainKey", b64(c.chainKey)) + put("counter", c.counter) + } + + private fun chainFromJson(o: JSONObject): ChainState = + ChainState(chainKey = fb64(o.getString("chainKey")), counter = o.getInt("counter")) + + private fun keyPairToJson(k: KeyPair): JSONObject = JSONObject().apply { + put("publicKey", b64(k.publicKey)) + put("privateKey", b64(k.privateKey)) + } + + private fun keyPairFromJson(o: JSONObject): KeyPair = KeyPair( + publicKey = fb64(o.getString("publicKey")), + privateKey = fb64(o.getString("privateKey")), + ) + + // android.util.Base64 isn't on the JVM classpath; java.util.Base64 + // is available on both modern JVM and Android API 26+. Use JDK + // Base64 throughout — it's present on both targets. + private fun b64(b: ByteArray): String = JdkBase64.getEncoder().encodeToString(b) + private fun fb64(s: String): ByteArray = JdkBase64.getDecoder().decode(s) +} diff --git a/android/shade-android/src/main/kotlin/no/zyon/shade/storage/PasswordKdf.kt b/android/shade-android/src/main/kotlin/no/zyon/shade/storage/PasswordKdf.kt new file mode 100644 index 0000000..8faf4fa --- /dev/null +++ b/android/shade-android/src/main/kotlin/no/zyon/shade/storage/PasswordKdf.kt @@ -0,0 +1,107 @@ +package no.zyon.shade.storage + +import org.bouncycastle.crypto.generators.Argon2BytesGenerator +import org.bouncycastle.crypto.generators.SCrypt +import org.bouncycastle.crypto.params.Argon2Parameters +import java.text.Normalizer + +/** + * Password / PIN key-derivation primitives. Mirror + * `@shade/storage-encrypted/crypto/kdf` (`deriveMasterKey` / + * `deriveMasterKeyArgon2id`) byte-for-byte — Tink doesn't ship password + * KDFs so we wrap Bouncy Castle. + * + * Both functions normalize string passphrases to NFKC before hashing, + * matching the TS implementation's `passphrase.normalize('NFKC')`. + * This ensures the same password typed on different OSes/keyboards + * produces the same master key regardless of which compatibility-form + * the input arrived in. + * + * The reference test-vector lives in `test-vectors/storage-encryption.json` + * and `test-vectors/blob-storage.json`. Cross-platform parity is gated + * by `CrossPlatformVectorTest`. + */ + +/** scrypt parameters. Defaults match `DEFAULT_SCRYPT` in TS. */ +data class ScryptParams( + /** CPU/memory cost. Must be a power of 2. */ + val n: Int = 1 shl 17, + /** Block size. */ + val r: Int = 8, + /** Parallelization. */ + val p: Int = 1, + /** Output length in bytes. */ + val dkLen: Int = 32, +) + +/** Argon2id parameters. Defaults match `DEFAULT_ARGON2ID` in TS. */ +data class Argon2idParams( + /** Memory cost in KiB. Default 64 MiB. */ + val m: Int = 64 * 1024, + /** Time cost (iterations). Default 3. */ + val t: Int = 3, + /** Parallelism. Default 1. */ + val p: Int = 1, + /** Output length in bytes. Default 32. */ + val dkLen: Int = 32, +) + +/** + * Derive a 32-byte master key from a passphrase + salt via scrypt. + * Salt MUST be at least 16 bytes and persisted alongside the + * encrypted database. Throws on empty passphrase. + */ +fun deriveMasterKey( + passphrase: String, + salt: ByteArray, + params: ScryptParams = ScryptParams(), +): ByteArray { + require(passphrase.isNotEmpty()) { "passphrase must be non-empty" } + require(salt.size >= 16) { "salt must be at least 16 bytes" } + val nfkc = Normalizer.normalize(passphrase, Normalizer.Form.NFKC) + val pwBytes = nfkc.toByteArray(Charsets.UTF_8) + return SCrypt.generate(pwBytes, salt, params.n, params.r, params.p, params.dkLen) +} + +/** + * Derive a 32-byte master key from a low-entropy secret (PIN) + salt + * via argon2id. Salt MUST be at least 16 bytes. The Bouncy Castle + * `Argon2BytesGenerator` parameters mirror RFC 9106's argon2id mode + * with version 1.3 (`Argon2Parameters.ARGON2_VERSION_13`), which is + * what `@noble/hashes/argon2` produces — keeping cross-platform parity. + */ +fun deriveMasterKeyArgon2id( + secret: String, + salt: ByteArray, + params: Argon2idParams = Argon2idParams(), +): ByteArray { + require(secret.isNotEmpty()) { "argon2id secret must be non-empty" } + require(salt.size >= 16) { "salt must be at least 16 bytes" } + val nfkc = Normalizer.normalize(secret, Normalizer.Form.NFKC) + return deriveMasterKeyArgon2id(nfkc.toByteArray(Charsets.UTF_8), salt, params) +} + +/** + * Byte-array overload — useful when the secret is already binary + * (e.g. derived from a hardware token rather than typed) and + * shouldn't be NFKC-normalized as text. + */ +fun deriveMasterKeyArgon2id( + secret: ByteArray, + salt: ByteArray, + params: Argon2idParams = Argon2idParams(), +): ByteArray { + require(secret.isNotEmpty()) { "argon2id secret must be non-empty" } + require(salt.size >= 16) { "salt must be at least 16 bytes" } + val builder = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) + .withVersion(Argon2Parameters.ARGON2_VERSION_13) + .withIterations(params.t) + .withMemoryAsKB(params.m) + .withParallelism(params.p) + .withSalt(salt) + val gen = Argon2BytesGenerator() + gen.init(builder.build()) + val out = ByteArray(params.dkLen) + gen.generateBytes(secret, out) + return out +} diff --git a/android/shade-android/src/test/kotlin/no/zyon/shade/BlobAndApprovalTest.kt b/android/shade-android/src/test/kotlin/no/zyon/shade/BlobAndApprovalTest.kt new file mode 100644 index 0000000..135efc8 --- /dev/null +++ b/android/shade-android/src/test/kotlin/no/zyon/shade/BlobAndApprovalTest.kt @@ -0,0 +1,593 @@ +package no.zyon.shade + +import no.zyon.shade.approval.ApprovalRequestFrame +import no.zyon.shade.approval.ApprovalRequestingDevice +import no.zyon.shade.approval.CanonicalProfileBlob +import no.zyon.shade.approval.DEFAULT_APPROVAL_DOMAIN +import no.zyon.shade.approval.ProfileClientEntry +import no.zyon.shade.approval.ProfileHostEntry +import no.zyon.shade.approval.ProxyApprovalFrame +import no.zyon.shade.approval.VerifyProxyApprovalResult +import no.zyon.shade.approval.buildApprovalRequest +import no.zyon.shade.approval.canonicalApprovalSigningBytes +import no.zyon.shade.approval.emptyCanonicalProfile +import no.zyon.shade.approval.findClientByAddress +import no.zyon.shade.approval.findClientByFingerprint +import no.zyon.shade.approval.isTrustedApprover +import no.zyon.shade.approval.parseCanonicalProfile +import no.zyon.shade.approval.removeClient +import no.zyon.shade.approval.serializeCanonicalProfile +import no.zyon.shade.approval.setTrustedApprover +import no.zyon.shade.approval.signProxyApproval +import no.zyon.shade.approval.upsertClient +import no.zyon.shade.approval.upsertHost +import no.zyon.shade.approval.verifyProxyApproval +import no.zyon.shade.blob.aeadOpen +import no.zyon.shade.blob.aeadSeal +import no.zyon.shade.blob.blobAadForSlot +import no.zyon.shade.blob.deriveBlobKey +import no.zyon.shade.blob.deriveBlobSigningSeed +import no.zyon.shade.blob.deriveBlobSlotId +import no.zyon.shade.blob.ed25519PublicKeyFromSeed +import no.zyon.shade.blob.slotIdToHex +import no.zyon.shade.crypto.TinkProvider +import no.zyon.shade.storage.Argon2idParams +import no.zyon.shade.storage.ScryptParams +import no.zyon.shade.storage.deriveMasterKey +import no.zyon.shade.storage.deriveMasterKeyArgon2id +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for the V4.9 blob primitive ports + V4.10 approval + * helpers + scrypt/argon2id wrappers. Cross-platform vector parity + * lives in `CrossPlatformVectorTest`; this file tests Kotlin-side + * round-trip behavior independent of the TS reference. + */ +class BlobAndApprovalTest { + + private val crypto = TinkProvider() + + private fun hex(bytes: ByteArray): String = + bytes.joinToString("") { "%02x".format(it) } + + // ─── V4.9 blob KDF ───────────────────────────────────────── + + @Test + fun deriveBlobSlotIdIsDeterministicPerMasterAndApp() { + val km = ByteArray(32) { it.toByte() } + val a1 = deriveBlobSlotId(crypto, km, "foo") + val a2 = deriveBlobSlotId(crypto, km, "foo") + assertArrayEquals(a1, a2) + + val b = deriveBlobSlotId(crypto, km, "bar") + assertFalse(a1.contentEquals(b)) + + val km2 = ByteArray(32) { (it + 1).toByte() } + val c = deriveBlobSlotId(crypto, km2, "foo") + assertFalse(a1.contentEquals(c)) + } + + @Test + fun blobKdfHelpersAreIndependent() { + val km = ByteArray(32) { it.toByte() } + val slot = deriveBlobSlotId(crypto, km, "x") + val key = deriveBlobKey(crypto, km, "x") + val seed = deriveBlobSigningSeed(crypto, km, "x") + assertFalse(slot.contentEquals(key)) + assertFalse(slot.contentEquals(seed)) + assertFalse(key.contentEquals(seed)) + } + + @Test + fun ed25519PublicKeyFromSeedIsDeterministic() { + val seed = ByteArray(32) { it.toByte() } + val pk1 = ed25519PublicKeyFromSeed(seed) + val pk2 = ed25519PublicKeyFromSeed(seed) + assertArrayEquals(pk1, pk2) + assertEquals(32, pk1.size) + } + + @Test + fun slotIdToHexProducesLowercase64Chars() { + val s = ByteArray(32) { 0xab.toByte() } + val hex = slotIdToHex(s) + assertEquals(64, hex.length) + assertEquals("a".repeat(0) + "ab".repeat(32), hex) + } + + // ─── V4.9 AEAD round-trip ────────────────────────────────── + + @Test + fun aeadSealOpenRoundTrip() { + val key = crypto.randomBytes(32) + val nonce = crypto.randomBytes(12) + val pt = "hello".toByteArray() + val aad = blobAadForSlot("00".repeat(32)) + val sealed = aeadSeal(key, nonce, pt, aad) + val opened = aeadOpen(key, sealed, aad) + assertArrayEquals(pt, opened) + } + + @Test + fun aeadOpenWithWrongAadFails() { + val key = crypto.randomBytes(32) + val nonce = crypto.randomBytes(12) + val pt = "hello".toByteArray() + val aad1 = blobAadForSlot("00".repeat(32)) + val aad2 = blobAadForSlot("ff".repeat(32)) + val sealed = aeadSeal(key, nonce, pt, aad1) + try { + aeadOpen(key, sealed, aad2) + org.junit.Assert.fail("expected AEAD to reject wrong AAD") + } catch (_: Exception) { + // expected + } + } + + @Test + fun aeadOpenWithExpectedNonceMismatchFails() { + val key = crypto.randomBytes(32) + val nonce = crypto.randomBytes(12) + val wrongNonce = crypto.randomBytes(12) + val pt = "hello".toByteArray() + val aad = blobAadForSlot("00".repeat(32)) + val sealed = aeadSeal(key, nonce, pt, aad) + try { + aeadOpen(key, sealed, aad, expectedNonce = wrongNonce) + org.junit.Assert.fail("expected expectedNonce check to reject") + } catch (_: IllegalArgumentException) { + // expected + } + } + + // ─── Password KDFs ───────────────────────────────────────── + + @Test + fun scryptDerivesDeterministically() { + val pw = "correct-horse-battery-staple" + val salt = ByteArray(16) { 0x42.toByte() } + val k1 = deriveMasterKey(pw, salt, ScryptParams(n = 1024)) + val k2 = deriveMasterKey(pw, salt, ScryptParams(n = 1024)) + assertArrayEquals(k1, k2) + assertEquals(32, k1.size) + } + + @Test + fun argon2idDerivesDeterministically() { + val pw = "1234" + val salt = ByteArray(16) { 0x55.toByte() } + val k1 = deriveMasterKeyArgon2id(pw, salt, Argon2idParams(m = 256, t = 2)) + val k2 = deriveMasterKeyArgon2id(pw, salt, Argon2idParams(m = 256, t = 2)) + assertArrayEquals(k1, k2) + assertEquals(32, k1.size) + } + + @Test + fun nfkcNormalizationMakesEquivalentInputsConverge() { + // "café" can be encoded either as 'c','a','f','é' (NFC) or + // 'c','a','f','e','́' (NFD). NFKC normalization on both + // should converge to the same bytes. + val nfc = "café" + val nfd = "café" + assertNotEquals(nfc, nfd) + val salt = ByteArray(16) { 1.toByte() } + val k1 = deriveMasterKey(nfc, salt, ScryptParams(n = 1024)) + val k2 = deriveMasterKey(nfd, salt, ScryptParams(n = 1024)) + assertArrayEquals(k1, k2) + } + + // ─── Canonical profile schema ────────────────────────────── + + private fun makeClient(name: String, trusted: Boolean = false): Pair { + val seed = crypto.randomBytes(32) + val pubkey = ed25519PublicKeyFromSeed(seed) + val fp = "fp-$name-${hex(pubkey).take(8)}" + return ProfileClientEntry( + address = "device:$name", + identityPublicKey = hex(pubkey), + identityFingerprint = fp, + name = name, + kind = "mobile", + addedAt = 1_700_000_000_000L, + trustedApprover = trusted, + ) to seed + } + + private fun makeHost(): ProfileHostEntry = ProfileHostEntry( + address = "device:host-server", + name = "Server", + kind = "server", + addedAt = 1_700_000_000_000L, + ) + + @Test + fun emptyCanonicalProfileRoundTrips() { + val blob = emptyCanonicalProfile(now = 123L) + val bytes = serializeCanonicalProfile(blob) + val parsed = parseCanonicalProfile(bytes) + assertEquals(1, parsed.version) + assertTrue(parsed.hosts.isEmpty()) + assertTrue(parsed.clients.isEmpty()) + assertTrue(parsed.trustedApproverFingerprints.isEmpty()) + assertEquals(123L, parsed.updatedAt) + } + + @Test + fun upsertClientDenormalizesTrustedApprovers() { + var blob = emptyCanonicalProfile(0) + val (a, _) = makeClient("phone-a", trusted = true) + val (b, _) = makeClient("phone-b", trusted = false) + blob = upsertClient(blob, a) + blob = upsertClient(blob, b) + assertEquals(2, blob.clients.size) + assertEquals(listOf(a.identityFingerprint), blob.trustedApproverFingerprints) + assertTrue(isTrustedApprover(blob, a.identityFingerprint)) + assertFalse(isTrustedApprover(blob, b.identityFingerprint)) + } + + @Test + fun setTrustedApproverIsIdempotentNoOpReturnsSameInstance() { + var blob = emptyCanonicalProfile(0) + val (c, _) = makeClient("phone", trusted = false) + blob = upsertClient(blob, c) + val before = blob + blob = setTrustedApprover(blob, c.identityFingerprint, false, now = 999L) + assertTrue(blob === before) + } + + @Test + fun setTrustedApproverFlipsFlagAndDenormalizedList() { + var blob = emptyCanonicalProfile(0) + val (c, _) = makeClient("phone", trusted = false) + blob = upsertClient(blob, c) + blob = setTrustedApprover(blob, c.identityFingerprint, true, now = 100L) + assertEquals(listOf(c.identityFingerprint), blob.trustedApproverFingerprints) + assertEquals(true, blob.clients[0].trustedApprover) + + blob = setTrustedApprover(blob, c.identityFingerprint, false, now = 200L) + assertTrue(blob.trustedApproverFingerprints.isEmpty()) + assertEquals(false, blob.clients[0].trustedApprover) + } + + @Test + fun removeClientCleansUpDenormalizedList() { + var blob = emptyCanonicalProfile(0) + val (c, _) = makeClient("phone", trusted = true) + blob = upsertClient(blob, c) + blob = removeClient(blob, c.identityFingerprint) + assertTrue(blob.clients.isEmpty()) + assertTrue(blob.trustedApproverFingerprints.isEmpty()) + } + + @Test + fun findClientByFingerprintAndAddress() { + var blob = emptyCanonicalProfile(0) + val (c, _) = makeClient("phone") + blob = upsertClient(blob, c) + assertEquals(c.address, findClientByFingerprint(blob, c.identityFingerprint)?.address) + assertEquals( + c.identityFingerprint, + findClientByAddress(blob, c.address)?.identityFingerprint, + ) + assertNull(findClientByFingerprint(blob, "unknown")) + assertNull(findClientByAddress(blob, "unknown")) + } + + @Test + fun parseRejectsMalformedProfile() { + try { + parseCanonicalProfile("not json") + org.junit.Assert.fail("expected throw") + } catch (_: IllegalArgumentException) {} + try { + parseCanonicalProfile("""{"version":2}""") + org.junit.Assert.fail("expected throw") + } catch (_: IllegalArgumentException) {} + try { + parseCanonicalProfile( + """{"version":1,"clients":[{"address":"x","name":"x","kind":"m","addedAt":0}]}""", + ) + org.junit.Assert.fail("expected throw — missing identityPublicKey") + } catch (_: IllegalArgumentException) {} + try { + parseCanonicalProfile( + """{"version":1,"clients":[{"address":"x","identityPublicKey":"NOTHEX","identityFingerprint":"x","name":"x","kind":"m","addedAt":0}]}""", + ) + org.junit.Assert.fail("expected throw — bad pubkey hex") + } catch (_: IllegalArgumentException) {} + } + + @Test + fun fullProfileSerializeParsePreservesAllFields() { + var blob = emptyCanonicalProfile(1L) + blob = upsertHost(blob, makeHost(), now = 2L) + val (c, _) = makeClient("phone", trusted = true) + blob = upsertClient(blob, c, now = 3L) + blob = blob.copy(signedBy = "aabbccdd") + + val bytes = serializeCanonicalProfile(blob) + val parsed = parseCanonicalProfile(bytes) + assertEquals(blob, parsed) + } + + // ─── Approval signing payload ────────────────────────────── + + @Test + fun canonicalApprovalSigningBytesIsDeterministic() { + val a = canonicalApprovalSigningBytes( + domain = DEFAULT_APPROVAL_DOMAIN, + requestId = "aabbccddeeff00112233445566778899", + hostFingerprint = "11111 22222 33333 44444", + requestingDeviceFingerprint = "55555 66666 77777 88888", + decision = "approve", + ) + val b = canonicalApprovalSigningBytes( + domain = DEFAULT_APPROVAL_DOMAIN, + requestId = "aabbccddeeff00112233445566778899", + hostFingerprint = "11111 22222 33333 44444", + requestingDeviceFingerprint = "55555 66666 77777 88888", + decision = "approve", + ) + assertArrayEquals(a, b) + } + + @Test + fun differentDecisionProducesDifferentSigningBytes() { + val approve = canonicalApprovalSigningBytes( + DEFAULT_APPROVAL_DOMAIN, "r", "h", "d", "approve", + ) + val reject = canonicalApprovalSigningBytes( + DEFAULT_APPROVAL_DOMAIN, "r", "h", "d", "reject", + ) + assertFalse(approve.contentEquals(reject)) + } + + @Test + fun differentDomainProducesDifferentSigningBytes() { + val a = canonicalApprovalSigningBytes("shade-link-approve-v1", "r", "h", "d", "approve") + val b = canonicalApprovalSigningBytes("prism-link-approve-v1", "r", "h", "d", "approve") + assertFalse(a.contentEquals(b)) + } + + // ─── Build / sign / verify ───────────────────────────────── + + private data class Scenario( + val phone: ProfileClientEntry, + val phoneSeed: ByteArray, + val profile: CanonicalProfileBlob, + val request: ApprovalRequestFrame, + ) + + private fun buildScenario(): Scenario { + val (phone, seed) = makeClient("phone", trusted = true) + var profile = emptyCanonicalProfile(0) + profile = upsertHost(profile, makeHost()) + profile = upsertClient(profile, phone) + + val request = buildApprovalRequest( + crypto = crypto, + hostAddress = "device:host-server", + hostFingerprint = "host-fp-12345", + requestingDeviceFingerprint = "cafe-laptop-fp-67890", + deviceName = "cafe-laptop", + userAgent = "Mozilla/5.0", + ipHint = "203.0.113.7", + ) + return Scenario(phone, seed, profile, request) + } + + @Test + fun happyPathApproveVerifies() { + val s = buildScenario() + val approval = signProxyApproval( + crypto, s.request, + decision = "approve", + approverFingerprint = s.phone.identityFingerprint, + approverSigningKey = s.phoneSeed, + ) + assertEquals("linkApproveByProxy", approval.kind) + assertEquals(s.request.requestId, approval.requestId) + assertEquals(128, approval.signature.length) + + val r = verifyProxyApproval(crypto, s.request, approval, s.profile) + assertTrue(r is VerifyProxyApprovalResult.Ok) + assertEquals(s.phone.address, (r as VerifyProxyApprovalResult.Ok).approver.address) + } + + @Test + fun happyPathRejectVerifies() { + val s = buildScenario() + val approval = signProxyApproval( + crypto, s.request, "reject", + s.phone.identityFingerprint, s.phoneSeed, + ) + val r = verifyProxyApproval(crypto, s.request, approval, s.profile) + assertTrue(r is VerifyProxyApprovalResult.Ok) + } + + @Test + fun replayAgainstDifferentRequestFails() { + val s = buildScenario() + val approval = signProxyApproval( + crypto, s.request, "approve", + s.phone.identityFingerprint, s.phoneSeed, + ) + val other = s.request.copy(requestId = "f".repeat(32)) + val r = verifyProxyApproval(crypto, other, approval, s.profile) + assertEquals( + VerifyProxyApprovalResult.Reason.REQUEST_ID_MISMATCH, + (r as VerifyProxyApprovalResult.Failed).reason, + ) + } + + @Test + fun decisionTamperingFails() { + val s = buildScenario() + val approval = signProxyApproval( + crypto, s.request, "approve", + s.phone.identityFingerprint, s.phoneSeed, + ) + val tampered = approval.copy(decision = "reject") + val r = verifyProxyApproval(crypto, s.request, tampered, s.profile) + assertEquals( + VerifyProxyApprovalResult.Reason.BAD_SIGNATURE, + (r as VerifyProxyApprovalResult.Failed).reason, + ) + } + + @Test + fun hostFingerprintSwapFails() { + val s = buildScenario() + val approval = signProxyApproval( + crypto, s.request, "approve", + s.phone.identityFingerprint, s.phoneSeed, + ) + val swapped = s.request.copy(hostFingerprint = "evil-host-fp") + val r = verifyProxyApproval(crypto, swapped, approval, s.profile) + assertEquals( + VerifyProxyApprovalResult.Reason.BAD_SIGNATURE, + (r as VerifyProxyApprovalResult.Failed).reason, + ) + } + + @Test + fun domainMismatchIsRejectedBeforeSignature() { + val s = buildScenario() + val approval = signProxyApproval( + crypto, s.request, "approve", + s.phone.identityFingerprint, s.phoneSeed, + ) + val r = verifyProxyApproval( + crypto, s.request, approval.copy(domain = "prism-link-approve-v1"), s.profile, + ) + assertEquals( + VerifyProxyApprovalResult.Reason.DOMAIN_MISMATCH, + (r as VerifyProxyApprovalResult.Failed).reason, + ) + } + + @Test + fun unknownApproverFails() { + val s = buildScenario() + val approval = signProxyApproval( + crypto, s.request, "approve", + s.phone.identityFingerprint, s.phoneSeed, + ) + val lying = approval.copy(approverFingerprint = "no-such-fingerprint") + val r = verifyProxyApproval(crypto, s.request, lying, s.profile) + assertEquals( + VerifyProxyApprovalResult.Reason.UNKNOWN_APPROVER, + (r as VerifyProxyApprovalResult.Failed).reason, + ) + } + + @Test + fun revokedApproverFailsWithNotTrusted() { + val s = buildScenario() + val approval = signProxyApproval( + crypto, s.request, "approve", + s.phone.identityFingerprint, s.phoneSeed, + ) + val revoked = setTrustedApprover(s.profile, s.phone.identityFingerprint, false) + val r = verifyProxyApproval(crypto, s.request, approval, revoked) + assertEquals( + VerifyProxyApprovalResult.Reason.NOT_TRUSTED, + (r as VerifyProxyApprovalResult.Failed).reason, + ) + } + + @Test + fun expiredRequestIsRejected() { + val s = buildScenario() + val approval = signProxyApproval( + crypto, s.request, "approve", + s.phone.identityFingerprint, s.phoneSeed, + ) + val r = verifyProxyApproval(crypto, s.request, approval, s.profile, now = s.request.expiresAt + 1) + assertEquals( + VerifyProxyApprovalResult.Reason.EXPIRED, + (r as VerifyProxyApprovalResult.Failed).reason, + ) + } + + @Test + fun signatureWithWrongKeyFails() { + val s = buildScenario() + val wrongSeed = crypto.randomBytes(32) + val approval = signProxyApproval( + crypto, s.request, "approve", + approverFingerprint = s.phone.identityFingerprint, // claim phone + approverSigningKey = wrongSeed, // sign with different key + ) + val r = verifyProxyApproval(crypto, s.request, approval, s.profile) + assertEquals( + VerifyProxyApprovalResult.Reason.BAD_SIGNATURE, + (r as VerifyProxyApprovalResult.Failed).reason, + ) + } + + @Test + fun customDomainSurvivesRoundTrip() { + val s = buildScenario() + val request = s.request.copy(domain = "prism-link-approve-v1") + val approval = signProxyApproval( + crypto, request, "approve", + s.phone.identityFingerprint, s.phoneSeed, + ) + assertEquals("prism-link-approve-v1", approval.domain) + val r = verifyProxyApproval(crypto, request, approval, s.profile) + assertTrue(r is VerifyProxyApprovalResult.Ok) + } + + @Test + fun requestIdIs32LowercaseHexChars() { + val r = buildApprovalRequest( + crypto, "device:h", "h", "r", + ) + assertTrue(r.requestId.matches(Regex("^[0-9a-f]{32}$"))) + } + + @Test + fun consecutiveBuildsProduceDistinctRequestIds() { + val a = buildApprovalRequest(crypto, "device:h", "h", "r") + val b = buildApprovalRequest(crypto, "device:h", "h", "r") + assertNotEquals(a.requestId, b.requestId) + } + + // ─── Sanity glue: TS-side reference frame parses on Kotlin ── + + @Test + fun tsStyleProxyApprovalFrameParsesAndStructurallyMatches() { + // Construct a frame the way @shade/sdk would emit it via JSON, + // and check our Kotlin types accept the same field names. + val expected = ProxyApprovalFrame( + requestId = "00112233445566778899aabbccddeeff", + decision = "approve", + approverFingerprint = "fp", + signature = "ab".repeat(64), + domain = DEFAULT_APPROVAL_DOMAIN, + ) + assertEquals("linkApproveByProxy", expected.kind) + assertEquals("approve", expected.decision) + + val req = ApprovalRequestFrame( + requestId = "00112233445566778899aabbccddeeff", + hostAddress = "device:h", + hostFingerprint = "host-fp", + requestingDevice = ApprovalRequestingDevice( + fingerprint = "req-fp", + receivedAt = 1L, + ), + expiresAt = 2L, + domain = DEFAULT_APPROVAL_DOMAIN, + ) + assertNotNull(req) + } +} diff --git a/android/shade-android/src/test/kotlin/no/zyon/shade/CrossPlatformVectorTest.kt b/android/shade-android/src/test/kotlin/no/zyon/shade/CrossPlatformVectorTest.kt index 21ed2f0..54342ce 100644 --- a/android/shade-android/src/test/kotlin/no/zyon/shade/CrossPlatformVectorTest.kt +++ b/android/shade-android/src/test/kotlin/no/zyon/shade/CrossPlatformVectorTest.kt @@ -1,6 +1,13 @@ package no.zyon.shade +import no.zyon.shade.approval.canonicalApprovalSigningBytes import no.zyon.shade.backup.deriveBackupKey +import no.zyon.shade.blob.aeadOpen +import no.zyon.shade.blob.blobAadForSlot +import no.zyon.shade.blob.deriveBlobKey +import no.zyon.shade.blob.deriveBlobSigningSeed +import no.zyon.shade.blob.deriveBlobSlotId +import no.zyon.shade.blob.ed25519PublicKeyFromSeed import no.zyon.shade.crypto.TinkProvider import no.zyon.shade.fingerprint.computeFingerprint import no.zyon.shade.group.encodeSenderHeader @@ -447,6 +454,92 @@ class CrossPlatformVectorTest { } } + @Test + fun blobKdfAndAeadVectorsMatch() { + val vectors = loadVectors("blob.json") + var kdfMatched = 0 + var aeadMatched = 0 + for (i in 0 until vectors.length()) { + val v = vectors.getJSONObject(i) + val desc = v.getString("description") + if (desc.startsWith("V4.9 blob KDF")) { + kdfMatched++ + val masterKey = fromHex(v.getString("masterKey")) + val app = v.getString("app") + val slotId = deriveBlobSlotId(crypto, masterKey, app) + assertEquals(v.getString("slotId"), hex(slotId)) + assertEquals(v.getString("blobKey"), hex(deriveBlobKey(crypto, masterKey, app))) + val seed = deriveBlobSigningSeed(crypto, masterKey, app) + assertEquals(v.getString("signingSeed"), hex(seed)) + assertEquals(v.getString("ownerPubkey"), hex(ed25519PublicKeyFromSeed(seed))) + } else if (desc.startsWith("V4.9 blob AEAD")) { + aeadMatched++ + val key = fromHex(v.getString("key")) + val slotIdHex = v.getString("slotIdHex") + val expectedPlaintext = fromHex(v.getString("plaintext")) + val wire = fromHex(v.getString("wire")) + val aad = blobAadForSlot(slotIdHex) + val opened = aeadOpen(key, wire, aad) + assertEquals(hex(expectedPlaintext), hex(opened)) + } + } + assertTrue("KDF vectors expected", kdfMatched >= 3) + assertTrue("AEAD vectors expected", aeadMatched >= 2) + } + + @Test + fun approvalSigningPayloadVectorsMatch() { + val vectors = loadVectors("approval.json") + var payloadMatched = 0 + var e2eMatched = 0 + for (i in 0 until vectors.length()) { + val v = vectors.getJSONObject(i) + val desc = v.getString("description") + if (desc.startsWith("V4.10 approval signing payload")) { + payloadMatched++ + val out = canonicalApprovalSigningBytes( + domain = v.getString("domain"), + requestId = v.getString("requestId"), + hostFingerprint = v.getString("hostFingerprint"), + requestingDeviceFingerprint = v.getString("requestingDeviceFingerprint"), + decision = v.getString("decision"), + ) + assertEquals(v.getString("signingPayload"), hex(out)) + } else if (desc.startsWith("V4.10 approval Ed25519 sign/verify")) { + e2eMatched++ + val seed = fromHex(v.getString("seed")) + val pubkey = fromHex(v.getString("publicKey")) + assertEquals(hex(pubkey), hex(ed25519PublicKeyFromSeed(seed))) + + val req = v.getJSONObject("request") + val payload = canonicalApprovalSigningBytes( + domain = req.getString("domain"), + requestId = req.getString("requestId"), + hostFingerprint = req.getString("hostFingerprint"), + requestingDeviceFingerprint = req.getString("requestingDeviceFingerprint"), + decision = req.getString("decision"), + ) + assertEquals(v.getString("signingPayload"), hex(payload)) + + // Verify the TS-generated signature against our pubkey + payload. + // This is the load-bearing parity check: a Kotlin-implemented + // verifyProxyApproval running against a TS-signed approval + // succeeds. + val sig = fromHex(v.getString("signature")) + val ok = crypto.verify(pubkey, payload, sig) + assertTrue("Ed25519 verify of TS-signed approval failed", ok) + + // And: Kotlin signs the same payload with the same seed and + // produces a sig the TS pubkey verifies. Ed25519 is + // deterministic, so the sig bytes also match exactly. + val mySig = crypto.sign(seed, payload) + assertEquals(v.getString("signature"), hex(mySig)) + } + } + assertTrue("payload vectors expected", payloadMatched >= 3) + assertTrue("e2e sign/verify vector expected", e2eMatched >= 1) + } + @Test fun ratchetStepRoundtripMatches() { val vectors = loadVectors("ratchet-step.json") diff --git a/android/shade-android/src/test/kotlin/no/zyon/shade/SessionStateJsonTest.kt b/android/shade-android/src/test/kotlin/no/zyon/shade/SessionStateJsonTest.kt new file mode 100644 index 0000000..ad38db3 --- /dev/null +++ b/android/shade-android/src/test/kotlin/no/zyon/shade/SessionStateJsonTest.kt @@ -0,0 +1,123 @@ +package no.zyon.shade + +import no.zyon.shade.serialization.SessionStateJson +import no.zyon.shade.types.ChainState +import no.zyon.shade.types.IdentityKeyPair +import no.zyon.shade.types.KeyPair +import no.zyon.shade.types.OneTimePreKey +import no.zyon.shade.types.SessionState +import no.zyon.shade.types.SignedPreKey +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Round-trip tests for the at-rest JSON serialization used by + * `KeystoreStorage`. The format isn't cross-platform (TS uses its + * own shape) — what matters is `serialize → deserialize` preserves + * every byte of every key. + */ +class SessionStateJsonTest { + + private fun bytes(n: Int, fill: Byte): ByteArray = ByteArray(n) { fill } + + @Test + fun identityKeyPairRoundTrip() { + val k = IdentityKeyPair( + signingPublicKey = bytes(32, 0x11), + signingPrivateKey = bytes(32, 0x22), + dhPublicKey = bytes(32, 0x33), + dhPrivateKey = bytes(32, 0x44), + ) + val s = SessionStateJson.serializeIdentityKeyPair(k) + val d = SessionStateJson.deserializeIdentityKeyPair(s) + assertArrayEquals(k.signingPublicKey, d.signingPublicKey) + assertArrayEquals(k.signingPrivateKey, d.signingPrivateKey) + assertArrayEquals(k.dhPublicKey, d.dhPublicKey) + assertArrayEquals(k.dhPrivateKey, d.dhPrivateKey) + } + + @Test + fun signedPreKeyRoundTrip() { + val k = SignedPreKey( + keyId = 42, + keyPair = KeyPair(publicKey = bytes(32, 0x55), privateKey = bytes(32, 0x66)), + signature = bytes(64, 0x77), + timestamp = 1_700_000_000_000L, + ) + val s = SessionStateJson.serializeSignedPreKey(k) + val d = SessionStateJson.deserializeSignedPreKey(s) + assertEquals(k.keyId, d.keyId) + assertArrayEquals(k.keyPair.publicKey, d.keyPair.publicKey) + assertArrayEquals(k.keyPair.privateKey, d.keyPair.privateKey) + assertArrayEquals(k.signature, d.signature) + assertEquals(k.timestamp, d.timestamp) + } + + @Test + fun oneTimePreKeyRoundTrip() { + val k = OneTimePreKey( + keyId = 7, + keyPair = KeyPair(publicKey = bytes(32, 0x88.toByte()), privateKey = bytes(32, 0x99.toByte())), + ) + val s = SessionStateJson.serializeOneTimePreKey(k) + val d = SessionStateJson.deserializeOneTimePreKey(s) + assertEquals(k.keyId, d.keyId) + assertArrayEquals(k.keyPair.publicKey, d.keyPair.publicKey) + assertArrayEquals(k.keyPair.privateKey, d.keyPair.privateKey) + } + + @Test + fun sessionStateRoundTripFullPopulated() { + val state = SessionState( + remoteIdentityKey = bytes(32, 0x01), + rootKey = bytes(32, 0x02), + sendChain = ChainState(chainKey = bytes(32, 0x03), counter = 5), + receiveChain = ChainState(chainKey = bytes(32, 0x04), counter = 3), + dhSend = KeyPair(publicKey = bytes(32, 0x05), privateKey = bytes(32, 0x06)), + dhReceive = bytes(32, 0x07), + previousSendCounter = 9, + skippedKeys = mutableMapOf( + "remote:1" to bytes(32, 0x0A), + "remote:2" to bytes(32, 0x0B), + ), + ) + val s = SessionStateJson.serialize(state) + val d = SessionStateJson.deserialize(s) + assertArrayEquals(state.remoteIdentityKey, d.remoteIdentityKey) + assertArrayEquals(state.rootKey, d.rootKey) + assertArrayEquals(state.sendChain.chainKey, d.sendChain.chainKey) + assertEquals(state.sendChain.counter, d.sendChain.counter) + assertNotNull(d.receiveChain) + assertArrayEquals(state.receiveChain!!.chainKey, d.receiveChain!!.chainKey) + assertArrayEquals(state.dhSend.publicKey, d.dhSend.publicKey) + assertArrayEquals(state.dhSend.privateKey, d.dhSend.privateKey) + assertArrayEquals(state.dhReceive, d.dhReceive) + assertEquals(state.previousSendCounter, d.previousSendCounter) + assertEquals(state.skippedKeys.size, d.skippedKeys.size) + for ((k, v) in state.skippedKeys) { + assertArrayEquals(v, d.skippedKeys[k]) + } + } + + @Test + fun sessionStateRoundTripWithNullableFields() { + val state = SessionState( + remoteIdentityKey = bytes(32, 0x01), + rootKey = bytes(32, 0x02), + sendChain = ChainState(chainKey = bytes(32, 0x03), counter = 0), + receiveChain = null, + dhSend = KeyPair(publicKey = bytes(32, 0x05), privateKey = bytes(32, 0x06)), + dhReceive = null, + previousSendCounter = 0, + skippedKeys = mutableMapOf(), + ) + val s = SessionStateJson.serialize(state) + val d = SessionStateJson.deserialize(s) + assertNull(d.receiveChain) + assertNull(d.dhReceive) + assertEquals(0, d.skippedKeys.size) + } +} diff --git a/scripts/generate-vectors.ts b/scripts/generate-vectors.ts index b76e575..93207cc 100644 --- a/scripts/generate-vectors.ts +++ b/scripts/generate-vectors.ts @@ -26,6 +26,18 @@ import { buildChunkAad, aesGcmEncryptWithNonce, } from '../packages/shade-streams/src/index.js'; +import { + deriveBlobSlotId, + deriveBlobKey, + deriveBlobSigningSeed, +} from '../packages/shade-storage-encrypted/src/crypto.js'; +import { ed25519PublicKeyFromSeed } from '../packages/shade-crypto-web/src/index.js'; +import { + canonicalApprovalSigningBytes, + signProxyApproval, + buildApprovalRequest, + type ApprovalRequestFrame, +} from '../packages/shade-sdk/src/index.js'; const VECTOR_FILE_VERSION = 2; @@ -653,6 +665,162 @@ async function generateStorageEncryptionSubset(): Promise { ]; } +async function generateBlobVectors(): Promise { + // Three (master, app) cases. The first two share a master with + // different app namespaces, exercising the namespace separation; + // the third uses a different master entirely. + const cases: Array<{ masterKey: Uint8Array; app: string }> = [ + { masterKey: new Uint8Array(32).map((_, i) => i + 1), app: 'prism-profile-v1' }, + { masterKey: new Uint8Array(32).map((_, i) => i + 1), app: 'test-namespace' }, + { masterKey: new Uint8Array(32).fill(0xff), app: 'prism-profile-v1' }, + ]; + + const kdf = cases.map((c) => { + const slotId = deriveBlobSlotId(c.masterKey, c.app); + const blobKey = deriveBlobKey(c.masterKey, c.app); + const signingSeed = deriveBlobSigningSeed(c.masterKey, c.app); + const ownerPubkey = ed25519PublicKeyFromSeed(signingSeed); + return { + description: `V4.9 blob KDF (master + app="${c.app}")`, + masterKey: hex(c.masterKey), + app: c.app, + slotId: hex(slotId), + blobKey: hex(blobKey), + signingSeed: hex(signingSeed), + ownerPubkey: hex(ownerPubkey), + }; + }); + + // Three deterministic AEAD round-trips: pinned key, pinned nonce, + // pinned plaintext. The wire form is `nonce || ct||tag`. + const aeadCases = [ + { + key: new Uint8Array(32).fill(0xab), + nonce: new Uint8Array(12).fill(0x01), + slotIdHex: '00'.repeat(32), + plaintext: new TextEncoder().encode('hello shade-blob-v1'), + }, + { + key: new Uint8Array(32).map((_, i) => i), + nonce: new Uint8Array(12).map((_, i) => 0xa0 + i), + slotIdHex: 'ff'.repeat(32), + plaintext: new TextEncoder().encode( + '{"version":1,"hosts":[],"clients":[],"trustedApproverFingerprints":[],"updatedAt":1}', + ), + }, + ]; + + const aead = await Promise.all( + aeadCases.map(async (c) => { + const aad = new TextEncoder().encode(`shade-profile-aad-v1:${c.slotIdHex}`); + const ctTag = await aesGcmEncryptDeterministic(c.key, c.nonce, c.plaintext, aad); + const wire = new Uint8Array(c.nonce.length + ctTag.length); + wire.set(c.nonce, 0); + wire.set(ctTag, c.nonce.length); + return { + description: 'V4.9 blob AEAD: nonce || AES-256-GCM(key, plaintext, aad="shade-profile-aad-v1:")', + key: hex(c.key), + nonce: hex(c.nonce), + slotIdHex: c.slotIdHex, + plaintext: hex(c.plaintext), + wire: hex(wire), + }; + }), + ); + + return [...kdf, ...aead]; +} + +async function generateApprovalVectors(): Promise { + // Pinned signing-payload bytes for canonical approval. Length- + // prefixed UTF-8 with u16 BE lengths — Kotlin/Swift implementations + // produce byte-identical input by spec. + const cases = [ + { + domain: 'shade-link-approve-v1', + requestId: 'aabbccddeeff00112233445566778899', + hostFingerprint: '11111 22222 33333 44444', + requestingDeviceFingerprint: '55555 66666 77777 88888', + decision: 'approve' as const, + }, + { + domain: 'shade-link-approve-v1', + requestId: 'aabbccddeeff00112233445566778899', + hostFingerprint: '11111 22222 33333 44444', + requestingDeviceFingerprint: '55555 66666 77777 88888', + decision: 'reject' as const, + }, + { + domain: 'prism-link-approve-v1', + requestId: '00000000000000000000000000000000', + hostFingerprint: 'a', + requestingDeviceFingerprint: 'b', + decision: 'approve' as const, + }, + ]; + + const payloads = cases.map((c) => ({ + description: 'V4.10 approval signing payload (length-prefixed u16 BE UTF-8)', + domain: c.domain, + requestId: c.requestId, + hostFingerprint: c.hostFingerprint, + requestingDeviceFingerprint: c.requestingDeviceFingerprint, + decision: c.decision, + signingPayload: hex(canonicalApprovalSigningBytes(c)), + })); + + // Pinned end-to-end sign + verify: deterministic seed → pubkey → + // sign(payload) → verify against the pubkey. Lets the Kotlin port + // assert the exact 64-byte signature without re-running RNG. + const seed = new Uint8Array(32).map((_, i) => 0x10 + i); + const pubkey = ed25519PublicKeyFromSeed(seed); + const fakeReq: ApprovalRequestFrame = { + kind: 'approvalNeeded', + requestId: 'cafebabe1234567890abcdef00112233', + hostAddress: 'device:host', + hostFingerprint: 'host-fp', + requestingDevice: { fingerprint: 'req-fp', receivedAt: 1 }, + expiresAt: 9_999_999_999_999, + domain: 'shade-link-approve-v1', + }; + const signed = await signProxyApproval({ + request: fakeReq, + decision: 'approve', + approverFingerprint: 'approver-fp', + approverSigningKey: seed, + crypto, + }); + + const e2e = { + description: 'V4.10 approval Ed25519 sign/verify (deterministic seed)', + seed: hex(seed), + publicKey: hex(pubkey), + request: { + requestId: fakeReq.requestId, + hostFingerprint: fakeReq.hostFingerprint, + requestingDeviceFingerprint: fakeReq.requestingDevice.fingerprint, + decision: 'approve', + domain: fakeReq.domain, + }, + signingPayload: hex( + canonicalApprovalSigningBytes({ + domain: fakeReq.domain, + requestId: fakeReq.requestId, + hostFingerprint: fakeReq.hostFingerprint, + requestingDeviceFingerprint: fakeReq.requestingDevice.fingerprint, + decision: 'approve', + }), + ), + signature: signed.signature, + }; + + // Sanity self-check at generation time so a silently broken sign + // path can't ship vectors that "verify" themselves. + void buildApprovalRequest; // imported but unused — keeps the symbol live + + return [...payloads, e2e]; +} + async function main() { console.log('Generating cross-platform test vectors…'); @@ -667,6 +835,8 @@ async function main() { ['backup.json', { vectors: await generateBackupVectors() }], ['group.json', { vectors: await generateGroupVectors() }], ['storage-hkdf.json', { vectors: await generateStorageEncryptionSubset() }], + ['blob.json', { vectors: await generateBlobVectors() }], + ['approval.json', { vectors: await generateApprovalVectors() }], ]; for (const [name, data] of files) { diff --git a/test-vectors/approval.json b/test-vectors/approval.json new file mode 100644 index 0000000..e3ff120 --- /dev/null +++ b/test-vectors/approval.json @@ -0,0 +1,46 @@ +{ + "version": 2, + "vectors": [ + { + "description": "V4.10 approval signing payload (length-prefixed u16 BE UTF-8)", + "domain": "shade-link-approve-v1", + "requestId": "aabbccddeeff00112233445566778899", + "hostFingerprint": "11111 22222 33333 44444", + "requestingDeviceFingerprint": "55555 66666 77777 88888", + "decision": "approve", + "signingPayload": "001573686164652d6c696e6b2d617070726f76652d76310020616162626363646465656666303031313232333334343535363637373838393900173131313131203232323232203333333333203434343434001735353535352036363636362037373737372038383838380007617070726f7665" + }, + { + "description": "V4.10 approval signing payload (length-prefixed u16 BE UTF-8)", + "domain": "shade-link-approve-v1", + "requestId": "aabbccddeeff00112233445566778899", + "hostFingerprint": "11111 22222 33333 44444", + "requestingDeviceFingerprint": "55555 66666 77777 88888", + "decision": "reject", + "signingPayload": "001573686164652d6c696e6b2d617070726f76652d7631002061616262636364646565666630303131323233333434353536363737383839390017313131313120323232323220333333333320343434343400173535353535203636363636203737373737203838383838000672656a656374" + }, + { + "description": "V4.10 approval signing payload (length-prefixed u16 BE UTF-8)", + "domain": "prism-link-approve-v1", + "requestId": "00000000000000000000000000000000", + "hostFingerprint": "a", + "requestingDeviceFingerprint": "b", + "decision": "approve", + "signingPayload": "0015707269736d2d6c696e6b2d617070726f76652d7631002030303030303030303030303030303030303030303030303030303030303030300001610001620007617070726f7665" + }, + { + "description": "V4.10 approval Ed25519 sign/verify (deterministic seed)", + "seed": "101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f", + "publicKey": "7776e870b93354f2a0b24c23f2a36cc4e80e223218c1b97926fdd018396a2b9b", + "request": { + "requestId": "cafebabe1234567890abcdef00112233", + "hostFingerprint": "host-fp", + "requestingDeviceFingerprint": "req-fp", + "decision": "approve", + "domain": "shade-link-approve-v1" + }, + "signingPayload": "001573686164652d6c696e6b2d617070726f76652d7631002063616665626162653132333435363738393061626364656630303131323233330007686f73742d667000067265712d66700007617070726f7665", + "signature": "2a60910d161466a10c5b256548267c6d58f7b25d6035dae4c7ab7770dfb1fe321c5b86120749544d18d0f40e1a3e9ca713724692e265083160da23cee926220e" + } + ] +} diff --git a/test-vectors/blob.json b/test-vectors/blob.json new file mode 100644 index 0000000..0d66e09 --- /dev/null +++ b/test-vectors/blob.json @@ -0,0 +1,48 @@ +{ + "version": 2, + "vectors": [ + { + "description": "V4.9 blob KDF (master + app=\"prism-profile-v1\")", + "masterKey": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "app": "prism-profile-v1", + "slotId": "cee6fe19af3c3ad20f91382938cd05ccf7f314566209f5debad17d8427508323", + "blobKey": "47ad8fc8fcb0f15ec75be95246e6040bb0674b1a9e4bc3cf7a2c3d1c1e57877b", + "signingSeed": "0bb58f21b588b44f22d5837602c1ee0049e56f99df5241702b65e5de0a1a0dab", + "ownerPubkey": "2be918c7af82278fb446bb3901e5a7691f5ac4123275d5e1b202882da2a637bc" + }, + { + "description": "V4.9 blob KDF (master + app=\"test-namespace\")", + "masterKey": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "app": "test-namespace", + "slotId": "b10a7e64f9902f48bc566d48c09c0276cdad2dc9ad55d456374c02a8f160aa46", + "blobKey": "9e140339142d23291f0f360f03072c66049cec2449994dce1b77a3aed43eeb37", + "signingSeed": "feec2d85ba7320fe34940abca082f056d5fa7927d940b267d44ae24acb486773", + "ownerPubkey": "94e8298ea69ba4b160934fb813ee3fa5b2a4254cc78cb3dd8339bdc7b68e660c" + }, + { + "description": "V4.9 blob KDF (master + app=\"prism-profile-v1\")", + "masterKey": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "app": "prism-profile-v1", + "slotId": "deffbe4e2934965ce63fff247331186579ff4ef13c867fa4597059c1d7047bfb", + "blobKey": "f498052d24513dccbdf538f2b9c13e9d6519fb06ead58eb3dfadf6b92d94227a", + "signingSeed": "e904e2f0f42297f16a29e636c43b9b72d57a49841ab0b9bfd29c03345e9f16d0", + "ownerPubkey": "822609f6b07f78d4692bfe708c05ce2d4d3c4eb25cf84a16a9d9e900015b3ca0" + }, + { + "description": "V4.9 blob AEAD: nonce || AES-256-GCM(key, plaintext, aad=\"shade-profile-aad-v1:\")", + "key": "abababababababababababababababababababababababababababababababab", + "nonce": "010101010101010101010101", + "slotIdHex": "0000000000000000000000000000000000000000000000000000000000000000", + "plaintext": "68656c6c6f2073686164652d626c6f622d7631", + "wire": "0101010101010101010101014590dd4c26e2abcaafd91815d4b40dab6512fecc82205c3484d87454602ca189ad213f" + }, + { + "description": "V4.9 blob AEAD: nonce || AES-256-GCM(key, plaintext, aad=\"shade-profile-aad-v1:\")", + "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "nonce": "a0a1a2a3a4a5a6a7a8a9aaab", + "slotIdHex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "plaintext": "7b2276657273696f6e223a312c22686f737473223a5b5d2c22636c69656e7473223a5b5d2c2274727573746564417070726f76657246696e6765727072696e7473223a5b5d2c22757064617465644174223a317d", + "wire": "a0a1a2a3a4a5a6a7a8a9aaab9d3a0a4837b86bd00c47bde22b58a8b103d82a32a8ec1f40be6d4aef1ac50172f04c1ca28300274f2aef70ad6d3bf3893574302d10967310263b792ed619ebc4c79ebf346d89c69584869e6ceaaa8dceef319ad9e4a96b1a15607fb08082dbb0f959738b" + } + ] +}