android: V4.9 + V4.10 Kotlin ports + KeystoreStorage adapter
Some checks failed
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Test / test (push) Has been cancelled

Pure-JVM additions to shade-android (no Android SDK needed):
- V4.9 blob primitives: BlobKdf (HKDF deriveBlobSlotId/Key/SigningSeed),
  BlobAead (nonce||ct||tag with shade-profile-aad-v1:<slot> AAD),
  BlobClient (java.net.http with hand-written canonical JSON signing
  matching TS signPayload output), Profile high-level namespace.
- V4.10 approval helpers: CanonicalProfileBlob schema with denormalized
  trustedApproverFingerprints, build/sign/verify proxy approvals via
  length-prefixed u16 BE UTF-8 canonical signing payload.
- Password KDFs: scrypt + argon2id via Bouncy Castle, NFKC-normalized.
- SessionStateJson at-rest serializer for persistence layer.

Cross-platform vectors (test-vectors/blob.json, approval.json) gate
byte-identical output between TS and Kotlin, including a TS-signed
Ed25519 signature the Kotlin port verifies and reproduces (Ed25519 is
deterministic).

New shade-android-keystore sibling Gradle module (Android-specific):
- KeystoreMasterKey: hardware-backed AES-256-GCM with BIOMETRIC_STRONG
  gating, StrongBox-backed when available, invalidated on enrollment.
- BiometricUnlock: coroutine wrapper around BiometricPrompt with
  tagged cancellation/failure exceptions.
- KeystoreStorage: StorageProvider over biometric-gated AES-encrypted
  SharedPreferences with AAD-bound row keys.

All 25 SDK packages typecheck clean; 104 SDK tests + 24 new Kotlin
tests + 11 cross-platform vector tests all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 17:38:15 +02:00
parent 1bd7037a6d
commit 188c3db56a
26 changed files with 3181 additions and 1 deletions

View File

@@ -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:<slotIdHex>` AAD),
`ed25519PublicKeyFromSeed`, `slotIdToHex`. Lives under `no.zyon.shade.blob`.
- `BlobClient` HTTP wrapper for `/v1/blob/<slotId>` (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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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")

View File

@@ -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`).

View File

@@ -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")
}

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />

View File

@@ -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")

View File

@@ -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"
}
}

View File

@@ -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:
* `<base64(nonce(12))>:<base64(ct||tag)>`
*
* Stored as `String` SharedPreferences entries. AAD = the row's
* preference key (`session:<address>`, `signedPreKey:<id>`, 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"
}
}

View File

@@ -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

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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<ProfileHostEntry> = emptyList(),
val clients: List<ProfileClientEntry> = emptyList(),
/** Denormalized list of trusted-approver fingerprints. Rederived on mutate. */
val trustedApproverFingerprints: List<String> = 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 <T> parseArray(
arr: JSONArray?,
field: String,
parse: (JSONObject) -> T,
): List<T> {
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<String> {
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<ProfileClientEntry>): List<String> =
clients.filter { it.trustedApprover }.map { it.identityFingerprint }

View File

@@ -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)
}

View File

@@ -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/<slotId>`). 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).
* - `<etag-string>`: 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<String, Any>().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<String, Any>().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<String>, 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, Any>): 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")

View File

@@ -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:<app>`) // relay-visible opaque ID
* blobKey = HKDF(masterKey, info=`shade-blob-key-v1:<app>`) // AEAD key for the blob
* sigSeed = HKDF(masterKey, info=`shade-blob-sig-v1:<app>`) // 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) }
}

View File

@@ -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).
* - "<etag>": 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,
)
}

View File

@@ -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<String, ByteArray>()
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)
}

View File

@@ -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
}

View File

@@ -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<ProfileClientEntry, ByteArray> {
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)
}
}

View File

@@ -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")

View File

@@ -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)
}
}

View File

@@ -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<Vector[]> {
];
}
async function generateBlobVectors(): Promise<Vector[]> {
// 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:<slotIdHex>")',
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<Vector[]> {
// 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) {

View File

@@ -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"
}
]
}

48
test-vectors/blob.json Normal file
View File

@@ -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:<slotIdHex>\")",
"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:<slotIdHex>\")",
"key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
"nonce": "a0a1a2a3a4a5a6a7a8a9aaab",
"slotIdHex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"plaintext": "7b2276657273696f6e223a312c22686f737473223a5b5d2c22636c69656e7473223a5b5d2c2274727573746564417070726f76657246696e6765727072696e7473223a5b5d2c22757064617465644174223a317d",
"wire": "a0a1a2a3a4a5a6a7a8a9aaab9d3a0a4837b86bd00c47bde22b58a8b103d82a32a8ec1f40be6d4aef1ac50172f04c1ca28300274f2aef70ad6d3bf3893574302d10967310263b792ed619ebc4c79ebf346d89c69584869e6ceaaa8dceef319ad9e4a96b1a15607fb08082dbb0f959738b"
}
]
}