Files
Shade/android/shade-android/src/test/kotlin/no/zyon/shade/BlobAndApprovalTest.kt
Sterister 188c3db56a
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
android: V4.9 + V4.10 Kotlin ports + KeystoreStorage adapter
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>
2026-05-09 17:38:15 +02:00

594 lines
22 KiB
Kotlin

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