diff --git a/changelog/v0.1.0-beta4.txt b/changelog/v0.1.0-beta4.txt index 1f308b7d..6108f889 100644 --- a/changelog/v0.1.0-beta4.txt +++ b/changelog/v0.1.0-beta4.txt @@ -36,6 +36,7 @@ SYNC - The same holds when a new device starts from a snapshot or a profile starts syncing: a damaged or forged object no longer fails the sync, and the profile is caught up once it can be read. - A snapshot or profile name from another device that carries an out-of-range number no longer stops a synced profile from being read. - A malformed roster entry from another device on the local network is refused, so the group stays readable on this computer. +- Linked devices refuse a device list entry whose sealed device name isn't in the one form every Flow app writes. - Joining a group over the local network now refuses a malformed welcome from the other device. - Linked devices: Replace recovery words gives your group a new recovery key from any linked device. The old words stop working, and your devices move to a new encryption key. - The Sync page now opens on Linked devices, which keep your devices in sync all the time. The one-time transfer to a nearby device moves below them, and Linked devices stay on screen while a transfer runs. diff --git a/src-tauri/src/sync2/roster.rs b/src-tauri/src/sync2/roster.rs index f8a34f04..c1f34559 100644 --- a/src-tauri/src/sync2/roster.rs +++ b/src-tauri/src/sync2/roster.rs @@ -155,6 +155,14 @@ fn b64(value: &str) -> Option<[u8; N]> { .map(|bytes| array(&bytes)) } +/// Base64url with nothing else in it. Past that, `meta` is opaque until its device opens it. +fn is_opaque_b64(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')) +} + fn well_formed(entry: &Entry) -> bool { let dids = |list: &[String]| !list.is_empty() && list.iter().all(|d| is_hex(d, 16)); entry.v == 2 @@ -178,10 +186,9 @@ fn well_formed(entry: &Entry) -> bool { .epoch .as_ref() .is_none_or(|e| b64::<32>(&e.kc).is_some() && dids(&e.to)) - && entry - .meta - .as_ref() - .is_none_or(|m| m.iter().all(|(d, v)| is_hex(d, 16) && !v.is_empty())) + && entry.meta.as_ref().is_none_or(|m| { + !m.is_empty() && m.iter().all(|(d, v)| is_hex(d, 16) && is_opaque_b64(v)) + }) && entry .sigs .iter() @@ -376,6 +383,10 @@ fn check_group_id(entry: &Entry) -> Result<()> { /// Applies one entry to the state before it, or says why it is not valid. pub fn apply_entry(state: Option<&RosterState>, entry: &Entry) -> Result { + // A LAN welcome's entries arrive without `parse_entry`, and would then fail it when read back. + if !well_formed(entry) { + return Err(Error::RosterFormat); + } check_chain(state, entry)?; check_shape(entry)?; if entry.op == Op::Genesis { diff --git a/src-tauri/tests/fixtures/flow-sync-2/README.md b/src-tauri/tests/fixtures/flow-sync-2/README.md index 76f83ff2..bf424545 100644 --- a/src-tauri/tests/fixtures/flow-sync-2/README.md +++ b/src-tauri/tests/fixtures/flow-sync-2/README.md @@ -14,10 +14,10 @@ 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 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; `replacements`, entries on top of the chain where the device that set the recovery key is removed or has left | +| `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 | | `senders.json` | Step 2 of [reading an envelope](../store.md#reading-an-envelope): a roster, then `senders` with who may write under which epoch and how far along its log, then `walks` of a log down from a `cut`, with the hashes found from the lowest `seq` up or the check that refuses it. An empty string in `log` is an object the store doesn't have | -| `pairing.json` | Pairing keys, SAS, mailbox slot, and a sealed `join` message | +| `pairing.json` | Commitment, proof, the scanner's first message, pairing keys, SAS, mailbox slot, and a sealed `join` message; `refused` holds a shower key that doesn't match the commitment and a proof made with another token | | `lan.json` | Discovery tag, hello AAD, transcripts, session keys, frame AAD | ## Using them diff --git a/src-tauri/tests/fixtures/flow-sync-2/roster.json b/src-tauri/tests/fixtures/flow-sync-2/roster.json index 819e1378..234c382b 100644 --- a/src-tauri/tests/fixtures/flow-sync-2/roster.json +++ b/src-tauri/tests/fixtures/flow-sync-2/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": [ diff --git a/src-tauri/tests/sync2_membership.rs b/src-tauri/tests/sync2_membership.rs index 1274c90e..c3f32888 100644 --- a/src-tauri/tests/sync2_membership.rs +++ b/src-tauri/tests/sync2_membership.rs @@ -95,7 +95,7 @@ async fn a_signed_name_that_can_not_be_read_is_left_out() { .unwrap(); let not_json = |seq| seal_device_meta(&g.epoch_key, &g.group, 0, seq, &g.a.did, b"name"); for sealed in [ - "not base64url!".to_owned(), + "A".to_owned(), URL_SAFE_NO_PAD.encode([0; 100]), URL_SAFE_NO_PAD.encode(not_json(head(&g.store).seq + 1).unwrap()), ] { diff --git a/src-tauri/tests/sync2_roster.rs b/src-tauri/tests/sync2_roster.rs index 866ef70a..4e98272d 100644 --- a/src-tauri/tests/sync2_roster.rs +++ b/src-tauri/tests/sync2_roster.rs @@ -111,6 +111,25 @@ fn invalid_entries_are_rejected_for_the_named_reason() { } } +#[test] +fn an_entry_that_skipped_the_parser_is_refused_for_its_meta_too() { + let v = vectors(); + let entries = entries(&v); + let named = |name: &&Value| name["name"].as_str().unwrap().contains("meta"); + let negatives = v["negatives"].as_array().unwrap().iter().filter(named); + let mut seen = 0; + for n in negatives { + let Ok(entry) = serde_json::from_value::(n["entry"].clone()) else { + continue; + }; + let state = base(&entries, &n["base"]); + let error = apply_entry(Some(&state), &entry).unwrap_err(); + assert_eq!(error.code(), n["error"].as_str().unwrap(), "{}", n["name"]); + seen += 1; + } + assert!(seen >= 6); +} + #[test] fn a_genesis_must_match_its_group_id() { for n in vectors()["geneses"].as_array().unwrap() { @@ -164,7 +183,12 @@ fn a_removal_replaces_a_recovery_key_set_by_a_device_that_is_gone() { Some(count) => base(&entries, count), None => replay_roster(&entries).unwrap(), }; - let steps: Vec = serde_json::from_value(case["steps"].clone()).unwrap(); + let steps: Vec = case["steps"] + .as_array() + .unwrap() + .iter() + .map(|step| parse_entry(step).unwrap()) + .collect(); let (last, before) = steps.split_last().unwrap(); for step in before { state = apply_entry(Some(&state), step).unwrap();