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>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user