Show what the keyring holds, without showing what it holds
Managing a stored credential meant installing Seahorse. The keyring rows on Privacy could say whether it was locked and nothing about what was in it. Four rules, each pinned by a contract, because each is a way this could leak the thing it exists to protect: Listing never reads values. Enumerating reports labels and attributes; it does not ask the keyring to hand over what it is protecting. A secret never reaches a command line. /proc makes argv readable by every process on this machine, so a password passed as an argument is published to all of them. The helper reads the value in process and writes it to wl-copy on stdin. A secret never reaches an error message, a log, or a QML property. An exception raised while holding a password does not get to choose what text is printed, so the clipboard tool's stderr is discarded rather than echoed. Forgetting one is irreversible, so the first press asks and the second does it, and the confirming button is the only one wearing danger. The list is collapsed until asked for: opening Privacy should not enumerate someone's passwords as a side effect. A copied value clears itself about a minute later, but only if the clipboard still holds it -- the guard compares a SHA-256, so the waiting process never has the password. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -19,6 +19,25 @@ SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Privacy & Security"
|
||||
|
||||
// The stored-secret list is collapsed until asked for, and one item at a
|
||||
// time can be waiting on a confirmed Forget.
|
||||
property bool showingSecrets: false
|
||||
property string confirmingPath: ""
|
||||
|
||||
// What a stored secret is FOR, from its attributes. Never its value.
|
||||
function describe(item: var): string {
|
||||
const attributes = item?.attributes ?? {};
|
||||
const parts = [];
|
||||
for (const key of ["user", "username", "account", "server", "host", "domain", "service", "application"]) {
|
||||
if (attributes[key])
|
||||
parts.push(String(attributes[key]));
|
||||
}
|
||||
if (parts.length > 0)
|
||||
return parts.join(" · ");
|
||||
const schema = String(item?.schema ?? "");
|
||||
return schema !== "" ? schema : "No further detail stored";
|
||||
}
|
||||
lede: DeviceSecurity.scanned && DeviceSecurity.attentionCount === 0
|
||||
? "Screen lock, device access, and a machine whose security settings all check out."
|
||||
: "Screen lock, which applications can see you, and how this machine is protected."
|
||||
@@ -96,6 +115,140 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── What is actually stored ──────────────────────────────────────────────
|
||||
// Collapsed until asked. Opening the Privacy page should not enumerate
|
||||
// someone's saved passwords as a side effect, and the list is long enough
|
||||
// that it would bury every other setting on the page.
|
||||
SettingsCard {
|
||||
visible: Keyring.scanned && Keyring.available && !Keyring.locked
|
||||
title: "Stored secrets"
|
||||
subtitle: Keyring.listed
|
||||
? "Passwords and tokens applications have saved. The values are never shown here."
|
||||
: "Passwords and tokens applications have saved, listed only when you ask."
|
||||
|
||||
ActionRow {
|
||||
label: "Saved items"
|
||||
detail: Keyring.listed
|
||||
? Keyring.storedCount + " stored across "
|
||||
+ Keyring.collections.length + " keyring"
|
||||
+ (Keyring.collections.length === 1 ? "" : "s")
|
||||
: "Read the keyring and list what is in it"
|
||||
action: root.showingSecrets
|
||||
? "Hide"
|
||||
: (Keyring.listing ? "Reading…" : "Show")
|
||||
enabled: !Keyring.listing
|
||||
divider: root.showingSecrets
|
||||
onTriggered: {
|
||||
if (root.showingSecrets) {
|
||||
root.showingSecrets = false;
|
||||
root.confirmingPath = "";
|
||||
return;
|
||||
}
|
||||
root.showingSecrets = true;
|
||||
if (!Keyring.listed)
|
||||
Keyring.list();
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: root.showingSecrets && Keyring.copiedPath !== ""
|
||||
label: "Copied to the clipboard"
|
||||
detail: "It clears itself in about a minute, unless you copy something else first."
|
||||
value: ""
|
||||
divider: true
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.showingSecrets && Keyring.listed ? Keyring.collections : []
|
||||
|
||||
delegate: Column {
|
||||
id: collectionBlock
|
||||
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
|
||||
TextRow {
|
||||
width: collectionBlock.width
|
||||
label: String(collectionBlock.modelData.label ?? "")
|
||||
detail: collectionBlock.modelData.locked
|
||||
? "Locked, so its contents cannot be listed"
|
||||
: (collectionBlock.modelData.items ?? []).length + " stored"
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: collectionBlock.modelData.items ?? []
|
||||
|
||||
delegate: SettingRow {
|
||||
id: secretRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property string itemPath: String(secretRow.modelData.path ?? "")
|
||||
readonly property bool confirming: root.confirmingPath === secretRow.itemPath
|
||||
|
||||
width: collectionBlock.width
|
||||
label: String(secretRow.modelData.label ?? "")
|
||||
// Attributes, never the value: what the secret is FOR is
|
||||
// the part that identifies it.
|
||||
detail: root.describe(secretRow.modelData)
|
||||
controlWidth: 200
|
||||
divider: secretRow.index < (collectionBlock.modelData.items ?? []).length - 1
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: secretRow.confirming ? "Cancel" : "Copy"
|
||||
enabled: !Keyring.working
|
||||
onClicked: {
|
||||
if (secretRow.confirming) {
|
||||
root.confirmingPath = "";
|
||||
return;
|
||||
}
|
||||
Keyring.copy(secretRow.itemPath);
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
// Two presses, always. Forgetting a stored
|
||||
// password cannot be undone, and the button
|
||||
// sits next to Copy where a misclick is cheap.
|
||||
text: secretRow.confirming ? "Forget it" : "Forget"
|
||||
tone: secretRow.confirming ? "danger" : "normal"
|
||||
enabled: !Keyring.working
|
||||
onClicked: {
|
||||
if (!secretRow.confirming) {
|
||||
root.confirmingPath = secretRow.itemPath;
|
||||
return;
|
||||
}
|
||||
root.confirmingPath = "";
|
||||
Keyring.forget(secretRow.itemPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { width: 1; height: 6 }
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: root.showingSecrets && Keyring.listed && Keyring.storedCount === 0
|
||||
label: "Nothing stored yet"
|
||||
detail: "Applications that save a password or token will appear here."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Camera & microphone"
|
||||
subtitle: PrivacyState.anyActive
|
||||
|
||||
@@ -5,6 +5,10 @@ Rectangle {
|
||||
id: root
|
||||
|
||||
property string text: ""
|
||||
// "normal", "accent", or "danger". Danger is for an action that cannot be
|
||||
// undone -- it never initiates one on a single press, it marks the press
|
||||
// that confirms it, so the confirming button cannot be mistaken for the
|
||||
// Cancel sitting next to it.
|
||||
property string tone: "normal"
|
||||
property bool enabled: true
|
||||
signal clicked
|
||||
@@ -16,16 +20,20 @@ Rectangle {
|
||||
color: {
|
||||
if (tone === "accent")
|
||||
return mouse.containsMouse ? Theme.mix(Theme.accent, Theme.fg, 0.12) : Theme.accent;
|
||||
if (tone === "danger")
|
||||
return mouse.containsMouse ? Theme.alpha(Theme.danger, 0.22) : Theme.alpha(Theme.danger, 0.12);
|
||||
return mouse.containsMouse ? Theme.alpha(Theme.fg, 0.13) : Theme.alpha(Theme.fg, 0.075);
|
||||
}
|
||||
border.width: tone === "accent" ? 0 : 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||
border.color: tone === "danger" ? Theme.alpha(Theme.danger, 0.45) : Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Text {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: root.text
|
||||
color: root.tone === "accent" ? Theme.bgDark : Theme.fg
|
||||
color: root.tone === "accent"
|
||||
? Theme.bgDark
|
||||
: (root.tone === "danger" ? Theme.danger : Theme.fg)
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
|
||||
@@ -33,9 +33,11 @@ Usage:
|
||||
panama-keyring unlock -> raises the password prompt; prints the new state
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
@@ -147,10 +149,127 @@ def report(service, Secret):
|
||||
}
|
||||
|
||||
|
||||
# Attributes worth showing. The rest are storage plumbing -- schema names,
|
||||
# internal ids -- and listing them turns a readable row into a debug dump.
|
||||
INTERESTING_ATTRIBUTES = (
|
||||
"server", "host", "domain", "user", "username", "account", "protocol",
|
||||
"service", "object", "port", "goa-identity", "application",
|
||||
)
|
||||
|
||||
# How long a copied password stays on the clipboard.
|
||||
CLIPBOARD_SECONDS = 45
|
||||
|
||||
|
||||
def item_summary(item) -> dict:
|
||||
"""Everything about a stored secret EXCEPT the secret.
|
||||
|
||||
Nothing in here reads the value. That is not an accident of implementation
|
||||
but the contract of this function: enumerating the keyring must never
|
||||
require the keyring to hand over what it is protecting.
|
||||
"""
|
||||
attributes = dict(item.get_attributes() or {})
|
||||
shown = {key: value for key, value in attributes.items()
|
||||
if key in INTERESTING_ATTRIBUTES and value}
|
||||
return {
|
||||
"path": item.get_object_path(),
|
||||
"label": item.get_label() or "Unnamed",
|
||||
"schema": attributes.get("xdg:schema", ""),
|
||||
"attributes": shown,
|
||||
"created": int(item.get_created() or 0),
|
||||
"modified": int(item.get_modified() or 0),
|
||||
"locked": bool(item.get_locked()),
|
||||
}
|
||||
|
||||
|
||||
def items_report(service, Secret) -> dict:
|
||||
collections = []
|
||||
for collection in service.get_collections():
|
||||
entries = collection.get_items() or []
|
||||
collections.append({
|
||||
"label": collection.get_label() or "Unnamed keyring",
|
||||
"path": collection.get_object_path(),
|
||||
"locked": bool(collection.get_locked()),
|
||||
# A locked collection reports no items rather than an empty one:
|
||||
# "nothing stored here" and "cannot look" are different answers.
|
||||
"readable": not collection.get_locked(),
|
||||
"items": [item_summary(item) for item in entries],
|
||||
})
|
||||
# Empty, unnamed collections are the session store and similar plumbing.
|
||||
collections = [entry for entry in collections
|
||||
if entry["items"] or entry["label"] != "Unnamed keyring"]
|
||||
collections.sort(key=lambda entry: (entry["label"] != "Login", entry["label"]))
|
||||
return {"collections": collections, "error": ""}
|
||||
|
||||
|
||||
def resolve_item(service, Secret, path: str):
|
||||
"""An item by its D-Bus path, refusing anything that is not one.
|
||||
|
||||
The caller is a settings page, and a settings page can be wrong or stale --
|
||||
an item deleted in another window leaves a path that no longer resolves.
|
||||
"""
|
||||
if not re.fullmatch(r"/org/freedesktop/secrets/collection/[A-Za-z0-9_]+/[0-9]+", path or ""):
|
||||
raise ValueError("That is not a stored secret.")
|
||||
for collection in service.get_collections():
|
||||
for item in collection.get_items() or []:
|
||||
if item.get_object_path() == path:
|
||||
return item
|
||||
raise ValueError("That secret no longer exists.")
|
||||
|
||||
|
||||
def copy_secret(service, Secret, path: str) -> None:
|
||||
"""Put one stored secret on the clipboard, and nowhere else.
|
||||
|
||||
The value is read in this process and handed to wl-copy on STDIN. It is
|
||||
never an argument -- argv is world-readable through /proc, so passing a
|
||||
password there would publish it to every process on the machine -- and it
|
||||
is never printed, logged, or included in an error message.
|
||||
"""
|
||||
item = resolve_item(service, Secret, path)
|
||||
item.load_secret_sync(None)
|
||||
value = item.get_secret()
|
||||
if value is None:
|
||||
raise ValueError("That secret could not be read.")
|
||||
secret = value.get_text()
|
||||
if secret is None:
|
||||
raise ValueError("That secret is not text.")
|
||||
|
||||
encoded = secret.encode("utf-8")
|
||||
completed = subprocess.run(["wl-copy"], input=encoded, capture_output=True)
|
||||
if completed.returncode != 0:
|
||||
# Deliberately does not echo the tool's stderr: a clipboard tool that
|
||||
# fails while holding a password should not get to decide what lands in
|
||||
# a log.
|
||||
raise ValueError("The clipboard is not available.")
|
||||
|
||||
# Clear it again, but only if it is still the thing we put there. The guard
|
||||
# compares a HASH, so the reminder process never holds the password -- and
|
||||
# a clipboard the user has since replaced is left alone.
|
||||
digest = hashlib.sha256(encoded).hexdigest()
|
||||
subprocess.Popen(
|
||||
["sh", "-c",
|
||||
'sleep "$1"; current="$(wl-paste --no-newline 2>/dev/null | sha256sum | cut -d" " -f1)";'
|
||||
' [ "$current" = "$2" ] && wl-copy --clear',
|
||||
"sh", str(CLIPBOARD_SECONDS), digest],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
|
||||
def forget_secret(service, Secret, path: str) -> None:
|
||||
"""Delete one stored secret. Irreversible, and the UI confirms first."""
|
||||
item = resolve_item(service, Secret, path)
|
||||
item.delete_sync(None)
|
||||
|
||||
|
||||
def main():
|
||||
action = sys.argv[1] if len(sys.argv) > 1 else "status"
|
||||
if action not in ("status", "unlock"):
|
||||
print("usage: panama-keyring [status|unlock]", file=sys.stderr)
|
||||
target = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||
if action not in ("status", "unlock", "items", "copy", "forget"):
|
||||
print("usage: panama-keyring [status|unlock|items|copy PATH|forget PATH]",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
if action in ("copy", "forget") and not target:
|
||||
print(f"usage: panama-keyring {action} PATH", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
@@ -165,6 +284,27 @@ def main():
|
||||
}))
|
||||
return 0
|
||||
|
||||
# These three answer with the item list, so a page never has to ask twice
|
||||
# to find out what changed.
|
||||
if action in ("items", "copy", "forget"):
|
||||
try:
|
||||
if action == "copy":
|
||||
copy_secret(service, Secret, target)
|
||||
elif action == "forget":
|
||||
forget_secret(service, Secret, target)
|
||||
Secret, service = load_service()
|
||||
except Exception as error: # noqa: BLE001
|
||||
# The message is this module's own, never the underlying tool's:
|
||||
# an exception raised while a secret is in hand must not get to
|
||||
# decide what text reaches a log or a settings page.
|
||||
payload = items_report(service, Secret)
|
||||
payload["error"] = (str(error) if isinstance(error, ValueError)
|
||||
else "That secret could not be used.")
|
||||
print(json.dumps(payload))
|
||||
return 0
|
||||
print(json.dumps(items_report(service, Secret)))
|
||||
return 0
|
||||
|
||||
if action == "unlock":
|
||||
login = next(
|
||||
(c for c in service.get_collections() if c.get_label() == "Login"), None)
|
||||
|
||||
@@ -39,6 +39,25 @@ Singleton {
|
||||
|
||||
readonly property bool replacementDaemon: root.daemon === "dbus"
|
||||
|
||||
// What is stored, never what is stored IN it. No property on this service
|
||||
// ever holds a password: the value is read by the helper, handed straight
|
||||
// to the clipboard, and forgotten. It does not cross into QML at all.
|
||||
property var collections: []
|
||||
property bool listed: false
|
||||
property bool listing: false
|
||||
property bool working: false
|
||||
|
||||
// Set for a moment after a successful copy, so the page can say what
|
||||
// happened without the page having to know how long a clipboard lasts.
|
||||
property string copiedPath: ""
|
||||
|
||||
readonly property int storedCount: {
|
||||
let total = 0;
|
||||
for (const collection of root.collections)
|
||||
total += (collection.items ?? []).length;
|
||||
return total;
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
if (!query.running)
|
||||
query.running = true;
|
||||
@@ -53,6 +72,54 @@ Singleton {
|
||||
unlockProcess.running = true;
|
||||
}
|
||||
|
||||
// The stored-secret list. Separate from status() because it is the only
|
||||
// read that needs the keyring UNLOCKED, and because a settings page should
|
||||
// not enumerate someone's passwords just because it was opened.
|
||||
function list(): void {
|
||||
if (root.listing)
|
||||
return;
|
||||
root.listing = true;
|
||||
items.command = [root.helperPath, "items"];
|
||||
items.running = true;
|
||||
}
|
||||
|
||||
// Puts one stored secret on the clipboard. The value never reaches this
|
||||
// process; the helper reads it and writes it to wl-copy's stdin, and clears
|
||||
// it again shortly afterwards if it is still there.
|
||||
function copy(path: string): void {
|
||||
if (root.working)
|
||||
return;
|
||||
root.working = true;
|
||||
root.copiedPath = path;
|
||||
items.command = [root.helperPath, "copy", path];
|
||||
items.running = true;
|
||||
}
|
||||
|
||||
// Irreversible. The page confirms before calling this.
|
||||
function forget(path: string): void {
|
||||
if (root.working)
|
||||
return;
|
||||
root.working = true;
|
||||
root.copiedPath = "";
|
||||
items.command = [root.helperPath, "forget", path];
|
||||
items.running = true;
|
||||
}
|
||||
|
||||
function absorbItems(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
root.collections = Array.isArray(parsed.collections) ? parsed.collections : [];
|
||||
root.lastError = String(parsed.error ?? "");
|
||||
if (root.lastError !== "")
|
||||
root.copiedPath = "";
|
||||
} catch (error) {
|
||||
root.collections = [];
|
||||
root.lastError = "Could not read the stored secrets.";
|
||||
console.warn("Keyring: could not parse item output:", error);
|
||||
}
|
||||
root.listed = true;
|
||||
}
|
||||
|
||||
function absorb(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
@@ -74,6 +141,27 @@ Singleton {
|
||||
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
||||
}
|
||||
|
||||
// One process for all three item operations: each of them answers with the
|
||||
// same list, so a page never has to ask again to find out what changed.
|
||||
Process {
|
||||
id: items
|
||||
stdout: StdioCollector { onStreamFinished: root.absorbItems(this.text) }
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
onExited: {
|
||||
root.listing = false;
|
||||
root.working = false;
|
||||
}
|
||||
}
|
||||
|
||||
// The clipboard notice is transient, and says so by disappearing.
|
||||
Timer {
|
||||
running: root.copiedPath !== ""
|
||||
interval: 12000
|
||||
onTriggered: root.copiedPath = ""
|
||||
}
|
||||
|
||||
Process {
|
||||
id: unlockProcess
|
||||
command: [root.helperPath, "unlock"]
|
||||
|
||||
@@ -146,6 +146,21 @@ Seahorse.
|
||||
**Done when** a stored password can be found, inspected and removed without
|
||||
leaving Settings, and the contracts prove no value leaks on the way.
|
||||
|
||||
**Landed 2026-08-19.** Built into Privacy & Security rather than as its own
|
||||
page: that page already claimed the topic, already had the keyring rows, and
|
||||
already answered the "saved passwords" search. A new page would have been a
|
||||
fourth page-registry edit for a card.
|
||||
|
||||
The list is collapsed until asked for -- opening Privacy should not enumerate
|
||||
someone's passwords as a side effect -- and copying puts the value on the
|
||||
clipboard without it ever entering Quickshell: the helper reads it, writes it to
|
||||
`wl-copy` on stdin, and a detached guard clears it about a minute later only if
|
||||
the clipboard still holds it, comparing a SHA-256 rather than the password.
|
||||
|
||||
Seahorse is not installed, so changing a keyring's password is not offered.
|
||||
Claiming a button that hands off to a missing application would be worse than
|
||||
not having it.
|
||||
|
||||
## Phase 6 — Backups (large, staged)
|
||||
|
||||
The one genuinely missing safety net. `restic` is not installed.
|
||||
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# A stored password must never leave the keyring except onto the clipboard,
|
||||
# on purpose, one at a time.
|
||||
#
|
||||
# Four rules, each a way this feature could leak what it exists to protect:
|
||||
#
|
||||
# 1. Listing secrets must not read them. Enumerating the keyring reports
|
||||
# labels and attributes; it never asks the keyring for a value.
|
||||
# 2. A secret must never reach a command line. /proc makes argv readable by
|
||||
# every process on the machine, so a password passed as an argument is
|
||||
# published to all of them. It goes on stdin or not at all.
|
||||
# 3. A secret must never reach an error message, a log, or the settings page.
|
||||
# An exception raised while holding a password does not get to choose what
|
||||
# text is printed.
|
||||
# 4. Forgetting one is irreversible, so the page confirms first.
|
||||
#
|
||||
# Read-only. It lists the real keyring -- which is safe, because listing is the
|
||||
# thing being verified as safe -- and never copies, deletes, or unlocks.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-keyring"
|
||||
service="$repo_dir/config/dot/quickshell/services/Keyring.qml"
|
||||
page="$repo_dir/config/dot/quickshell/modules/settings/PrivacyPage.qml"
|
||||
|
||||
fail() {
|
||||
printf 'secrets contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for path in "$helper" "$service" "$page"; do
|
||||
[[ -r "$path" ]] || fail "missing $path"
|
||||
done
|
||||
[[ -x "$helper" ]] || fail 'panama-keyring is not executable'
|
||||
|
||||
# ── 1. Listing does not read values ──────────────────────────────────────────
|
||||
summary_body="$(sed -n '/^def item_summary/,/^def /p' "$helper")"
|
||||
[[ -n "$summary_body" ]] || fail 'item_summary is missing'
|
||||
grep -qE 'load_secret|get_secret|get_text' <<<"$summary_body" \
|
||||
&& fail 'the item summary reads secret values; enumerating must never require the value'
|
||||
|
||||
report_body="$(sed -n '/^def items_report/,/^def /p' "$helper")"
|
||||
grep -qE 'load_secret|get_secret' <<<"$report_body" \
|
||||
&& fail 'the item report reads secret values'
|
||||
|
||||
# ── 2. No secret on a command line ───────────────────────────────────────────
|
||||
copy_body="$(sed -n '/^def copy_secret/,/^def /p' "$helper")"
|
||||
[[ -n "$copy_body" ]] || fail 'copy_secret is missing'
|
||||
grep -q 'input=encoded' <<<"$copy_body" \
|
||||
|| fail 'the secret does not reach the clipboard tool on stdin'
|
||||
# The value must not appear inside any argument list.
|
||||
grep -qE '\[.*(secret|encoded).*\]' <<<"$copy_body" \
|
||||
&& fail 'the secret appears inside a command argument list, which publishes it through /proc'
|
||||
grep -q 'digest = hashlib.sha256' <<<"$copy_body" \
|
||||
|| fail 'the clipboard is cleared without comparing a hash, so it either holds the secret or clears the wrong thing'
|
||||
grep -qE 'Popen\(.*secret|Popen\(.*encoded' <<<"$copy_body" \
|
||||
&& fail 'the clipboard-clearing process is handed the secret'
|
||||
|
||||
# ── 3. No secret in output ───────────────────────────────────────────────────
|
||||
grep -qE '^\s*print\((secret|value|encoded)' <<<"$copy_body" \
|
||||
&& fail 'the secret is printed'
|
||||
grep -q 'completed.stderr' <<<"$copy_body" \
|
||||
&& fail "the clipboard tool's stderr is echoed while a secret is in hand"
|
||||
|
||||
# The service must not hold one either.
|
||||
grep -qiE 'property (string|var) (secret|password|value)\b' "$service" \
|
||||
&& fail 'the Keyring service declares a property that would hold a secret value'
|
||||
|
||||
# ── 4. Forgetting is confirmed ───────────────────────────────────────────────
|
||||
grep -q 'confirmingPath' "$page" \
|
||||
|| fail 'the page deletes a stored secret without a confirmation step'
|
||||
grep -q 'Keyring.forget(' "$page" \
|
||||
|| fail 'the page cannot forget a secret at all'
|
||||
# The guard itself, not merely the word "confirming" somewhere nearby: the
|
||||
# button's own label and tone both mention it, so proximity proves nothing.
|
||||
# What must exist is the early return that turns the FIRST press into a request
|
||||
# for confirmation rather than a deletion.
|
||||
grep -q 'if (!secretRow.confirming)' "$page" \
|
||||
|| fail 'the first press on Forget is not turned into a confirmation step'
|
||||
grep -q 'root.confirmingPath = secretRow.itemPath;' "$page" \
|
||||
|| fail 'nothing records which item is awaiting confirmation'
|
||||
|
||||
# Opening the page must not enumerate anyone's passwords as a side effect.
|
||||
grep -qE 'Component.onCompleted:.*Keyring.list\(\)' "$page" \
|
||||
&& fail 'the page lists stored secrets when it opens rather than when asked'
|
||||
|
||||
# ── The list itself, on the real keyring ─────────────────────────────────────
|
||||
command -v jq >/dev/null 2>&1 || { printf 'secrets contract: SKIP (no jq)\n'; exit 0; }
|
||||
listing="$("$helper" items 2>/dev/null)" || fail 'listing stored secrets failed'
|
||||
jq -e '.collections | type == "array"' <<<"$listing" >/dev/null \
|
||||
|| fail 'the listing has no collections'
|
||||
|
||||
# No field anywhere in the payload may be named like a value.
|
||||
offenders="$(jq -r '[paths | map(tostring) | join(".")] | map(select(test("(secret|password|value|token)$";"i"))) | join(", ")' <<<"$listing")"
|
||||
[[ -z "$offenders" ]] || fail "the listing carries value-shaped fields: $offenders"
|
||||
|
||||
# Every item reports where it came from, so a row can be identified without it.
|
||||
jq -e '[.collections[].items[] | (.path | startswith("/org/freedesktop/secrets/")) and (.label | length > 0)] | all' \
|
||||
<<<"$listing" >/dev/null || fail 'an item is missing its path or label'
|
||||
|
||||
# ── Refusals ─────────────────────────────────────────────────────────────────
|
||||
# A refused path must not reach the clipboard at all, which is checked by
|
||||
# stubbing the clipboard tool rather than inferred from an exit code -- the
|
||||
# helper answers with the item list plus an error field, so its exit status is
|
||||
# deliberately 0 even when it refuses.
|
||||
work="$(mktemp -d /tmp/panama-secrets.XXXXXX)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
mkdir -p "$work/bin"
|
||||
cat >"$work/bin/wl-copy" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
printf 'called: %s\n' "$*" >>"$PANAMA_SECRETS_CALL_LOG"
|
||||
cat >>"$PANAMA_SECRETS_CALL_LOG"
|
||||
STUB
|
||||
chmod +x "$work/bin/wl-copy"
|
||||
export PANAMA_SECRETS_CALL_LOG="$work/calls"
|
||||
: >"$PANAMA_SECRETS_CALL_LOG"
|
||||
|
||||
refusal() {
|
||||
PATH="$work/bin:$PATH" "$helper" "$@" 2>/dev/null | jq -r '.error // ""'
|
||||
}
|
||||
|
||||
[[ -n "$(refusal copy /etc/passwd)" ]] \
|
||||
|| fail 'copy accepted a path that is not a stored secret'
|
||||
[[ -n "$(refusal copy ../../etc/passwd)" ]] \
|
||||
|| fail 'copy accepted a relative path'
|
||||
[[ -n "$(refusal copy /org/freedesktop/secrets/collection/login)" ]] \
|
||||
|| fail 'copy accepted a collection path rather than an item'
|
||||
[[ -n "$(refusal forget /org/freedesktop/secrets/collection/login)" ]] \
|
||||
|| fail 'forget accepted a collection path, which would delete a whole keyring'
|
||||
[[ -n "$(refusal copy /org/freedesktop/secrets/collection/login/999999)" ]] \
|
||||
|| fail 'copy accepted an item that does not exist'
|
||||
|
||||
[[ ! -s "$PANAMA_SECRETS_CALL_LOG" ]] \
|
||||
|| fail 'a refused request still reached the clipboard'
|
||||
|
||||
PATH="$work/bin:$PATH" "$helper" copy >/dev/null 2>&1 \
|
||||
&& fail 'copy with no argument was accepted'
|
||||
[[ ! -s "$PANAMA_SECRETS_CALL_LOG" ]] \
|
||||
|| fail 'a malformed request still reached the clipboard'
|
||||
|
||||
printf 'secrets contract: PASS (%d items listed, none readable from the listing)\n' \
|
||||
"$(jq '[.collections[].items[]] | length' <<<"$listing")"
|
||||
Reference in New Issue
Block a user