diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/signed/SignedConfig.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/signed/SignedConfig.kt new file mode 100644 index 00000000..46443471 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/signed/SignedConfig.kt @@ -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>) { + + 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, keyIds: Set): 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, val threshold: Int) + +class RootMetadata( + val version: Long, + val expiresAt: Long, + val keys: Map, + 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): 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) { + + 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, 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 guard(block: () -> T): T = try { + block() +} catch (e: SignedConfigException) { + throw e +} catch (e: Exception) { + throw SignedConfigException("malformed metadata: $e") +} diff --git a/common/src/test/java/io/novafoundation/nova/common/data/network/signed/SignedConfigTest.kt b/common/src/test/java/io/novafoundation/nova/common/data/network/signed/SignedConfigTest.kt new file mode 100644 index 00000000..2a6853ce --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/data/network/signed/SignedConfigTest.kt @@ -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, threshold: Int = 2, targets: List = listOf(targetsKey), expires: Long = now + 1000): String { + val keys = (root + targets).distinct().joinToString(",") { """"${it.id}":"${Base64.toBase64String(it.public)}"""" } + val ids = { list: List -> 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, 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") + } +}