mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-11-10 12:47:09 +00:00
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:
1 parent
524cd81493
commit
acabb902f8
2 files changed
+325
No files matched your search
+168
@@ -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")
|
||||
}
|
||||
+157
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user