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,273 @@
|
||||
package no.zyon.shade.approval
|
||||
|
||||
import no.zyon.shade.crypto.CryptoProvider
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* V4.10 — cross-host approval routing helpers. Mirror
|
||||
* `@shade/sdk/approval.ts` byte-for-byte.
|
||||
*
|
||||
* The frames themselves (`approvalNeeded` / `linkApproveByProxy`) are
|
||||
* app-defined payloads sent over the existing Shade bilateral E2EE
|
||||
* channel. This file ships the canonical signing-payload layout, the
|
||||
* Ed25519 sign step a phone runs after biometric unlock, and the
|
||||
* verify step a host runs against the freshest profile blob.
|
||||
*
|
||||
* The signing payload is length-prefixed binary (u16 BE) so any
|
||||
* platform — Kotlin, Swift, Go — can produce byte-identical input
|
||||
* without needing a JSON canonicalizer. Cross-platform parity is
|
||||
* gated by `test-vectors/blob-storage.json` (signing payload
|
||||
* fixtures) plus a Kotlin↔TS round-trip in `CrossPlatformVectorTest`.
|
||||
*/
|
||||
|
||||
/** Default domain separator. Apps with their own canonical name (e.g. Prism) override. */
|
||||
const val DEFAULT_APPROVAL_DOMAIN = "shade-link-approve-v1"
|
||||
|
||||
/** Default expiry: 5 minutes after the host issues the request. */
|
||||
const val DEFAULT_APPROVAL_EXPIRES_IN_MS = 5L * 60 * 1000
|
||||
|
||||
/** Information about the device the host received a `linkRequest` from. */
|
||||
data class ApprovalRequestingDevice(
|
||||
val fingerprint: String,
|
||||
val deviceName: String? = null,
|
||||
val userAgent: String? = null,
|
||||
val ipHint: String? = null,
|
||||
val receivedAt: Long,
|
||||
)
|
||||
|
||||
data class ApprovalRequestFrame(
|
||||
val kind: String = "approvalNeeded",
|
||||
/** 128-bit hex (32 chars) random idempotency key. */
|
||||
val requestId: String,
|
||||
val hostAddress: String,
|
||||
val hostFingerprint: String,
|
||||
val requestingDevice: ApprovalRequestingDevice,
|
||||
val expiresAt: Long,
|
||||
val domain: String,
|
||||
)
|
||||
|
||||
data class ProxyApprovalFrame(
|
||||
val kind: String = "linkApproveByProxy",
|
||||
val requestId: String,
|
||||
val decision: String,
|
||||
val approverFingerprint: String,
|
||||
/** 64-byte Ed25519 signature, lowercase hex (128 chars). */
|
||||
val signature: String,
|
||||
val domain: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Build a fresh `approvalNeeded` frame with a 128-bit random
|
||||
* `requestId`. Hosts SHOULD persist the requestId in a pending-set
|
||||
* keyed by `expiresAt` so a returning `linkApproveByProxy` can be
|
||||
* matched up — that's app state, the SDK doesn't track it.
|
||||
*/
|
||||
fun buildApprovalRequest(
|
||||
crypto: CryptoProvider,
|
||||
hostAddress: String,
|
||||
hostFingerprint: String,
|
||||
requestingDeviceFingerprint: String,
|
||||
deviceName: String? = null,
|
||||
userAgent: String? = null,
|
||||
ipHint: String? = null,
|
||||
expiresInMs: Long = DEFAULT_APPROVAL_EXPIRES_IN_MS,
|
||||
domain: String = DEFAULT_APPROVAL_DOMAIN,
|
||||
now: Long = System.currentTimeMillis(),
|
||||
): ApprovalRequestFrame {
|
||||
val requestId = crypto.randomBytes(16).joinToString("") { "%02x".format(it) }
|
||||
return ApprovalRequestFrame(
|
||||
requestId = requestId,
|
||||
hostAddress = hostAddress,
|
||||
hostFingerprint = hostFingerprint,
|
||||
requestingDevice = ApprovalRequestingDevice(
|
||||
fingerprint = requestingDeviceFingerprint,
|
||||
deviceName = deviceName,
|
||||
userAgent = userAgent,
|
||||
ipHint = ipHint,
|
||||
receivedAt = now,
|
||||
),
|
||||
expiresAt = now + expiresInMs,
|
||||
domain = domain,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a `linkApproveByProxy` frame with the approver's long-term
|
||||
* Ed25519 identity key. The seed is the 32-byte Ed25519 private key
|
||||
* (Tink's `Ed25519Sign(seed)` consumes it directly).
|
||||
*/
|
||||
fun signProxyApproval(
|
||||
crypto: CryptoProvider,
|
||||
request: ApprovalRequestFrame,
|
||||
decision: String,
|
||||
approverFingerprint: String,
|
||||
approverSigningKey: ByteArray,
|
||||
): ProxyApprovalFrame {
|
||||
require(decision == "approve" || decision == "reject") {
|
||||
"decision must be 'approve' or 'reject'"
|
||||
}
|
||||
require(approverSigningKey.size == 32) {
|
||||
"approverSigningKey must be 32 bytes (Ed25519 seed)"
|
||||
}
|
||||
val payload = canonicalApprovalSigningBytes(
|
||||
domain = request.domain,
|
||||
requestId = request.requestId,
|
||||
hostFingerprint = request.hostFingerprint,
|
||||
requestingDeviceFingerprint = request.requestingDevice.fingerprint,
|
||||
decision = decision,
|
||||
)
|
||||
val sig = crypto.sign(approverSigningKey, payload)
|
||||
return ProxyApprovalFrame(
|
||||
requestId = request.requestId,
|
||||
decision = decision,
|
||||
approverFingerprint = approverFingerprint,
|
||||
signature = sig.joinToString("") { "%02x".format(it) },
|
||||
domain = request.domain,
|
||||
)
|
||||
}
|
||||
|
||||
/** Tagged result of `verifyProxyApproval`. */
|
||||
sealed class VerifyProxyApprovalResult {
|
||||
data class Ok(val approver: ProfileClientEntry) : VerifyProxyApprovalResult()
|
||||
data class Failed(val reason: Reason) : VerifyProxyApprovalResult()
|
||||
|
||||
enum class Reason {
|
||||
REQUEST_ID_MISMATCH,
|
||||
DOMAIN_MISMATCH,
|
||||
UNKNOWN_APPROVER,
|
||||
NOT_TRUSTED,
|
||||
BAD_SIGNATURE,
|
||||
EXPIRED,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a `linkApproveByProxy` against the originating
|
||||
* `approvalNeeded` and the host's freshest profile blob. Returns a
|
||||
* tagged result rather than throwing — callers usually want to log
|
||||
* the reason before deciding what to surface to the user.
|
||||
*
|
||||
* Order of checks:
|
||||
*
|
||||
* 1. requestId match (replay defense)
|
||||
* 2. domain match (cross-app confusion defense)
|
||||
* 3. approver resolves to a `clients[]` entry
|
||||
* 4. approver is in `trustedApproverFingerprints[]` AND has the
|
||||
* `trustedApprover` flag (cross-checked via `isTrustedApprover`)
|
||||
* 5. expiresAt in the future
|
||||
* 6. Ed25519 signature verifies against `clients[].identityPublicKey`
|
||||
*
|
||||
* Hosts MUST refetch the profile blob fresh before calling this — see
|
||||
* the FR §5 "approver-revocation propagation" rationale.
|
||||
*/
|
||||
fun verifyProxyApproval(
|
||||
crypto: CryptoProvider,
|
||||
request: ApprovalRequestFrame,
|
||||
approval: ProxyApprovalFrame,
|
||||
profile: CanonicalProfileBlob,
|
||||
now: Long = System.currentTimeMillis(),
|
||||
): VerifyProxyApprovalResult {
|
||||
if (approval.requestId != request.requestId) {
|
||||
return VerifyProxyApprovalResult.Failed(
|
||||
VerifyProxyApprovalResult.Reason.REQUEST_ID_MISMATCH,
|
||||
)
|
||||
}
|
||||
if (approval.domain != request.domain) {
|
||||
return VerifyProxyApprovalResult.Failed(
|
||||
VerifyProxyApprovalResult.Reason.DOMAIN_MISMATCH,
|
||||
)
|
||||
}
|
||||
|
||||
val approver = findClientByFingerprint(profile, approval.approverFingerprint)
|
||||
?: return VerifyProxyApprovalResult.Failed(
|
||||
VerifyProxyApprovalResult.Reason.UNKNOWN_APPROVER,
|
||||
)
|
||||
|
||||
if (!isTrustedApprover(profile, approval.approverFingerprint)) {
|
||||
return VerifyProxyApprovalResult.Failed(
|
||||
VerifyProxyApprovalResult.Reason.NOT_TRUSTED,
|
||||
)
|
||||
}
|
||||
|
||||
if (now > request.expiresAt) {
|
||||
return VerifyProxyApprovalResult.Failed(VerifyProxyApprovalResult.Reason.EXPIRED)
|
||||
}
|
||||
|
||||
val pubkey = try {
|
||||
hexToBytes(approver.identityPublicKey)
|
||||
} catch (_: Exception) {
|
||||
return VerifyProxyApprovalResult.Failed(VerifyProxyApprovalResult.Reason.BAD_SIGNATURE)
|
||||
}
|
||||
val sig = try {
|
||||
hexToBytes(approval.signature)
|
||||
} catch (_: Exception) {
|
||||
return VerifyProxyApprovalResult.Failed(VerifyProxyApprovalResult.Reason.BAD_SIGNATURE)
|
||||
}
|
||||
if (pubkey.size != 32 || sig.size != 64) {
|
||||
return VerifyProxyApprovalResult.Failed(VerifyProxyApprovalResult.Reason.BAD_SIGNATURE)
|
||||
}
|
||||
|
||||
val payload = canonicalApprovalSigningBytes(
|
||||
domain = approval.domain,
|
||||
requestId = approval.requestId,
|
||||
hostFingerprint = request.hostFingerprint,
|
||||
requestingDeviceFingerprint = request.requestingDevice.fingerprint,
|
||||
decision = approval.decision,
|
||||
)
|
||||
val ok = crypto.verify(pubkey, payload, sig)
|
||||
return if (ok) VerifyProxyApprovalResult.Ok(approver)
|
||||
else VerifyProxyApprovalResult.Failed(VerifyProxyApprovalResult.Reason.BAD_SIGNATURE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonical signing payload bytes for a proxy approval.
|
||||
*
|
||||
* Format (length-prefixed UTF-8, big-endian u16 lengths):
|
||||
*
|
||||
* u16(len(domain)) || domain
|
||||
* u16(len(requestId)) || requestId
|
||||
* u16(len(hostFp)) || hostFingerprint
|
||||
* u16(len(requestFp)) || requestingDeviceFingerprint
|
||||
* u16(len(decision)) || decision
|
||||
*
|
||||
* This is the EXACT byte layout `@shade/sdk`'s
|
||||
* `canonicalApprovalSigningBytes` produces, ensuring an Android-signed
|
||||
* approval verifies on a TS host and vice versa.
|
||||
*/
|
||||
fun canonicalApprovalSigningBytes(
|
||||
domain: String,
|
||||
requestId: String,
|
||||
hostFingerprint: String,
|
||||
requestingDeviceFingerprint: String,
|
||||
decision: String,
|
||||
): ByteArray {
|
||||
val fields = listOf(
|
||||
domain.toByteArray(Charsets.UTF_8),
|
||||
requestId.toByteArray(Charsets.UTF_8),
|
||||
hostFingerprint.toByteArray(Charsets.UTF_8),
|
||||
requestingDeviceFingerprint.toByteArray(Charsets.UTF_8),
|
||||
decision.toByteArray(Charsets.UTF_8),
|
||||
)
|
||||
for (f in fields) {
|
||||
require(f.size <= 0xFFFF) { "signing field too long: ${f.size} bytes (max 65535)" }
|
||||
}
|
||||
val total = fields.sumOf { 2 + it.size }
|
||||
val buf = ByteBuffer.allocate(total).order(ByteOrder.BIG_ENDIAN)
|
||||
for (f in fields) {
|
||||
buf.putShort(f.size.toShort())
|
||||
buf.put(f)
|
||||
}
|
||||
return buf.array()
|
||||
}
|
||||
|
||||
private fun hexToBytes(hex: String): ByteArray {
|
||||
require(hex.length % 2 == 0) { "hex length must be even" }
|
||||
require(hex.all { it.isDigit() || it in 'a'..'f' }) { "hex must be lowercase 0-9a-f" }
|
||||
val out = ByteArray(hex.length / 2)
|
||||
for (i in out.indices) {
|
||||
out[i] = ((Character.digit(hex[i * 2], 16) shl 4) +
|
||||
Character.digit(hex[i * 2 + 1], 16)).toByte()
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package no.zyon.shade.approval
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* V4.10 — canonical profile-blob schema. Mirror
|
||||
* `@shade/sdk/approval.ts` byte-for-byte: same field names, same
|
||||
* JSON shape, same denormalization invariants.
|
||||
*
|
||||
* The blob is the AEAD plaintext stored in the V4.9 profile slot. It
|
||||
* holds the user's list of paired hosts + clients; cross-host
|
||||
* approval routing reads `clients[]` to find trusted approvers when
|
||||
* a headless host needs to dispatch a `linkRequest` to a phone.
|
||||
*
|
||||
* Mutators (`upsertHost`, `setTrustedApprover`, ...) are immutable —
|
||||
* they return a new blob and never modify the input. The denormalized
|
||||
* `trustedApproverFingerprints[]` is rederived on every mutation so it
|
||||
* can never drift from the per-client `trustedApprover` flag.
|
||||
*/
|
||||
|
||||
/** A host: a device that receives `linkRequest` frames and runs pairing. */
|
||||
data class ProfileHostEntry(
|
||||
val address: String,
|
||||
val name: String,
|
||||
/** Open enum: `"desktop" | "server" | "laptop" | ...`. */
|
||||
val kind: String,
|
||||
val addedAt: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* A client: a device that initiates link/approval flows and may
|
||||
* proxy-approve when `trustedApprover == true`. Stores both the
|
||||
* 32-byte Ed25519 identity public key (hex) and the safety-number
|
||||
* fingerprint — the public key is what `verifyProxyApproval` checks
|
||||
* signatures against; the fingerprint is what UIs display.
|
||||
*/
|
||||
data class ProfileClientEntry(
|
||||
val address: String,
|
||||
/** 32-byte Ed25519 long-term identity public key, lowercase hex (64 chars). */
|
||||
val identityPublicKey: String,
|
||||
/** Safety-number fingerprint of the identity key (computeFingerprint output). */
|
||||
val identityFingerprint: String,
|
||||
val name: String,
|
||||
/** Open enum: `"mobile" | "tablet" | "browser" | ...`. */
|
||||
val kind: String,
|
||||
val addedAt: Long,
|
||||
val trustedApprover: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Canonical profile blob. `version=1` is the only currently-supported
|
||||
* shape; bump when an incompatible field is added. Unknown top-level
|
||||
* fields are dropped on parse — additive changes need a coordinated
|
||||
* schema bump on both platforms.
|
||||
*/
|
||||
data class CanonicalProfileBlob(
|
||||
val version: Int = 1,
|
||||
val hosts: List<ProfileHostEntry> = emptyList(),
|
||||
val clients: List<ProfileClientEntry> = emptyList(),
|
||||
/** Denormalized list of trusted-approver fingerprints. Rederived on mutate. */
|
||||
val trustedApproverFingerprints: List<String> = emptyList(),
|
||||
val updatedAt: Long = 0,
|
||||
/** Optional hex-encoded pubkey of the writer; informational only. */
|
||||
val signedBy: String? = null,
|
||||
)
|
||||
|
||||
/** Build a fresh empty profile blob with `updatedAt = now ?? System.currentTimeMillis()`. */
|
||||
fun emptyCanonicalProfile(now: Long? = null): CanonicalProfileBlob =
|
||||
CanonicalProfileBlob(updatedAt = now ?: System.currentTimeMillis())
|
||||
|
||||
/**
|
||||
* Decode a profile-blob plaintext (the AEAD-opened bytes) into the
|
||||
* canonical shape. Throws `IllegalArgumentException` on malformed JSON
|
||||
* or wrong shape.
|
||||
*/
|
||||
fun parseCanonicalProfile(plaintext: ByteArray): CanonicalProfileBlob =
|
||||
parseCanonicalProfile(plaintext.toString(Charsets.UTF_8))
|
||||
|
||||
fun parseCanonicalProfile(plaintext: String): CanonicalProfileBlob {
|
||||
val obj = try {
|
||||
JSONObject(plaintext)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalArgumentException("profile blob is not valid JSON: ${e.message}")
|
||||
}
|
||||
val version = obj.optInt("version", -1)
|
||||
require(version == 1) { "unsupported profile blob version: $version" }
|
||||
|
||||
val hosts = parseArray(obj.optJSONArray("hosts"), "hosts", ::parseHostEntry)
|
||||
val clients = parseArray(obj.optJSONArray("clients"), "clients", ::parseClientEntry)
|
||||
val trustedApproverFingerprints = parseStringArray(
|
||||
obj.optJSONArray("trustedApproverFingerprints"),
|
||||
"trustedApproverFingerprints",
|
||||
)
|
||||
val updatedAt = if (obj.has("updatedAt") && !obj.isNull("updatedAt"))
|
||||
obj.getLong("updatedAt") else 0L
|
||||
val signedBy = obj.optString("signedBy", "").takeIf { it.isNotEmpty() }
|
||||
|
||||
return CanonicalProfileBlob(
|
||||
version = 1,
|
||||
hosts = hosts,
|
||||
clients = clients,
|
||||
trustedApproverFingerprints = trustedApproverFingerprints,
|
||||
updatedAt = updatedAt,
|
||||
signedBy = signedBy,
|
||||
)
|
||||
}
|
||||
|
||||
/** Serialize a profile blob to UTF-8 JSON ready for `Profile.put`. */
|
||||
fun serializeCanonicalProfile(blob: CanonicalProfileBlob): ByteArray {
|
||||
val json = JSONObject()
|
||||
json.put("version", blob.version)
|
||||
json.put("hosts", JSONArray().apply {
|
||||
blob.hosts.forEach { put(hostEntryToJson(it)) }
|
||||
})
|
||||
json.put("clients", JSONArray().apply {
|
||||
blob.clients.forEach { put(clientEntryToJson(it)) }
|
||||
})
|
||||
json.put("trustedApproverFingerprints", JSONArray(blob.trustedApproverFingerprints))
|
||||
json.put("updatedAt", blob.updatedAt)
|
||||
if (blob.signedBy != null) json.put("signedBy", blob.signedBy)
|
||||
return json.toString().toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun parseHostEntry(o: JSONObject): ProfileHostEntry =
|
||||
ProfileHostEntry(
|
||||
address = o.requireString("address", "hosts"),
|
||||
name = o.requireString("name", "hosts"),
|
||||
kind = o.requireString("kind", "hosts"),
|
||||
addedAt = o.requireLong("addedAt", "hosts"),
|
||||
)
|
||||
|
||||
private fun parseClientEntry(o: JSONObject): ProfileClientEntry {
|
||||
val identityPublicKey = o.requireString("identityPublicKey", "clients")
|
||||
require(identityPublicKey.matches(Regex("^[0-9a-f]{64}$"))) {
|
||||
"clients[].identityPublicKey must be 64 lowercase hex chars"
|
||||
}
|
||||
return ProfileClientEntry(
|
||||
address = o.requireString("address", "clients"),
|
||||
identityPublicKey = identityPublicKey,
|
||||
identityFingerprint = o.requireString("identityFingerprint", "clients"),
|
||||
name = o.requireString("name", "clients"),
|
||||
kind = o.requireString("kind", "clients"),
|
||||
addedAt = o.requireLong("addedAt", "clients"),
|
||||
trustedApprover = o.optBoolean("trustedApprover", false),
|
||||
)
|
||||
}
|
||||
|
||||
private fun hostEntryToJson(e: ProfileHostEntry): JSONObject = JSONObject().apply {
|
||||
put("address", e.address)
|
||||
put("name", e.name)
|
||||
put("kind", e.kind)
|
||||
put("addedAt", e.addedAt)
|
||||
}
|
||||
|
||||
private fun clientEntryToJson(e: ProfileClientEntry): JSONObject = JSONObject().apply {
|
||||
put("address", e.address)
|
||||
put("identityPublicKey", e.identityPublicKey)
|
||||
put("identityFingerprint", e.identityFingerprint)
|
||||
put("name", e.name)
|
||||
put("kind", e.kind)
|
||||
put("addedAt", e.addedAt)
|
||||
if (e.trustedApprover) put("trustedApprover", true)
|
||||
}
|
||||
|
||||
private fun <T> parseArray(
|
||||
arr: JSONArray?,
|
||||
field: String,
|
||||
parse: (JSONObject) -> T,
|
||||
): List<T> {
|
||||
if (arr == null) return emptyList()
|
||||
return (0 until arr.length()).map { i ->
|
||||
val item = arr.opt(i)
|
||||
require(item is JSONObject) { "$field[$i] must be an object" }
|
||||
parse(item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseStringArray(arr: JSONArray?, field: String): List<String> {
|
||||
if (arr == null) return emptyList()
|
||||
return (0 until arr.length()).map { i ->
|
||||
val item = arr.opt(i)
|
||||
require(item is String) { "$field[$i] must be a string" }
|
||||
item
|
||||
}
|
||||
}
|
||||
|
||||
private fun JSONObject.requireString(key: String, ctx: String): String {
|
||||
require(has(key) && !isNull(key)) { "$ctx[].$key is required" }
|
||||
val v = get(key)
|
||||
require(v is String) { "$ctx[].$key must be a string" }
|
||||
return v
|
||||
}
|
||||
|
||||
private fun JSONObject.requireLong(key: String, ctx: String): Long {
|
||||
require(has(key) && !isNull(key)) { "$ctx[].$key is required" }
|
||||
return when (val v = get(key)) {
|
||||
is Number -> v.toLong()
|
||||
else -> throw IllegalArgumentException("$ctx[].$key must be a number")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Mutators (immutable; return new blob, never mutate input) ──
|
||||
|
||||
/** Insert or replace a host entry by address. Bumps `updatedAt`. */
|
||||
fun upsertHost(
|
||||
blob: CanonicalProfileBlob,
|
||||
host: ProfileHostEntry,
|
||||
now: Long? = null,
|
||||
): CanonicalProfileBlob {
|
||||
val hosts = blob.hosts.filter { it.address != host.address } + host
|
||||
return blob.copy(hosts = hosts, updatedAt = now ?: System.currentTimeMillis())
|
||||
}
|
||||
|
||||
/** Remove the host with the given address, if any. */
|
||||
fun removeHost(
|
||||
blob: CanonicalProfileBlob,
|
||||
address: String,
|
||||
now: Long? = null,
|
||||
): CanonicalProfileBlob {
|
||||
val hosts = blob.hosts.filter { it.address != address }
|
||||
if (hosts.size == blob.hosts.size) return blob
|
||||
return blob.copy(hosts = hosts, updatedAt = now ?: System.currentTimeMillis())
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or replace a client entry by `identityFingerprint`. Re-derives
|
||||
* `trustedApproverFingerprints` from the resulting `clients[]` so the
|
||||
* denormalized list never drifts.
|
||||
*/
|
||||
fun upsertClient(
|
||||
blob: CanonicalProfileBlob,
|
||||
client: ProfileClientEntry,
|
||||
now: Long? = null,
|
||||
): CanonicalProfileBlob {
|
||||
val clients = blob.clients
|
||||
.filter { it.identityFingerprint != client.identityFingerprint } + client
|
||||
return blob.copy(
|
||||
clients = clients,
|
||||
trustedApproverFingerprints = deriveTrustedApprovers(clients),
|
||||
updatedAt = now ?: System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Remove the client with the given identityFingerprint, if any. */
|
||||
fun removeClient(
|
||||
blob: CanonicalProfileBlob,
|
||||
identityFingerprint: String,
|
||||
now: Long? = null,
|
||||
): CanonicalProfileBlob {
|
||||
val clients = blob.clients.filter { it.identityFingerprint != identityFingerprint }
|
||||
if (clients.size == blob.clients.size) return blob
|
||||
return blob.copy(
|
||||
clients = clients,
|
||||
trustedApproverFingerprints = deriveTrustedApprovers(clients),
|
||||
updatedAt = now ?: System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the `trustedApprover` flag on a client by fingerprint.
|
||||
* Returns the input unchanged if fingerprint isn't found OR the
|
||||
* desired state already matches (no spurious updatedAt bump).
|
||||
*/
|
||||
fun setTrustedApprover(
|
||||
blob: CanonicalProfileBlob,
|
||||
identityFingerprint: String,
|
||||
trusted: Boolean,
|
||||
now: Long? = null,
|
||||
): CanonicalProfileBlob {
|
||||
var touched = false
|
||||
val clients = blob.clients.map { c ->
|
||||
if (c.identityFingerprint != identityFingerprint) c
|
||||
else if (c.trustedApprover == trusted) c
|
||||
else {
|
||||
touched = true
|
||||
c.copy(trustedApprover = trusted)
|
||||
}
|
||||
}
|
||||
if (!touched) return blob
|
||||
return blob.copy(
|
||||
clients = clients,
|
||||
trustedApproverFingerprints = deriveTrustedApprovers(clients),
|
||||
updatedAt = now ?: System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff the given fingerprint resolves to a client with both
|
||||
* `trustedApprover == true` AND an entry in `trustedApproverFingerprints[]`.
|
||||
*/
|
||||
fun isTrustedApprover(blob: CanonicalProfileBlob, identityFingerprint: String): Boolean {
|
||||
if (!blob.trustedApproverFingerprints.contains(identityFingerprint)) return false
|
||||
val c = findClientByFingerprint(blob, identityFingerprint) ?: return false
|
||||
return c.trustedApprover
|
||||
}
|
||||
|
||||
fun findClientByFingerprint(
|
||||
blob: CanonicalProfileBlob,
|
||||
identityFingerprint: String,
|
||||
): ProfileClientEntry? = blob.clients.firstOrNull { it.identityFingerprint == identityFingerprint }
|
||||
|
||||
fun findClientByAddress(blob: CanonicalProfileBlob, address: String): ProfileClientEntry? =
|
||||
blob.clients.firstOrNull { it.address == address }
|
||||
|
||||
private fun deriveTrustedApprovers(clients: List<ProfileClientEntry>): List<String> =
|
||||
clients.filter { it.trustedApprover }.map { it.identityFingerprint }
|
||||
@@ -0,0 +1,94 @@
|
||||
package no.zyon.shade.blob
|
||||
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* AEAD wrapper for the V4.9 profile blob.
|
||||
*
|
||||
* Wire format for one ciphertext blob:
|
||||
* `nonce(12) || ciphertext(N) || tag(16)`
|
||||
*
|
||||
* Mirror `@shade/storage-encrypted/crypto/aead.ts` byte-for-byte. The
|
||||
* relay stores this as a single opaque BLOB column; AAD is reconstructed
|
||||
* at read-time as `"shade-profile-aad-v1:" + slotIdHex` and is NOT
|
||||
* stored on the relay.
|
||||
*/
|
||||
|
||||
const val BLOB_AEAD_NONCE_LEN = 12
|
||||
const val BLOB_AEAD_TAG_LEN = 16
|
||||
|
||||
private const val MIN_CIPHERTEXT_LEN = BLOB_AEAD_NONCE_LEN + BLOB_AEAD_TAG_LEN
|
||||
|
||||
/**
|
||||
* Seal a plaintext blob. Returns `nonce || ct||tag` ready for direct
|
||||
* blob storage. The caller supplies the nonce so this function is
|
||||
* deterministic — the high-level Profile namespace generates a fresh
|
||||
* 12-byte random nonce per write to keep (key, nonce, plaintext)
|
||||
* unique across re-uploads.
|
||||
*/
|
||||
fun aeadSeal(
|
||||
key: ByteArray,
|
||||
nonce: ByteArray,
|
||||
plaintext: ByteArray,
|
||||
aad: ByteArray,
|
||||
): ByteArray {
|
||||
require(key.size == 32) { "AES-256-GCM key must be 32 bytes" }
|
||||
require(nonce.size == BLOB_AEAD_NONCE_LEN) {
|
||||
"nonce must be $BLOB_AEAD_NONCE_LEN bytes"
|
||||
}
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
val spec = GCMParameterSpec(128, nonce)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), spec)
|
||||
cipher.updateAAD(aad)
|
||||
val ctTag = cipher.doFinal(plaintext)
|
||||
val out = ByteArray(BLOB_AEAD_NONCE_LEN + ctTag.size)
|
||||
System.arraycopy(nonce, 0, out, 0, BLOB_AEAD_NONCE_LEN)
|
||||
System.arraycopy(ctTag, 0, out, BLOB_AEAD_NONCE_LEN, ctTag.size)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a `nonce || ct||tag` blob and return the plaintext. Throws on
|
||||
* tamper (AEAD tag mismatch) or short input. The caller may pass an
|
||||
* `expectedNonce` to enforce a deterministic nonce — mismatch throws
|
||||
* before the AEAD even runs (defense-in-depth against a relay returning
|
||||
* the wrong slot's blob).
|
||||
*/
|
||||
fun aeadOpen(
|
||||
key: ByteArray,
|
||||
blob: ByteArray,
|
||||
aad: ByteArray,
|
||||
expectedNonce: ByteArray? = null,
|
||||
): ByteArray {
|
||||
require(key.size == 32) { "AES-256-GCM key must be 32 bytes" }
|
||||
require(blob.size >= MIN_CIPHERTEXT_LEN) { "ciphertext blob too short" }
|
||||
val nonce = blob.copyOfRange(0, BLOB_AEAD_NONCE_LEN)
|
||||
if (expectedNonce != null && !ctEqual(nonce, expectedNonce)) {
|
||||
throw IllegalArgumentException(
|
||||
"nonce mismatch — ciphertext blob has been tampered or row identity changed",
|
||||
)
|
||||
}
|
||||
val ctTag = blob.copyOfRange(BLOB_AEAD_NONCE_LEN, blob.size)
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
val spec = GCMParameterSpec(128, nonce)
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), spec)
|
||||
cipher.updateAAD(aad)
|
||||
return cipher.doFinal(ctTag)
|
||||
}
|
||||
|
||||
private fun ctEqual(a: ByteArray, b: ByteArray): Boolean {
|
||||
if (a.size != b.size) return false
|
||||
var diff = 0
|
||||
for (i in a.indices) {
|
||||
diff = diff or (a[i].toInt() xor b[i].toInt())
|
||||
}
|
||||
return diff == 0
|
||||
}
|
||||
|
||||
/** Build the AAD for a given slotId hex string. */
|
||||
fun blobAadForSlot(slotIdHex: String): ByteArray {
|
||||
require(slotIdHex.length == 64) { "slotIdHex must be 64 hex chars" }
|
||||
return "shade-profile-aad-v1:$slotIdHex".toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package no.zyon.shade.blob
|
||||
|
||||
import no.zyon.shade.crypto.CryptoProvider
|
||||
import org.json.JSONObject
|
||||
import java.net.URI
|
||||
import java.net.http.HttpClient
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpResponse
|
||||
import java.time.Duration
|
||||
import java.util.Base64
|
||||
|
||||
/**
|
||||
* Low-level HTTP client for the V4.9 encrypted-blob primitive
|
||||
* (`/v1/blob/<slotId>`). Mirror `@shade/inbox`'s `BlobClient` —
|
||||
* stateless, reusable, and protocol-compatible with the TypeScript
|
||||
* relay endpoints.
|
||||
*
|
||||
* The client doesn't care what the blob bytes mean — it just
|
||||
* transports them. Higher-level wrappers (e.g. `Profile`) compose
|
||||
* this client with AEAD-sealing of the actual payload.
|
||||
*
|
||||
* Auth model: every PUT/DELETE carries a detached Ed25519 signature
|
||||
* (base64) over a canonical-JSON form of the request body. The
|
||||
* canonicalization is deterministic — sorted keys, compact JSON, no
|
||||
* trailing whitespace — so signatures generated on Kotlin verify on
|
||||
* the TS server.
|
||||
*/
|
||||
class BlobClient(
|
||||
private val baseUrl: String,
|
||||
private val crypto: CryptoProvider,
|
||||
private val httpClient: HttpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(15))
|
||||
.build(),
|
||||
) {
|
||||
|
||||
data class GetResult(
|
||||
val blob: ByteArray,
|
||||
val etag: String,
|
||||
val updatedAt: Long,
|
||||
)
|
||||
|
||||
data class PutResult(
|
||||
val created: Boolean,
|
||||
val etag: String,
|
||||
val updatedAt: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* Read a slot. Returns null if no blob has ever been written there
|
||||
* (or if it was DELETE'd). GET is unauthenticated by design — the
|
||||
* slotId is itself a 256-bit secret derived from the master key.
|
||||
*/
|
||||
fun get(slotIdHex: String): GetResult? {
|
||||
validateSlotIdHex(slotIdHex)
|
||||
val req = HttpRequest.newBuilder(URI.create(joinUrl(baseUrl, "/v1/blob/$slotIdHex")))
|
||||
.GET()
|
||||
.build()
|
||||
val res = httpClient.send(req, HttpResponse.BodyHandlers.ofString())
|
||||
if (res.statusCode() == 404) return null
|
||||
val json = parseJson(res, "GET")
|
||||
val blob = Base64.getDecoder().decode(json.getString("blob"))
|
||||
return GetResult(
|
||||
blob = blob,
|
||||
etag = json.getString("etag"),
|
||||
updatedAt = json.getLong("updatedAt"),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a slot.
|
||||
*
|
||||
* `ifMatch` semantics:
|
||||
* - `null`: create-only. Slot must be empty (else 409).
|
||||
* - `<etag-string>`: compare-and-swap. Must match (else 412).
|
||||
* - `"*"`: unconditional overwrite. Slot must already exist (else 412).
|
||||
*/
|
||||
fun put(
|
||||
slotIdHex: String,
|
||||
blob: ByteArray,
|
||||
signingSeed: ByteArray,
|
||||
ownerPubkey: ByteArray,
|
||||
ifMatch: String? = null,
|
||||
): PutResult {
|
||||
validateSlotIdHex(slotIdHex)
|
||||
require(blob.isNotEmpty()) { "Empty blob" }
|
||||
require(ownerPubkey.size == 32) { "ownerPubkey must be 32 bytes (Ed25519)" }
|
||||
require(signingSeed.size == 32) { "signingSeed must be 32 bytes (Ed25519)" }
|
||||
|
||||
// Canonical form for signing: sorted keys, slotId included,
|
||||
// signature field absent. The wire body strips slotId (it's
|
||||
// in the URL) but the signature is computed over the
|
||||
// slotId-bearing form.
|
||||
val signedAt = System.currentTimeMillis()
|
||||
val canonical = sortedMapOf<String, Any>().apply {
|
||||
put("blob", Base64.getEncoder().encodeToString(blob))
|
||||
if (ifMatch != null) put("ifMatch", ifMatch)
|
||||
put("ownerPubkey", Base64.getEncoder().encodeToString(ownerPubkey))
|
||||
put("signedAt", signedAt)
|
||||
put("slotId", slotIdHex)
|
||||
}
|
||||
val canonicalBytes = canonicalJson(canonical).toByteArray(Charsets.UTF_8)
|
||||
val sig = crypto.sign(signingSeed, canonicalBytes)
|
||||
|
||||
// Wire body: same as canonical minus slotId, plus signature.
|
||||
val wire = JSONObject()
|
||||
wire.put("blob", Base64.getEncoder().encodeToString(blob))
|
||||
if (ifMatch != null) wire.put("ifMatch", ifMatch)
|
||||
wire.put("ownerPubkey", Base64.getEncoder().encodeToString(ownerPubkey))
|
||||
wire.put("signedAt", signedAt)
|
||||
wire.put("signature", Base64.getEncoder().encodeToString(sig))
|
||||
|
||||
val req = HttpRequest.newBuilder(URI.create(joinUrl(baseUrl, "/v1/blob/$slotIdHex")))
|
||||
.header("content-type", "application/json")
|
||||
.PUT(HttpRequest.BodyPublishers.ofString(wire.toString()))
|
||||
.build()
|
||||
val res = httpClient.send(req, HttpResponse.BodyHandlers.ofString())
|
||||
val json = parseJson(res, "PUT")
|
||||
return PutResult(
|
||||
created = json.optBoolean("created"),
|
||||
etag = json.getString("etag"),
|
||||
updatedAt = json.getLong("updatedAt"),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a slot. The next PUT TOFU-claims it again, possibly under
|
||||
* a fresh signing key (e.g. after rotation). Used by "forget
|
||||
* everything" flows.
|
||||
*/
|
||||
fun delete(slotIdHex: String, signingSeed: ByteArray): Boolean {
|
||||
validateSlotIdHex(slotIdHex)
|
||||
require(signingSeed.size == 32) { "signingSeed must be 32 bytes (Ed25519)" }
|
||||
|
||||
val signedAt = System.currentTimeMillis()
|
||||
val canonical = sortedMapOf<String, Any>().apply {
|
||||
put("signedAt", signedAt)
|
||||
put("slotId", slotIdHex)
|
||||
}
|
||||
val canonicalBytes = canonicalJson(canonical).toByteArray(Charsets.UTF_8)
|
||||
val sig = crypto.sign(signingSeed, canonicalBytes)
|
||||
|
||||
val wire = JSONObject()
|
||||
wire.put("signedAt", signedAt)
|
||||
wire.put("signature", Base64.getEncoder().encodeToString(sig))
|
||||
|
||||
val req = HttpRequest.newBuilder(URI.create(joinUrl(baseUrl, "/v1/blob/$slotIdHex")))
|
||||
.header("content-type", "application/json")
|
||||
.method("DELETE", HttpRequest.BodyPublishers.ofString(wire.toString()))
|
||||
.build()
|
||||
val res = httpClient.send(req, HttpResponse.BodyHandlers.ofString())
|
||||
val json = parseJson(res, "DELETE")
|
||||
return json.optBoolean("ok", false)
|
||||
}
|
||||
|
||||
private fun parseJson(res: HttpResponse<String>, op: String): JSONObject {
|
||||
val text = res.body() ?: ""
|
||||
val json = if (text.isEmpty()) JSONObject() else try {
|
||||
JSONObject(text)
|
||||
} catch (e: Exception) {
|
||||
throw BlobClientException(
|
||||
code = "SHADE_NETWORK",
|
||||
statusCode = res.statusCode(),
|
||||
message = "Blob $op response not JSON: ${text.take(200)}",
|
||||
)
|
||||
}
|
||||
if (res.statusCode() !in 200..299) {
|
||||
throw BlobClientException(
|
||||
code = json.optString("code", "SHADE_NETWORK"),
|
||||
statusCode = res.statusCode(),
|
||||
message = json.optString("message", text),
|
||||
)
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
private fun validateSlotIdHex(s: String) {
|
||||
require(s.matches(Regex("^[0-9a-f]{64}$"))) {
|
||||
"slotIdHex must be 64 lowercase hex chars (32 bytes)"
|
||||
}
|
||||
}
|
||||
|
||||
private fun joinUrl(base: String, path: String): String =
|
||||
when {
|
||||
base.endsWith("/") && path.startsWith("/") -> base + path.substring(1)
|
||||
!base.endsWith("/") && !path.startsWith("/") -> "$base/$path"
|
||||
else -> base + path
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of TS `signPayload`'s canonicalization: sorted keys, compact
|
||||
* JSON, signature field absent. Only handles the subset of types we
|
||||
* need (strings + longs + base64 strings) — keeping the implementation
|
||||
* narrow so it can't accidentally diverge from `JSON.stringify` on
|
||||
* structurally-different inputs.
|
||||
*/
|
||||
internal fun canonicalJson(map: Map<String, Any>): String {
|
||||
val sb = StringBuilder()
|
||||
sb.append('{')
|
||||
var first = true
|
||||
for ((key, value) in map.toSortedMap()) {
|
||||
if (!first) sb.append(',')
|
||||
first = false
|
||||
appendJsonString(sb, key)
|
||||
sb.append(':')
|
||||
appendJsonValue(sb, value)
|
||||
}
|
||||
sb.append('}')
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun appendJsonValue(sb: StringBuilder, value: Any) {
|
||||
when (value) {
|
||||
is String -> appendJsonString(sb, value)
|
||||
is Long, is Int -> sb.append(value.toString())
|
||||
is Boolean -> sb.append(value.toString())
|
||||
else -> throw IllegalArgumentException(
|
||||
"canonicalJson: unsupported value type ${value::class.java}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun appendJsonString(sb: StringBuilder, s: String) {
|
||||
sb.append('"')
|
||||
for (c in s) {
|
||||
when (c) {
|
||||
'\\' -> sb.append("\\\\")
|
||||
'"' -> sb.append("\\\"")
|
||||
'\b' -> sb.append("\\b")
|
||||
'\u000C' -> sb.append("\\f")
|
||||
'\n' -> sb.append("\\n")
|
||||
'\r' -> sb.append("\\r")
|
||||
'\t' -> sb.append("\\t")
|
||||
else -> {
|
||||
if (c.code < 0x20) {
|
||||
sb.append("\\u").append("%04x".format(c.code))
|
||||
} else {
|
||||
sb.append(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.append('"')
|
||||
}
|
||||
|
||||
class BlobClientException(
|
||||
val code: String,
|
||||
val statusCode: Int,
|
||||
message: String,
|
||||
) : RuntimeException("[$code @ $statusCode] $message")
|
||||
@@ -0,0 +1,88 @@
|
||||
package no.zyon.shade.blob
|
||||
|
||||
import com.google.crypto.tink.subtle.Ed25519Sign
|
||||
import no.zyon.shade.crypto.CryptoProvider
|
||||
|
||||
/**
|
||||
* V4.9 — relay-side encrypted blob primitive: deterministic
|
||||
* derivations from a 32-byte master key + per-app namespace string.
|
||||
*
|
||||
* Mirror `@shade/storage-encrypted/crypto/kdf.ts` byte-for-byte.
|
||||
* Reference vectors: `test-vectors/blob-storage.json`.
|
||||
*
|
||||
* Three independent 32-byte derivations:
|
||||
*
|
||||
* slotId = HKDF(masterKey, info=`shade-blob-slot-v1:<app>`) // relay-visible opaque ID
|
||||
* blobKey = HKDF(masterKey, info=`shade-blob-key-v1:<app>`) // AEAD key for the blob
|
||||
* sigSeed = HKDF(masterKey, info=`shade-blob-sig-v1:<app>`) // Ed25519 owner signing seed
|
||||
*
|
||||
* `app` is a caller-supplied namespace string — distinct apps under
|
||||
* the same master MUST pass different values (e.g. `prism-profile-v1`)
|
||||
* so they don't collide on the same slot.
|
||||
*
|
||||
* The signing seed is an Ed25519 *seed* in the @noble/curves convention:
|
||||
* `pubkey = Ed25519.publicFromSeed(seed)` is what the relay TOFU-stores
|
||||
* on the first PUT and verifies subsequent writes against.
|
||||
*/
|
||||
|
||||
private const val SLOT_INFO_PREFIX = "shade-blob-slot-v1:"
|
||||
private const val BLOB_KEY_INFO_PREFIX = "shade-blob-key-v1:"
|
||||
private const val SIG_SEED_INFO_PREFIX = "shade-blob-sig-v1:"
|
||||
|
||||
private const val DERIVED_LEN = 32
|
||||
|
||||
/** Lower-hex 64-char slotId derived deterministically from the master key. */
|
||||
fun deriveBlobSlotId(crypto: CryptoProvider, masterKey: ByteArray, app: String): ByteArray {
|
||||
require(masterKey.size == 32) { "masterKey must be 32 bytes" }
|
||||
require(app.isNotEmpty()) { "app namespace must be non-empty" }
|
||||
return crypto.hkdf(
|
||||
masterKey,
|
||||
ByteArray(0),
|
||||
(SLOT_INFO_PREFIX + app).toByteArray(Charsets.UTF_8),
|
||||
DERIVED_LEN,
|
||||
)
|
||||
}
|
||||
|
||||
/** AEAD key for sealing/opening the blob. The slotId hex is bound as AAD. */
|
||||
fun deriveBlobKey(crypto: CryptoProvider, masterKey: ByteArray, app: String): ByteArray {
|
||||
require(masterKey.size == 32) { "masterKey must be 32 bytes" }
|
||||
require(app.isNotEmpty()) { "app namespace must be non-empty" }
|
||||
return crypto.hkdf(
|
||||
masterKey,
|
||||
ByteArray(0),
|
||||
(BLOB_KEY_INFO_PREFIX + app).toByteArray(Charsets.UTF_8),
|
||||
DERIVED_LEN,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 32-byte Ed25519 signing seed. The pubkey, derived deterministically
|
||||
* from the seed, is what the relay TOFU-stores on the first PUT.
|
||||
*/
|
||||
fun deriveBlobSigningSeed(crypto: CryptoProvider, masterKey: ByteArray, app: String): ByteArray {
|
||||
require(masterKey.size == 32) { "masterKey must be 32 bytes" }
|
||||
require(app.isNotEmpty()) { "app namespace must be non-empty" }
|
||||
return crypto.hkdf(
|
||||
masterKey,
|
||||
ByteArray(0),
|
||||
(SIG_SEED_INFO_PREFIX + app).toByteArray(Charsets.UTF_8),
|
||||
DERIVED_LEN,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the Ed25519 public key from a 32-byte seed. Mirrors
|
||||
* `@shade/crypto-web`'s `ed25519PublicKeyFromSeed`. Tink's
|
||||
* `Ed25519Sign.KeyPair.newKeyPairFromSeed(seed)` exposes both halves;
|
||||
* we discard the private half here.
|
||||
*/
|
||||
fun ed25519PublicKeyFromSeed(seed: ByteArray): ByteArray {
|
||||
require(seed.size == 32) { "Ed25519 seed must be 32 bytes" }
|
||||
return Ed25519Sign.KeyPair.newKeyPairFromSeed(seed).publicKey
|
||||
}
|
||||
|
||||
/** Convert a 32-byte slotId into the lowercase-hex wire form (64 chars). */
|
||||
fun slotIdToHex(slotId: ByteArray): String {
|
||||
require(slotId.size == 32) { "slotId must be 32 bytes" }
|
||||
return slotId.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package no.zyon.shade.blob
|
||||
|
||||
import no.zyon.shade.crypto.CryptoProvider
|
||||
|
||||
/**
|
||||
* V4.9 — high-level profile namespace. Mirror
|
||||
* `@shade/sdk`'s `createProfileNamespace`. The relay never sees
|
||||
* plaintext; AAD binds the slotId so a relay returning the wrong
|
||||
* slot's blob fails to open.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* val crypto = TinkProvider()
|
||||
* val masterKey = deriveMasterKey("password", salt) // V4.5 KDF chain
|
||||
* val profile = createProfileNamespace(
|
||||
* baseUrl = "https://shade.example/",
|
||||
* crypto = crypto,
|
||||
* masterKey = masterKey,
|
||||
* app = "prism-profile-v1",
|
||||
* )
|
||||
*
|
||||
* val current = profile.get() // null if no blob yet
|
||||
* profile.put(serializeCanonicalProfile(...), ifMatch = current?.etag)
|
||||
* profile.delete()
|
||||
*
|
||||
* Apps with the same master key + app namespace converge on the same
|
||||
* slot — that's the whole point: a brand new device with the right
|
||||
* credentials can locate, decrypt, and update the blob.
|
||||
*/
|
||||
class ProfileNamespace internal constructor(
|
||||
/** Lower-hex 64-char slotId. Stable per (master, app). */
|
||||
val slotIdHex: String,
|
||||
private val blobKey: ByteArray,
|
||||
private val signingSeed: ByteArray,
|
||||
private val ownerPubkey: ByteArray,
|
||||
private val aad: ByteArray,
|
||||
private val client: BlobClient,
|
||||
private val crypto: CryptoProvider,
|
||||
) {
|
||||
|
||||
data class GetResult(
|
||||
val plaintext: ByteArray,
|
||||
val etag: String,
|
||||
val updatedAt: Long,
|
||||
)
|
||||
|
||||
data class PutResult(
|
||||
val created: Boolean,
|
||||
val etag: String,
|
||||
val updatedAt: Long,
|
||||
)
|
||||
|
||||
/** Returns null when the slot has never been written (or was deleted). */
|
||||
fun get(): GetResult? {
|
||||
val raw = client.get(slotIdHex) ?: return null
|
||||
val plaintext = aeadOpen(blobKey, raw.blob, aad)
|
||||
return GetResult(plaintext = plaintext, etag = raw.etag, updatedAt = raw.updatedAt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update. `ifMatch`:
|
||||
* - null: create-only (fails with 409 if slot populated).
|
||||
* - "<etag>": CAS (fails with 412 on stale).
|
||||
* - "*": unconditional overwrite.
|
||||
*/
|
||||
fun put(plaintext: ByteArray, ifMatch: String? = null): PutResult {
|
||||
// Fresh random nonce per write — see `BlobAead`. Re-uploading
|
||||
// the same plaintext after a transient error reuses neither
|
||||
// (key, nonce, plaintext) nor (key, nonce).
|
||||
val nonce = crypto.randomBytes(BLOB_AEAD_NONCE_LEN)
|
||||
val sealed = aeadSeal(blobKey, nonce, plaintext, aad)
|
||||
val r = client.put(
|
||||
slotIdHex = slotIdHex,
|
||||
blob = sealed,
|
||||
signingSeed = signingSeed,
|
||||
ownerPubkey = ownerPubkey,
|
||||
ifMatch = ifMatch,
|
||||
)
|
||||
return PutResult(created = r.created, etag = r.etag, updatedAt = r.updatedAt)
|
||||
}
|
||||
|
||||
/** "Forget everything" path — the next PUT TOFU-claims it again. */
|
||||
fun delete(): Boolean = client.delete(slotIdHex, signingSeed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Profile namespace bound to a (master key, app) pair. The
|
||||
* derivations are deterministic: any device with the same master
|
||||
* key + app namespace produces the same slot, so a fresh device
|
||||
* after credential entry can locate the existing profile blob.
|
||||
*/
|
||||
fun createProfileNamespace(
|
||||
baseUrl: String,
|
||||
crypto: CryptoProvider,
|
||||
masterKey: ByteArray,
|
||||
app: String,
|
||||
): ProfileNamespace {
|
||||
require(masterKey.size == 32) { "masterKey must be 32 bytes" }
|
||||
require(app.isNotEmpty()) { "app namespace must be non-empty" }
|
||||
|
||||
val slotIdBytes = deriveBlobSlotId(crypto, masterKey, app)
|
||||
val slotIdHex = slotIdToHex(slotIdBytes)
|
||||
val blobKey = deriveBlobKey(crypto, masterKey, app)
|
||||
val signingSeed = deriveBlobSigningSeed(crypto, masterKey, app)
|
||||
val ownerPubkey = ed25519PublicKeyFromSeed(signingSeed)
|
||||
val aad = blobAadForSlot(slotIdHex)
|
||||
|
||||
val client = BlobClient(baseUrl = baseUrl, crypto = crypto)
|
||||
return ProfileNamespace(
|
||||
slotIdHex = slotIdHex,
|
||||
blobKey = blobKey,
|
||||
signingSeed = signingSeed,
|
||||
ownerPubkey = ownerPubkey,
|
||||
aad = aad,
|
||||
client = client,
|
||||
crypto = crypto,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package no.zyon.shade.serialization
|
||||
|
||||
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.json.JSONObject
|
||||
import java.util.Base64 as JdkBase64
|
||||
|
||||
/**
|
||||
* Plain-JSON serialization for the persisted protocol state types
|
||||
* (`IdentityKeyPair`, `SignedPreKey`, `OneTimePreKey`, `SessionState`).
|
||||
*
|
||||
* The on-disk shape is for at-rest storage only — it does NOT need
|
||||
* to round-trip across platforms (TS uses its own JSON shape via
|
||||
* `@shade/core/serialization`). What matters is that the Kotlin
|
||||
* round-trip (`serialize` then `deserialize`) preserves every byte.
|
||||
*
|
||||
* Both Android-targeted (`shade-android-keystore`) and pure-JVM
|
||||
* (`shade-android` tests) callers use this — the function works
|
||||
* without any `android.*` imports so it compiles in both.
|
||||
*/
|
||||
object SessionStateJson {
|
||||
|
||||
fun serialize(state: SessionState): String {
|
||||
val o = JSONObject()
|
||||
o.put("remoteIdentityKey", b64(state.remoteIdentityKey))
|
||||
o.put("rootKey", b64(state.rootKey))
|
||||
o.put("sendChain", chainToJson(state.sendChain))
|
||||
if (state.receiveChain != null) o.put("receiveChain", chainToJson(state.receiveChain!!))
|
||||
o.put("dhSend", keyPairToJson(state.dhSend))
|
||||
if (state.dhReceive != null) o.put("dhReceive", b64(state.dhReceive!!))
|
||||
o.put("previousSendCounter", state.previousSendCounter)
|
||||
val skipped = JSONObject()
|
||||
for ((k, v) in state.skippedKeys) skipped.put(k, b64(v))
|
||||
o.put("skippedKeys", skipped)
|
||||
return o.toString()
|
||||
}
|
||||
|
||||
fun deserialize(s: String): SessionState {
|
||||
val o = JSONObject(s)
|
||||
val skipped = mutableMapOf<String, ByteArray>()
|
||||
val skJson = o.optJSONObject("skippedKeys")
|
||||
if (skJson != null) {
|
||||
val it = skJson.keys()
|
||||
while (it.hasNext()) {
|
||||
val k = it.next()
|
||||
skipped[k] = fb64(skJson.getString(k))
|
||||
}
|
||||
}
|
||||
return SessionState(
|
||||
remoteIdentityKey = fb64(o.getString("remoteIdentityKey")),
|
||||
rootKey = fb64(o.getString("rootKey")),
|
||||
sendChain = chainFromJson(o.getJSONObject("sendChain")),
|
||||
receiveChain = if (o.has("receiveChain"))
|
||||
chainFromJson(o.getJSONObject("receiveChain")) else null,
|
||||
dhSend = keyPairFromJson(o.getJSONObject("dhSend")),
|
||||
dhReceive = if (o.has("dhReceive")) fb64(o.getString("dhReceive")) else null,
|
||||
previousSendCounter = o.getInt("previousSendCounter"),
|
||||
skippedKeys = skipped,
|
||||
)
|
||||
}
|
||||
|
||||
fun serializeIdentityKeyPair(k: IdentityKeyPair): String = JSONObject().apply {
|
||||
put("signingPublicKey", b64(k.signingPublicKey))
|
||||
put("signingPrivateKey", b64(k.signingPrivateKey))
|
||||
put("dhPublicKey", b64(k.dhPublicKey))
|
||||
put("dhPrivateKey", b64(k.dhPrivateKey))
|
||||
}.toString()
|
||||
|
||||
fun deserializeIdentityKeyPair(s: String): IdentityKeyPair = JSONObject(s).run {
|
||||
IdentityKeyPair(
|
||||
signingPublicKey = fb64(getString("signingPublicKey")),
|
||||
signingPrivateKey = fb64(getString("signingPrivateKey")),
|
||||
dhPublicKey = fb64(getString("dhPublicKey")),
|
||||
dhPrivateKey = fb64(getString("dhPrivateKey")),
|
||||
)
|
||||
}
|
||||
|
||||
fun serializeSignedPreKey(k: SignedPreKey): String = JSONObject().apply {
|
||||
put("keyId", k.keyId)
|
||||
put("keyPair", keyPairToJson(k.keyPair))
|
||||
put("signature", b64(k.signature))
|
||||
put("timestamp", k.timestamp)
|
||||
}.toString()
|
||||
|
||||
fun deserializeSignedPreKey(s: String): SignedPreKey = JSONObject(s).run {
|
||||
SignedPreKey(
|
||||
keyId = getInt("keyId"),
|
||||
keyPair = keyPairFromJson(getJSONObject("keyPair")),
|
||||
signature = fb64(getString("signature")),
|
||||
timestamp = getLong("timestamp"),
|
||||
)
|
||||
}
|
||||
|
||||
fun serializeOneTimePreKey(k: OneTimePreKey): String = JSONObject().apply {
|
||||
put("keyId", k.keyId)
|
||||
put("keyPair", keyPairToJson(k.keyPair))
|
||||
}.toString()
|
||||
|
||||
fun deserializeOneTimePreKey(s: String): OneTimePreKey = JSONObject(s).run {
|
||||
OneTimePreKey(
|
||||
keyId = getInt("keyId"),
|
||||
keyPair = keyPairFromJson(getJSONObject("keyPair")),
|
||||
)
|
||||
}
|
||||
|
||||
private fun chainToJson(c: ChainState): JSONObject = JSONObject().apply {
|
||||
put("chainKey", b64(c.chainKey))
|
||||
put("counter", c.counter)
|
||||
}
|
||||
|
||||
private fun chainFromJson(o: JSONObject): ChainState =
|
||||
ChainState(chainKey = fb64(o.getString("chainKey")), counter = o.getInt("counter"))
|
||||
|
||||
private fun keyPairToJson(k: KeyPair): JSONObject = JSONObject().apply {
|
||||
put("publicKey", b64(k.publicKey))
|
||||
put("privateKey", b64(k.privateKey))
|
||||
}
|
||||
|
||||
private fun keyPairFromJson(o: JSONObject): KeyPair = KeyPair(
|
||||
publicKey = fb64(o.getString("publicKey")),
|
||||
privateKey = fb64(o.getString("privateKey")),
|
||||
)
|
||||
|
||||
// android.util.Base64 isn't on the JVM classpath; java.util.Base64
|
||||
// is available on both modern JVM and Android API 26+. Use JDK
|
||||
// Base64 throughout — it's present on both targets.
|
||||
private fun b64(b: ByteArray): String = JdkBase64.getEncoder().encodeToString(b)
|
||||
private fun fb64(s: String): ByteArray = JdkBase64.getDecoder().decode(s)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package no.zyon.shade.storage
|
||||
|
||||
import org.bouncycastle.crypto.generators.Argon2BytesGenerator
|
||||
import org.bouncycastle.crypto.generators.SCrypt
|
||||
import org.bouncycastle.crypto.params.Argon2Parameters
|
||||
import java.text.Normalizer
|
||||
|
||||
/**
|
||||
* Password / PIN key-derivation primitives. Mirror
|
||||
* `@shade/storage-encrypted/crypto/kdf` (`deriveMasterKey` /
|
||||
* `deriveMasterKeyArgon2id`) byte-for-byte — Tink doesn't ship password
|
||||
* KDFs so we wrap Bouncy Castle.
|
||||
*
|
||||
* Both functions normalize string passphrases to NFKC before hashing,
|
||||
* matching the TS implementation's `passphrase.normalize('NFKC')`.
|
||||
* This ensures the same password typed on different OSes/keyboards
|
||||
* produces the same master key regardless of which compatibility-form
|
||||
* the input arrived in.
|
||||
*
|
||||
* The reference test-vector lives in `test-vectors/storage-encryption.json`
|
||||
* and `test-vectors/blob-storage.json`. Cross-platform parity is gated
|
||||
* by `CrossPlatformVectorTest`.
|
||||
*/
|
||||
|
||||
/** scrypt parameters. Defaults match `DEFAULT_SCRYPT` in TS. */
|
||||
data class ScryptParams(
|
||||
/** CPU/memory cost. Must be a power of 2. */
|
||||
val n: Int = 1 shl 17,
|
||||
/** Block size. */
|
||||
val r: Int = 8,
|
||||
/** Parallelization. */
|
||||
val p: Int = 1,
|
||||
/** Output length in bytes. */
|
||||
val dkLen: Int = 32,
|
||||
)
|
||||
|
||||
/** Argon2id parameters. Defaults match `DEFAULT_ARGON2ID` in TS. */
|
||||
data class Argon2idParams(
|
||||
/** Memory cost in KiB. Default 64 MiB. */
|
||||
val m: Int = 64 * 1024,
|
||||
/** Time cost (iterations). Default 3. */
|
||||
val t: Int = 3,
|
||||
/** Parallelism. Default 1. */
|
||||
val p: Int = 1,
|
||||
/** Output length in bytes. Default 32. */
|
||||
val dkLen: Int = 32,
|
||||
)
|
||||
|
||||
/**
|
||||
* Derive a 32-byte master key from a passphrase + salt via scrypt.
|
||||
* Salt MUST be at least 16 bytes and persisted alongside the
|
||||
* encrypted database. Throws on empty passphrase.
|
||||
*/
|
||||
fun deriveMasterKey(
|
||||
passphrase: String,
|
||||
salt: ByteArray,
|
||||
params: ScryptParams = ScryptParams(),
|
||||
): ByteArray {
|
||||
require(passphrase.isNotEmpty()) { "passphrase must be non-empty" }
|
||||
require(salt.size >= 16) { "salt must be at least 16 bytes" }
|
||||
val nfkc = Normalizer.normalize(passphrase, Normalizer.Form.NFKC)
|
||||
val pwBytes = nfkc.toByteArray(Charsets.UTF_8)
|
||||
return SCrypt.generate(pwBytes, salt, params.n, params.r, params.p, params.dkLen)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a 32-byte master key from a low-entropy secret (PIN) + salt
|
||||
* via argon2id. Salt MUST be at least 16 bytes. The Bouncy Castle
|
||||
* `Argon2BytesGenerator` parameters mirror RFC 9106's argon2id mode
|
||||
* with version 1.3 (`Argon2Parameters.ARGON2_VERSION_13`), which is
|
||||
* what `@noble/hashes/argon2` produces — keeping cross-platform parity.
|
||||
*/
|
||||
fun deriveMasterKeyArgon2id(
|
||||
secret: String,
|
||||
salt: ByteArray,
|
||||
params: Argon2idParams = Argon2idParams(),
|
||||
): ByteArray {
|
||||
require(secret.isNotEmpty()) { "argon2id secret must be non-empty" }
|
||||
require(salt.size >= 16) { "salt must be at least 16 bytes" }
|
||||
val nfkc = Normalizer.normalize(secret, Normalizer.Form.NFKC)
|
||||
return deriveMasterKeyArgon2id(nfkc.toByteArray(Charsets.UTF_8), salt, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* Byte-array overload — useful when the secret is already binary
|
||||
* (e.g. derived from a hardware token rather than typed) and
|
||||
* shouldn't be NFKC-normalized as text.
|
||||
*/
|
||||
fun deriveMasterKeyArgon2id(
|
||||
secret: ByteArray,
|
||||
salt: ByteArray,
|
||||
params: Argon2idParams = Argon2idParams(),
|
||||
): ByteArray {
|
||||
require(secret.isNotEmpty()) { "argon2id secret must be non-empty" }
|
||||
require(salt.size >= 16) { "salt must be at least 16 bytes" }
|
||||
val builder = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
|
||||
.withVersion(Argon2Parameters.ARGON2_VERSION_13)
|
||||
.withIterations(params.t)
|
||||
.withMemoryAsKB(params.m)
|
||||
.withParallelism(params.p)
|
||||
.withSalt(salt)
|
||||
val gen = Argon2BytesGenerator()
|
||||
gen.init(builder.build())
|
||||
val out = ByteArray(params.dkLen)
|
||||
gen.generateBytes(secret, out)
|
||||
return out
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
package no.zyon.shade
|
||||
|
||||
import no.zyon.shade.approval.canonicalApprovalSigningBytes
|
||||
import no.zyon.shade.backup.deriveBackupKey
|
||||
import no.zyon.shade.blob.aeadOpen
|
||||
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.crypto.TinkProvider
|
||||
import no.zyon.shade.fingerprint.computeFingerprint
|
||||
import no.zyon.shade.group.encodeSenderHeader
|
||||
@@ -447,6 +454,92 @@ class CrossPlatformVectorTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blobKdfAndAeadVectorsMatch() {
|
||||
val vectors = loadVectors("blob.json")
|
||||
var kdfMatched = 0
|
||||
var aeadMatched = 0
|
||||
for (i in 0 until vectors.length()) {
|
||||
val v = vectors.getJSONObject(i)
|
||||
val desc = v.getString("description")
|
||||
if (desc.startsWith("V4.9 blob KDF")) {
|
||||
kdfMatched++
|
||||
val masterKey = fromHex(v.getString("masterKey"))
|
||||
val app = v.getString("app")
|
||||
val slotId = deriveBlobSlotId(crypto, masterKey, app)
|
||||
assertEquals(v.getString("slotId"), hex(slotId))
|
||||
assertEquals(v.getString("blobKey"), hex(deriveBlobKey(crypto, masterKey, app)))
|
||||
val seed = deriveBlobSigningSeed(crypto, masterKey, app)
|
||||
assertEquals(v.getString("signingSeed"), hex(seed))
|
||||
assertEquals(v.getString("ownerPubkey"), hex(ed25519PublicKeyFromSeed(seed)))
|
||||
} else if (desc.startsWith("V4.9 blob AEAD")) {
|
||||
aeadMatched++
|
||||
val key = fromHex(v.getString("key"))
|
||||
val slotIdHex = v.getString("slotIdHex")
|
||||
val expectedPlaintext = fromHex(v.getString("plaintext"))
|
||||
val wire = fromHex(v.getString("wire"))
|
||||
val aad = blobAadForSlot(slotIdHex)
|
||||
val opened = aeadOpen(key, wire, aad)
|
||||
assertEquals(hex(expectedPlaintext), hex(opened))
|
||||
}
|
||||
}
|
||||
assertTrue("KDF vectors expected", kdfMatched >= 3)
|
||||
assertTrue("AEAD vectors expected", aeadMatched >= 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun approvalSigningPayloadVectorsMatch() {
|
||||
val vectors = loadVectors("approval.json")
|
||||
var payloadMatched = 0
|
||||
var e2eMatched = 0
|
||||
for (i in 0 until vectors.length()) {
|
||||
val v = vectors.getJSONObject(i)
|
||||
val desc = v.getString("description")
|
||||
if (desc.startsWith("V4.10 approval signing payload")) {
|
||||
payloadMatched++
|
||||
val out = canonicalApprovalSigningBytes(
|
||||
domain = v.getString("domain"),
|
||||
requestId = v.getString("requestId"),
|
||||
hostFingerprint = v.getString("hostFingerprint"),
|
||||
requestingDeviceFingerprint = v.getString("requestingDeviceFingerprint"),
|
||||
decision = v.getString("decision"),
|
||||
)
|
||||
assertEquals(v.getString("signingPayload"), hex(out))
|
||||
} else if (desc.startsWith("V4.10 approval Ed25519 sign/verify")) {
|
||||
e2eMatched++
|
||||
val seed = fromHex(v.getString("seed"))
|
||||
val pubkey = fromHex(v.getString("publicKey"))
|
||||
assertEquals(hex(pubkey), hex(ed25519PublicKeyFromSeed(seed)))
|
||||
|
||||
val req = v.getJSONObject("request")
|
||||
val payload = canonicalApprovalSigningBytes(
|
||||
domain = req.getString("domain"),
|
||||
requestId = req.getString("requestId"),
|
||||
hostFingerprint = req.getString("hostFingerprint"),
|
||||
requestingDeviceFingerprint = req.getString("requestingDeviceFingerprint"),
|
||||
decision = req.getString("decision"),
|
||||
)
|
||||
assertEquals(v.getString("signingPayload"), hex(payload))
|
||||
|
||||
// Verify the TS-generated signature against our pubkey + payload.
|
||||
// This is the load-bearing parity check: a Kotlin-implemented
|
||||
// verifyProxyApproval running against a TS-signed approval
|
||||
// succeeds.
|
||||
val sig = fromHex(v.getString("signature"))
|
||||
val ok = crypto.verify(pubkey, payload, sig)
|
||||
assertTrue("Ed25519 verify of TS-signed approval failed", ok)
|
||||
|
||||
// And: Kotlin signs the same payload with the same seed and
|
||||
// produces a sig the TS pubkey verifies. Ed25519 is
|
||||
// deterministic, so the sig bytes also match exactly.
|
||||
val mySig = crypto.sign(seed, payload)
|
||||
assertEquals(v.getString("signature"), hex(mySig))
|
||||
}
|
||||
}
|
||||
assertTrue("payload vectors expected", payloadMatched >= 3)
|
||||
assertTrue("e2e sign/verify vector expected", e2eMatched >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ratchetStepRoundtripMatches() {
|
||||
val vectors = loadVectors("ratchet-step.json")
|
||||
|
||||
@@ -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