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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user