Skip to content
Merged
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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,22 @@ popover, not for the person who wrote the commit.

## [Unreleased]

## [1.8.9] - 2026-09-26

### Security
- **Attaching a file to an encrypted stash no longer risks its key.** On an account with
encryption on, syncing a stash right after adding an attachment to it could overwrite that
file's encryption key with a copy of its plain name and size - which destroyed the key.
The file then looked broken on every other device, and only the one that uploaded it still
had a readable copy. Cloud sync no longer sends an attachment's name, size or type on an
ordinary sync; only the upload itself sets them
- **Files attached before encryption was turned on are re-encrypted automatically.** They
used to stay readable on the server indefinitely, because nothing re-uploaded them once
encryption was on. A few are now re-encrypted in the background after each sync until
none are left
- An uploaded file's type is no longer sent to cloud storage in the clear on an encrypted
account

## [1.8.8] - 2026-09-25

### Changed
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "stashpad",
"version": "1.8.8",
"version": "1.8.9",
"description": "The staging area for your AI context.",
"author": {
"name": "Nico Wiedemann",
Expand Down
2 changes: 2 additions & 0 deletions screenshots/mock-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ export function installMockBackend(overrides: Partial<DemoState> = {}): void {
};
case 'upload_attachment_to_cloud':
return false;
case 'convert_attachments_to_encrypted':
return { converted: 0, remaining: 0, unrecoverable: 0 };
case 'check_screen_recording_permission':
return true;
case 'check_apple_intelligence_available':
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "stashpad"
version = "1.8.8"
version = "1.8.9"
description = "The staging area for your AI context."
authors = ["Nico Wiedemann <nico.wiedemann@gmail.com>"]
repository = "https://github.com/EarMaster/stashpad"
Expand Down
95 changes: 90 additions & 5 deletions src-tauri/src/e2ee_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,15 @@ pub fn seal_stash_payload(
for stash in stashes.iter_mut() {
let id = stash["id"].as_str().unwrap_or_default().to_string();

// A tombstone carries no text, and the server stores none for one either. Sealing
// an empty string would only add a blob nobody reads.
strip_attachment_details(stash);

// A tombstone carries no text: the server stores none for one, and sealing an empty
// string would only add a blob nobody reads. So the text is removed here rather
// than skipped - the local row keeps it after a delete, and skipping sealing used
// to send it along in the clear.
if stash["deleted"].as_bool().unwrap_or(false) {
stash["content"] = serde_json::json!("");
stash["enhancedContent"] = serde_json::Value::Null;
continue;
}

Expand All @@ -213,6 +219,25 @@ pub fn seal_stash_payload(
Ok(())
}

/// Reduce each attachment in a pushed stash to its id and deleted flag.
///
/// Its name, type and size already travel sealed, in the descriptor `seal_attachment`
/// builds and the upload stores - together with the file's own key. This installation keeps
/// no copy of that descriptor, so it cannot send it again, and sending the local plaintext
/// instead is what went wrong: the server wrote it over the descriptor, the key was lost,
/// and the file name was back on the server in the clear. The id is all a push needs to
/// file an attachment under its stash, and the flag to say it was removed.
fn strip_attachment_details(stash: &mut serde_json::Value) {
let Some(attachments) = stash["attachments"].as_array_mut() else {
return;
};
for attachment in attachments.iter_mut() {
if let Some(fields) = attachment.as_object_mut() {
fields.retain(|name, _| name == "id" || name == "deleted");
}
}
}

/// Open a stash-sync response on its way in.
///
/// Records that cannot be opened are removed from `synced` and returned, so the caller can
Expand Down Expand Up @@ -273,7 +298,11 @@ pub fn seal_context_payload(

for ctx in contexts.iter_mut() {
let id = ctx["id"].as_str().unwrap_or_default().to_string();
// As for a stash: a deleted context leaves without its name, description or rules.
if ctx["deleted"].as_bool().unwrap_or(false) {
ctx["name"] = serde_json::json!("");
ctx["description"] = serde_json::Value::Null;
ctx["rules"] = serde_json::json!([]);
continue;
}

Expand Down Expand Up @@ -733,6 +762,40 @@ mod tests {
});
}

/// An attachment's plaintext name, type and size must not leave with the push: they
/// travel sealed in the descriptor, and the server once wrote this copy over it.
#[test]
fn a_push_carries_no_attachment_details() {
with_key(|| {
let mut payload = stash_payload();
payload["stashes"][0]["attachments"] = serde_json::json!([{
"id": "44444444-4444-4444-8444-444444444444",
"fileName": "Q3-layoffs.xlsx",
"fileSize": 9001,
"mimeType": "application/vnd.ms-excel",
"syntax": null
}]);
seal_stash_payload(&mut payload, USER).expect("seal");

assert_eq!(
payload["stashes"][0]["attachments"],
serde_json::json!([{ "id": "44444444-4444-4444-8444-444444444444" }])
);
assert!(!payload.to_string().contains("Q3-layoffs"));
});
}

#[test]
fn without_a_key_attachment_details_are_sent_as_before() {
let _guard = lock_or_recover(&TEST_LOCK);
clear_content_key();
let mut payload = stash_payload();
payload["stashes"][0]["attachments"] =
serde_json::json!([{ "id": "a", "fileName": "shot.png", "fileSize": 5 }]);
seal_stash_payload(&mut payload, USER).expect("seal");
assert_eq!(payload["stashes"][0]["attachments"][0]["fileName"], "shot.png");
}

#[test]
fn without_a_key_nothing_is_touched() {
let _guard = lock_or_recover(&TEST_LOCK);
Expand All @@ -743,15 +806,37 @@ mod tests {
assert!(payload["cryptoVersion"].is_null());
}

/// A tombstone carries no text and the server stores none for one either.
/// A tombstone carries no text, and the local row still holds it after a delete - so it
/// has to be removed on the way out, not merely left unsealed.
#[test]
fn a_delete_is_not_sealed() {
fn a_delete_leaves_without_its_text() {
with_key(|| {
let mut payload = stash_payload();
payload["stashes"][0]["deleted"] = serde_json::json!(true);
payload["stashes"][0]["content"] = serde_json::json!("");
seal_stash_payload(&mut payload, USER).expect("seal");
assert_eq!(payload["stashes"][0]["content"], "");
assert!(payload["stashes"][0]["enhancedContent"].is_null());
assert!(!payload.to_string().contains("the original text"));
});
}

#[test]
fn a_deleted_context_leaves_without_its_name_or_rules() {
with_key(|| {
let mut payload = serde_json::json!({
"contexts": [{
"id": "55555555-5555-4555-8555-555555555555",
"name": "Steuer 2026",
"description": "Belege",
"rules": [{ "ruleType": "process", "value": "excel.exe" }],
"deleted": true
}]
});
seal_context_payload(&mut payload, USER).expect("seal");
let ctx = &payload["contexts"][0];
assert_eq!(ctx["name"], "");
assert!(ctx["description"].is_null());
assert_eq!(ctx["rules"], serde_json::json!([]));
});
}

Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,7 @@ pub fn run() {
sync::sync_stashes_api,
sync::sync_contexts_api,
sync::upload_attachment_to_cloud,
sync::convert_attachments_to_encrypted,
sync::download_attachment_from_cloud,
sync::connect_websocket,
sync::disconnect_websocket,
Expand Down
Loading
Loading