Flutter · sync · Shellby

A Pairing Code You Can Freely Photograph — Because the Passphrase Isn't in It

A Shellby postmortem: a new device joining cross-stack sync has to type a pile of sensitive config. Encode it into a passphrase-encrypted pairing code, render it as a QR, scan once. The key design is that the passphrase isn't in the code — it's only the decryption key — so the code is useless when photographed; plus HarmonyOS white-screen QR and desktop pick-image decode.

Shellby’s cross-stack sync lets iPhone, Android, Windows, Linux, and HarmonyOS sync through one shared third-party storage account. Joining a new device means typing a pile of sensitive config: a WebDAV URL and password, or an S3 endpoint / region / bucket / access key / secret. Poking those strings into a phone is misery.

So there’s a pairing code: an already-configured end encodes the whole sync config into a passphrase-encrypted pairing code, rendered as a QR; a new device scans it (or pastes it), enters the same passphrase, and the config is decrypted and applied. Apple (Swift) and Flutter interoperate on the same format. This post covers its cryptographic design — especially one decision that makes the code safe to photograph — and two cross-platform pits.

What’s in the pairing code

The format is a frozen cross-stack contract:

shellby-sync-v1: + base64url( salt(16) | nonce(12) | ciphertext | mac(16) )

The key is derived from the passphrase with Argon2id (lightweight interactive params memory=19 MiB / iterations=2 / parallelism=1, decryption in a few hundred ms, balancing security and feel), and encrypted with ChaCha20-Poly1305 (IETF, 12-byte nonce). The payload is a JSON:

{ "backend": "s3", "includeCredentials": true,
  "s3Endpoint": "...", "s3Region": "...", "s3Bucket": "...",
  "s3AccessKey": "...", "s3Secret": "...", "webdavPassword": "..." }

The two most sensitive credentials (s3Secret, webdavPassword) are written only when non-empty — in “config only” mode, or if the user didn’t fill them, they’re omitted entirely and decode to nil on the other end.

The key design: the passphrase isn’t in the code

The most important clause of the whole design: the passphrase does not go into the pairing code; it’s only the decryption key.

Why does this matter? Because that passphrase already has another identity — it’s the end-to-end passphrase for the encrypted sync archive. The user’s sync data is ciphertext on the third-party storage, and this passphrase is the only key that opens it, so it must already be consistent across all devices. The pairing code reuses it as the decryption key.

That reuse yields a lovely property: the pairing code can be photographed, screenshotted, even pasted into a group chat, and it’s useless. The code holds only ciphertext, no passphrase; and the passphrase is typed by the user on the new device by hand. That naturally completes “only someone who knows the passphrase can import” — no extra signature, token, or expiry needed, a single “knowledge factor” covers it all. On import the code explicitly writes the entered passphrase into the new device’s secure storage (comment: “the new device must store the same passphrase to read/write the archive”), so pairing and “gaining archive access” happen in one step.

// Lightweight Argon2id for interactive pairing; decryption within a few hundred ms. Passphrase not in payload.
static Argon2id _kdf() =>
    Argon2id(parallelism: 1, memory: 19 * 1024, iterations: 2, hashLength: 32);

final salt = _random(_saltLen);
final key = await _kdf().deriveKey(
    secretKey: SecretKey(utf8.encode(passphrase)), nonce: salt);
final box = await _aead.encrypt(plain, secretKey: key, nonce: _random(_nonceLen));
return '$_prefix${base64Url.encode([...salt, ...box.nonce, ...box.cipherText, ...box.mac.bytes])}';

Pit one: the QR white-screens on HarmonyOS, fixed three times

The QR was fine on iOS/Android; on HarmonyOS it rendered and the whole dialog white-screened. This took three fixes to get clean:

  1. Add a text-code fallback: originally just qr_flutter’s QrImageView; a render failure white-screened the dialog. The first pass added an errorStateBuilder and always showed a copyable text code, so if the QR died you could still pair by copying text;
  2. Discover the fallback doesn’t catch it: errorStateBuilder only catches encoding errors, not the render-time exception HarmonyOS throws — the subtree still crashed. So a Platform.operatingSystem == 'ohos' check simply hid the QR widget on HarmonyOS and left only the text code, which pairs fine via copy-paste;
  3. Switch to self-drawing: finally ditch qr_flutter entirely and draw it ourselves — compute the module matrix with the qr package and Canvas.drawRect cell by cell in a CustomPainter. Pure Canvas is stable on HarmonyOS, the _isOhos hide was deleted, and all platforms including HarmonyOS display the QR normally.

Self-drawing also brought two small tradeoffs: use the lowest error-correction level QrErrorCorrectLevel.L for maximum capacity (the pairing code is long encrypted base64; close-range scanning doesn’t need high correction, so prioritize avoiding over-capacity generation failure); and each cell’s drawRect is cell + 0.5 in width/height so adjacent cells overlap the anti-aliasing seams — otherwise thin white lines lower recognition.

Pit two: devices without a camera use “pick-image decode”

Desktops (Windows/Linux/macOS) and many HarmonyOS devices have no camera and can’t scan. So the scan path splits by platform:

iOS / Android  → mobile_scanner live camera
other platforms → file_picker pick an image → decode pixels via image → zxing2 pure-Dart decode

mobile_scanner has no HarmonyOS implementation, but it isn’t in HarmonyOS’s compile path (HarmonyOS doesn’t take the camera branch), so the HarmonyOS build passes. When an image is picked but no QR decodes, it throws an explicit QrScanNotFoundException (hint “no QR recognized”), distinguished from the silent null of a user cancellation — a failure should say whether it’s “not found” or “you cancelled.”

How cross-stack interop is guaranteed

The Apple side is an independent implementation of the same format (ChaChaPoly + Argon2Swift, same params):

let key = try deriveKey(passphrase: passphrase, salt: salt)   // Argon2id, same params
let sealed = try ChaChaPoly.seal(plain, using: SymmetricKey(data: key),
                                 nonce: try ChaChaPoly.Nonce(data: nonce))
return prefix + base64urlEncode(salt + nonce + sealed.ciphertext + sealed.tag)

The question is: how do you ensure a code Swift generates is decodable by Dart and vice versa? The answer isn’t running both stacks against each other, but introducing shared vectors from a third independent implementation: the pairing code in Tests/Fixtures/sync-pairing.json is generated by a Python implementation (argon2-cffi + cryptography, fixed salt / nonce), and Swift and Dart each decoding it must arrive at a field-identical payload.

Why a third party, not feeding one stack’s output to the other? Because two stacks testing each other with the same error logic can “be wrong the same way” and mutually pass. Bringing in an independent implementation as referee keeps that collusive error out. (A detail: a random nonce already makes ciphertext non-byte-identical, so the contract only constrains field names and types, not JSON field order.)

Takeaways

  • Reusing a “knowledge factor” as authentication saves a whole mechanism. The passphrase is both the archive’s E2E key and the pairing code’s decryption key, so the code holds no secret and is useless when photographed, and “only someone who knows the passphrase can import” needs no extra signature or expiry — one passphrase in the user’s head covers it;
  • Sensitive fields go into the payload only when non-empty. “Config only” mode not exporting credentials hands the “send the password or not” choice to the user, rather than defaulting to including it all;
  • Cross-platform rendering differences need a degradation chain; don’t bet one component works on every platform. The HarmonyOS QR went “add text fallback → hide the widget → self-draw” over three fixes, and the lesson is that “error callbacks” like errorStateBuilder don’t catch render-time exceptions — what’s truly stable is not depending on the problematic component;
  • No camera? Pick-image decode, so a platform missing a capability takes another path instead of being left without; and a failure should distinguish “not found” from “you cancelled”;
  • Cross-stack interop should rest on an independent third-party vector, not two stacks testing each other. The same logic verifying itself can “be wrong the same way” and falsely pass; an independent implementation as referee is what catches collusive errors.

Comments

  • Loading…

Comments are reviewed before publishing; email is visible only to me.