From b2e11ff1fe7c9c121f3cb23544da9dc09f64e6f9 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:17:52 -0400 Subject: [PATCH] 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. --- README.md | 44 +++-- crates/gp-core/src/config.rs | 85 +++++++++ crates/gp-core/src/setup.rs | 136 +++++++++++++-- crates/gp-server/src/main.rs | 8 + crates/gp-server/src/setup.rs | 312 ++++++++++++++++++++++++++++++---- deploy/.env.example | 16 +- deploy/gp-server.service | 43 +++-- deploy/install.sh | 12 +- 8 files changed, 584 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 194b5a7..f66d1a7 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/crates/gp-core/src/config.rs b/crates/gp-core/src/config.rs index 7b2c3ff..860522a 100644 --- a/crates/gp-core/src/config.rs +++ b/crates/gp-core/src/config.rs @@ -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, } +/// 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::>(); + 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 { + 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::>() + .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(&[ diff --git a/crates/gp-core/src/setup.rs b/crates/gp-core/src/setup.rs index 55e4421..adc744d 100644 --- a/crates/gp-core/src/setup.rs +++ b/crates/gp-core/src/setup.rs @@ -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. diff --git a/crates/gp-server/src/main.rs b/crates/gp-server/src/main.rs index 3ee1fe4..6dca6ba 100644 --- a/crates/gp-server/src/main.rs +++ b/crates/gp-server/src/main.rs @@ -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"), diff --git a/crates/gp-server/src/setup.rs b/crates/gp-server/src/setup.rs index fb84e3b..85434ae 100644 --- a/crates/gp-server/src/setup.rs +++ b/crates/gp-server/src/setup.rs @@ -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; + /// 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>, +} + +/// 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 { + 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( // 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 = 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( // 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( 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( // 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( 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( // 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( } 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( } } +/// 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( + input: &mut R, + out: &mut W, + hidden: bool, +) -> Result { + 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( } /// 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( paths: &Paths, mode: core_setup::RestartMode, password: &str, -) -> Result<(), String> { + seal: &Sealer, + out: &mut W, +) -> Result { 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 { + 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"); diff --git a/deploy/.env.example b/deploy/.env.example index 64a2d7f..2c0b0e5 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -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 diff --git a/deploy/gp-server.service b/deploy/gp-server.service index 31c359c..8010282 100644 --- a/deploy/gp-server.service +++ b/deploy/gp-server.service @@ -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 diff --git a/deploy/install.sh b/deploy/install.sh index 805f6b6..99e2f51 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -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