From 1e02c1e81a7111b0f3f0b2edbc453267470f4aa7 Mon Sep 17 00:00:00 2001 From: turinglambdaai Date: Tue, 27 Oct 2026 09:32:40 +0800 Subject: [PATCH 1/2] fix: bind copies to the visible product; state custody, purposes and clipboard exposure Implements the interaction contract from issue #3 and the purpose hints from issue #2, one bounded slice, all three hosts. - Selection generation guards: entry-list completions and copies carry the generation they were issued under; a stale result is dropped instead of overwriting rows or landing a secret against a different product header. Store switches invalidate in-flight completions the same way. - Copies bound to the on-screen product; the copy message states the real exposure (raw value on the system clipboard, readable by any app) and a 30 s auto-clear that only erases Keyward's own copy: changeCount guard (macOS), non-secret marker Title (Windows), ownership-marker mime (Linux). A later unrelated user copy is never erased. - Plaintext custody is stated in the app: intro, no-vault, snapshots and About surfaces say the file and snapshots are plain text, not OS-encrypted custody, and that the synced folder must be trusted. - Per-entry purpose hints (issue #2): known update/license/FPR names get a consumer line; unknown names get an explicit confirm-before-delete note. The delete confirmation repeats the purpose line. Policy read at this slice boundary: repo start 185637d; PRODUCT-STANDARD 79a2b70; DOCUMENTATION-LANGUAGES 311fea2; PORTFOLIO df86808; INTERACTION-REVIEW-2026-10 79a2b70; authorization decision 2026-10-auth-1 (no Permit/Commerce, free product). CI rivet pin v0.11.0 unchanged. Verification: 69 engine tests pass; synthetic acceptance (multi-private sections, purpose-scoped anchors, FPR pairing, zero value leakage in check output, orphan line numbers) passes; macOS host builds and survives an 8 s launch smoke against a synthetic vault. Windows/Linux compile via CI. Closes #2 Closes #3 --- CHANGELOG.md | 28 +++ README.md | 4 +- README.zh-CN.md | 2 + docs/GUIDE.zh-CN.md | 11 +- docs/USER-GUIDE.md | 4 +- docs/USER-GUIDE.zh-CN.md | 4 +- linux/src/main.cpp | 189 +++++++++++++++--- .../Sources/RivetHost/ContentView.swift | 55 ++++- .../Sources/RivetHost/RivetHostApp.swift | 51 ++++- windows/MainWindow.xaml | 12 +- windows/MainWindow.xaml.cpp | 159 +++++++++++++-- windows/MainWindow.xaml.h | 11 + 12 files changed, 468 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4add9a4..83cca2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ All notable changes to Keyward are documented here. Format follows ## [Unreleased] +### Added + +- **Per-entry purpose hints** (all three hosts): every known entry name now + carries a one-line consumer note — CI manifest signing, Racket-side + updater, macOS host `UpdateService`, update-manifest key id, Deep Check + pairing anchor, commercial-signing lifecycle. Unknown names get an + explicit "confirm what reads it before deleting" line, so the app itself + answers "can I delete this?" instead of silence. The delete confirmation + shows the same purpose line. +- **Clipboard handling that tells the truth**: a copy reports that the raw + value is on the system clipboard where any app can read it, and auto-clears + after 30 s — only while the clipboard still holds Keyward's copy (change + count on macOS, a non-secret marker title on Windows, an ownership-marker + mime on Linux), so a later unrelated copy by the user is never erased. + Clipboard history/sync stays disclosed as out of Keyward's control. +- **Plaintext custody stated in the app**: the intro, no-vault, snapshots + and About surfaces on all three hosts now say plainly that the vault file + and its snapshots are plain text on disk — not OS-encrypted custody — and + that the synced folder must be trusted. + +### Fixed + +- Rapidly selecting two products could leave rows and the copy target + bound to different sections: entry-list completions and copies are now + generation-guarded and bound to the product on screen; a stale copy is + skipped instead of landing a secret under a different header. A store + switch invalidates in-flight completions the same way. + ### Fixed - Deep check now evaluates every private entry and all public anchors within its purpose. A valid update key no longer hides a corrupt or mismatched license key; multiple safe fingerprints remain labelled within the existing RPC row layout. Empty private entries are reported as unreadable. Regression coverage uses generated temporary keys only. diff --git a/README.md b/README.md index 6972ea0..d4e84e5 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,9 @@ Rivet line — a Racket engine with native hosts — it keeps the vault file in your own synced folder and adds the discipline a plaintext store lacks: - **Never displayed, copy-only.** Key values are only ever returned by an - explicit copy-to-clipboard action. + explicit copy-to-clipboard action. The copy announces its own exposure + (any app can read the clipboard) and auto-clears after 30 s — unless + you have copied something else in between, which is never erased. - **Loss protection.** Every mutation snapshots the store first (append-only, inside the synced folder); writes are atomic and permissions are tightened. - **Deep check.** Parses each key (Ed25519 / RSA PKCS#8, base64-DER, diff --git a/README.zh-CN.md b/README.zh-CN.md index d428320..9bd93b9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -25,6 +25,8 @@ Keyward 是一个守护这些密钥的小型桌面应用。建在家族 Rivet 明文存储缺的那份纪律: - **永不显示,只进剪贴板。** 密钥值只通过显式的「复制到剪贴板」动作返回。 + 复制时会明示暴露面(任何应用都能读取剪贴板),并在 30 秒后自动清空—— + 期间你自己复制的其他内容永远不会被误删。 - **防丢失。** 每次改动先对存储做快照(同步盘内、只追加);写入原子化, 权限收紧。 - **深度校验。** 解析每把密钥(Ed25519 / RSA PKCS#8、base64-DER、 diff --git a/docs/GUIDE.zh-CN.md b/docs/GUIDE.zh-CN.md index aa7a791..54b0375 100644 --- a/docs/GUIDE.zh-CN.md +++ b/docs/GUIDE.zh-CN.md @@ -188,7 +188,16 @@ raco rivet package # 产物在 dist/keyward.app 2. **鼠标悬停**在这一行上 → 行尾出现 **Copy** 按钮 → 点击; (或点行尾 `⋯` 菜单 → "Copy value to clipboard") 3. 值已进剪贴板,去粘贴。**界面上任何地方都不会显示值**——这是 - 故意的,防偷窥防误贴。 + 故意的,防偷窥防误贴; +4. **30 秒后自动清空**。复制完成后状态栏会如实说明:原始值就在 + 系统剪贴板上,任何应用都能读到;30 秒后 Keyward 自动清空—— + 除非你期间又复制了别的东西,那份新内容永远不会被动。系统剪贴板 + 历史/同步不受 Keyward 控制,敏感值贴完即用、别久放。 + +> 每个条目名下方都有一行**用途提示**(CI 签名 / Racket 更新器 / +> macOS 宿主 / 清单代号 / 配对锚点……)。看到"没见过"的条目先读 +> 提示再决定动不动手——3/4/5 条个数的差异来自格式代际和新旧密钥 +> 组合,条条都有人消费,别凭感觉删。 ### 4.2 新建一条 diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 9c9cac1..5f0b30e 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -26,11 +26,11 @@ The vault is a plaintext INI file; file permissions and snapshots do not encrypt ## Network, keys and privacy -The vault engine has no network operations. Clipboard copies and any user-chosen folder sync create additional exposure. Keyward is not an HSM, OS keychain or protection from an already compromised user session. +The vault engine has no network operations. Copy-to-clipboard is the only path a value takes out of the app: the status line says so at each copy, notes that any application can read the clipboard, and auto-clears Keyward's copy after 30 s — a later unrelated copy of your own is never erased. What the app cannot control (OS clipboard history or cross-device clipboard sync) stays disclosed as out of scope. Any user-chosen folder sync creates additional exposure. Keyward is not an HSM, OS keychain or protection from an already compromised user session. ## Known limits -Multi-key validation was corrected in #4. Backup/restore, orphan detection, clipboard exposure and packaged release evidence still require the work tracked in #3; do not infer a security certification. +Multi-key validation was corrected in #4. In-app disclosure of plaintext custody, per-entry purpose hints, copy-consequence messaging, selection-bound copies and the 30 s clipboard auto-clear have landed in source (#3); packaged three-platform release evidence for that slice is still pending. Backup/restore and rotation workflow work remains tracked in #3; do not infer a security certification. ## Updates and removal diff --git a/docs/USER-GUIDE.zh-CN.md b/docs/USER-GUIDE.zh-CN.md index c6c0d1a..c2725df 100644 --- a/docs/USER-GUIDE.zh-CN.md +++ b/docs/USER-GUIDE.zh-CN.md @@ -26,11 +26,11 @@ AGPL-3.0 许可的免费发布基础设施工具,不接商业激活。 ## 联网、密钥与隐私 -密钥库引擎没有网络操作。剪贴板复制和用户选择的文件夹同步会增加暴露面。Keyward 不是 HSM 或系统钥匙串,也不能抵御已被攻陷的用户会话。 +密钥库引擎没有网络操作。值离开应用的唯一路径是复制到剪贴板:每次复制都会在状态栏明示这一点、注明任何应用都能读取剪贴板,并在 30 秒后自动清空 Keyward 的这次复制——期间你自己复制的其他内容永远不会被误删。应用无法控制的部分(系统剪贴板历史、跨设备剪贴板同步)如实声明为不在管辖范围。用户选择的文件夹同步会增加暴露面。Keyward 不是 HSM 或系统钥匙串,也不能抵御已被攻陷的用户会话。 ## 已知限制 -多密钥校验已由 #4 修正。备份恢复、孤立条目检测、剪贴板暴露与打包发布证据仍需按 #3 推进;不构成安全认证。 +多密钥校验已由 #4 修正。明文托管的应用内说明、逐条目用途提示、复制后果明示、复制绑定所选产品与 30 秒剪贴板自动清空已进入源码(#3);该切片的三平台打包发布证据仍待完成。备份恢复与轮换流程仍按 #3 推进;不构成安全认证。 ## 更新与卸载 diff --git a/linux/src/main.cpp b/linux/src/main.cpp index 681d6eb..5a81d53 100644 --- a/linux/src/main.cpp +++ b/linux/src/main.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -67,7 +68,6 @@ struct AppState { std::unique_ptr backend; std::unique_ptr api; - std::unique_ptr clipboard; std::mutex startup_mutex; std::thread startup_thread; std::unique_ptr startup_backend; @@ -82,6 +82,16 @@ struct AppState { std::vector entries; std::vector snapshots; std::string selected; + // Bumped on every selection change; async entry/copy completions carry + // the generation they were issued under and are dropped when stale, so + // rows and copy targets always bind to the product on screen (issue #3). + unsigned selection_generation{0}; + // GLib source id of the pending 30 s clipboard expiry (0 = none). A new + // copy cancels and reschedules it; the tick clears the clipboard only + // when it still holds our copy (checked through an ownership-marker mime, + // never by reading the value back), so a later unrelated copy by the + // user is never erased. + guint clipboard_expiry_source{0}; GSimpleAction* deep_check_action{nullptr}; GSimpleAction* snapshots_action{nullptr}; // Rows refresher of the open snapshots dialog (null when closed). @@ -246,6 +256,91 @@ void set_status(std::string const& text) { } } +// Purpose hints for the family's known entry names (issue #2): every name +// is load-bearing, so the app itself answers "what consumes this?" before +// anyone guesses at deleting it. Unknown names get an explicit confirm +// line instead of silence. +char const* entry_purpose_hint(std::string const& name) { + if (name == "UPDATE_ED25519_PRIVATE_KEY_B64") { + return "Consumed by the release workflow (CI manifest signing)"; + } + if (name == "UPDATE_ED25519_PUBLIC_KEY_SPKI_B64") { + return "Pasted into the Racket-side updater (SPKI form)"; + } + if (name == "UPDATE_ED25519_PUBLIC_KEY_RAW_TAIL32_B64") { + return "Pasted into the macOS Swift host UpdateService (raw 32-byte form)"; + } + if (name == "RIVET_UPDATE_KEY_ID") { + return "Written into the signed update manifest"; + } + auto const ends_with = [&name](char const* suffix) { + size_t const len = std::strlen(suffix); + return name.size() >= len && + name.compare(name.size() - len, len, suffix) == 0; + }; + auto const starts_with = [&name](char const* prefix) { + return name.rfind(prefix, 0) == 0; + }; + if (ends_with("_FPR")) { + return "Pairing anchor for Deep Check"; + } + if (starts_with("LICENSE_") || starts_with("PERMIT_")) { + return "Commercial signing — different lifecycle from update keys"; + } + return "No known consumer — confirm what reads it before deleting"; +} + +// Ownership-marker mime for the clipboard expiry: it rides along with the +// copied text and disappears the moment anything else claims the clipboard, +// so the expiry tick can recognize "still ours" without reading the value +// back. The marker carries no secret. +constexpr char kOwnedCopyMime[] = "application/x-keyward-owned-copy"; + +// Copy with the ownership marker. Returns TRUE when the local claim was +// taken; the caller schedules the expiry only then. +gboolean clipboard_copy_owned(std::string const& value) { + auto* clipboard = gdk_display_get_clipboard(gdk_display_get_default()); + GdkContentProvider* text = + gdk_content_provider_new_for_string("text/plain;charset=utf-8", + value.c_str()); + GBytes* empty = g_bytes_new(nullptr, 0); + GdkContentProvider* marker = + gdk_content_provider_new_for_bytes(kOwnedCopyMime, empty); + g_bytes_unref(empty); + GdkContentProvider* parts[] = {text, marker}; + GdkContentProvider* union_provider = gdk_content_provider_new_union( + parts, sizeof(parts) / sizeof(parts[0])); + g_object_unref(text); + g_object_unref(marker); + gboolean const ok = + gdk_clipboard_set_content(clipboard, union_provider); + g_object_unref(union_provider); + return ok; +} + +gboolean clipboard_expiry_tick(gpointer) { + g_state.clipboard_expiry_source = 0; + auto* clipboard = gdk_display_get_clipboard(gdk_display_get_default()); + GdkContentFormats const* formats = + gdk_clipboard_get_formats(clipboard); + if (formats != nullptr && + gdk_content_formats_contain_mime_type(formats, kOwnedCopyMime)) { + // Still ours: empty the clipboard. Anything the user copied since has + // replaced the content and carries no marker, so it survives. + gdk_clipboard_set_content(clipboard, nullptr); + set_status("Auto-cleared the copied key from the clipboard"); + } + return G_SOURCE_REMOVE; +} + +void schedule_clipboard_expiry() { + if (g_state.clipboard_expiry_source != 0) { + g_source_remove(g_state.clipboard_expiry_source); + } + g_state.clipboard_expiry_source = + g_timeout_add_seconds(30, clipboard_expiry_tick, nullptr); +} + void refresh_store_path_label() { if (g_state.store_path != nullptr) { gtk_label_set_text(g_state.store_path, @@ -366,6 +461,13 @@ void rebuild_detail() { "INI format works. The choice is remembered across launches.", "kw-dim"); gtk_label_set_justify(GTK_LABEL(body), GTK_JUSTIFY_CENTER); + auto* custody = make_label( + "The file and its snapshots stay plain text on disk —\n" + "Keyward is not OS-encrypted custody. Keep the synced folder " + "trusted.", + "kw-dim"); + gtk_widget_add_css_class(custody, "kw-caption"); + gtk_label_set_justify(GTK_LABEL(custody), GTK_JUSTIFY_CENTER); auto* pick = gtk_button_new_with_label("Choose Vault…"); gtk_widget_add_css_class(pick, "suggested-action"); g_signal_connect( @@ -375,6 +477,7 @@ void rebuild_detail() { gtk_box_append(GTK_BOX(pane), glyph); gtk_box_append(GTK_BOX(pane), title); gtk_box_append(GTK_BOX(pane), body); + gtk_box_append(GTK_BOX(pane), custody); gtk_box_append(GTK_BOX(pane), pick); gtk_box_append(g_state.detail, pane); return; @@ -389,8 +492,10 @@ void rebuild_detail() { auto* title = make_label("A local vault for release-signing keys", "kw-title"); auto* body = make_label( - "Pick a product on the left. Values are never displayed —\n" - "they only ever leave through copy-to-clipboard.", + "Your keys live as plain text in one INI file that you control —\n" + "Keyward adds discipline, not encryption. Values are never\n" + "displayed: they leave only through copy-to-clipboard, and any\n" + "app can read the clipboard.", "kw-dim"); gtk_label_set_justify(GTK_LABEL(body), GTK_JUSTIFY_CENTER); gtk_box_append(GTK_BOX(pane), glyph); @@ -496,13 +601,20 @@ void rebuild_detail() { gtk_widget_set_valign(lock, GTK_ALIGN_CENTER); gtk_box_append(GTK_BOX(row), lock); + auto* names = gtk_box_new(GTK_ORIENTATION_VERTICAL, 1); auto* name = gtk_label_new(entry.name.c_str()); gtk_widget_add_css_class(name, "kw-mono"); gtk_label_set_ellipsize(GTK_LABEL(name), PANGO_ELLIPSIZE_END); gtk_label_set_xalign(GTK_LABEL(name), 0.0f); - gtk_widget_set_hexpand(name, TRUE); - gtk_widget_set_valign(name, GTK_ALIGN_CENTER); - gtk_box_append(GTK_BOX(row), name); + gtk_widget_set_hexpand(names, TRUE); + gtk_box_append(GTK_BOX(names), name); + auto* hint = make_label(entry_purpose_hint(entry.name), "kw-dim"); + gtk_widget_add_css_class(hint, "kw-caption"); + gtk_label_set_ellipsize(GTK_LABEL(hint), PANGO_ELLIPSIZE_END); + gtk_label_set_xalign(GTK_LABEL(hint), 0.0f); + gtk_box_append(GTK_BOX(names), hint); + gtk_widget_set_valign(names, GTK_ALIGN_CENTER); + gtk_box_append(GTK_BOX(row), names); if (entry.state == "empty") { auto* badge = make_label("empty", "kw-empty-badge"); @@ -688,8 +800,11 @@ void show_delete_confirm(std::string const& name) { auto* dialog = gtk_message_dialog_new( GTK_WINDOW(g_state.window), GTK_DIALOG_MODAL, GTK_MESSAGE_QUESTION, GTK_BUTTONS_OK_CANCEL, "Delete %s?", name.c_str()); + // The purpose line answers "can I delete this?" from the app itself + // (issue #2); the snapshot sentence keeps the recovery path visible. gtk_message_dialog_format_secondary_text( - GTK_MESSAGE_DIALOG(dialog), + GTK_MESSAGE_DIALOG(dialog), "%s\n%s", + entry_purpose_hint(name), "A snapshot is taken first and the old value stays recoverable from " "it."); g_signal_connect( @@ -756,7 +871,8 @@ void show_snapshots_dialog() { gtk_widget_set_margin_end(content, 12); gtk_box_append( GTK_BOX(content), - make_label("Every write lands here first — restoring takes one click.", + make_label("Every write lands here first as a plain-text copy in " + ".history/keyward — restoring takes one click.", "kw-dim")); auto* rows = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); @@ -825,7 +941,8 @@ void show_about_dialog() { rivet_app::kVersion); gtk_about_dialog_set_comments( GTK_ABOUT_DIALOG(dialog), - "A signing-key vault over your synced INI file — local by design."); + "A signing-key vault over your synced INI file — local by design.\n" + "The file and its snapshots are plain text, not encrypted custody."); gtk_about_dialog_set_license(GTK_ABOUT_DIALOG(dialog), "Released under the GNU AGPL-3.0"); // The GTK dialog carries a single website row (the taskly form); it @@ -947,15 +1064,20 @@ void check_all() { void select_product(std::string const& name) { g_state.selected = name; + ++g_state.selection_generation; + auto const generation = g_state.selection_generation; g_state.entries.clear(); rebuild_product_list(); rebuild_detail(); if (g_state.api == nullptr) return; (void)g_state.api->entry_list_async( name, - [](rivet_app::Result>> result) { + [generation](rivet_app::Result>> result) { on_result(std::move(result), - [](std::vector> const& rows) { + [generation](std::vector> const& rows) { + // A stale entry list never overwrites the newer + // selection. + if (generation != g_state.selection_generation) return; g_state.entries.clear(); for (auto const& row : rows) { EntryRow entry; @@ -970,16 +1092,35 @@ void select_product(std::string const& name) { void copy_entry(std::string const& name) { if (g_state.api == nullptr || g_state.selected.empty()) return; + auto const product = g_state.selected; + auto const generation = g_state.selection_generation; (void)g_state.api->entry_get_async( - g_state.selected, name, - [name](rivet_app::Result result) { - on_result(std::move(result), [name](std::string const& value) { - // The value's only way out: clipboard, never the screen. - if (g_state.clipboard != nullptr) { - g_state.clipboard->write_text(value); - } - set_status("Copied " + name + " — value stays off-screen"); - }); + product, name, + [name, product, generation](rivet_app::Result result) { + on_result(std::move(result), + [name, product, generation](std::string const& value) { + // The copy is bound to the product whose rows are on + // screen; a selection change since the click drops it + // instead of landing a secret against a different + // header. + if (product != g_state.selected || + generation != g_state.selection_generation) { + set_status("Copy skipped — selection changed"); + return; + } + // The value's only way out: clipboard, never the + // screen. + if (clipboard_copy_owned(value)) { + schedule_clipboard_expiry(); + set_status("Copied " + name + + " — raw value on the clipboard, other " + "apps can read it; auto-clears in 30 s " + "unless something else copies first"); + } else { + set_status("Copy failed — the clipboard rejected " + "the content"); + } + }); }); } @@ -1078,6 +1219,9 @@ void open_store(std::string const& path) { refresh_store_path_label(); g_state.products.clear(); g_state.selected.clear(); + // Invalidate entry/copy completions still in flight for the + // previous store's selection. + ++g_state.selection_generation; g_state.checks.clear(); g_state.entries.clear(); rebuild_product_list(); @@ -1278,9 +1422,8 @@ int on_backend_finished(gpointer) { g_state.backend = std::move(backend); g_state.api = std::make_unique(*g_state.backend); - // Plain-text clipboard through the GTK display (rivet's adapter; compiled - // into this build per linux/CMakeLists.txt). - g_state.clipboard = std::make_unique(); + // Copies go straight through the GTK display clipboard (see + // clipboard_copy_owned); no rivet adapter state is needed here. // racket_boot installs Racket CS signal handlers. Install Rivet's process // shutdown bridge after the Hello handshake so SIGTERM/SIGINT keep reaching diff --git a/macos-host/Sources/RivetHost/ContentView.swift b/macos-host/Sources/RivetHost/ContentView.swift index fc8dc0d..fe73f42 100644 --- a/macos-host/Sources/RivetHost/ContentView.swift +++ b/macos-host/Sources/RivetHost/ContentView.swift @@ -207,6 +207,29 @@ private struct ProductRowView: View { // ---- product pane --------------------------------------------------------------- +// Purpose hints for the family's known entry names (issue #2): every name +// is load-bearing, so the app itself answers "what consumes this?" before +// anyone guesses at deleting it. Unknown names get an explicit confirm +// line instead of silence. +private func entryPurpose(_ name: String) -> String { + switch name { + case "UPDATE_ED25519_PRIVATE_KEY_B64": + return "Consumed by the release workflow (CI manifest signing)" + case "UPDATE_ED25519_PUBLIC_KEY_SPKI_B64": + return "Pasted into the Racket-side updater (SPKI form)" + case "UPDATE_ED25519_PUBLIC_KEY_RAW_TAIL32_B64": + return "Pasted into the macOS Swift host UpdateService (raw 32-byte form)" + case "RIVET_UPDATE_KEY_ID": + return "Written into the signed update manifest" + default: + if name.hasSuffix("_FPR") { return "Pairing anchor for Deep Check" } + if name.hasPrefix("LICENSE_") || name.hasPrefix("PERMIT_") { + return "Commercial signing — different lifecycle from update keys" + } + return "No known consumer — confirm what reads it before deleting" + } +} + private struct ProductPaneView: View { @EnvironmentObject private var model: KeywardModel @@ -232,6 +255,10 @@ private struct ProductPaneView: View { .font(.system(size: 13)) .foregroundStyle(model.theme.secondaryText) .multilineTextAlignment(.center) + Text("The file and its snapshots stay plain text on disk —\nKeyward is not OS-encrypted custody. Keep the synced folder trusted.") + .font(.system(size: 12)) + .foregroundStyle(model.theme.tertiaryText) + .multilineTextAlignment(.center) Button("Choose Vault…") { model.requestOpenStore() } .buttonStyle(.borderedProminent) .tint(model.theme.accent) @@ -251,7 +278,7 @@ private struct KeywardIntroPane: View { Text("A local vault for release-signing keys") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(model.theme.onSurface) - Text("Pick a product on the left. Values are never displayed —\nthey only ever leave through copy-to-clipboard.") + Text("Your keys live as plain text in one INI file that you control —\nKeyward adds discipline, not encryption. Values are never\ndisplayed: they leave only through copy-to-clipboard, and any\napp can read the clipboard.") .font(.system(size: 13)) .foregroundStyle(model.theme.secondaryText) .multilineTextAlignment(.center) @@ -400,7 +427,7 @@ private struct ProductDetail: View { } ForEach(model.entries) { entry in EntryCard(entry: entry, - onCopy: { model.copyEntry(entry) }, + onCopy: { model.copyEntry(entry, in: product) }, onImport: { importTarget = entry showingImporter = true @@ -434,7 +461,11 @@ private struct ProductDetail: View { } private var deleteTitle: String { "Delete \(deleteTarget?.name ?? "")?" } private var deleteMessage: String { - "A snapshot is taken first and the old value stays recoverable from it." + guard let name = deleteTarget?.name else { + return "A snapshot is taken first and the old value stays recoverable from it." + } + return "\(name): \(entryPurpose(name)). A snapshot is taken first " + + "and the old value stays recoverable from it." } private var deleteBinding: Binding { Binding(get: { deleteTarget != nil }, @@ -514,10 +545,16 @@ private struct EntryCard: View { .foregroundStyle(entry.state == "set" ? model.theme.accent : model.theme.tertiaryText) - Text(entry.name) - .font(.system(size: 13, design: .monospaced)) - .foregroundStyle(model.theme.onSurface) - .lineLimit(1) + VStack(alignment: .leading, spacing: 1) { + Text(entry.name) + .font(.system(size: 13, design: .monospaced)) + .foregroundStyle(model.theme.onSurface) + .lineLimit(1) + Text(entryPurpose(entry.name)) + .font(.system(size: 10)) + .foregroundStyle(model.theme.tertiaryText) + .lineLimit(1) + } if entry.state == "empty" { Text("empty") .font(.system(size: 10, weight: .semibold)) @@ -600,7 +637,7 @@ private struct SnapshotsSheet: View { Text("Store snapshots") .font(.system(size: 15, weight: .bold)) .foregroundStyle(model.theme.onSurface) - Text("Every write lands here first — restoring takes one click.") + Text("Every write lands here first as a plain-text copy in\n.history/keyward — restoring takes one click.") .font(.system(size: 12)) .foregroundStyle(model.theme.secondaryText) } @@ -653,7 +690,7 @@ struct AboutSheet: View { Text("Keyward v\(version)") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(model.theme.onSurface) - Text("A signing-key vault over your synced INI file — local by design.") + Text("A signing-key vault over your synced INI file — local by design.\nThe file and its snapshots are plain text, not encrypted custody.") .font(.system(size: 12)) .foregroundStyle(model.theme.secondaryText) .multilineTextAlignment(.center) diff --git a/macos-host/Sources/RivetHost/RivetHostApp.swift b/macos-host/Sources/RivetHost/RivetHostApp.swift index fb022dd..1a804e5 100644 --- a/macos-host/Sources/RivetHost/RivetHostApp.swift +++ b/macos-host/Sources/RivetHost/RivetHostApp.swift @@ -137,6 +137,17 @@ final class KeywardModel: ObservableObject { products.filter { $0.status != "ok" && $0.status != "no-key" && $0.status != "empty-section" }.count } + /// Bumped on every selection change. Async entry/copy completions carry + /// the generation they were issued under and are dropped when it no + /// longer matches — rows and copy targets always bind to the product on + /// screen, never to a stale selection (issue #3). + private var selectionGeneration = 0 + /// changeCount captured right after our copy; the expiry timer clears + /// the pasteboard only while this is still the current count, so a + /// later unrelated copy by the user is never erased. + private var clipboardCopyChangeCount = 0 + private var clipboardExpiryWork: DispatchWorkItem? + private var backend: EmbeddedRacketBackend? init() { @@ -259,18 +270,49 @@ final class KeywardModel: ObservableObject { func select(_ product: ProductInfo) { selected = product + selectionGeneration += 1 + let generation = selectionGeneration call("entry-list") { try await $0.entry_list(product: product.name) } then: { rows in + guard generation == self.selectionGeneration else { return } self.entries = rows.map { EntryRow(name: $0[0], state: $0[1]) } } } - func copyEntry(_ entry: EntryRow) { - guard let product = selected else { return } + /// Copy is bound to the product whose rows are on screen. If the user + /// has since selected another product, the result is dropped instead of + /// landing a secret on the clipboard against a different header. + func copyEntry(_ entry: EntryRow, in product: ProductInfo) { + let generation = selectionGeneration call("entry-get") { try await $0.entry_get(product: product.name, name: entry.name) } then: { value in + guard product.name == self.selected?.name, + generation == self.selectionGeneration else { + self.statusMessage = "Copy skipped — selection changed" + return + } NSPasteboard.general.clearContents() NSPasteboard.general.setString(value, forType: .string) - self.statusMessage = "Copied \(entry.name) — value stays off-screen" + self.scheduleClipboardExpiry() + self.statusMessage = "Copied \(entry.name) — raw value on the clipboard, " + + "other apps can read it; auto-clears in 30 s unless something else copies first" + } + } + + /// Optional expiry: erase the copied value 30 s later, but only while + /// the pasteboard still holds exactly what this action put there. A + /// user copy in between bumps changeCount and survives untouched. The + /// OS pasteboard history/sync is out of our control and never claimed + /// otherwise. + private func scheduleClipboardExpiry() { + clipboardExpiryWork?.cancel() + clipboardCopyChangeCount = NSPasteboard.general.changeCount + let work = DispatchWorkItem { [weak self] in + guard let self, + NSPasteboard.general.changeCount == self.clipboardCopyChangeCount else { return } + NSPasteboard.general.clearContents() + self.statusMessage = "Auto-cleared the copied key from the clipboard" } + clipboardExpiryWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + 30, execute: work) } func setEntry(name: String, value: String) { @@ -326,6 +368,9 @@ final class KeywardModel: ObservableObject { self.storePath = path self.products = [] self.selected = nil + // Invalidate entry/copy completions still in flight for the + // previous store's selection. + self.selectionGeneration += 1 self.checks = [] self.entries = [] self.refreshProducts() diff --git a/windows/MainWindow.xaml b/windows/MainWindow.xaml index 005474e..58a27d6 100644 --- a/windows/MainWindow.xaml +++ b/windows/MainWindow.xaml @@ -120,6 +120,12 @@ INI format works. The choice is remembered across launches." FontSize="13" TextAlignment="Center" Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> +