From b71eaaf6ca232e8e933774574555cc6c8c8d1cda Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:21:39 +0200 Subject: [PATCH] fix(sync): refuse a roster entry's meta unless it is base64url and not empty --- .../github/aedev/flow/sync2/roster/Roster.kt | 9 +- app/src/test/resources/sync2/README.md | 2 +- app/src/test/resources/sync2/roster.json | 295 ++++++++++++++++++ 3 files changed, 304 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/sync2/roster/Roster.kt b/app/src/main/java/io/github/aedev/flow/sync2/roster/Roster.kt index 731d0e293..0a2019243 100644 --- a/app/src/main/java/io/github/aedev/flow/sync2/roster/Roster.kt +++ b/app/src/main/java/io/github/aedev/flow/sync2/roster/Roster.kt @@ -149,6 +149,9 @@ fun b64( .getOrNull() ?.takeIf { it.size == size && SyncBytes.b64urlEncode(it) == value } +/** A `meta` value, which is opaque past this: trailing bits and length stay unchecked, so it isn't decoded here (spec/protocol.md, Roster). */ +private val META_VALUE = Regex("[A-Za-z0-9_-]+") + /** The largest integer every implementation reads exactly. */ private const val MAX_SEQ = (1L shl 53) - 1 @@ -194,7 +197,11 @@ private fun wellFormed(entry: Entry): Boolean { } ?: true ) && (entry.epoch?.let { it.n >= 0 && b64(it.kc, 32) != null && dids(it.to) } ?: true) && - (entry.meta?.all { (did, sealed) -> isHex(did, 16) && sealed.isNotEmpty() } ?: true) && + ( + entry.meta?.let { meta -> + meta.isNotEmpty() && meta.all { (did, sealed) -> isHex(did, 16) && META_VALUE.matches(sealed) } + } ?: true + ) && entry.sigs.all { (did, signature) -> isHex(did, 16) && b64(signature, 64) != null } } diff --git a/app/src/test/resources/sync2/README.md b/app/src/test/resources/sync2/README.md index e4df2893a..d6a5f43d4 100644 --- a/app/src/test/resources/sync2/README.md +++ b/app/src/test/resources/sync2/README.md @@ -14,7 +14,7 @@ All byte strings are lowercase hex, except inside roster entries, which use the | `keys.json` | Member ids, epoch commitment, purpose keys, HPKE wrap with a fixed ephemeral key, wrong-epoch and wrong-member unwraps, history chain, wrap and chain paths | | `recovery.json` | Recovery secret, 24 words and derived keys; words with a bad checksum, 23 words, and a valid 12-word phrase | | `envelope.json` | Log objects of each type, including a store login, opened in stream order, a live message, a snapshot blob; truncation, magic, suite, group, unknown sender, signature, tampering, replay, gap, a log read from the middle, wrong `prev`, swapped epoch, `seq` above 253 − 1, blob hash | -| `roster.json` | A chain through genesis, add, rename, remove, restore, recovery change, add, leave and rotate, with the state after each entry (including whether a rotate is due) and the device names it carries; bad and missing signatures, unknown fields, wrong `seq`, truncated chain, removed signer, weak key, re-adding a removed device, wrong epoch sets and numbers, self-removal, renaming the recovery key, a non-member signer, a used recovery key; a leave by a non-member, by the recovery member and by the last device, a leave removing another device or carrying an epoch, a rotate without an epoch, with a removal, or wrapped to a device that left; six forks and their winners, including leave against remove and rotate, and concurrent rotates; a genesis whose group id isn't the one its keys commit to | +| `roster.json` | A chain through genesis, add, rename, remove, restore, recovery change, add, leave and rotate, with the state after each entry (including whether a rotate is due, and each member's epochs and `cut`) and the device names it carries; bad and missing signatures, unknown fields, wrong `seq`, truncated chain, removed signer, weak key, re-adding a removed device, wrong epoch sets and numbers, self-removal, renaming the recovery key, a non-member signer, a used recovery key; a leave by a non-member, by the recovery member and by the last device, a leave removing another device or carrying an epoch, a rotate without an epoch, with a removal, or wrapped to a device that left; six forks and their winners, including leave against remove and rotate, and concurrent rotates; a genesis whose group id isn't the one its keys commit to; a `cut` on the wrong op, for a device not removed, empty, or at `seq` 0; `meta` that is empty, or holds a value outside `[A-Za-z0-9_-]`; `replacements`, entries on top of the chain: where the device that set the recovery key is removed or has left, and `meta` values that are valid though not strict base64 | | `forks.json` | Named entries, and cases that settle a store's view of them: `applied` is what the device had applied, `view` is the store's answer, `pins` the chosen entries, `linear` a Flow server. Each case gives the outcome and, in any order of `view`, the same chain, standing entries that lost and start an epoch, contested set and sides | | `pairing.json` | Pairing keys, SAS, mailbox slot, and a sealed `join` message | | `lan.json` | Discovery tag, hello AAD, transcripts, session keys, frame AAD | diff --git a/app/src/test/resources/sync2/roster.json b/app/src/test/resources/sync2/roster.json index 819e13785..234c382b0 100644 --- a/app/src/test/resources/sync2/roster.json +++ b/app/src/test/resources/sync2/roster.json @@ -1227,6 +1227,127 @@ }, "error": "roster-signer" }, + { + "name": "meta-not-base64url", + "base": 1, + "entry": { + "v": 2, + "group": "669a44456c08e4a12e4ceb3538860317", + "seq": 1, + "prev": "c2d714b286d2cc8b4f11911a6024e65e916302ec7a0c1ecbef9db9bdcac76143", + "op": "rename", + "signer": "d8576f2de42861ceb8030cc722b4c669", + "meta": { + "d8576f2de42861ceb8030cc722b4c669": "not base64url!" + }, + "sigs": { + "d8576f2de42861ceb8030cc722b4c669": "Oy_ES7SHj8WtQi7X9DBtHhlHwlcDXH4Zl3TeYHF7W8LEvyeM1LgM4hqWojmpPLRwDreup7E50LeuHelIc3mBAg" + } + }, + "error": "roster-format" + }, + { + "name": "meta-padded", + "base": 1, + "entry": { + "v": 2, + "group": "669a44456c08e4a12e4ceb3538860317", + "seq": 1, + "prev": "c2d714b286d2cc8b4f11911a6024e65e916302ec7a0c1ecbef9db9bdcac76143", + "op": "rename", + "signer": "d8576f2de42861ceb8030cc722b4c669", + "meta": { + "d8576f2de42861ceb8030cc722b4c669": "AAAA=" + }, + "sigs": { + "d8576f2de42861ceb8030cc722b4c669": "A0yJXEP4W-s1DKyRE-w3twbCDU07lQrjX6hnWQQF0Ux0Vak_E1KoogScOuYrbANxlGcd4NaXwcELltT17M6zDg" + } + }, + "error": "roster-format" + }, + { + "name": "meta-non-ascii", + "base": 1, + "entry": { + "v": 2, + "group": "669a44456c08e4a12e4ceb3538860317", + "seq": 1, + "prev": "c2d714b286d2cc8b4f11911a6024e65e916302ec7a0c1ecbef9db9bdcac76143", + "op": "rename", + "signer": "d8576f2de42861ceb8030cc722b4c669", + "meta": { + "d8576f2de42861ceb8030cc722b4c669": "€" + }, + "sigs": { + "d8576f2de42861ceb8030cc722b4c669": "FgMXNPkt1kLdC1kzQtr9WRQ4HsDNikKp5hi89bd2lRAaSWw4FxyOjc66iPjrdLU7bf2bwFCjHNi3hwqnQD76Dw" + } + }, + "error": "roster-format" + }, + { + "name": "meta-trailing-newline", + "base": 1, + "entry": { + "v": 2, + "group": "669a44456c08e4a12e4ceb3538860317", + "seq": 1, + "prev": "c2d714b286d2cc8b4f11911a6024e65e916302ec7a0c1ecbef9db9bdcac76143", + "op": "rename", + "signer": "d8576f2de42861ceb8030cc722b4c669", + "meta": { + "d8576f2de42861ceb8030cc722b4c669": "AAAA\n" + }, + "sigs": { + "d8576f2de42861ceb8030cc722b4c669": "FjJSzA0T1ikYvmRHTzh8oVInlW6ARylrSvt5aKgKf0SC5uN4bpTQw6DDCP_JLBX2JduxKm6XITaReSx5WKV0Dw" + } + }, + "error": "roster-format" + }, + { + "name": "meta-empty-value", + "base": 1, + "entry": { + "v": 2, + "group": "669a44456c08e4a12e4ceb3538860317", + "seq": 1, + "prev": "c2d714b286d2cc8b4f11911a6024e65e916302ec7a0c1ecbef9db9bdcac76143", + "op": "rename", + "signer": "d8576f2de42861ceb8030cc722b4c669", + "meta": { + "d8576f2de42861ceb8030cc722b4c669": "" + }, + "sigs": { + "d8576f2de42861ceb8030cc722b4c669": "YEhIf6kYQQNiLfthHfmlrXoox0wBRm8WfBK62GuYcwYPOVH4zmQ9YpImzcLqhmIPrcykF1fVx465WCnRWgCkAg" + } + }, + "error": "roster-format" + }, + { + "name": "empty-meta", + "base": 1, + "entry": { + "v": 2, + "group": "669a44456c08e4a12e4ceb3538860317", + "seq": 1, + "prev": "c2d714b286d2cc8b4f11911a6024e65e916302ec7a0c1ecbef9db9bdcac76143", + "op": "add", + "signer": "d8576f2de42861ceb8030cc722b4c669", + "add": [ + { + "did": "addf195306ed8f3c4d02642d8b6ebd6a", + "sig": "Pv9XrxPpjXsHr8QrMZKgC1ZO9Q2cOgBPCr7P22C3MWY", + "kem": "bmiQy52XcNEUqT_nj29df8adxbK84jlUCWdacit50FA", + "role": "device" + } + ], + "meta": {}, + "sigs": { + "d8576f2de42861ceb8030cc722b4c669": "22GvNL1vzwVik9qHks7bM5-o6RTNCUrmI6MS3eBQ7vMiHsa4mwPSmbBSiNJnXb03bgrjWE4WZ0IKFEidW6tdAg", + "addf195306ed8f3c4d02642d8b6ebd6a": "TqDWI5AItI_yB_WFdVk_J01UThgsJb1iRXANB_HF4rlhcPgTazrXsrZC9Nqq5CyyBHftw2FOVI9gFVpwzbwsBw" + } + }, + "error": "roster-format" + }, { "name": "leave-by-non-member", "base": 7, @@ -1711,6 +1832,180 @@ } ], "replacements": [ + { + "name": "meta-with-trailing-bits", + "steps": [ + { + "v": 2, + "group": "669a44456c08e4a12e4ceb3538860317", + "seq": 9, + "prev": "18eb362b0fb928a0f35b684a8cbfae6f0073cfcd3ba0c5c6dbd7af74343f3224", + "op": "rename", + "signer": "d8576f2de42861ceb8030cc722b4c669", + "meta": { + "d8576f2de42861ceb8030cc722b4c669": "AB" + }, + "sigs": { + "d8576f2de42861ceb8030cc722b4c669": "xGqhs0OS1uBZ2EhSpa5oBLl_RG_r6LmK6VyHg4TnFfknqGgHzaL9hhwQ2YBj_LkYIiuM99Zo4PK4eoAHHXFsAQ" + } + } + ], + "state": { + "seq": 9, + "head": "4c0ef6ca2e835eafc55b3a446a7e49f61b9506eb2125557ad3c0a4456ea4e1c2", + "epoch": 4, + "rotate": false, + "recoveryDue": false, + "members": [ + { + "did": "6e1946f855d4d7de61c0d3e3e4491cae", + "role": "recovery", + "used": false, + "installer": "addf195306ed8f3c4d02642d8b6ebd6a" + }, + { + "did": "addf195306ed8f3c4d02642d8b6ebd6a", + "role": "device", + "used": false + }, + { + "did": "d8576f2de42861ceb8030cc722b4c669", + "role": "device", + "used": false + } + ], + "senders": [ + { + "did": "1edce5f29fbda5bba7ada194b0a578c6", + "role": "device", + "since": 3, + "until": 3, + "cut": { + "seq": 1, + "hash": "301bf18e9cce13b93870928117aecd06ff793354402b03810bff6476e123865b" + } + }, + { + "did": "6e1946f855d4d7de61c0d3e3e4491cae", + "role": "recovery", + "since": 3 + }, + { + "did": "894e311b2b2058e3bcc2ef3bde8ec864", + "role": "device", + "since": 0, + "until": 0, + "cut": { + "seq": 2, + "hash": "4d4573758bd47b198e4305475ff362af7b9806c157caff61d657837e94134ffe" + } + }, + { + "did": "ab6095990ebaaf36d4c49e86fc3a65c3", + "role": "recovery", + "since": 0, + "until": 2 + }, + { + "did": "addf195306ed8f3c4d02642d8b6ebd6a", + "role": "device", + "since": 2 + }, + { + "did": "d8576f2de42861ceb8030cc722b4c669", + "role": "device", + "since": 0 + } + ] + } + }, + { + "name": "meta-of-an-undecodable-length", + "steps": [ + { + "v": 2, + "group": "669a44456c08e4a12e4ceb3538860317", + "seq": 9, + "prev": "18eb362b0fb928a0f35b684a8cbfae6f0073cfcd3ba0c5c6dbd7af74343f3224", + "op": "rename", + "signer": "d8576f2de42861ceb8030cc722b4c669", + "meta": { + "d8576f2de42861ceb8030cc722b4c669": "A" + }, + "sigs": { + "d8576f2de42861ceb8030cc722b4c669": "NxJy_7T86gFZOHmdKkciYUdz-3lQ6GRdD1dJfaYFwGecpOjUW3WuOHDt0bAT9y-akYYvwvdfThEppJ5KFlNpAw" + } + } + ], + "state": { + "seq": 9, + "head": "7993b447f137be0f54e3ec108d0c21beb5687584e9ec626993c2834906048e35", + "epoch": 4, + "rotate": false, + "recoveryDue": false, + "members": [ + { + "did": "6e1946f855d4d7de61c0d3e3e4491cae", + "role": "recovery", + "used": false, + "installer": "addf195306ed8f3c4d02642d8b6ebd6a" + }, + { + "did": "addf195306ed8f3c4d02642d8b6ebd6a", + "role": "device", + "used": false + }, + { + "did": "d8576f2de42861ceb8030cc722b4c669", + "role": "device", + "used": false + } + ], + "senders": [ + { + "did": "1edce5f29fbda5bba7ada194b0a578c6", + "role": "device", + "since": 3, + "until": 3, + "cut": { + "seq": 1, + "hash": "301bf18e9cce13b93870928117aecd06ff793354402b03810bff6476e123865b" + } + }, + { + "did": "6e1946f855d4d7de61c0d3e3e4491cae", + "role": "recovery", + "since": 3 + }, + { + "did": "894e311b2b2058e3bcc2ef3bde8ec864", + "role": "device", + "since": 0, + "until": 0, + "cut": { + "seq": 2, + "hash": "4d4573758bd47b198e4305475ff362af7b9806c157caff61d657837e94134ffe" + } + }, + { + "did": "ab6095990ebaaf36d4c49e86fc3a65c3", + "role": "recovery", + "since": 0, + "until": 2 + }, + { + "did": "addf195306ed8f3c4d02642d8b6ebd6a", + "role": "device", + "since": 2 + }, + { + "did": "d8576f2de42861ceb8030cc722b4c669", + "role": "device", + "since": 0 + } + ] + } + }, { "name": "removing-the-device-that-set-the-key-replaces-it", "steps": [