Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/v0.1.0-beta4.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 15 additions & 4 deletions src-tauri/src/sync2/roster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,14 @@ fn b64<const N: usize>(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
Expand All @@ -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()
Expand Down Expand Up @@ -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<RosterState> {
// 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 {
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/tests/fixtures/flow-sync-2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 2<sup>53</sup> − 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
Expand Down
295 changes: 295 additions & 0 deletions src-tauri/tests/fixtures/flow-sync-2/roster.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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": [
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tests/sync2_membership.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
] {
Expand Down
Loading
Loading