Skip to content

Commit 40b4cae

Browse files
authored
Merge branch 'main' into feat/restore-sticky-virtual
2 parents 4098d84 + 44f3853 commit 40b4cae

15 files changed

Lines changed: 1538 additions & 64 deletions

File tree

.github/workflows/release.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,39 @@ jobs:
173173
with:
174174
node-version: 22
175175

176+
# Issue #29: drop the stray `latest.json` updater manifest tauri-action
177+
# attaches to the GitHub Release. `uploadUpdaterJson: false` (set on the
178+
# build job's tauri-action step, verified the correct input name) is SUPPOSED
179+
# to suppress it, but it does not reliably do so on the floating
180+
# `tauri-apps/tauri-action@v0` tag: tauri.conf.json sets
181+
# `createUpdaterArtifacts: true`, so the bundler emits updater artifacts and
182+
# the pinned `@v0` build still uploads a generated `latest.json` (a
183+
# tauri-action version quirk - the current `dev` branch gates the upload on
184+
# the flag, but the resolved `@v0` build's behavior differs). Driven serves
185+
# its own channel-in-path manifests from Cloudflare Pages
186+
# (updates/<channel>/<os>/<arch>/update.json), so a target-flat `latest.json`
187+
# on the Release is just confusing clutter. Delete it post-publish. This job
188+
# runs ONCE (needs: build) after every matrix build's tauri-action upload has
189+
# completed and has the tag (RELEASE_TAG) + GITHUB_TOKEN, so the cleanup
190+
# happens exactly once after all assets exist. Idempotent: a no-op (exit 0)
191+
# when no `latest.json` asset is present.
192+
- name: Remove stray latest.json from the release
193+
env:
194+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
195+
run: |
196+
set -euo pipefail
197+
has_latest="$(gh release view "$RELEASE_TAG" \
198+
--repo "$GITHUB_REPOSITORY" \
199+
--json assets \
200+
--jq '[.assets[] | select(.name == "latest.json")] | length')"
201+
if [ "${has_latest:-0}" -gt 0 ]; then
202+
echo "deleting stray latest.json from release $RELEASE_TAG"
203+
gh release delete-asset "$RELEASE_TAG" latest.json --yes \
204+
--repo "$GITHUB_REPOSITORY"
205+
else
206+
echo "no latest.json asset on release $RELEASE_TAG (nothing to clean up)"
207+
fi
208+
176209
# R1-P1-2: the generator reads bundles + `.sig` from a local dir, but the
177210
# matrix build artifacts live as GitHub Release assets. Download the
178211
# just-published release's assets into a flat dir so the generator has real

crates/driven-crypto/src/keystore.rs

Lines changed: 107 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -74,17 +74,7 @@ impl Keystore {
7474
/// blob is the wrong length, or [`KeystoreError::Backend`] on a backend
7575
/// failure.
7676
pub fn load_master_key(&self) -> Result<MasterKey, KeystoreError> {
77-
let secret = match self.entry.get_secret() {
78-
Ok(s) => Zeroizing::new(s),
79-
Err(keyring::Error::NoEntry) => return Err(KeystoreError::NotFound),
80-
Err(e) => return Err(KeystoreError::Backend(e)),
81-
};
82-
if secret.len() != KEY_LEN {
83-
return Err(KeystoreError::MalformedKey(secret.len()));
84-
}
85-
let mut bytes = [0u8; KEY_LEN];
86-
bytes.copy_from_slice(&secret);
87-
Ok(MasterKey::from_bytes(bytes))
77+
map_load_secret(self.entry.get_secret())
8878
}
8979

9080
/// Deletes the master key entry (account removal / encryption opt-out).
@@ -94,9 +84,111 @@ impl Keystore {
9484
/// Returns [`KeystoreError::Backend`] on a backend failure other than a
9585
/// missing entry.
9686
pub fn delete_master_key(&self) -> Result<(), KeystoreError> {
97-
match self.entry.delete_credential() {
98-
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
99-
Err(e) => Err(KeystoreError::Backend(e)),
100-
}
87+
map_delete_result(self.entry.delete_credential())
88+
}
89+
}
90+
91+
/// Maps a `keyring` `get_secret` result onto a loaded [`MasterKey`].
92+
///
93+
/// This is the keyring-result -> domain mapping that is Driven's own
94+
/// responsibility (length validation, `NoEntry` -> recovery-phrase signal),
95+
/// split out as a PURE free fn so it is unit-tested WITHOUT an OS keychain -
96+
/// the same testability pattern as `driven-drive`'s
97+
/// `token_store::map_load_result` (the 4.1.2 mock store is not a declared
98+
/// dependency and a real round-trip would be flaky on headless CI). A missing
99+
/// entry maps to [`KeystoreError::NotFound`]; a secret that is not exactly
100+
/// [`KEY_LEN`] bytes maps to [`KeystoreError::MalformedKey`]; any other
101+
/// backend error maps to [`KeystoreError::Backend`]. The retrieved bytes are
102+
/// held in a [`Zeroizing`] buffer and scrubbed after the copy into the key.
103+
fn map_load_secret(result: keyring::Result<Vec<u8>>) -> Result<MasterKey, KeystoreError> {
104+
let secret = match result {
105+
Ok(s) => Zeroizing::new(s),
106+
Err(keyring::Error::NoEntry) => return Err(KeystoreError::NotFound),
107+
Err(e) => return Err(KeystoreError::Backend(e)),
108+
};
109+
if secret.len() != KEY_LEN {
110+
return Err(KeystoreError::MalformedKey(secret.len()));
111+
}
112+
let mut bytes = [0u8; KEY_LEN];
113+
bytes.copy_from_slice(&secret);
114+
Ok(MasterKey::from_bytes(bytes))
115+
}
116+
117+
/// Maps a `keyring` `delete_credential` result onto the idempotent-delete
118+
/// domain result (pure, OS-keychain-free; mirrors `driven-drive`'s
119+
/// `token_store::map_delete_result`). A missing entry is NOT an error
120+
/// (delete is idempotent); any other backend failure maps to
121+
/// [`KeystoreError::Backend`].
122+
fn map_delete_result(result: keyring::Result<()>) -> Result<(), KeystoreError> {
123+
match result {
124+
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
125+
Err(e) => Err(KeystoreError::Backend(e)),
126+
}
127+
}
128+
129+
#[cfg(test)]
130+
mod tests {
131+
use super::*;
132+
133+
#[test]
134+
fn load_maps_no_entry_to_not_found() {
135+
// A missing keychain entry is the first-run / wiped-keychain signal the
136+
// recovery-phrase flow keys off - it must NOT be a generic backend error.
137+
assert!(matches!(
138+
map_load_secret(Err(keyring::Error::NoEntry)),
139+
Err(KeystoreError::NotFound)
140+
));
141+
}
142+
143+
#[test]
144+
fn load_maps_correct_length_secret_to_master_key() {
145+
// A well-formed 32-byte secret reconstructs the master key byte-for-byte.
146+
let raw = [7u8; KEY_LEN];
147+
let key = map_load_secret(Ok(raw.to_vec())).unwrap();
148+
assert_eq!(key.as_bytes(), &raw);
149+
}
150+
151+
#[test]
152+
fn load_rejects_wrong_length_secret_as_malformed() {
153+
// A foreign / corrupt write of the wrong length must surface MalformedKey
154+
// carrying the observed length - never be silently truncated or panic.
155+
assert!(matches!(
156+
map_load_secret(Ok(vec![0u8; 16])),
157+
Err(KeystoreError::MalformedKey(16))
158+
));
159+
assert!(matches!(
160+
map_load_secret(Ok(Vec::new())),
161+
Err(KeystoreError::MalformedKey(0))
162+
));
163+
assert!(matches!(
164+
map_load_secret(Ok(vec![0u8; KEY_LEN + 1])),
165+
Err(KeystoreError::MalformedKey(n)) if n == KEY_LEN + 1
166+
));
167+
}
168+
169+
#[test]
170+
fn load_maps_other_backend_error() {
171+
// A real backend failure (anything but NoEntry) is preserved as Backend.
172+
let r = map_load_secret(Err(keyring::Error::Invalid(
173+
"service".to_string(),
174+
"boom".to_string(),
175+
)));
176+
assert!(matches!(r, Err(KeystoreError::Backend(_))));
177+
}
178+
179+
#[test]
180+
fn delete_is_idempotent_for_missing_entry() {
181+
// Both a successful delete and a no-such-entry delete are Ok (idempotent).
182+
assert!(map_delete_result(Ok(())).is_ok());
183+
assert!(map_delete_result(Err(keyring::Error::NoEntry)).is_ok());
184+
}
185+
186+
#[test]
187+
fn delete_surfaces_other_backend_error() {
188+
let r = map_delete_result(Err(keyring::Error::Invalid(
189+
"service".to_string(),
190+
"boom".to_string(),
191+
)));
192+
assert!(matches!(r, Err(KeystoreError::Backend(_))));
101193
}
102194
}

crates/driven-crypto/src/lib.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,4 +272,81 @@ mod suite_tests {
272272
Err(CryptoError::Protocol(_))
273273
));
274274
}
275+
276+
#[test]
277+
fn full_keychain_loss_recovery_decrypts_old_ciphertext() {
278+
// The end-to-end disaster-recovery promise (DESIGN s7.3): a user whose OS
279+
// keychain is wiped (machine reformat) can paste back their 24-word BIP39
280+
// phrase and STILL decrypt everything previously uploaded. This ties
281+
// together the four pieces each unit-tested in isolation - master-key
282+
// recovery phrase, master-wraps-source, content STREAM, and filename
283+
// encryption - in the exact order the recovery flow exercises them.
284+
//
285+
// 1. Original install: a master key wraps a fresh per-source key; the
286+
// wrapped blob is what persists in SQLite (`wrapped_source_key`), the
287+
// master key is what lived ONLY in the now-lost keychain.
288+
let master = MasterKey::generate();
289+
let (source_key, wrapped) = master.wrap_new_source_key().unwrap();
290+
let wrapped_blob = wrapped.to_bytes(); // the on-disk form
291+
let phrase = master_key_to_phrase(&master).unwrap(); // what the user wrote down
292+
293+
// 2. Encrypt a file + its path under the original source key, capturing the
294+
// header, ciphertext chunks, and the encrypted folder/leaf names.
295+
let suite = DrivenCryptoSuite::new(source_key);
296+
let dir_name = suite.encrypt_filename("Taxes", &[]).unwrap();
297+
let leaf_name = suite
298+
.encrypt_filename("2023-return.pdf", dir_name.as_bytes())
299+
.unwrap();
300+
let mut enc = suite.content_encryptor();
301+
let header = enc.header();
302+
let c0 = enc.encrypt_chunk(b"page one of the return").unwrap();
303+
let (c1, _md5) = enc.finalize_last(b"and the final page").unwrap();
304+
// Drop the original suite + source key, modelling the wiped keychain: from
305+
// here on ONLY the phrase and the on-disk wrapped blob exist.
306+
drop(suite);
307+
308+
// 3. Recover: phrase -> master key -> unwrap the SAME source key from the
309+
// persisted blob -> rebuild the suite.
310+
let recovered_master = phrase_to_master_key(&phrase).unwrap();
311+
let restored_wrapped = WrappedSourceKey::from_bytes(&wrapped_blob).unwrap();
312+
let recovered_source = recovered_master
313+
.unwrap_source_key(&restored_wrapped)
314+
.unwrap();
315+
let recovered_suite = DrivenCryptoSuite::new(recovered_source);
316+
317+
// 4. The recovered suite decrypts both the plaintext path components and
318+
// the file content that the lost-key suite produced.
319+
assert_eq!(
320+
recovered_suite.decrypt_filename(&dir_name, &[]).unwrap(),
321+
"Taxes"
322+
);
323+
assert_eq!(
324+
recovered_suite
325+
.decrypt_filename(&leaf_name, dir_name.as_bytes())
326+
.unwrap(),
327+
"2023-return.pdf"
328+
);
329+
let mut dec = recovered_suite.content_decryptor(&header).unwrap();
330+
let mut out = Vec::new();
331+
out.extend_from_slice(&dec.decrypt_chunk(&c0).unwrap());
332+
out.extend_from_slice(&dec.decrypt_last(&c1).unwrap());
333+
assert_eq!(out, b"page one of the returnand the final page");
334+
}
335+
336+
#[test]
337+
fn wrong_recovery_phrase_cannot_unwrap_the_source_key() {
338+
// A DIFFERENT (valid) recovery phrase reconstructs a different master key,
339+
// which must FAIL to unwrap the source key (AEAD tag mismatch) - so a
340+
// mistyped-but-checksum-valid phrase can never silently yield garbage.
341+
let master = MasterKey::generate();
342+
let (_source_key, wrapped) = master.wrap_new_source_key().unwrap();
343+
344+
let other_master = MasterKey::generate();
345+
let other_phrase = master_key_to_phrase(&other_master).unwrap();
346+
let wrong = phrase_to_master_key(&other_phrase).unwrap();
347+
assert!(matches!(
348+
wrong.unwrap_source_key(&wrapped),
349+
Err(CryptoError::DecryptFailed)
350+
));
351+
}
275352
}

ui/src/App.vue

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { onMounted } from "vue";
33
import { useRoute } from "vue-router";
44
import { useI18n } from "vue-i18n";
55
6+
import GlobalProgressBar from "./components/GlobalProgressBar.vue";
7+
import { useProgressStore } from "./stores/progress";
68
import { useUpdaterStore } from "./stores/updater";
79
810
// M6 shell: the app is a router host. Each SPEC s25 route renders its own view
@@ -29,6 +31,12 @@ const route = useRoute();
2931
// update so an emit that fired before the webview attached is still reflected.
3032
const updater = useUpdaterStore();
3133
34+
// Global backup progress bar (issue #46): own the `sync:status_changed`
35+
// subscription at the app root - just like the updater above - so the thin top
36+
// bar reflects a backup/sync run in progress on ANY route, even one that started
37+
// before the active view mounted.
38+
const progress = useProgressStore();
39+
3240
// R4-P2-1: subscribe() can reject on a partial listener-registration failure (it
3341
// now cleans up + resets state so a later retry can re-subscribe). A failed
3442
// subscribe must NOT skip pending-update hydration: the backend's startup check
@@ -44,6 +52,17 @@ onMounted(async () => {
4452
} finally {
4553
await updater.hydratePending();
4654
}
55+
// Same pattern for the global progress bar: subscribe first so no live status
56+
// event is missed, then hydrate from the current aggregate so a run already
57+
// underway at boot shows immediately. A subscribe failure must not skip
58+
// hydration (get_sync_status is an independent path), so hydrate in `finally`.
59+
try {
60+
await progress.subscribe();
61+
} catch (e) {
62+
console.error("progress subscribe failed at app boot", e);
63+
} finally {
64+
await progress.hydrate();
65+
}
4766
});
4867
4968
// The top-nav surfaces. `match` is the set of route paths for which the item is
@@ -76,6 +95,7 @@ const NAV_LINK_ACTIVE = "text-teal-700 dark:text-teal-300 font-semibold";
7695

7796
<template>
7897
<div class="min-h-screen flex flex-col">
98+
<GlobalProgressBar />
7999
<nav
80100
class="flex flex-wrap items-center gap-x-6 gap-y-2 border-b border-zinc-200 bg-white px-6 py-3 text-sm dark:border-zinc-800 dark:bg-zinc-900"
81101
:aria-label="t('nav.primary')"

0 commit comments

Comments
 (0)