| 63 |
63 |
|
//! locale to list. The theme row has always read it this way, since a search
|
| 64 |
64 |
|
//! path that found no themes still leaves `system` behind.
|
| 65 |
65 |
|
//!
|
|
66 |
+ |
//! ## Rows with no front at all
|
|
67 |
+ |
//!
|
|
68 |
+ |
//! "Live state, fronted by commands" is the shape, not the whole membership.
|
|
69 |
+ |
//! Two rows front a filesystem instead: the theme row reads makeover's search
|
|
70 |
+ |
//! path, and the secrets row ([`Age`]) reports whether gopass has an identity to
|
|
71 |
+ |
//! decrypt with. Both still produce a [`Field`] the form cannot distinguish, so
|
|
72 |
+ |
//! the seam holds; only the source moved.
|
|
73 |
+ |
//!
|
|
74 |
+ |
//! The secrets row is also the only one that is closed in every state, because
|
|
75 |
+ |
//! there is no privilege that would open it. Placing an age identity from a
|
|
76 |
+ |
//! settings form is the thing the provisioning decision ruled out, so the row
|
|
77 |
+ |
//! exists to say a key is missing and never to accept one.
|
|
78 |
+ |
//!
|
| 66 |
79 |
|
//! See wiki note `alloy-privilege`.
|
| 67 |
80 |
|
//!
|
| 68 |
81 |
|
//! <!-- wiki: alloy-settings -->
|
| 69 |
82 |
|
|
|
83 |
+ |
use std::path::{Path, PathBuf};
|
|
84 |
+ |
|
| 70 |
85 |
|
use anyhow::Result;
|
| 71 |
86 |
|
use toml::Value;
|
| 72 |
87 |
|
|
| 85 |
100 |
|
const KEYMAP: &str = "locale.keymap";
|
| 86 |
101 |
|
/// The one row that writes a file rather than running a command.
|
| 87 |
102 |
|
pub(crate) const THEME: &str = "appearance.theme";
|
|
103 |
+ |
/// Shown, never set. See [`Age`].
|
|
104 |
+ |
const AGE: &str = "secrets.age";
|
| 88 |
105 |
|
|
| 89 |
106 |
|
/// Live system settings, fronted by `timedatectl`, `hostnamectl`, `localectl`.
|
| 90 |
107 |
|
pub(crate) struct SystemBind {
|
| 93 |
110 |
|
time: Vec<(String, String)>,
|
| 94 |
111 |
|
host: Host,
|
| 95 |
112 |
|
locale: Locale,
|
|
113 |
+ |
age: Age,
|
| 96 |
114 |
|
}
|
| 97 |
115 |
|
|
| 98 |
116 |
|
/// What `hostnamectl` reported.
|
| 115 |
133 |
|
answered: bool,
|
| 116 |
134 |
|
}
|
| 117 |
135 |
|
|
|
136 |
+ |
/// Whether gopass has the age identity it decrypts with.
|
|
137 |
+ |
///
|
|
138 |
+ |
/// The one row on this screen fronted by neither a command nor a config file.
|
|
139 |
+ |
/// Alloy ships gopass and provisions no identity for it, on purpose: the key
|
|
140 |
+ |
/// already exists elsewhere, and a fresh install has no channel to carry it in
|
|
141 |
+ |
/// over. See `docs/STACK.md#secrets`. So the failure a new machine hits is
|
|
142 |
+ |
/// cloning the store, running `gopass show`, and being refused, with nothing on
|
|
143 |
+ |
/// the machine having said a key was needed. The row is that missing sentence.
|
|
144 |
+ |
///
|
|
145 |
+ |
/// It answers "is there a file" and nothing more. The identity is a bearer
|
|
146 |
+ |
/// secret, and a settings screen that read any part of it into a scrollback
|
|
147 |
+ |
/// would be a worse bug than the silence it is here to fix.
|
|
148 |
+ |
#[derive(Default)]
|
|
149 |
+ |
struct Age {
|
|
150 |
+ |
/// Where gopass would look, or `None` on a machine with no home directory
|
|
151 |
+ |
/// to resolve against.
|
|
152 |
+ |
dir: Option<PathBuf>,
|
|
153 |
+ |
/// An `identities` file is there and is not empty.
|
|
154 |
+ |
present: bool,
|
|
155 |
+ |
}
|
|
156 |
+ |
|
|
157 |
+ |
/// Where gopass keeps its age identity, following gopass's own precedence.
|
|
158 |
+ |
///
|
|
159 |
+ |
/// `GOPASS_HOMEDIR` wins, then `XDG_CONFIG_HOME`, then the home directory,
|
|
160 |
+ |
/// which is what `pkg/appdir` resolves. Taken as arguments rather than read
|
|
161 |
+ |
/// here so the precedence is testable without setting process environment,
|
|
162 |
+ |
/// which no test can do safely while its siblings are running.
|
|
163 |
+ |
fn age_dir_from(homedir: Option<&str>, xdg: Option<&str>, home: Option<&str>) -> Option<PathBuf> {
|
|
164 |
+ |
let base = match (homedir, xdg, home) {
|
|
165 |
+ |
(Some(dir), _, _) if !dir.is_empty() => PathBuf::from(dir).join(".config").join("gopass"),
|
|
166 |
+ |
(_, Some(dir), _) if !dir.is_empty() => PathBuf::from(dir).join("gopass"),
|
|
167 |
+ |
(_, _, Some(dir)) if !dir.is_empty() => PathBuf::from(dir).join(".config").join("gopass"),
|
|
168 |
+ |
_ => return None,
|
|
169 |
+ |
};
|
|
170 |
+ |
Some(base.join("age"))
|
|
171 |
+ |
}
|
|
172 |
+ |
|
|
173 |
+ |
/// Read the environment gopass reads, and look for the file.
|
|
174 |
+ |
///
|
|
175 |
+ |
/// Hardcoding the tail of the path while honouring the variables that move it
|
|
176 |
+ |
/// is the honest middle here. Shelling out to `gopass config` and parsing it
|
|
177 |
+ |
/// would be exact, but it costs a subprocess on every reload of a row that
|
|
178 |
+ |
/// reports one bit, and it fails on the machine this row most exists for: the
|
|
179 |
+ |
/// one where gopass is not yet working.
|
|
180 |
+ |
fn read_age() -> Age {
|
|
181 |
+ |
let value = |key: &str| std::env::var(key).ok();
|
|
182 |
+ |
let dir = age_dir_from(
|
|
183 |
+ |
value("GOPASS_HOMEDIR").as_deref(),
|
|
184 |
+ |
value("XDG_CONFIG_HOME").as_deref(),
|
|
185 |
+ |
value("HOME").as_deref(),
|
|
186 |
+ |
);
|
|
187 |
+ |
let present = dir.as_deref().is_some_and(identity_present);
|
|
188 |
+ |
Age { dir, present }
|
|
189 |
+ |
}
|
|
190 |
+ |
|
|
191 |
+ |
/// Whether `dir` holds a non-empty `identities`, which is the file the age
|
|
192 |
+ |
/// backend decrypts with.
|
|
193 |
+ |
///
|
|
194 |
+ |
/// Emptiness counts as absence: gopass writes the file before it has anything
|
|
195 |
+ |
/// to put in it, and a zero-byte `identities` refuses exactly like no file at
|
|
196 |
+ |
/// all. Metadata only, and the contents are never opened.
|
|
197 |
+ |
fn identity_present(dir: &Path) -> bool {
|
|
198 |
+ |
std::fs::metadata(dir.join("identities")).is_ok_and(|meta| meta.is_file() && meta.len() > 0)
|
|
199 |
+ |
}
|
|
200 |
+ |
|
| 118 |
201 |
|
impl SystemBind {
|
| 119 |
202 |
|
/// Read the machine's state and build the rows, or `None` when there is no
|
| 120 |
203 |
|
/// `timedatectl` answering.
|
| 144 |
227 |
|
time,
|
| 145 |
228 |
|
host: read_host(log),
|
| 146 |
229 |
|
locale: read_locale(log),
|
|
230 |
+ |
age: read_age(),
|
| 147 |
231 |
|
};
|
| 148 |
232 |
|
bind.annotate();
|
| 149 |
233 |
|
Some(bind)
|
| 271 |
355 |
|
"How the console renders. Pinned, whatever the terminal does.".to_string()
|
| 272 |
356 |
|
};
|
| 273 |
357 |
|
set(self.field_mut(THEME), ¬e, bare);
|
|
358 |
+ |
|
|
359 |
+ |
// Naming the path and the consequence, rather than reporting "not
|
|
360 |
+ |
// configured" and leaving both to be guessed at. What hurts on a fresh
|
|
361 |
+ |
// install is not placing the key, it is not learning one was wanted
|
|
362 |
+ |
// until gopass refuses.
|
|
363 |
+ |
let note = match (&self.age.dir, self.age.present) {
|
|
364 |
+ |
(None, _) => {
|
|
365 |
+ |
"No home directory, so there is nowhere for gopass to keep a key.".to_string()
|
|
366 |
+ |
}
|
|
367 |
+ |
(Some(_), true) => {
|
|
368 |
+ |
"Where gopass keeps the identity it decrypts the store with.".to_string()
|
|
369 |
+ |
}
|
|
370 |
+ |
(Some(dir), false) => format!(
|
|
371 |
+ |
"No age identity at {}. gopass will not decrypt until one is placed there.",
|
|
372 |
+ |
dir.display(),
|
|
373 |
+ |
),
|
|
374 |
+ |
};
|
|
375 |
+ |
// Always closed. Writing an identity from a settings form is the one
|
|
376 |
+ |
// thing the provisioning decision ruled out, so this row has no setter
|
|
377 |
+ |
// to open even when everything answered.
|
|
378 |
+ |
set(self.field_mut(AGE), ¬e, true);
|
| 274 |
379 |
|
}
|
| 275 |
380 |
|
}
|
| 276 |
381 |
|
|
| 312 |
417 |
|
path: "appearance".to_string(),
|
| 313 |
418 |
|
description: Some("How the console renders.".to_string()),
|
| 314 |
419 |
|
},
|
|
420 |
+ |
Section {
|
|
421 |
+ |
path: "secrets".to_string(),
|
|
422 |
+ |
description: Some("Where gopass looks for its key.".to_string()),
|
|
423 |
+ |
},
|
| 315 |
424 |
|
]
|
| 316 |
425 |
|
}
|
| 317 |
426 |
|
|
| 358 |
467 |
|
enumerated(LOCALE, locales),
|
| 359 |
468 |
|
enumerated(KEYMAP, keymaps),
|
| 360 |
469 |
|
enumerated(THEME, themes),
|
|
470 |
+ |
// Not a setting either, and for a stronger reason than the clock: there
|
|
471 |
+ |
// is no command behind it that a wider grant could reach.
|
|
472 |
+ |
text(AGE, true),
|
| 361 |
473 |
|
]
|
| 362 |
474 |
|
}
|
| 363 |
475 |
|
|
| 534 |
646 |
|
HOSTNAME => text(self.host.fixed.as_ref().or(self.host.effective.as_ref())),
|
| 535 |
647 |
|
LOCALE => text(self.locale.lang.as_ref()),
|
| 536 |
648 |
|
KEYMAP => text(self.locale.keymap.as_ref()),
|
| 537 |
|
- |
THEME => Some(Value::String(crate::theme::current_id())),
|
|
649 |
+ |
// The path, not the answer: "is a key there" is what the help line
|
|
650 |
+ |
// carries, and the directory is the thing a reader wants to copy.
|
|
651 |
+ |
AGE => self
|
|
652 |
+ |
.age
|
|
653 |
+ |
.dir
|
|
654 |
+ |
.as_ref()
|
|
655 |
+ |
.map(|dir| Value::String(dir.display().to_string())),
|
| 538 |
656 |
|
_ => None,
|
| 539 |
657 |
|
}
|
| 540 |
658 |
|
}
|
| 627 |
745 |
|
}
|
| 628 |
746 |
|
self.host = read_host(log);
|
| 629 |
747 |
|
self.locale = read_locale(log);
|
|
748 |
+ |
// Cheap, and the case worth catching: the user reads the row, places
|
|
749 |
+ |
// the identity in another window, and refreshes.
|
|
750 |
+ |
self.age = read_age();
|
| 630 |
751 |
|
self.annotate();
|
| 631 |
752 |
|
}
|
| 632 |
753 |
|
}
|
| 639 |
760 |
|
/// tests and this module's cannot disagree about what a row holds.
|
| 640 |
761 |
|
#[cfg(test)]
|
| 641 |
762 |
|
pub(crate) fn fixture() -> Self {
|
|
763 |
+ |
Self::fixture_with_age(Age {
|
|
764 |
+ |
dir: Some(PathBuf::from("/home/tester/.config/gopass/age")),
|
|
765 |
+ |
present: true,
|
|
766 |
+ |
})
|
|
767 |
+ |
}
|
|
768 |
+ |
|
|
769 |
+ |
/// The fixture with the secrets row's state chosen.
|
|
770 |
+ |
///
|
|
771 |
+ |
/// The age state is passed in rather than probed because the alternative is
|
|
772 |
+ |
/// a test whose result depends on whether the developer running it happens
|
|
773 |
+ |
/// to use gopass.
|
|
774 |
+ |
#[cfg(test)]
|
|
775 |
+ |
fn fixture_with_age(age: Age) -> Self {
|
| 642 |
776 |
|
let mut bind = Self {
|
| 643 |
777 |
|
sections: sections(),
|
| 644 |
778 |
|
fields: rows(
|
| 650 |
784 |
|
time: parse_show(tests::SHOW),
|
| 651 |
785 |
|
host: parse_host(tests::HOST),
|
| 652 |
786 |
|
locale: parse_locale(tests::LOCALE_STATUS),
|
|
787 |
+ |
age,
|
| 653 |
788 |
|
};
|
| 654 |
789 |
|
bind.annotate();
|
| 655 |
790 |
|
bind
|
| 723 |
858 |
|
assert_eq!(bind.read(KEYMAP), None, "the fixture reports (unset)");
|
| 724 |
859 |
|
}
|
| 725 |
860 |
|
|
|
861 |
+ |
// The pair the locale row got, applied to the row that reports a file
|
|
862 |
+ |
// rather than a vocabulary. Both states are closed; only the note moves.
|
|
863 |
+ |
#[test]
|
|
864 |
+ |
fn the_secrets_row_is_quiet_when_the_identity_is_there() {
|
|
865 |
+ |
let bind = bind();
|
|
866 |
+ |
assert_eq!(
|
|
867 |
+ |
bind.read(AGE),
|
|
868 |
+ |
Some(Value::String("/home/tester/.config/gopass/age".into())),
|
|
869 |
+ |
);
|
|
870 |
+ |
assert_eq!(
|
|
871 |
+ |
help(&bind, AGE),
|
|
872 |
+ |
"Where gopass keeps the identity it decrypts the store with.",
|
|
873 |
+ |
);
|
|
874 |
+ |
assert!(bind.field(AGE).unwrap().readonly);
|
|
875 |
+ |
}
|
|
876 |
+ |
|
|
877 |
+ |
#[test]
|
|
878 |
+ |
fn the_secrets_row_names_the_path_when_the_identity_is_missing() {
|
|
879 |
+ |
let bind = SystemBind::fixture_with_age(Age {
|
|
880 |
+ |
dir: Some(PathBuf::from("/home/tester/.config/gopass/age")),
|
|
881 |
+ |
present: false,
|
|
882 |
+ |
});
|
|
883 |
+ |
let note = help(&bind, AGE);
|
|
884 |
+ |
assert!(
|
|
885 |
+ |
note.contains("/home/tester/.config/gopass/age"),
|
|
886 |
+ |
"the note has to name the path a key goes at, got {note:?}",
|
|
887 |
+ |
);
|
|
888 |
+ |
assert!(note.contains("will not decrypt"), "and the consequence");
|
|
889 |
+ |
assert!(
|
|
890 |
+ |
bind.field(AGE).unwrap().readonly,
|
|
891 |
+ |
"closed in both states: nothing here writes a key",
|
|
892 |
+ |
);
|
|
893 |
+ |
}
|
|
894 |
+ |
|
|
895 |
+ |
// Placing an identity from a settings form is exactly what the provisioning
|
|
896 |
+ |
// decision ruled out, so the row must refuse rather than quietly no-op.
|
|
897 |
+ |
#[test]
|
|
898 |
+ |
fn the_secrets_row_takes_no_write() {
|
|
899 |
+ |
let mut bind = bind();
|
|
900 |
+ |
assert_eq!(
|
|
901 |
+ |
message(bind.commit(AGE, Value::String("AGE-SECRET-KEY-1".into()))),
|
|
902 |
+ |
"`secrets.age` is shown, not set",
|
|
903 |
+ |
);
|
|
904 |
+ |
}
|
|
905 |
+ |
|
|
906 |
+ |
// gopass's own precedence, which is what makes the row right on a machine
|
|
907 |
+ |
// that has moved its store rather than confidently naming a path nothing
|
|
908 |
+ |
// reads.
|
|
909 |
+ |
#[test]
|
|
910 |
+ |
fn the_age_path_follows_gopass() {
|
|
911 |
+ |
let dir = |homedir, xdg, home| age_dir_from(homedir, xdg, home).unwrap();
|
|
912 |
+ |
assert_eq!(
|
|
913 |
+ |
dir(None, None, Some("/home/tester")),
|
|
914 |
+ |
PathBuf::from("/home/tester/.config/gopass/age"),
|
|
915 |
+ |
);
|
|
916 |
+ |
assert_eq!(
|
|
917 |
+ |
dir(None, Some("/elsewhere"), Some("/home/tester")),
|
|
918 |
+ |
PathBuf::from("/elsewhere/gopass/age"),
|
|
919 |
+ |
"XDG_CONFIG_HOME moves it, and is already the whole config root",
|
|
920 |
+ |
);
|
|
921 |
+ |
assert_eq!(
|
|
922 |
+ |
dir(Some("/sandbox"), Some("/elsewhere"), Some("/home/tester")),
|
|
923 |
+ |
PathBuf::from("/sandbox/.config/gopass/age"),
|
|
924 |
+ |
"GOPASS_HOMEDIR wins over both",
|
|
925 |
+ |
);
|
|
926 |
+ |
assert_eq!(
|
|
927 |
+ |
age_dir_from(None, None, None),
|
|
928 |
+ |
None,
|
|
929 |
+ |
"nothing to resolve against",
|
|
930 |
+ |
);
|
|
931 |
+ |
assert_eq!(
|
|
932 |
+ |
dir(Some(""), None, Some("/home/tester")),
|
|
933 |
+ |
PathBuf::from("/home/tester/.config/gopass/age"),
|
|
934 |
+ |
"an empty variable is unset, not a path of nothing",
|
|
935 |
+ |
);
|
|
936 |
+ |
}
|
|
937 |
+ |
|
|
938 |
+ |
// A file gopass wrote before it had anything to put in it refuses exactly
|
|
939 |
+ |
// like no file at all, so the row must not call it present.
|
|
940 |
+ |
#[test]
|
|
941 |
+ |
fn an_empty_identities_file_is_not_an_identity() {
|
|
942 |
+ |
let dir = std::env::temp_dir().join("alloy-age-identity-test");
|
|
943 |
+ |
let _ = std::fs::remove_dir_all(&dir);
|
|
944 |
+ |
std::fs::create_dir_all(&dir).unwrap();
|
|
945 |
+ |
assert!(!identity_present(&dir), "no file at all");
|
|
946 |
+ |
|
|
947 |
+ |
std::fs::write(dir.join("identities"), "").unwrap();
|
|
948 |
+ |
assert!(!identity_present(&dir), "written, empty, decrypts nothing");
|
|
949 |
+ |
|
|
950 |
+ |
std::fs::write(dir.join("identities"), "AGE-SECRET-KEY-1EXAMPLE\n").unwrap();
|
|
951 |
+ |
assert!(identity_present(&dir));
|
|
952 |
+ |
|
|
953 |
+ |
std::fs::remove_dir_all(&dir).unwrap();
|
|
954 |
+ |
}
|
|
955 |
+ |
|
| 726 |
956 |
|
// The writing commands this bind is allowed, and no others. Each is in the
|
| 727 |
957 |
|
// polkit grant; if any argv changes, the grant has to change with it.
|
| 728 |
958 |
|
#[test]
|
| 855 |
1085 |
|
time: parse_show(SHOW),
|
| 856 |
1086 |
|
host: parse_host(HOST),
|
| 857 |
1087 |
|
locale: parse_locale(LOCALE_STATUS),
|
|
1088 |
+ |
age: Age::default(),
|
| 858 |
1089 |
|
};
|
| 859 |
1090 |
|
bind.annotate();
|
| 860 |
1091 |
|
|
| 879 |
1110 |
|
time: parse_show(SHOW),
|
| 880 |
1111 |
|
host: parse_host(HOST),
|
| 881 |
1112 |
|
locale: parse_locale(LOCALE_STATUS),
|
|
1113 |
+ |
age: Age::default(),
|
| 882 |
1114 |
|
};
|
| 883 |
1115 |
|
bind.annotate();
|
| 884 |
1116 |
|
bind
|