feat(config): verify signed config metadata, TUF style

The rules the app will apply to config it fetches, not wired yet:

- A root, signed by a threshold of root keys, names the keys that may sign
  the targets list and how many must. The APK carries the first root.
- A new root is accepted one version at a time, signed by a threshold of the
  old root's keys and of its own, so keys can be replaced without a release
  and a lost targets key can be revoked.
- The targets list gives the sha256 of each config file; it cannot go back a
  version, cannot jump more than 10,000 at once, and is refused once it or
  its root has expired.
- Ed25519 over the payload bytes exactly as published (BouncyCastle, already
  a dependency); a key signing twice counts once; a key must match its id.
This commit is contained in:
Satoshi Qazi Muhammed committed 2026-10-31 00:53:53 +04:00
1 parent 524cd81493
commit acabb902f8
2 files changed
+325

No files matched your search

@@ -0,0 +1,168 @@
package io.novafoundation.nova.common.data.network.signed
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
import org.bouncycastle.crypto.signers.Ed25519Signer
import org.bouncycastle.util.encoders.Base64
import java.security.MessageDigest
/**
* Signed config metadata, after TUF: a root, signed by a threshold of root keys, names the keys that may sign the
* targets list and how many must; the targets list gives the sha256 of every config file. The APK carries a first
* root. A new root is accepted only one version at a time and only when a threshold of the old root's keys and of
* its own keys signed it, so keys can be replaced without an app release and a stolen targets key can be revoked.
*
* Every metadata file is an envelope: `{"signed": base64(payload), "signatures": [{"keyid", "sig"}]}`, where the
* signature is Ed25519 over the payload bytes exactly as published and keyid is hex sha256 of the 32-byte key.
*/
class SignedConfigException(message: String) : Exception(message)
class Envelope(val payload: ByteArray, val signatures: List<Pair<String, ByteArray>>) {
companion object {
fun parse(bytes: ByteArray): Envelope = guard {
val json = JsonParser.parseString(String(bytes, Charsets.UTF_8)).asJsonObject
val signatures = json.getAsJsonArray("signatures").map {
val signature = it.asJsonObject
signature["keyid"].asString to Base64.decode(signature["sig"].asString)
}
Envelope(Base64.decode(json["signed"].asString), signatures)
}
}
/** How many of [keyIds] signed the payload; a key signing twice counts once, a key not in [keys] not at all. */
fun validSignatures(keys: Map<String, ByteArray>, keyIds: Set<String>): Int {
return signatures
.filter { (keyId, signature) -> keyId in keyIds && keys[keyId]?.let { verifyEd25519(it, payload, signature) } == true }
.map { (keyId, _) -> keyId }
.toSet()
.size
}
fun payloadJson(expectedType: String): JsonObject = guard {
val json = JsonParser.parseString(String(payload, Charsets.UTF_8)).asJsonObject
if (json["type"].asString != expectedType) throw SignedConfigException("expected $expectedType, got ${json["type"]}")
json
}
}
class Role(val keyIds: Set<String>, val threshold: Int)
class RootMetadata(
val version: Long,
val expiresAt: Long,
val keys: Map<String, ByteArray>,
val root: Role,
val targets: Role,
) {
companion object {
fun parse(envelope: Envelope): RootMetadata = guard {
val json = envelope.payloadJson("root")
val keys = json.getAsJsonObject("keys").entrySet().associate { (keyId, key) ->
val bytes = Base64.decode(key.asString)
if (bytes.size != 32 || keyIdOf(bytes) != keyId) throw SignedConfigException("key $keyId does not match its id")
keyId to bytes
}
RootMetadata(
version = json["version"].asLong,
expiresAt = json["expires"].asLong,
keys = keys,
root = parseRole(json.getAsJsonObject("root"), keys),
targets = parseRole(json.getAsJsonObject("targets"), keys),
)
}
private fun parseRole(json: JsonObject, keys: Map<String, ByteArray>): Role {
val keyIds = json.getAsJsonArray("keyids").map { it.asString }.toSet()
val threshold = json["threshold"].asInt
if (threshold < 1 || threshold > keyIds.size || !keys.keys.containsAll(keyIds)) throw SignedConfigException("unusable role")
return Role(keyIds, threshold)
}
}
}
class TargetsMetadata(val version: Long, val expiresAt: Long, val files: Map<String, String>) {
companion object {
fun parse(envelope: Envelope): TargetsMetadata = guard {
val json = envelope.payloadJson("targets")
TargetsMetadata(
version = json["version"].asLong,
expiresAt = json["expires"].asLong,
files = json.getAsJsonObject("files").entrySet().associate { (path, hash) -> path to hash.asString.lowercase() },
)
}
}
fun matches(path: String, content: ByteArray): Boolean = files[path] == sha256Hex(content)
}
object SignedConfigRules {
/** A targets version may move forward by at most this much at once, so a stolen key cannot freeze updates. */
const val MAX_TARGETS_VERSION_JUMP = 10_000L
/** The APK's own root: it must be signed by a threshold of its own root keys. */
fun trustBundledRoot(envelope: Envelope): RootMetadata {
val root = RootMetadata.parse(envelope)
requireThreshold(envelope, root.keys, root.root, "bundled root")
return root
}
/** The root that follows [trusted]; expiry is not checked here, so an expired root can still be rotated out. */
fun nextRoot(trusted: RootMetadata, envelope: Envelope): RootMetadata {
val next = RootMetadata.parse(envelope)
if (next.version != trusted.version + 1) throw SignedConfigException("root ${next.version} does not follow ${trusted.version}")
requireThreshold(envelope, trusted.keys, trusted.root, "root ${next.version} by the previous root")
requireThreshold(envelope, next.keys, next.root, "root ${next.version} by itself")
return next
}
/**
* [now] is seconds since the epoch; callers pass the latest of the device clock and anything already trusted,
* so turning the clock back does not revive an expired list.
*/
fun verifyTargets(root: RootMetadata, envelope: Envelope, lastTrustedVersion: Long?, now: Long): TargetsMetadata {
if (root.expiresAt <= now) throw SignedConfigException("root ${root.version} expired")
requireThreshold(envelope, root.keys, root.targets, "targets")
val targets = TargetsMetadata.parse(envelope)
if (targets.expiresAt <= now) throw SignedConfigException("targets ${targets.version} expired")
if (lastTrustedVersion != null) {
if (targets.version < lastTrustedVersion) throw SignedConfigException("targets ${targets.version} is older than $lastTrustedVersion")
if (targets.version - lastTrustedVersion > MAX_TARGETS_VERSION_JUMP) throw SignedConfigException("targets ${targets.version} jumps too far")
}
return targets
}
private fun requireThreshold(envelope: Envelope, keys: Map<String, ByteArray>, role: Role, what: String) {
val valid = envelope.validSignatures(keys, role.keyIds)
if (valid < role.threshold) throw SignedConfigException("$what: $valid of ${role.threshold} required signatures")
}
}
fun keyIdOf(publicKey: ByteArray): String = sha256Hex(publicKey)
fun sha256Hex(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
private fun verifyEd25519(publicKey: ByteArray, message: ByteArray, signature: ByteArray): Boolean = runCatching {
Ed25519Signer().apply {
init(false, Ed25519PublicKeyParameters(publicKey, 0))
update(message, 0, message.size)
}.verifySignature(signature)
}.getOrDefault(false)
private inline fun <T> guard(block: () -> T): T = try {
block()
} catch (e: SignedConfigException) {
throw e
} catch (e: Exception) {
throw SignedConfigException("malformed metadata: $e")
}
@@ -0,0 +1,157 @@
package io.novafoundation.nova.common.data.network.signed
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
import org.bouncycastle.crypto.signers.Ed25519Signer
import org.bouncycastle.util.encoders.Base64
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.security.SecureRandom
class SignedConfigTest {
private class Key {
val private = Ed25519PrivateKeyParameters(SecureRandom())
val public: ByteArray = private.generatePublicKey().encoded
val id = keyIdOf(public)
fun sign(payload: ByteArray): ByteArray = Ed25519Signer().apply {
init(true, private)
update(payload, 0, payload.size)
}.generateSignature()
}
private val rootKeys = List(3) { Key() }
private val targetsKey = Key()
private val now = 1_800_000_000L
private fun envelope(payload: String, vararg signers: Key): Envelope {
val bytes = payload.toByteArray()
val signatures = signers.joinToString(",") { """{"keyid":"${it.id}","sig":"${Base64.toBase64String(it.sign(bytes))}"}""" }
return Envelope.parse("""{"signed":"${Base64.toBase64String(bytes)}","signatures":[$signatures]}""".toByteArray())
}
private fun rootPayload(version: Long, root: List<Key>, threshold: Int = 2, targets: List<Key> = listOf(targetsKey), expires: Long = now + 1000): String {
val keys = (root + targets).distinct().joinToString(",") { """"${it.id}":"${Base64.toBase64String(it.public)}"""" }
val ids = { list: List<Key> -> list.joinToString(",") { "\"${it.id}\"" } }
return """{"type":"root","version":$version,"expires":$expires,"keys":{$keys},""" +
""""root":{"keyids":[${ids(root)}],"threshold":$threshold},"targets":{"keyids":[${ids(targets)}],"threshold":1}}"""
}
private fun targetsPayload(version: Long, files: Map<String, ByteArray>, expires: Long = now + 1000): String {
val entries = files.entries.joinToString(",") { (path, content) -> "\"$path\":\"${sha256Hex(content)}\"" }
return """{"type":"targets","version":$version,"expires":$expires,"files":{$entries}}"""
}
private val bundledRoot = SignedConfigRules.trustBundledRoot(envelope(rootPayload(1, rootKeys), rootKeys[0], rootKeys[1]))
@Test
fun `a root needs two of its three keys`() {
val one = envelope(rootPayload(1, rootKeys), rootKeys[0])
val sameKeyTwice = envelope(rootPayload(1, rootKeys), rootKeys[0], rootKeys[0])
assertRejected { SignedConfigRules.trustBundledRoot(one) }
assertRejected { SignedConfigRules.trustBundledRoot(sameKeyTwice) }
}
@Test
fun `targets signed by the targets key are trusted and checked file by file`() {
val chains = "[1,2,3]".toByteArray()
val targets = SignedConfigRules.verifyTargets(bundledRoot, envelope(targetsPayload(5, mapOf("chains.json" to chains)), targetsKey), null, now)
assertTrue(targets.matches("chains.json", chains))
assertFalse(targets.matches("chains.json", "[1,2,666]".toByteArray()))
assertFalse(targets.matches("other.json", chains))
}
@Test
fun `targets signed by anyone else are rejected`() {
val stranger = Key()
assertRejected { SignedConfigRules.verifyTargets(bundledRoot, envelope(targetsPayload(5, emptyMap()), stranger), null, now) }
}
@Test
fun `a payload changed after signing is rejected`() {
val signed = envelope(targetsPayload(5, emptyMap()), targetsKey)
val tampered = Envelope(targetsPayload(6, emptyMap()).toByteArray(), signed.signatures)
assertRejected { SignedConfigRules.verifyTargets(bundledRoot, tampered, null, now) }
}
@Test
fun `targets cannot go back, jump too far, or be used after they expire`() {
assertRejected { SignedConfigRules.verifyTargets(bundledRoot, envelope(targetsPayload(4, emptyMap()), targetsKey), 5, now) }
val tooFar = 5 + SignedConfigRules.MAX_TARGETS_VERSION_JUMP + 1
assertRejected { SignedConfigRules.verifyTargets(bundledRoot, envelope(targetsPayload(tooFar, emptyMap()), targetsKey), 5, now) }
assertRejected { SignedConfigRules.verifyTargets(bundledRoot, envelope(targetsPayload(6, emptyMap(), expires = now), targetsKey), 5, now) }
assertEquals(5L, SignedConfigRules.verifyTargets(bundledRoot, envelope(targetsPayload(5, emptyMap()), targetsKey), 5, now).version)
}
@Test
fun `a metadata file of the wrong type is rejected`() {
// Well formed as targets in every field but its type: only the type check stands in the way.
val mislabelled = targetsPayload(5, emptyMap()).replace("\"type\":\"targets\"", "\"type\":\"root\"")
assertRejected { SignedConfigRules.verifyTargets(bundledRoot, envelope(mislabelled, targetsKey), null, now) }
}
@Test
fun `a new root needs the old keys and its own`() {
val newKeys = List(3) { Key() }
val byOldOnly = envelope(rootPayload(2, newKeys), rootKeys[0], rootKeys[1])
val byNewOnly = envelope(rootPayload(2, newKeys), newKeys[0], newKeys[1])
val byBoth = envelope(rootPayload(2, newKeys), rootKeys[0], rootKeys[2], newKeys[1], newKeys[2])
assertRejected { SignedConfigRules.nextRoot(bundledRoot, byOldOnly) }
assertRejected { SignedConfigRules.nextRoot(bundledRoot, byNewOnly) }
assertEquals(2L, SignedConfigRules.nextRoot(bundledRoot, byBoth).version)
}
@Test
fun `roots move one version at a time`() {
val skipping = envelope(rootPayload(3, rootKeys), rootKeys[0], rootKeys[1])
assertRejected { SignedConfigRules.nextRoot(bundledRoot, skipping) }
}
@Test
fun `a rotated root revokes the old targets key`() {
val newTargetsKey = Key()
val root2 = SignedConfigRules.nextRoot(bundledRoot, envelope(rootPayload(2, rootKeys, targets = listOf(newTargetsKey)), rootKeys[0], rootKeys[1]))
assertRejected { SignedConfigRules.verifyTargets(root2, envelope(targetsPayload(6, emptyMap()), targetsKey), 5, now) }
assertEquals(6L, SignedConfigRules.verifyTargets(root2, envelope(targetsPayload(6, emptyMap()), newTargetsKey), 5, now).version)
}
@Test
fun `an expired root signs nothing`() {
val expired = SignedConfigRules.trustBundledRoot(envelope(rootPayload(1, rootKeys, expires = now), rootKeys[0], rootKeys[1]))
assertRejected { SignedConfigRules.verifyTargets(expired, envelope(targetsPayload(5, emptyMap()), targetsKey), null, now) }
}
@Test
fun `a key that does not match its id is rejected`() {
val forged = rootPayload(1, rootKeys).replace(Base64.toBase64String(rootKeys[2].public), Base64.toBase64String(Key().public))
assertRejected { SignedConfigRules.trustBundledRoot(envelope(forged, rootKeys[0], rootKeys[1])) }
}
@Test
fun `garbage is rejected, not thrown as something else`() {
assertRejected { Envelope.parse("not json".toByteArray()) }
}
private fun assertRejected(block: () -> Unit) {
try {
block()
} catch (e: SignedConfigException) {
return
}
throw AssertionError("expected SignedConfigException")
}
}