security: encrypt the wallet password at rest + deprecate inline secrets
Close the remaining "secrets touch a plaintext file" gap. The unattended restart mode now SEALS the operator-chosen wallet password to the host with `systemd-creds encrypt` (ciphertext at rest, read via LoadCredentialEncrypted, decrypted into a tmpfs credentials dir at each start), so no plaintext wallet key is written to disk. When systemd-creds is unavailable it falls back to the legacy root-owned 0400 plaintext file with a loud warning, and re-running `setup --reconfigure` once it is available upgrades to encryption. - wizard: seal via systemd-creds (injectable for tests), write the encrypted.conf drop-in, keep the manual.conf tmpfs path unchanged; reconfiguring a till that keeps no readable password on disk (encrypted or manual) now prompts for the existing password to reopen the wallet. - startup: loud deprecation warning when a money secret (GP_MNEMONIC, GP_WALLET_PASSWORD, GP_NSEC) is supplied INLINE rather than via a *_FILE or systemd credential; the value is never logged, only the variable name. - docs: README, .env.example, install.sh, and the systemd unit updated to the encrypted-at-rest default and the honest fallback/trade-off. Tests: gp-core config + gp-core/gp-server setup cover the inline-secret warning, the encrypted drop-in, the sealed-vs-plaintext shapes, and encrypted reconfigure. Full workspace suite green; fmt introduces no new drift; clippy -D warnings clean on the workspace crates.
This commit is contained in:
@@ -111,8 +111,9 @@ Everything else it does for you:
|
||||
- defaults the relays to an external vetted pool (the wallet's proven relays);
|
||||
- writes `/etc/goblinpay.env` (mode 0640, holds the config plus the bearer
|
||||
tokens) exactly where the shipped `gp-server.service` looks (`EnvironmentFile`),
|
||||
and — in unattended mode — `/etc/goblinpay/secrets/wallet_password` (mode 0400)
|
||||
where its `LoadCredential` reads it;
|
||||
and, in unattended mode, seals your wallet password to this host with
|
||||
`systemd-creds` as an encrypted-at-rest credential (`wallet_password.cred`,
|
||||
read via `LoadCredentialEncrypted`), so no plaintext key is written to disk;
|
||||
- prints the webhook URL and the three values to paste into WooCommerce
|
||||
(GoblinPay URL, API Token, Webhook Secret) plus the private admin token.
|
||||
|
||||
@@ -121,12 +122,20 @@ Everything else it does for you:
|
||||
The wizard asks how the till should restart after a reboot; press Enter for the
|
||||
default. Both are honest about their trade-off:
|
||||
|
||||
- **Unattended (default).** Your chosen password is sealed to *this host* as a
|
||||
systemd credential (the 0400 file above), so the service auto-restarts with no
|
||||
human in the loop. Be clear-eyed about the trade-off: whoever fully controls
|
||||
this machine controls the wallet. Treat the till as a small hot wallet — hold
|
||||
only a working balance and sweep to your own wallet regularly (see *Secrets and
|
||||
the wallet seed*).
|
||||
- **Unattended (default).** Your chosen password is sealed to *this host* and the
|
||||
service auto-restarts with no human in the loop. By default it is **encrypted at
|
||||
rest**: the wizard runs `systemd-creds encrypt` to write a ciphertext blob
|
||||
(`/etc/goblinpay/secrets/wallet_password.cred`) that only this host (and its TPM,
|
||||
when present) can decrypt, and systemd decrypts it into a tmpfs credentials
|
||||
directory at each start, so no plaintext key is ever written to disk. If
|
||||
`systemd-creds` is unavailable (older systemd, or no host credential key) it
|
||||
falls back to a root-owned 0400 plaintext file and prints a loud warning; re-run
|
||||
`gp-server setup --reconfigure` once systemd-creds is available to encrypt it.
|
||||
Be clear-eyed about the trade-off: whoever fully controls the *running* machine
|
||||
can still have systemd decrypt the credential, so a live-host compromise means
|
||||
wallet compromise. Treat the till as a small hot wallet: hold only a working
|
||||
balance and sweep to your own wallet regularly (see *Secrets and the wallet
|
||||
seed*).
|
||||
- **Manual.** The password lives only in your head; nothing sensitive is written
|
||||
to disk. The wizard drops in `gp-server.service.d/manual.conf`, which repoints
|
||||
the credential to a tmpfs path (`/run/goblinpay/wallet_password`). You supply
|
||||
@@ -297,17 +306,26 @@ Deliver both secrets as files rather than plain environment variables:
|
||||
`GP_NCRYPTSEC_FILE` (mode 0400 recommended). Setting both a variable and its
|
||||
`_FILE` variant is an error, as is setting both `GP_NSEC` and `GP_NCRYPTSEC`.
|
||||
An environment variable is visible to the whole process (and via `/proc` to the
|
||||
same user and root) for the life of the service; a file is not. The shipped
|
||||
`deploy/gp-server.service` reads the seed and password with systemd
|
||||
`LoadCredential` (they land under `$CREDENTIALS_DIRECTORY`, pointed at by the
|
||||
same user and root) for the life of the service; a file is not. **Supplying
|
||||
`GP_MNEMONIC`, `GP_WALLET_PASSWORD`, or `GP_NSEC` inline is deprecated: the
|
||||
server prints a loud warning at startup and the path may be removed.** The
|
||||
shipped `deploy/gp-server.service` reads the seed and password with systemd
|
||||
credentials (they land under `$CREDENTIALS_DIRECTORY`, pointed at by the
|
||||
`_FILE` variables), and `deploy/docker-compose.yml` mounts them under
|
||||
`/run/secrets`, so with either deployment nothing sensitive is in the
|
||||
environment.
|
||||
|
||||
You choose `GP_WALLET_PASSWORD` yourself (the wizard prompts for it twice and
|
||||
confirms the match; it is never auto-generated). How it reaches the service on
|
||||
restart is the restart-mode choice above: sealed to the host for unattended
|
||||
auto-restart, or re-entered by hand each start in manual mode.
|
||||
restart is the restart-mode choice above. In **unattended** mode the wizard seals
|
||||
it with `systemd-creds` into an encrypted-at-rest credential
|
||||
(`wallet_password.cred`), which the unit reads via `LoadCredentialEncrypted` and
|
||||
systemd decrypts into a tmpfs credentials dir at each start, so the key exists as
|
||||
plaintext only in the memory of the running service, never as a plaintext file on
|
||||
disk (with a 0400-plaintext fallback, plus a warning, when `systemd-creds` is
|
||||
unavailable). In **manual** mode nothing is stored on disk and you re-enter it at
|
||||
each start. Reconfiguring an encrypted or manual till (which keeps no readable
|
||||
password on disk) prompts you for the existing password to reopen the wallet.
|
||||
|
||||
Treat the till as a small hot wallet. Grin receives are interactive, so the
|
||||
till must hold live keys; keep the risk small by giving it a seed of its own,
|
||||
|
||||
@@ -254,8 +254,22 @@ pub struct Config {
|
||||
/// onion service proxies to (`GP_GRIN1_FOREIGN_PORT`, default 3416). Only
|
||||
/// used when `grin1_rail` is on.
|
||||
pub grin1_foreign_port: u16,
|
||||
/// Names of the money/at-rest-encryption secrets that were supplied INLINE
|
||||
/// (the bare `GP_MNEMONIC` / `GP_WALLET_PASSWORD` / `GP_NSEC` env vars)
|
||||
/// rather than via their `_FILE` variant or a systemd credential. An inline
|
||||
/// env var is visible to the whole process and (via `/proc`) to root for the
|
||||
/// life of the service, so the startup path warns loudly when this is
|
||||
/// non-empty. Never holds a secret VALUE, only variable names.
|
||||
#[serde(skip)]
|
||||
pub inline_secret_vars: Vec<String>,
|
||||
}
|
||||
|
||||
/// The money/at-rest-encryption secrets whose INLINE env-var form is deprecated
|
||||
/// (deliver them as a `_FILE`/systemd credential instead). The bearer tokens and
|
||||
/// webhook secret are deliberately excluded: owner ruling O4 keeps those in the
|
||||
/// root-owned env file, so warning on them would be noise.
|
||||
pub const DEPRECATED_INLINE_SECRETS: &[&str] = &["GP_MNEMONIC", "GP_WALLET_PASSWORD", "GP_NSEC"];
|
||||
|
||||
/// Default supported fiat currency when `GP_RATE_CURRENCIES` is unset.
|
||||
pub const DEFAULT_RATE_CURRENCY: &str = "usd";
|
||||
/// Default rate cache freshness (seconds).
|
||||
@@ -314,6 +328,7 @@ impl Default for Config {
|
||||
confirmations_required: DEFAULT_CONFIRMATIONS,
|
||||
grin1_rail: false,
|
||||
grin1_foreign_port: DEFAULT_GRIN1_FOREIGN_PORT,
|
||||
inline_secret_vars: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -404,6 +419,16 @@ impl Config {
|
||||
let nsec = secret(get, "GP_NSEC")?;
|
||||
let ncryptsec = secret(get, "GP_NCRYPTSEC")?;
|
||||
|
||||
// Record which deprecated money secrets arrived INLINE (the bare var is
|
||||
// set, not its `_FILE` variant). `secret()` already rejected setting both
|
||||
// for a key, so a present bare var means the value came from the process
|
||||
// environment, which the startup path warns about.
|
||||
let inline_secret_vars = DEPRECATED_INLINE_SECRETS
|
||||
.iter()
|
||||
.filter(|k| get(k).is_some())
|
||||
.map(|k| k.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let public_url = get("GP_PUBLIC_URL")
|
||||
.map(|u| u.trim_end_matches('/').to_string())
|
||||
.filter(|u| !u.is_empty())
|
||||
@@ -497,11 +522,37 @@ impl Config {
|
||||
confirmations_required,
|
||||
grin1_rail,
|
||||
grin1_foreign_port,
|
||||
inline_secret_vars,
|
||||
};
|
||||
cfg.validate()?;
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// A loud, single-line-per-secret warning when any money secret was supplied
|
||||
/// inline (the bare env var) instead of via its `_FILE` variant or a systemd
|
||||
/// credential. `None` when every secret was delivered as a file/credential
|
||||
/// (the wizard and both shipped deployments), so a hardened deploy is silent.
|
||||
/// Never contains a secret value, only the offending variable names.
|
||||
pub fn inline_secret_warning(&self) -> Option<String> {
|
||||
if self.inline_secret_vars.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let names = self.inline_secret_vars.join(", ");
|
||||
Some(format!(
|
||||
"DEPRECATED: money secret(s) supplied inline as environment variables: {names}. \
|
||||
An inline env var is readable by the whole process and, via /proc, by root for \
|
||||
the life of the service. Deliver them as files instead ({names_file}) or via a \
|
||||
systemd credential; see the shipped gp-server.service. The inline path still \
|
||||
works but is deprecated and may be removed.",
|
||||
names_file = self
|
||||
.inline_secret_vars
|
||||
.iter()
|
||||
.map(|n| format!("{n}_FILE"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
))
|
||||
}
|
||||
|
||||
/// The QR center logo to render: the inlined Goblin mark by default, an
|
||||
/// external image when the operator sets a custom URL, or none.
|
||||
pub fn qr_logo(&self) -> crate::qr::Logo<'_> {
|
||||
@@ -997,6 +1048,40 @@ mod tests {
|
||||
assert!(load(&[("GP_RATE_STALE_MAX", "-5")]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_money_secrets_are_flagged_but_files_are_silent() {
|
||||
// Inline money secrets are recorded and produce a loud, value-free warning.
|
||||
let cfg = load(&[
|
||||
("GP_MNEMONIC", "topsecret words"),
|
||||
("GP_WALLET_PASSWORD", "hushhush"),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cfg.inline_secret_vars,
|
||||
vec!["GP_MNEMONIC".to_string(), "GP_WALLET_PASSWORD".to_string()]
|
||||
);
|
||||
let warn = cfg.inline_secret_warning().unwrap();
|
||||
assert!(warn.contains("GP_MNEMONIC"));
|
||||
assert!(warn.contains("GP_WALLET_PASSWORD"));
|
||||
assert!(warn.contains("GP_MNEMONIC_FILE"));
|
||||
assert!(warn.contains("DEPRECATED"));
|
||||
// The warning never carries the secret VALUES.
|
||||
assert!(!warn.contains("topsecret"));
|
||||
assert!(!warn.contains("hushhush"));
|
||||
|
||||
// A file-delivered secret is NOT flagged (the hardened path is silent).
|
||||
let path = std::env::temp_dir().join(format!("gp-pw-{}", std::process::id()));
|
||||
std::fs::write(&path, "hushhush\n").unwrap();
|
||||
let cfg = load(&[("GP_WALLET_PASSWORD_FILE", path.to_str().unwrap())]).unwrap();
|
||||
assert!(cfg.inline_secret_vars.is_empty());
|
||||
assert!(cfg.inline_secret_warning().is_none());
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
|
||||
// Bearer tokens/webhook secret inline are deliberately NOT flagged (O4).
|
||||
let cfg = load(&[("GP_API_TOKEN", "gp_live_x"), ("GP_ADMIN_TOKEN", "gpadm_x")]).unwrap();
|
||||
assert!(cfg.inline_secret_warning().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_and_summary_never_leak_secrets() {
|
||||
let cfg = load(&[
|
||||
|
||||
+125
-11
@@ -183,6 +183,54 @@ pub fn parse_ack(input: &str) -> bool {
|
||||
/// tmpfs, so the password is gone on reboot: a powered-off disk holds no key.
|
||||
pub const RUNTIME_WALLET_PASSWORD_FILE: &str = "/run/goblinpay/wallet_password";
|
||||
|
||||
/// The systemd unit name, used to derive the runtime credentials directory.
|
||||
pub const SYSTEMD_UNIT_NAME: &str = "gp-server.service";
|
||||
|
||||
/// Where the wizard writes the host-SEALED (ciphertext) wallet password in the
|
||||
/// UNATTENDED-encrypted path. `systemd-creds encrypt` produces this blob, bound
|
||||
/// to this host's key (and the TPM when present); it is NOT plaintext, so unlike
|
||||
/// the legacy 0400 file a stolen or copied disk yields no wallet key. The unit
|
||||
/// decrypts it with `LoadCredentialEncrypted` into the tmpfs credentials dir at
|
||||
/// each start.
|
||||
pub const ENCRYPTED_WALLET_PASSWORD_FILE: &str = "/etc/goblinpay/secrets/wallet_password.cred";
|
||||
|
||||
/// The runtime path systemd exposes the decrypted wallet password at for the
|
||||
/// shipped unit (`$CREDENTIALS_DIRECTORY/gp_wallet_password`, i.e.
|
||||
/// `%d/gp_wallet_password`). Written into the env file as `GP_WALLET_PASSWORD_FILE`
|
||||
/// for the encrypted-unattended path so it matches the unit exactly: absent at
|
||||
/// setup time (so an in-process first run defers to `systemctl start`), present
|
||||
/// and decrypted once systemd starts the service.
|
||||
pub const CREDENTIALS_WALLET_PASSWORD_PATH: &str =
|
||||
"/run/credentials/gp-server.service/gp_wallet_password";
|
||||
|
||||
/// Render the systemd drop-in that switches the UNATTENDED unit to an ENCRYPTED
|
||||
/// credential at rest: it resets the base unit's plaintext `LoadCredential` and
|
||||
/// reads the host-sealed ciphertext via `LoadCredentialEncrypted`, which systemd
|
||||
/// decrypts into the tmpfs credentials dir at each start. Written to
|
||||
/// `gp-server.service.d/encrypted.conf` by the wizard whenever `systemd-creds`
|
||||
/// could seal the password (the default, secure path). No plaintext wallet
|
||||
/// password ever touches the disk in this mode.
|
||||
pub fn render_encrypted_dropin(cred_source_file: &str) -> String {
|
||||
format!(
|
||||
"# GoblinPay UNATTENDED restart mode, ENCRYPTED at rest (the secure default),\n\
|
||||
# generated by `gp-server setup`. The wallet password was sealed to THIS host\n\
|
||||
# with `systemd-creds encrypt` (host key, plus the TPM when present), so the\n\
|
||||
# file below is ciphertext, not plaintext: a stolen or copied disk yields no\n\
|
||||
# wallet key. systemd decrypts it into the tmpfs credentials directory at each\n\
|
||||
# start, so the service still auto-restarts with no human.\n\
|
||||
#\n\
|
||||
# Honest trade-off (unchanged): whoever fully controls this RUNNING host can\n\
|
||||
# still have systemd decrypt the credential, so a live-machine compromise means\n\
|
||||
# wallet compromise. Keep the till a small hot float and sweep regularly.\n\
|
||||
[Service]\n\
|
||||
# Reset the base unit's plaintext credential, then read the sealed one.\n\
|
||||
LoadCredential=\n\
|
||||
LoadCredentialEncrypted=\n\
|
||||
LoadCredentialEncrypted=gp_wallet_password:{src}\n",
|
||||
src = cred_source_file
|
||||
)
|
||||
}
|
||||
|
||||
/// Render the systemd drop-in that switches the unit to MANUAL restart mode: it
|
||||
/// repoints `LoadCredential` from the persistent 0400 file to the tmpfs path the
|
||||
/// operator populates by hand at each start, and documents exactly how. Written
|
||||
@@ -365,6 +413,12 @@ pub struct SetupParams {
|
||||
pub wallet_password_file: String,
|
||||
/// How the till restarts (operator's choice; default UNATTENDED).
|
||||
pub restart_mode: RestartMode,
|
||||
/// In UNATTENDED mode, whether the password was sealed with `systemd-creds`
|
||||
/// (ciphertext at rest, the secure default) or fell back to the legacy 0400
|
||||
/// plaintext file (when `systemd-creds` was unavailable). Ignored in MANUAL
|
||||
/// mode. Only changes the rendered narrative + which credential the unit
|
||||
/// reads; the password value is never inlined either way.
|
||||
pub unattended_encrypted: bool,
|
||||
}
|
||||
|
||||
impl SetupParams {
|
||||
@@ -415,14 +469,35 @@ impl SetupParams {
|
||||
// Wallet password: operator-chosen (grin-wallet-faithful), never
|
||||
// inlined here. How it reaches the service depends on the restart mode.
|
||||
match self.restart_mode {
|
||||
RestartMode::Unattended => {
|
||||
s.push_str("# --- wallet password: restart mode UNATTENDED (host-sealed) ---\n");
|
||||
RestartMode::Unattended if self.unattended_encrypted => {
|
||||
s.push_str(
|
||||
"# You chose this password; it is sealed to THIS host as a systemd\n\
|
||||
# credential (the 0400 file below), so the service auto-restarts with\n\
|
||||
# no human. Honest trade-off: a full-machine compromise means wallet\n\
|
||||
# compromise. Mitigate by keeping the till a small hot float and\n\
|
||||
# sweeping to your own wallet regularly.\n",
|
||||
"# --- wallet password: restart mode UNATTENDED (ENCRYPTED at rest) ---\n",
|
||||
);
|
||||
s.push_str(
|
||||
"# You chose this password; it was sealed to THIS host with\n\
|
||||
# `systemd-creds encrypt` (see the gp-server.service.d/encrypted.conf\n\
|
||||
# drop-in). The file on disk is CIPHERTEXT, not plaintext, so a stolen\n\
|
||||
# or copied disk yields no wallet key; systemd decrypts it into a tmpfs\n\
|
||||
# credentials dir at each start, so the service still auto-restarts.\n\
|
||||
# Honest trade-off: a compromise of the RUNNING host still means wallet\n\
|
||||
# compromise (systemd can decrypt it there), so keep the till a small hot\n\
|
||||
# float and sweep to your own wallet regularly. The GP_WALLET_PASSWORD_FILE\n\
|
||||
# below is the runtime (decrypted) credential path; the systemd unit sets\n\
|
||||
# it too.\n",
|
||||
);
|
||||
}
|
||||
RestartMode::Unattended => {
|
||||
s.push_str(
|
||||
"# --- wallet password: restart mode UNATTENDED (plaintext fallback) ---\n",
|
||||
);
|
||||
s.push_str(
|
||||
"# WARNING: `systemd-creds` was not available at setup, so the chosen\n\
|
||||
# password fell back to a root-owned 0400 PLAINTEXT file (below). It is\n\
|
||||
# off the world-readable env file, but it is still plaintext at rest.\n\
|
||||
# For encryption at rest, install systemd >= 250 with a host credential\n\
|
||||
# key and re-run `gp-server setup --reconfigure`. Honest trade-off: a\n\
|
||||
# full-machine compromise means wallet compromise; keep the till a small\n\
|
||||
# hot float and sweep to your own wallet regularly.\n",
|
||||
);
|
||||
}
|
||||
RestartMode::Manual => {
|
||||
@@ -629,6 +704,26 @@ mod tests {
|
||||
assert!(d.contains("[Service]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_dropin_reads_the_sealed_credential() {
|
||||
let d = render_encrypted_dropin(ENCRYPTED_WALLET_PASSWORD_FILE);
|
||||
// Resets the base unit's plaintext credential, then reads the sealed one.
|
||||
assert!(d.contains("LoadCredential=\n"));
|
||||
assert!(d.contains("LoadCredentialEncrypted=\n"));
|
||||
assert!(d.contains(&format!(
|
||||
"LoadCredentialEncrypted=gp_wallet_password:{ENCRYPTED_WALLET_PASSWORD_FILE}"
|
||||
)));
|
||||
// Documents the ciphertext-at-rest rationale and the running-host caveat.
|
||||
assert!(d.to_lowercase().contains("ciphertext"));
|
||||
assert!(d.contains("systemd-creds"));
|
||||
assert!(d.contains("[Service]"));
|
||||
// The runtime credential path matches the unit's %d/gp_wallet_password.
|
||||
assert_eq!(
|
||||
CREDENTIALS_WALLET_PASSWORD_PATH,
|
||||
"/run/credentials/gp-server.service/gp_wallet_password"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_url_trims_and_requires_scheme() {
|
||||
assert_eq!(
|
||||
@@ -721,8 +816,9 @@ mod tests {
|
||||
webhook_secret: "whsec_abcdef0123456789abcdef0123456789".into(),
|
||||
data_dir: "/var/lib/goblinpay/gp-data".into(),
|
||||
db_path: "/var/lib/goblinpay/goblinpay.db".into(),
|
||||
wallet_password_file: "/etc/goblinpay/secrets/wallet_password".into(),
|
||||
wallet_password_file: CREDENTIALS_WALLET_PASSWORD_PATH.into(),
|
||||
restart_mode: RestartMode::Unattended,
|
||||
unattended_encrypted: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,14 +837,32 @@ mod tests {
|
||||
assert!(env.contains("GP_API_TOKEN=gp_live_"));
|
||||
assert!(env.contains("GP_ADMIN_TOKEN=gpadm_"));
|
||||
// Password by file reference, never inlined; operator-chosen, not
|
||||
// auto-generated (no such wording anywhere).
|
||||
assert!(env.contains("GP_WALLET_PASSWORD_FILE=/etc/goblinpay/secrets/wallet_password"));
|
||||
// auto-generated (no such wording anywhere). The encrypted-unattended
|
||||
// default points GP_WALLET_PASSWORD_FILE at the runtime (decrypted)
|
||||
// credential path, and never at a plaintext file.
|
||||
assert!(env.contains(&format!(
|
||||
"GP_WALLET_PASSWORD_FILE={CREDENTIALS_WALLET_PASSWORD_PATH}"
|
||||
)));
|
||||
assert!(!env.contains("GP_WALLET_PASSWORD="));
|
||||
assert!(!env.to_lowercase().contains("auto-generat"));
|
||||
// Unattended mode is disclosed honestly (host-sealed + the trade-off).
|
||||
// Unattended-encrypted mode is disclosed honestly (ciphertext at rest,
|
||||
// sealed to this host, plus the running-host trade-off).
|
||||
assert!(env.contains("UNATTENDED"));
|
||||
assert!(env.contains("ENCRYPTED at rest"));
|
||||
assert!(env.to_lowercase().contains("ciphertext"));
|
||||
assert!(env.to_lowercase().contains("host"));
|
||||
assert!(env.to_lowercase().contains("compromise"));
|
||||
|
||||
// The plaintext fallback (systemd-creds unavailable) is disclosed just
|
||||
// as honestly, and still never inlines the password.
|
||||
let mut fb = sample_params();
|
||||
fb.unattended_encrypted = false;
|
||||
fb.wallet_password_file = "/etc/goblinpay/secrets/wallet_password".into();
|
||||
let env_fb = fb.render_env();
|
||||
assert!(env_fb.contains("plaintext fallback"));
|
||||
assert!(env_fb.to_lowercase().contains("still plaintext at rest"));
|
||||
assert!(env_fb.contains("GP_WALLET_PASSWORD_FILE=/etc/goblinpay/secrets/wallet_password"));
|
||||
assert!(!env_fb.contains("GP_WALLET_PASSWORD="));
|
||||
// The Grin seed is NEVER in the env (O2).
|
||||
assert!(!env.contains("GP_MNEMONIC"));
|
||||
// grin1 rail off -> commented out, not armed.
|
||||
|
||||
@@ -245,6 +245,7 @@ fn run_setup(args: &[String]) -> i32 {
|
||||
node_override: None,
|
||||
force_run: false,
|
||||
stdin_is_tty: std::io::stdin().is_terminal(),
|
||||
seal: None,
|
||||
};
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
@@ -323,6 +324,7 @@ fn first_run_or_boot(cfg: Config) -> Config {
|
||||
node_override: None,
|
||||
force_run: false,
|
||||
stdin_is_tty: true,
|
||||
seal: None,
|
||||
};
|
||||
let stdin = std::io::stdin();
|
||||
let mut stdout = std::io::stdout();
|
||||
@@ -373,6 +375,12 @@ async fn main() -> io::Result<()> {
|
||||
// guide the operator through the wizard, then boot from what it wrote. No-op
|
||||
// for configured deploys and non-terminal runs (zero behavior change).
|
||||
let cfg = first_run_or_boot(cfg);
|
||||
// Loud deprecation warning if a money secret arrived inline (the bare env
|
||||
// var) rather than as a file/systemd credential. The wizard and both shipped
|
||||
// deployments use the file path, so a hardened deploy is silent here.
|
||||
if let Some(warning) = cfg.inline_secret_warning() {
|
||||
eprintln!("warning: {warning}");
|
||||
}
|
||||
println!(
|
||||
"gp-server {} starting: {}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
|
||||
+282
-30
@@ -33,6 +33,16 @@ use gp_core::setup as core_setup;
|
||||
use gp_core::setup::SetupParams;
|
||||
use gp_wallet::GpWallet;
|
||||
|
||||
/// Seals the wallet password to this host as an encrypted credential at rest.
|
||||
/// Given the plaintext password and the destination `.cred` path, returns
|
||||
/// `Ok(true)` when it wrote a ciphertext blob (the secure path), `Ok(false)`
|
||||
/// when sealing is unavailable on this host (the caller then falls back to the
|
||||
/// legacy 0400 plaintext file with a loud warning), or `Err` on an unexpected
|
||||
/// failure. The real implementation shells out to `systemd-creds encrypt`; tests
|
||||
/// inject a deterministic fake so neither the encrypted nor the fallback path
|
||||
/// depends on the host's systemd.
|
||||
pub type Sealer = dyn Fn(&str, &Path) -> Result<bool, String>;
|
||||
|
||||
/// Options parsed from `gp-server setup [flags]` in `main.rs`.
|
||||
pub struct SetupOptions {
|
||||
/// `--reconfigure`: proceed even if a wallet/env already exists.
|
||||
@@ -49,6 +59,47 @@ pub struct SetupOptions {
|
||||
/// TTY and `force_run` is false, the wizard prints guidance and exits
|
||||
/// rather than hanging on a prompt.
|
||||
pub stdin_is_tty: bool,
|
||||
/// How the unattended wallet password is sealed at rest. `None` uses the real
|
||||
/// `systemd-creds` sealer; tests inject a deterministic fake.
|
||||
pub seal: Option<Box<Sealer>>,
|
||||
}
|
||||
|
||||
/// Seal `password` to this host with `systemd-creds encrypt`, writing a ciphertext
|
||||
/// blob to `dest`. Returns `Ok(false)` (fall back to the plaintext file) when
|
||||
/// `systemd-creds` is missing or cannot access a host credential key (e.g. an
|
||||
/// older systemd, or a non-root dry run), so the wizard degrades gracefully
|
||||
/// instead of failing. The plaintext password is passed on stdin, never as an
|
||||
/// argv arg (which would be visible in the process table).
|
||||
fn systemd_creds_seal(password: &str, dest: &Path) -> Result<bool, String> {
|
||||
use std::io::Write as _;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut child = match Command::new("systemd-creds")
|
||||
.arg("encrypt")
|
||||
.arg("--name=gp_wallet_password")
|
||||
.arg("-")
|
||||
.arg(dest)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
{
|
||||
Ok(c) => c,
|
||||
// No systemd-creds on this host: fall back to the plaintext file.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||
Err(e) => return Err(format!("could not run systemd-creds: {e}")),
|
||||
};
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin
|
||||
.write_all(password.as_bytes())
|
||||
.map_err(|e| format!("could not pass the password to systemd-creds: {e}"))?;
|
||||
}
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|e| format!("systemd-creds did not complete: {e}"))?;
|
||||
// A non-zero exit (commonly: no host credential key without root) is a
|
||||
// graceful fallback, not a hard error.
|
||||
Ok(status.success())
|
||||
}
|
||||
|
||||
/// What the wizard does about the wallet seed on this run.
|
||||
@@ -67,11 +118,21 @@ enum SeedPlan {
|
||||
struct Paths {
|
||||
env_file: PathBuf,
|
||||
secrets_dir: PathBuf,
|
||||
/// The legacy PLAINTEXT 0400 wallet-password file (unattended fallback when
|
||||
/// `systemd-creds` is unavailable).
|
||||
wallet_password_file: PathBuf,
|
||||
/// The host-SEALED (ciphertext) wallet-password credential
|
||||
/// (`wallet_password.cred`), written in the encrypted-unattended default.
|
||||
encrypted_cred_file: PathBuf,
|
||||
data_dir: PathBuf,
|
||||
db_path: PathBuf,
|
||||
/// The MANUAL-mode systemd drop-in (`gp-server.service.d/manual.conf`).
|
||||
/// The MANUAL-mode systemd drop-in (`gp-server.service.d/manual.conf`): its
|
||||
/// presence is also how a reconfigure detects the till is in manual mode.
|
||||
dropin_file: PathBuf,
|
||||
/// The encrypted-unattended systemd drop-in
|
||||
/// (`gp-server.service.d/encrypted.conf`), switching the unit to
|
||||
/// `LoadCredentialEncrypted`.
|
||||
encrypted_dropin_file: PathBuf,
|
||||
/// The tmpfs credential the MANUAL mode reads the password from at start.
|
||||
runtime_password_file: PathBuf,
|
||||
}
|
||||
@@ -83,13 +144,16 @@ impl Paths {
|
||||
None => PathBuf::from(abs),
|
||||
};
|
||||
let secrets_dir = join("etc/goblinpay/secrets");
|
||||
let dropin_dir = "etc/systemd/system/gp-server.service.d";
|
||||
Paths {
|
||||
env_file: join("etc/goblinpay.env"),
|
||||
wallet_password_file: secrets_dir.join("wallet_password"),
|
||||
encrypted_cred_file: secrets_dir.join("wallet_password.cred"),
|
||||
secrets_dir,
|
||||
data_dir: join("var/lib/goblinpay/gp-data"),
|
||||
db_path: join("var/lib/goblinpay/goblinpay.db"),
|
||||
dropin_file: join("etc/systemd/system/gp-server.service.d/manual.conf"),
|
||||
dropin_file: join(&format!("{dropin_dir}/manual.conf")),
|
||||
encrypted_dropin_file: join(&format!("{dropin_dir}/encrypted.conf")),
|
||||
runtime_password_file: join(core_setup::RUNTIME_WALLET_PASSWORD_FILE),
|
||||
}
|
||||
}
|
||||
@@ -354,20 +418,31 @@ pub fn run<R: BufRead, W: Write>(
|
||||
// the service environment (owner ruling O2). Reopening an existing wallet
|
||||
// reuses the password already on file, so a --reconfigure never re-encrypts
|
||||
// (which would need the old password) or touches the seed.
|
||||
// The at-rest password sealer: the real systemd-creds one in production, a
|
||||
// deterministic fake when tests inject it.
|
||||
let default_seal: Box<Sealer> = Box::new(systemd_creds_seal);
|
||||
let seal: &Sealer = opts.seal.as_deref().unwrap_or(default_seal.as_ref());
|
||||
|
||||
ensure_dir(&paths.data_dir, 0o700)?;
|
||||
ensure_dir(&paths.secrets_dir, 0o700)?;
|
||||
let wallet = match &seed_plan {
|
||||
let (wallet, unattended_encrypted) = match &seed_plan {
|
||||
SeedPlan::Existing => {
|
||||
let password = fs::read_to_string(&paths.wallet_password_file)
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"existing wallet found but its password file {} is unreadable ({e}); \
|
||||
cannot reopen. This looks like a partial install, not a reconfigure.",
|
||||
paths.wallet_password_file.display()
|
||||
// Reopen the existing wallet. Its password may be on the legacy 0400
|
||||
// plaintext file (plaintext-unattended tills); the secure encrypted
|
||||
// and manual shapes keep no readable password on disk, so prompt for
|
||||
// it. A wrong password simply fails the reopen below.
|
||||
let password = match fs::read_to_string(&paths.wallet_password_file) {
|
||||
Ok(s) => s.trim_end_matches(['\n', '\r']).to_string(),
|
||||
Err(_) => {
|
||||
writeln!(
|
||||
out,
|
||||
"This till keeps no readable password on disk. Enter its wallet\n\
|
||||
password to reopen the wallet and reconfigure it."
|
||||
)
|
||||
})?
|
||||
.trim_end_matches(['\n', '\r'])
|
||||
.to_string();
|
||||
.map_err(io_err)?;
|
||||
prompt_existing_password(&mut input, out, hidden)?
|
||||
}
|
||||
};
|
||||
writeln!(out, "Reopening the existing till wallet...").map_err(io_err)?;
|
||||
let w =
|
||||
GpWallet::create_at(&paths.data_dir, None, &password, &node_url, Chain::Mainnet)
|
||||
@@ -375,8 +450,8 @@ pub fn run<R: BufRead, W: Write>(
|
||||
// Re-apply the (possibly changed) restart mode. Preserving the mode
|
||||
// leaves these files as they were; switching mode moves the password
|
||||
// on/off disk accordingly.
|
||||
apply_restart_persistence(&paths, restart_mode, &password)?;
|
||||
w
|
||||
let enc = apply_restart_persistence(&paths, restart_mode, &password, seal, out)?;
|
||||
(w, enc)
|
||||
}
|
||||
SeedPlan::Fresh(m) | SeedPlan::Pasted(m) => {
|
||||
let password = chosen_password
|
||||
@@ -391,8 +466,8 @@ pub fn run<R: BufRead, W: Write>(
|
||||
Chain::Mainnet,
|
||||
)
|
||||
.map_err(|e| format!("wallet creation failed: {e}"))?;
|
||||
apply_restart_persistence(&paths, restart_mode, password)?;
|
||||
w
|
||||
let enc = apply_restart_persistence(&paths, restart_mode, password, seal, out)?;
|
||||
(w, enc)
|
||||
}
|
||||
};
|
||||
match wallet.slatepack_address() {
|
||||
@@ -404,6 +479,13 @@ pub fn run<R: BufRead, W: Write>(
|
||||
// restart mode: the persistent 0400 file (unattended) or the tmpfs
|
||||
// credential the operator populates at each start (manual).
|
||||
let wallet_password_file = match restart_mode {
|
||||
// Encrypted default: the env points at the runtime (decrypted) credential
|
||||
// path systemd populates, matching the unit's %d/gp_wallet_password. It is
|
||||
// absent at setup time (so an in-process first run defers to systemctl)
|
||||
// and present once the service starts.
|
||||
core_setup::RestartMode::Unattended if unattended_encrypted => {
|
||||
core_setup::CREDENTIALS_WALLET_PASSWORD_PATH.to_string()
|
||||
}
|
||||
core_setup::RestartMode::Unattended => paths.wallet_password_file.display().to_string(),
|
||||
core_setup::RestartMode::Manual => paths.runtime_password_file.display().to_string(),
|
||||
};
|
||||
@@ -427,6 +509,7 @@ pub fn run<R: BufRead, W: Write>(
|
||||
db_path: paths.db_path.display().to_string(),
|
||||
wallet_password_file,
|
||||
restart_mode,
|
||||
unattended_encrypted,
|
||||
};
|
||||
write_env_file(&paths.env_file, ¶ms.render_env())?;
|
||||
|
||||
@@ -454,10 +537,22 @@ pub fn run<R: BufRead, W: Write>(
|
||||
// Final screen: how to start (per restart mode), and the WooCommerce block.
|
||||
writeln!(out, "\nGoblinPay is set up.").map_err(io_err)?;
|
||||
match restart_mode {
|
||||
core_setup::RestartMode::Unattended if unattended_encrypted => writeln!(
|
||||
out,
|
||||
"Restart mode: UNATTENDED (encrypted at rest). Start it:\n\
|
||||
\x20 sudo systemctl daemon-reload && sudo systemctl start gp-server\n\
|
||||
The password is sealed to this host with systemd-creds (ciphertext on\n\
|
||||
disk); systemd decrypts it at each start, so the service auto-restarts.\n\
|
||||
(Keep the till a small hot float and sweep to your own wallet often:\n\
|
||||
a compromise of the RUNNING host still means wallet compromise.)\n"
|
||||
)
|
||||
.map_err(io_err)?,
|
||||
core_setup::RestartMode::Unattended => writeln!(
|
||||
out,
|
||||
"Restart mode: UNATTENDED. Start it: sudo systemctl start gp-server\n\
|
||||
It will auto-restart after reboots using the host-sealed password.\n\
|
||||
"Restart mode: UNATTENDED (plaintext fallback). Start it:\n\
|
||||
\x20 sudo systemctl start gp-server\n\
|
||||
systemd-creds was unavailable, so the password is a root-owned 0400\n\
|
||||
PLAINTEXT file. It will auto-restart, but the key is plaintext at rest.\n\
|
||||
(Keep the till a small hot float and sweep to your own wallet often:\n\
|
||||
a full-machine compromise means wallet compromise.)\n"
|
||||
)
|
||||
@@ -508,9 +603,17 @@ pub fn run<R: BufRead, W: Write>(
|
||||
}
|
||||
|
||||
match restart_mode {
|
||||
core_setup::RestartMode::Unattended if unattended_encrypted => writeln!(
|
||||
out,
|
||||
"\nWrote {} and {} (sealed credential, ciphertext) plus the\n\
|
||||
encrypted.conf drop-in. No plaintext wallet password is stored on disk.",
|
||||
paths.env_file.display(),
|
||||
paths.encrypted_cred_file.display()
|
||||
)
|
||||
.map_err(io_err)?,
|
||||
core_setup::RestartMode::Unattended => writeln!(
|
||||
out,
|
||||
"\nWrote {} and {} (password file, mode 0400).",
|
||||
"\nWrote {} and {} (PLAINTEXT password file, mode 0400).",
|
||||
paths.env_file.display(),
|
||||
paths.wallet_password_file.display()
|
||||
)
|
||||
@@ -611,6 +714,26 @@ fn prompt_wallet_password<R: BufRead, W: Write>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt once for an EXISTING wallet password (reconfiguring a till that keeps
|
||||
/// no readable password on disk: the encrypted or manual shapes). No confirm
|
||||
/// entry: correctness is checked by the wallet reopen that follows, which fails
|
||||
/// on a wrong password. Fails cleanly at end of input (batch/scripted runs).
|
||||
fn prompt_existing_password<R: BufRead, W: Write>(
|
||||
input: &mut R,
|
||||
out: &mut W,
|
||||
hidden: bool,
|
||||
) -> Result<String, String> {
|
||||
loop {
|
||||
write!(out, " wallet password > ").map_err(io_err)?;
|
||||
out.flush().map_err(io_err)?;
|
||||
match read_secret_line(input, out, hidden)? {
|
||||
None => return Err("no wallet password provided (end of input)".into()),
|
||||
Some(s) if !s.is_empty() => return Ok(s),
|
||||
Some(_) => writeln!(out, " ! password must not be empty\n").map_err(io_err)?,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Require an explicit yes/y acknowledgement before proceeding (the operator
|
||||
/// confirming they wrote the seed down, like grin-wallet init). Re-prompts on
|
||||
/// any other non-empty answer; fails cleanly at end of input.
|
||||
@@ -724,21 +847,58 @@ fn prompt_restart_mode<R: BufRead, W: Write>(
|
||||
}
|
||||
|
||||
/// Persist the wallet password per restart mode, idempotently, so this also
|
||||
/// handles a reconfigure that SWITCHES modes. UNATTENDED seals the chosen
|
||||
/// password to this host as a 0400 credential file (systemd LoadCredential reads
|
||||
/// it, so the service auto-restarts) and clears any stale manual drop-in. MANUAL
|
||||
/// keeps NOTHING sensitive on disk: it writes only the drop-in that repoints the
|
||||
/// credential to a tmpfs path the operator populates by hand, and removes the
|
||||
/// persistent password file if one was there.
|
||||
fn apply_restart_persistence(
|
||||
/// handles a reconfigure that SWITCHES modes. Returns whether an UNATTENDED
|
||||
/// password was ENCRYPTED at rest (the secure default) versus the plaintext
|
||||
/// fallback; the flag is meaningless (false) for MANUAL.
|
||||
///
|
||||
/// UNATTENDED first tries to SEAL the chosen password to this host with
|
||||
/// `systemd-creds` (ciphertext at rest, read by `LoadCredentialEncrypted`); no
|
||||
/// plaintext wallet password ever touches the disk. If sealing is unavailable it
|
||||
/// falls back to the legacy root-owned 0400 PLAINTEXT file (read by the base
|
||||
/// unit's `LoadCredential`) with a loud warning. MANUAL keeps NOTHING sensitive
|
||||
/// on disk: it writes only the drop-in that repoints the credential to a tmpfs
|
||||
/// path the operator populates by hand. Every stale artifact of the other shapes
|
||||
/// is cleared so a reconfigure that switches modes leaves exactly one in place.
|
||||
fn apply_restart_persistence<W: Write>(
|
||||
paths: &Paths,
|
||||
mode: core_setup::RestartMode,
|
||||
password: &str,
|
||||
) -> Result<(), String> {
|
||||
seal: &Sealer,
|
||||
out: &mut W,
|
||||
) -> Result<bool, String> {
|
||||
match mode {
|
||||
core_setup::RestartMode::Unattended => {
|
||||
write_secret_file(&paths.wallet_password_file, password)?;
|
||||
remove_if_exists(&paths.dropin_file)?;
|
||||
ensure_dir(&paths.secrets_dir, 0o700)?;
|
||||
if seal(password, paths.encrypted_cred_file.as_path())? {
|
||||
// Encrypted at rest (secure default): write the drop-in that reads
|
||||
// the sealed credential, and clear the plaintext + manual shapes.
|
||||
let src = paths.encrypted_cred_file.display().to_string();
|
||||
write_dropin_file(
|
||||
&paths.encrypted_dropin_file,
|
||||
&core_setup::render_encrypted_dropin(&src),
|
||||
)?;
|
||||
remove_if_exists(&paths.wallet_password_file)?;
|
||||
remove_if_exists(&paths.dropin_file)?;
|
||||
Ok(true)
|
||||
} else {
|
||||
// Fallback: systemd-creds unavailable. Root-owned 0400 plaintext
|
||||
// file, matching the base unit's LoadCredential. Warn loudly.
|
||||
writeln!(
|
||||
out,
|
||||
"WARNING: systemd-creds is unavailable on this host, so the wallet\n\
|
||||
password could not be encrypted at rest. Falling back to a root-owned\n\
|
||||
0400 PLAINTEXT file ({}). It is off the world-readable env file, but it\n\
|
||||
is still plaintext on disk. For encryption at rest, install systemd >= 250\n\
|
||||
with a host credential key and re-run: sudo gp-server setup --reconfigure",
|
||||
paths.wallet_password_file.display()
|
||||
)
|
||||
.map_err(io_err)?;
|
||||
write_secret_file(&paths.wallet_password_file, password)?;
|
||||
remove_if_exists(&paths.encrypted_cred_file)?;
|
||||
remove_if_exists(&paths.encrypted_dropin_file)?;
|
||||
remove_if_exists(&paths.dropin_file)?;
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
core_setup::RestartMode::Manual => {
|
||||
let runtime_pw = paths.runtime_password_file.display().to_string();
|
||||
@@ -747,9 +907,11 @@ fn apply_restart_persistence(
|
||||
&core_setup::render_manual_dropin(&runtime_pw),
|
||||
)?;
|
||||
remove_if_exists(&paths.wallet_password_file)?;
|
||||
remove_if_exists(&paths.encrypted_cred_file)?;
|
||||
remove_if_exists(&paths.encrypted_dropin_file)?;
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a file if it exists (ignoring a not-found race); used when a
|
||||
@@ -870,9 +1032,23 @@ mod tests {
|
||||
node_override: Some("http://127.0.0.1:3413".into()),
|
||||
force_run: true,
|
||||
stdin_is_tty: false,
|
||||
// Force the PLAINTEXT fallback deterministically (independent of the
|
||||
// host's systemd), so these tests exercise the 0400-file shape. The
|
||||
// encrypted path is covered by its own test with a fake sealer.
|
||||
seal: Some(Box::new(|_, _| Ok(false))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A fake sealer that "succeeds", writing a non-secret marker to the `.cred`
|
||||
/// path so the encrypted-unattended shape can be tested without real
|
||||
/// systemd-creds (and without ever writing the password to that file).
|
||||
fn fake_sealer_ok() -> Box<Sealer> {
|
||||
Box::new(|_pw, dest| {
|
||||
fs::write(dest, b"(sealed by test)\n").map_err(|e| format!("fake seal failed: {e}"))?;
|
||||
Ok(true)
|
||||
})
|
||||
}
|
||||
|
||||
/// All-zero 32-byte entropy -> the well-known dev seed (24 words). Used only
|
||||
/// as a test fixture; never a real till seed.
|
||||
const DEV_SEED_24: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art";
|
||||
@@ -959,6 +1135,82 @@ mod tests {
|
||||
assert!(transcript.contains("Restart mode: UNATTENDED"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_unattended_seals_and_writes_no_plaintext() {
|
||||
let dir = TempDir::new("encrypted");
|
||||
let mut opts = opts_with(&dir.0);
|
||||
opts.seal = Some(fake_sealer_ok());
|
||||
// bind (Enter), password (twice), fresh seed, acknowledge, restart
|
||||
// (Enter = unattended), currencies, rail.
|
||||
let answers = "https://pay.myshop.com\nhttps://myshop.com\n\npw\npw\n\nyes\n\n\n\n";
|
||||
let mut out = Vec::new();
|
||||
run(Cursor::new(answers), &mut out, &opts).unwrap();
|
||||
let transcript = String::from_utf8(out).unwrap();
|
||||
let paths = Paths::resolve(Some(&dir.0));
|
||||
|
||||
// The sealed ciphertext credential + the encrypted.conf drop-in exist;
|
||||
// NO plaintext password file and NO manual drop-in.
|
||||
assert!(paths.encrypted_cred_file.exists());
|
||||
assert!(paths.encrypted_dropin_file.exists());
|
||||
assert!(
|
||||
!paths.wallet_password_file.exists(),
|
||||
"no plaintext password on disk"
|
||||
);
|
||||
assert!(!paths.dropin_file.exists());
|
||||
// The sealed file never contains the plaintext password.
|
||||
let cred = fs::read_to_string(&paths.encrypted_cred_file).unwrap();
|
||||
assert!(!cred.contains("pw") || cred.contains("(sealed"));
|
||||
|
||||
let dropin = fs::read_to_string(&paths.encrypted_dropin_file).unwrap();
|
||||
assert!(dropin.contains("LoadCredentialEncrypted=gp_wallet_password:"));
|
||||
assert!(dropin.contains("LoadCredential=\n")); // resets the plaintext one
|
||||
|
||||
// Env points GP_WALLET_PASSWORD_FILE at the runtime credential path and
|
||||
// never inlines the password; the narrative says encrypted-at-rest.
|
||||
let env = fs::read_to_string(&paths.env_file).unwrap();
|
||||
assert!(env.contains(&format!(
|
||||
"GP_WALLET_PASSWORD_FILE={}",
|
||||
core_setup::CREDENTIALS_WALLET_PASSWORD_PATH
|
||||
)));
|
||||
assert!(!env.contains("GP_WALLET_PASSWORD="));
|
||||
assert!(env.contains("ENCRYPTED at rest"));
|
||||
assert!(GpWallet::seed_path(&paths.data_dir).exists());
|
||||
assert!(transcript.contains("Restart mode: UNATTENDED (encrypted at rest)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconfigure_encrypted_till_prompts_for_the_password() {
|
||||
let dir = TempDir::new("recfg-enc");
|
||||
let mut opts = opts_with(&dir.0);
|
||||
opts.seal = Some(fake_sealer_ok());
|
||||
// Fresh encrypted till, password "pw".
|
||||
let answers = "https://pay.myshop.com\nhttps://myshop.com\n\npw\npw\n\nyes\n\n\n\n";
|
||||
let mut out = Vec::new();
|
||||
run(Cursor::new(answers), &mut out, &opts).unwrap();
|
||||
let paths = Paths::resolve(Some(&dir.0));
|
||||
assert!(
|
||||
!paths.wallet_password_file.exists(),
|
||||
"encrypted: no plaintext"
|
||||
);
|
||||
|
||||
// Reconfigure: there is no readable password on disk, so the wizard must
|
||||
// prompt for it. Supplying the right password reopens the wallet.
|
||||
let mut recfg = opts_with(&dir.0);
|
||||
recfg.reconfigure = true;
|
||||
recfg.seal = Some(fake_sealer_ok());
|
||||
// public, webhook, bind (Enter), restart (Enter = preserve), currencies
|
||||
// gbp, rail n, then the EXISTING password prompt (pw) at wallet reopen.
|
||||
let recfg_answers = "https://pay.myshop.com\nhttps://myshop.com\n\n\ngbp\nn\npw\n";
|
||||
let mut out2 = Vec::new();
|
||||
run(Cursor::new(recfg_answers), &mut out2, &recfg).unwrap();
|
||||
let transcript = String::from_utf8(out2).unwrap();
|
||||
assert!(transcript.contains("keeps no readable password on disk"));
|
||||
assert!(transcript.contains("Reopening the existing till wallet"));
|
||||
let env = fs::read_to_string(&paths.env_file).unwrap();
|
||||
assert!(env.contains("GP_RATE_CURRENCIES=gbp"));
|
||||
assert!(env.contains("ENCRYPTED at rest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_mode_writes_dropin_and_no_password_file() {
|
||||
let dir = TempDir::new("manual");
|
||||
|
||||
+12
-4
@@ -1,14 +1,22 @@
|
||||
# GoblinPay environment. Copy to /etc/goblinpay.env (bare metal) or deploy/.env
|
||||
# (docker compose), then edit. NON-SECRET config only: the Grin seed and the
|
||||
# wallet password live as mode-0400 files (systemd LoadCredential / the compose
|
||||
# wallet password live as separate files (a systemd credential / the compose
|
||||
# ./secrets mount), never in this file.
|
||||
#
|
||||
# SECURITY: never put GP_MNEMONIC or GP_WALLET_PASSWORD (the seed and the key
|
||||
# that decrypts it) inline in this file or the environment. An inline env var is
|
||||
# readable by the whole process and, via /proc, by root for the life of the
|
||||
# service; the server prints a loud deprecation warning if you do. Deliver them
|
||||
# as files (GP_MNEMONIC_FILE / GP_WALLET_PASSWORD_FILE) or a systemd credential.
|
||||
#
|
||||
# The recommended path is `gp-server setup`, which has YOU choose the wallet
|
||||
# password (entered twice, grin-wallet-faithful; never auto-generated), shows a
|
||||
# fresh 24-word seed once for you to write down (or takes your recovery phrase),
|
||||
# and asks how the till should restart: UNATTENDED (default; the password is
|
||||
# host-sealed so the service auto-restarts) or MANUAL (nothing on disk; you
|
||||
# re-enter the password after every restart). This file is the by-hand path.
|
||||
# and asks how the till should restart: UNATTENDED (default) or MANUAL. In
|
||||
# UNATTENDED mode the password is sealed to this host with `systemd-creds encrypt`
|
||||
# (ENCRYPTED at rest, decrypted at each start) so the service auto-restarts with
|
||||
# no plaintext key on disk; MANUAL keeps nothing on disk and you re-enter it after
|
||||
# every restart. This file is the by-hand path.
|
||||
|
||||
# --- domain / URLs ---
|
||||
# docker-compose serves GoblinPay on GP_DOMAIN and the bundled relay on
|
||||
|
||||
+32
-11
@@ -14,10 +14,14 @@
|
||||
# exposed read-only to the dynamic service user) rather than left world-readable.
|
||||
#
|
||||
# Restart mode (the wizard asks; default UNATTENDED):
|
||||
# UNATTENDED — the chosen password is sealed to THIS host as the 0400 file
|
||||
# below, so the service auto-restarts with no human. Honest trade-off: a
|
||||
# full-machine compromise means wallet compromise; keep the till a small hot
|
||||
# float and sweep to your own wallet regularly.
|
||||
# UNATTENDED: the chosen password is sealed to THIS host and the service
|
||||
# auto-restarts with no human. By default (systemd-creds present) it is
|
||||
# ENCRYPTED at rest: a `systemd-creds encrypt` ciphertext blob, decrypted
|
||||
# into a tmpfs credentials dir at each start, so no plaintext key is on disk.
|
||||
# If systemd-creds is unavailable it falls back to a root-owned 0400 plaintext
|
||||
# file (with a loud warning). Honest trade-off: a compromise of the RUNNING
|
||||
# host still means wallet compromise; keep the till a small hot float and
|
||||
# sweep to your own wallet regularly.
|
||||
# MANUAL — nothing is stored on disk; the operator re-enters the password after
|
||||
# every restart. The wizard drops in gp-server.service.d/manual.conf, which
|
||||
# repoints LoadCredential to a tmpfs path (/run/goblinpay/wallet_password)
|
||||
@@ -46,13 +50,30 @@ DynamicUser=yes
|
||||
# as root, so a 0640 root:root file is fine even under DynamicUser.
|
||||
EnvironmentFile=/etc/goblinpay.env
|
||||
|
||||
# The wallet password as a credential: the source file stays root-owned 0400
|
||||
# (in UNATTENDED mode the wizard writes the operator's CHOSEN password there);
|
||||
# systemd exposes a copy under $CREDENTIALS_DIRECTORY (%d), readable by the
|
||||
# dynamic service user. The wallet is pointed at it via the *_FILE variant. This
|
||||
# overrides any GP_WALLET_PASSWORD_FILE in the env file above, so the service
|
||||
# always reads the credential copy. In MANUAL mode the manual.conf drop-in resets
|
||||
# this line and reads /run/goblinpay/wallet_password (tmpfs) instead.
|
||||
# The wallet password as a credential. systemd exposes it under
|
||||
# $CREDENTIALS_DIRECTORY (%d), readable only by the dynamic service user, and the
|
||||
# wallet is pointed at it via the *_FILE variant. This overrides any
|
||||
# GP_WALLET_PASSWORD_FILE in the env file above, so the service always reads the
|
||||
# credential copy at %d/gp_wallet_password.
|
||||
#
|
||||
# The wizard (`gp-server setup`) writes a drop-in that picks the delivery:
|
||||
# * UNATTENDED, ENCRYPTED (the secure default): gp-server.service.d/encrypted.conf
|
||||
# resets the line below and uses `LoadCredentialEncrypted=` to read a
|
||||
# host-sealed CIPHERTEXT blob (wallet_password.cred, made with
|
||||
# `systemd-creds encrypt`). No plaintext wallet password is on disk.
|
||||
# * UNATTENDED, plaintext fallback (only when systemd-creds is unavailable):
|
||||
# no drop-in; the line below reads the root-owned 0400 PLAINTEXT file.
|
||||
# * MANUAL: gp-server.service.d/manual.conf resets the line and reads
|
||||
# /run/goblinpay/wallet_password (tmpfs), which the operator populates by hand.
|
||||
#
|
||||
# The line below is the plaintext-fallback default so a by-hand install works out
|
||||
# of the box. To harden a by-hand install, seal the password and switch to the
|
||||
# encrypted directive:
|
||||
# printf '%s' 'YOUR-WALLET-PASSWORD' \
|
||||
# | sudo systemd-creds encrypt --name=gp_wallet_password - \
|
||||
# /etc/goblinpay/secrets/wallet_password.cred
|
||||
# # then, in a drop-in, replace the LoadCredential line below with:
|
||||
# # LoadCredentialEncrypted=gp_wallet_password:/etc/goblinpay/secrets/wallet_password.cred
|
||||
LoadCredential=gp_wallet_password:/etc/goblinpay/secrets/wallet_password
|
||||
Environment=GP_WALLET_PASSWORD_FILE=%d/gp_wallet_password
|
||||
# First-boot-only seed bootstrap WITHOUT the wizard (remove after the wallet
|
||||
|
||||
+9
-3
@@ -84,9 +84,15 @@ else
|
||||
|
||||
Skipped the wizard. Finish setup either way:
|
||||
Guided (recommended): $SUDO gp-server setup
|
||||
By hand (advanced): copy deploy/.env.example to $ENV_FILE and edit it,
|
||||
write $SECRETS_DIR/wallet_password (mode 0400), and
|
||||
bootstrap the wallet once with GP_MNEMONIC_FILE.
|
||||
By hand (advanced): copy deploy/.env.example to $ENV_FILE and edit it, then
|
||||
deliver the wallet password. Prefer ENCRYPTED at rest:
|
||||
printf '%s' 'YOUR-PASSWORD' | $SUDO systemd-creds encrypt \\
|
||||
--name=gp_wallet_password - $SECRETS_DIR/wallet_password.cred
|
||||
and switch the unit to LoadCredentialEncrypted (see
|
||||
deploy/gp-server.service). Or fall back to a 0400 plaintext
|
||||
$SECRETS_DIR/wallet_password. Bootstrap the wallet once with
|
||||
GP_MNEMONIC_FILE (a file, never the inline env var), then
|
||||
remove it.
|
||||
Then start it: $SUDO systemctl start gp-server
|
||||
Check it: curl -s http://127.0.0.1:8080/health
|
||||
EOF
|
||||
|
||||
Reference in New Issue
Block a user