594 lines
22 KiB
Kotlin
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)
|
||
|
|
}
|
||
|
|
}
|