package no.zyon.shade import no.zyon.shade.approval.ApprovalRequestFrame import no.zyon.shade.approval.ApprovalRequestingDevice import no.zyon.shade.approval.CanonicalProfileBlob import no.zyon.shade.approval.DEFAULT_APPROVAL_DOMAIN import no.zyon.shade.approval.ProfileClientEntry import no.zyon.shade.approval.ProfileHostEntry import no.zyon.shade.approval.ProxyApprovalFrame import no.zyon.shade.approval.VerifyProxyApprovalResult import no.zyon.shade.approval.buildApprovalRequest import no.zyon.shade.approval.canonicalApprovalSigningBytes import no.zyon.shade.approval.emptyCanonicalProfile import no.zyon.shade.approval.findClientByAddress import no.zyon.shade.approval.findClientByFingerprint import no.zyon.shade.approval.isTrustedApprover import no.zyon.shade.approval.parseCanonicalProfile import no.zyon.shade.approval.removeClient import no.zyon.shade.approval.serializeCanonicalProfile import no.zyon.shade.approval.setTrustedApprover import no.zyon.shade.approval.signProxyApproval import no.zyon.shade.approval.upsertClient import no.zyon.shade.approval.upsertHost import no.zyon.shade.approval.verifyProxyApproval import no.zyon.shade.blob.aeadOpen import no.zyon.shade.blob.aeadSeal import no.zyon.shade.blob.blobAadForSlot import no.zyon.shade.blob.deriveBlobKey import no.zyon.shade.blob.deriveBlobSigningSeed import no.zyon.shade.blob.deriveBlobSlotId import no.zyon.shade.blob.ed25519PublicKeyFromSeed import no.zyon.shade.blob.slotIdToHex import no.zyon.shade.crypto.TinkProvider import no.zyon.shade.storage.Argon2idParams import no.zyon.shade.storage.ScryptParams import no.zyon.shade.storage.deriveMasterKey import no.zyon.shade.storage.deriveMasterKeyArgon2id import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue import org.junit.Test /** * Unit tests for the V4.9 blob primitive ports + V4.10 approval * helpers + scrypt/argon2id wrappers. Cross-platform vector parity * lives in `CrossPlatformVectorTest`; this file tests Kotlin-side * round-trip behavior independent of the TS reference. */ class BlobAndApprovalTest { private val crypto = TinkProvider() private fun hex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it) } // ─── V4.9 blob KDF ───────────────────────────────────────── @Test fun deriveBlobSlotIdIsDeterministicPerMasterAndApp() { val km = ByteArray(32) { it.toByte() } val a1 = deriveBlobSlotId(crypto, km, "foo") val a2 = deriveBlobSlotId(crypto, km, "foo") assertArrayEquals(a1, a2) val b = deriveBlobSlotId(crypto, km, "bar") assertFalse(a1.contentEquals(b)) val km2 = ByteArray(32) { (it + 1).toByte() } val c = deriveBlobSlotId(crypto, km2, "foo") assertFalse(a1.contentEquals(c)) } @Test fun blobKdfHelpersAreIndependent() { val km = ByteArray(32) { it.toByte() } val slot = deriveBlobSlotId(crypto, km, "x") val key = deriveBlobKey(crypto, km, "x") val seed = deriveBlobSigningSeed(crypto, km, "x") assertFalse(slot.contentEquals(key)) assertFalse(slot.contentEquals(seed)) assertFalse(key.contentEquals(seed)) } @Test fun ed25519PublicKeyFromSeedIsDeterministic() { val seed = ByteArray(32) { it.toByte() } val pk1 = ed25519PublicKeyFromSeed(seed) val pk2 = ed25519PublicKeyFromSeed(seed) assertArrayEquals(pk1, pk2) assertEquals(32, pk1.size) } @Test fun slotIdToHexProducesLowercase64Chars() { val s = ByteArray(32) { 0xab.toByte() } val hex = slotIdToHex(s) assertEquals(64, hex.length) assertEquals("a".repeat(0) + "ab".repeat(32), hex) } // ─── V4.9 AEAD round-trip ────────────────────────────────── @Test fun aeadSealOpenRoundTrip() { val key = crypto.randomBytes(32) val nonce = crypto.randomBytes(12) val pt = "hello".toByteArray() val aad = blobAadForSlot("00".repeat(32)) val sealed = aeadSeal(key, nonce, pt, aad) val opened = aeadOpen(key, sealed, aad) assertArrayEquals(pt, opened) } @Test fun aeadOpenWithWrongAadFails() { val key = crypto.randomBytes(32) val nonce = crypto.randomBytes(12) val pt = "hello".toByteArray() val aad1 = blobAadForSlot("00".repeat(32)) val aad2 = blobAadForSlot("ff".repeat(32)) val sealed = aeadSeal(key, nonce, pt, aad1) try { aeadOpen(key, sealed, aad2) org.junit.Assert.fail("expected AEAD to reject wrong AAD") } catch (_: Exception) { // expected } } @Test fun aeadOpenWithExpectedNonceMismatchFails() { val key = crypto.randomBytes(32) val nonce = crypto.randomBytes(12) val wrongNonce = crypto.randomBytes(12) val pt = "hello".toByteArray() val aad = blobAadForSlot("00".repeat(32)) val sealed = aeadSeal(key, nonce, pt, aad) try { aeadOpen(key, sealed, aad, expectedNonce = wrongNonce) org.junit.Assert.fail("expected expectedNonce check to reject") } catch (_: IllegalArgumentException) { // expected } } // ─── Password KDFs ───────────────────────────────────────── @Test fun scryptDerivesDeterministically() { val pw = "correct-horse-battery-staple" val salt = ByteArray(16) { 0x42.toByte() } val k1 = deriveMasterKey(pw, salt, ScryptParams(n = 1024)) val k2 = deriveMasterKey(pw, salt, ScryptParams(n = 1024)) assertArrayEquals(k1, k2) assertEquals(32, k1.size) } @Test fun argon2idDerivesDeterministically() { val pw = "1234" val salt = ByteArray(16) { 0x55.toByte() } val k1 = deriveMasterKeyArgon2id(pw, salt, Argon2idParams(m = 256, t = 2)) val k2 = deriveMasterKeyArgon2id(pw, salt, Argon2idParams(m = 256, t = 2)) assertArrayEquals(k1, k2) assertEquals(32, k1.size) } @Test fun nfkcNormalizationMakesEquivalentInputsConverge() { // "café" can be encoded either as 'c','a','f','é' (NFC) or // 'c','a','f','e','́' (NFD). NFKC normalization on both // should converge to the same bytes. val nfc = "café" val nfd = "café" assertNotEquals(nfc, nfd) val salt = ByteArray(16) { 1.toByte() } val k1 = deriveMasterKey(nfc, salt, ScryptParams(n = 1024)) val k2 = deriveMasterKey(nfd, salt, ScryptParams(n = 1024)) assertArrayEquals(k1, k2) } // ─── Canonical profile schema ────────────────────────────── private fun makeClient(name: String, trusted: Boolean = false): Pair { val seed = crypto.randomBytes(32) val pubkey = ed25519PublicKeyFromSeed(seed) val fp = "fp-$name-${hex(pubkey).take(8)}" return ProfileClientEntry( address = "device:$name", identityPublicKey = hex(pubkey), identityFingerprint = fp, name = name, kind = "mobile", addedAt = 1_700_000_000_000L, trustedApprover = trusted, ) to seed } private fun makeHost(): ProfileHostEntry = ProfileHostEntry( address = "device:host-server", name = "Server", kind = "server", addedAt = 1_700_000_000_000L, ) @Test fun emptyCanonicalProfileRoundTrips() { val blob = emptyCanonicalProfile(now = 123L) val bytes = serializeCanonicalProfile(blob) val parsed = parseCanonicalProfile(bytes) assertEquals(1, parsed.version) assertTrue(parsed.hosts.isEmpty()) assertTrue(parsed.clients.isEmpty()) assertTrue(parsed.trustedApproverFingerprints.isEmpty()) assertEquals(123L, parsed.updatedAt) } @Test fun upsertClientDenormalizesTrustedApprovers() { var blob = emptyCanonicalProfile(0) val (a, _) = makeClient("phone-a", trusted = true) val (b, _) = makeClient("phone-b", trusted = false) blob = upsertClient(blob, a) blob = upsertClient(blob, b) assertEquals(2, blob.clients.size) assertEquals(listOf(a.identityFingerprint), blob.trustedApproverFingerprints) assertTrue(isTrustedApprover(blob, a.identityFingerprint)) assertFalse(isTrustedApprover(blob, b.identityFingerprint)) } @Test fun setTrustedApproverIsIdempotentNoOpReturnsSameInstance() { var blob = emptyCanonicalProfile(0) val (c, _) = makeClient("phone", trusted = false) blob = upsertClient(blob, c) val before = blob blob = setTrustedApprover(blob, c.identityFingerprint, false, now = 999L) assertTrue(blob === before) } @Test fun setTrustedApproverFlipsFlagAndDenormalizedList() { var blob = emptyCanonicalProfile(0) val (c, _) = makeClient("phone", trusted = false) blob = upsertClient(blob, c) blob = setTrustedApprover(blob, c.identityFingerprint, true, now = 100L) assertEquals(listOf(c.identityFingerprint), blob.trustedApproverFingerprints) assertEquals(true, blob.clients[0].trustedApprover) blob = setTrustedApprover(blob, c.identityFingerprint, false, now = 200L) assertTrue(blob.trustedApproverFingerprints.isEmpty()) assertEquals(false, blob.clients[0].trustedApprover) } @Test fun removeClientCleansUpDenormalizedList() { var blob = emptyCanonicalProfile(0) val (c, _) = makeClient("phone", trusted = true) blob = upsertClient(blob, c) blob = removeClient(blob, c.identityFingerprint) assertTrue(blob.clients.isEmpty()) assertTrue(blob.trustedApproverFingerprints.isEmpty()) } @Test fun findClientByFingerprintAndAddress() { var blob = emptyCanonicalProfile(0) val (c, _) = makeClient("phone") blob = upsertClient(blob, c) assertEquals(c.address, findClientByFingerprint(blob, c.identityFingerprint)?.address) assertEquals( c.identityFingerprint, findClientByAddress(blob, c.address)?.identityFingerprint, ) assertNull(findClientByFingerprint(blob, "unknown")) assertNull(findClientByAddress(blob, "unknown")) } @Test fun parseRejectsMalformedProfile() { try { parseCanonicalProfile("not json") org.junit.Assert.fail("expected throw") } catch (_: IllegalArgumentException) {} try { parseCanonicalProfile("""{"version":2}""") org.junit.Assert.fail("expected throw") } catch (_: IllegalArgumentException) {} try { parseCanonicalProfile( """{"version":1,"clients":[{"address":"x","name":"x","kind":"m","addedAt":0}]}""", ) org.junit.Assert.fail("expected throw — missing identityPublicKey") } catch (_: IllegalArgumentException) {} try { parseCanonicalProfile( """{"version":1,"clients":[{"address":"x","identityPublicKey":"NOTHEX","identityFingerprint":"x","name":"x","kind":"m","addedAt":0}]}""", ) org.junit.Assert.fail("expected throw — bad pubkey hex") } catch (_: IllegalArgumentException) {} } @Test fun fullProfileSerializeParsePreservesAllFields() { var blob = emptyCanonicalProfile(1L) blob = upsertHost(blob, makeHost(), now = 2L) val (c, _) = makeClient("phone", trusted = true) blob = upsertClient(blob, c, now = 3L) blob = blob.copy(signedBy = "aabbccdd") val bytes = serializeCanonicalProfile(blob) val parsed = parseCanonicalProfile(bytes) assertEquals(blob, parsed) } // ─── Approval signing payload ────────────────────────────── @Test fun canonicalApprovalSigningBytesIsDeterministic() { val a = canonicalApprovalSigningBytes( domain = DEFAULT_APPROVAL_DOMAIN, requestId = "aabbccddeeff00112233445566778899", hostFingerprint = "11111 22222 33333 44444", requestingDeviceFingerprint = "55555 66666 77777 88888", decision = "approve", ) val b = canonicalApprovalSigningBytes( domain = DEFAULT_APPROVAL_DOMAIN, requestId = "aabbccddeeff00112233445566778899", hostFingerprint = "11111 22222 33333 44444", requestingDeviceFingerprint = "55555 66666 77777 88888", decision = "approve", ) assertArrayEquals(a, b) } @Test fun differentDecisionProducesDifferentSigningBytes() { val approve = canonicalApprovalSigningBytes( DEFAULT_APPROVAL_DOMAIN, "r", "h", "d", "approve", ) val reject = canonicalApprovalSigningBytes( DEFAULT_APPROVAL_DOMAIN, "r", "h", "d", "reject", ) assertFalse(approve.contentEquals(reject)) } @Test fun differentDomainProducesDifferentSigningBytes() { val a = canonicalApprovalSigningBytes("shade-link-approve-v1", "r", "h", "d", "approve") val b = canonicalApprovalSigningBytes("prism-link-approve-v1", "r", "h", "d", "approve") assertFalse(a.contentEquals(b)) } // ─── Build / sign / verify ───────────────────────────────── private data class Scenario( val phone: ProfileClientEntry, val phoneSeed: ByteArray, val profile: CanonicalProfileBlob, val request: ApprovalRequestFrame, ) private fun buildScenario(): Scenario { val (phone, seed) = makeClient("phone", trusted = true) var profile = emptyCanonicalProfile(0) profile = upsertHost(profile, makeHost()) profile = upsertClient(profile, phone) val request = buildApprovalRequest( crypto = crypto, hostAddress = "device:host-server", hostFingerprint = "host-fp-12345", requestingDeviceFingerprint = "cafe-laptop-fp-67890", deviceName = "cafe-laptop", userAgent = "Mozilla/5.0", ipHint = "203.0.113.7", ) return Scenario(phone, seed, profile, request) } @Test fun happyPathApproveVerifies() { val s = buildScenario() val approval = signProxyApproval( crypto, s.request, decision = "approve", approverFingerprint = s.phone.identityFingerprint, approverSigningKey = s.phoneSeed, ) assertEquals("linkApproveByProxy", approval.kind) assertEquals(s.request.requestId, approval.requestId) assertEquals(128, approval.signature.length) val r = verifyProxyApproval(crypto, s.request, approval, s.profile) assertTrue(r is VerifyProxyApprovalResult.Ok) assertEquals(s.phone.address, (r as VerifyProxyApprovalResult.Ok).approver.address) } @Test fun happyPathRejectVerifies() { val s = buildScenario() val approval = signProxyApproval( crypto, s.request, "reject", s.phone.identityFingerprint, s.phoneSeed, ) val r = verifyProxyApproval(crypto, s.request, approval, s.profile) assertTrue(r is VerifyProxyApprovalResult.Ok) } @Test fun replayAgainstDifferentRequestFails() { val s = buildScenario() val approval = signProxyApproval( crypto, s.request, "approve", s.phone.identityFingerprint, s.phoneSeed, ) val other = s.request.copy(requestId = "f".repeat(32)) val r = verifyProxyApproval(crypto, other, approval, s.profile) assertEquals( VerifyProxyApprovalResult.Reason.REQUEST_ID_MISMATCH, (r as VerifyProxyApprovalResult.Failed).reason, ) } @Test fun decisionTamperingFails() { val s = buildScenario() val approval = signProxyApproval( crypto, s.request, "approve", s.phone.identityFingerprint, s.phoneSeed, ) val tampered = approval.copy(decision = "reject") val r = verifyProxyApproval(crypto, s.request, tampered, s.profile) assertEquals( VerifyProxyApprovalResult.Reason.BAD_SIGNATURE, (r as VerifyProxyApprovalResult.Failed).reason, ) } @Test fun hostFingerprintSwapFails() { val s = buildScenario() val approval = signProxyApproval( crypto, s.request, "approve", s.phone.identityFingerprint, s.phoneSeed, ) val swapped = s.request.copy(hostFingerprint = "evil-host-fp") val r = verifyProxyApproval(crypto, swapped, approval, s.profile) assertEquals( VerifyProxyApprovalResult.Reason.BAD_SIGNATURE, (r as VerifyProxyApprovalResult.Failed).reason, ) } @Test fun domainMismatchIsRejectedBeforeSignature() { val s = buildScenario() val approval = signProxyApproval( crypto, s.request, "approve", s.phone.identityFingerprint, s.phoneSeed, ) val r = verifyProxyApproval( crypto, s.request, approval.copy(domain = "prism-link-approve-v1"), s.profile, ) assertEquals( VerifyProxyApprovalResult.Reason.DOMAIN_MISMATCH, (r as VerifyProxyApprovalResult.Failed).reason, ) } @Test fun unknownApproverFails() { val s = buildScenario() val approval = signProxyApproval( crypto, s.request, "approve", s.phone.identityFingerprint, s.phoneSeed, ) val lying = approval.copy(approverFingerprint = "no-such-fingerprint") val r = verifyProxyApproval(crypto, s.request, lying, s.profile) assertEquals( VerifyProxyApprovalResult.Reason.UNKNOWN_APPROVER, (r as VerifyProxyApprovalResult.Failed).reason, ) } @Test fun revokedApproverFailsWithNotTrusted() { val s = buildScenario() val approval = signProxyApproval( crypto, s.request, "approve", s.phone.identityFingerprint, s.phoneSeed, ) val revoked = setTrustedApprover(s.profile, s.phone.identityFingerprint, false) val r = verifyProxyApproval(crypto, s.request, approval, revoked) assertEquals( VerifyProxyApprovalResult.Reason.NOT_TRUSTED, (r as VerifyProxyApprovalResult.Failed).reason, ) } @Test fun expiredRequestIsRejected() { val s = buildScenario() val approval = signProxyApproval( crypto, s.request, "approve", s.phone.identityFingerprint, s.phoneSeed, ) val r = verifyProxyApproval(crypto, s.request, approval, s.profile, now = s.request.expiresAt + 1) assertEquals( VerifyProxyApprovalResult.Reason.EXPIRED, (r as VerifyProxyApprovalResult.Failed).reason, ) } @Test fun signatureWithWrongKeyFails() { val s = buildScenario() val wrongSeed = crypto.randomBytes(32) val approval = signProxyApproval( crypto, s.request, "approve", approverFingerprint = s.phone.identityFingerprint, // claim phone approverSigningKey = wrongSeed, // sign with different key ) val r = verifyProxyApproval(crypto, s.request, approval, s.profile) assertEquals( VerifyProxyApprovalResult.Reason.BAD_SIGNATURE, (r as VerifyProxyApprovalResult.Failed).reason, ) } @Test fun customDomainSurvivesRoundTrip() { val s = buildScenario() val request = s.request.copy(domain = "prism-link-approve-v1") val approval = signProxyApproval( crypto, request, "approve", s.phone.identityFingerprint, s.phoneSeed, ) assertEquals("prism-link-approve-v1", approval.domain) val r = verifyProxyApproval(crypto, request, approval, s.profile) assertTrue(r is VerifyProxyApprovalResult.Ok) } @Test fun requestIdIs32LowercaseHexChars() { val r = buildApprovalRequest( crypto, "device:h", "h", "r", ) assertTrue(r.requestId.matches(Regex("^[0-9a-f]{32}$"))) } @Test fun consecutiveBuildsProduceDistinctRequestIds() { val a = buildApprovalRequest(crypto, "device:h", "h", "r") val b = buildApprovalRequest(crypto, "device:h", "h", "r") assertNotEquals(a.requestId, b.requestId) } // ─── Sanity glue: TS-side reference frame parses on Kotlin ── @Test fun tsStyleProxyApprovalFrameParsesAndStructurallyMatches() { // Construct a frame the way @shade/sdk would emit it via JSON, // and check our Kotlin types accept the same field names. val expected = ProxyApprovalFrame( requestId = "00112233445566778899aabbccddeeff", decision = "approve", approverFingerprint = "fp", signature = "ab".repeat(64), domain = DEFAULT_APPROVAL_DOMAIN, ) assertEquals("linkApproveByProxy", expected.kind) assertEquals("approve", expected.decision) val req = ApprovalRequestFrame( requestId = "00112233445566778899aabbccddeeff", hostAddress = "device:h", hostFingerprint = "host-fp", requestingDevice = ApprovalRequestingDevice( fingerprint = "req-fp", receivedAt = 1L, ), expiresAt = 2L, domain = DEFAULT_APPROVAL_DOMAIN, ) assertNotNull(req) } }