Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ edition = "2021"
# the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a
# release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet)
# keep their own independent versions — only the released binary tracks the workspace version.
version = "0.252.0"
version = "0.252.1"

# Release hardening, matching digstore: keep integer-overflow checks ON in release.
# The node parses untrusted serialized input and does offset/length arithmetic over
Expand Down
22 changes: 22 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1569,6 +1569,24 @@ is a property of a SERVICE RUN and not of one packaging target.
An `DIG_IDENTITY_DIR` / `DIG_NODE_CACHE` value the operator set explicitly MUST be preserved; a CLI
run MUST be left untouched, keeping the user's identity shared with their other DIG tools.

**The wallet base — same anchoring, with one condition.** The node's operator wallet (§16.4) resolves
its base from `DIG_WALLET_BASE`, then `%LOCALAPPDATA%`, then `$HOME`. Inside the packaged systemd
unit none of those is set: the unit declares no `User=`, so systemd sets no `$HOME`, and no
`WorkingDirectory=`, so the working directory is `/`. A relative fallback therefore resolves the seed
to `/DigWallet/seed.bin`, and that write SUCCEEDS — the unit runs as root and `ProtectSystem=full`
leaves `/` writable — so the node operates normally with its wallet at the filesystem root.

A SERVICE run MUST therefore anchor `DIG_WALLET_BASE` at the resolved state dir, giving
`<state_dir>/DigWallet/seed.bin` and `<state_dir>/DigNode/device/device.key`. The override names the
BASE and never either directory: both roots MUST derive from one value, or the sibling relationship
§16.4 requires could be broken by configuration alone.

It MUST be adopted ONLY when no wallet is present at the base the service would otherwise have
resolved. The operator wallet holds real $DIG for mirror-coin collateral, so re-rooting a host that
already has one would leave the funded seed unreferenced and mint an empty replacement; a Windows
LocalSystem service, whose `%LOCALAPPDATA%` IS set, is exactly that case. Presence that cannot be
DETERMINED MUST count as present. Key material MUST NOT be moved or copied automatically.

**Creation + ACL — the HARDENING CONTRACT.** The state dir holds the control token that grants FULL
local control, so its ACL MUST NOT be world/all-users-readable. On Windows this is the HARD case:
`%PROGRAMDATA%` grants `BUILTIN\Users` "create subfolder", so ANY low-priv user can pre-create
Expand Down Expand Up @@ -4533,6 +4551,10 @@ protected `D:P(A;;FA;;;<user>)` DACL on Windows, never the ACL inherited from `%
| `<device_dir>/device.key` | 32 raw CSPRNG bytes, no header |
| `<wallet_dir>/wallet.meta.json` | `origin`, `created_at` (RFC 3339), `ever_funded` |

`<user_base>` is resolved from `DIG_WALLET_BASE`, then `%LOCALAPPDATA%`, then `$HOME`, by ONE resolver
that both roots below use. A service run anchors that base at the machine state dir under the
condition stated in §7.3a.

`<device_dir>` is `<user_base>/DigNode/device/` — a **SIBLING** of `<wallet_dir>`
(`<user_base>/DigWallet/`), never a child. **That separation IS the partial-exfiltration boundary and
MUST NOT be collapsed.** Placing the key inside the wallet directory degrades the seal to a
Expand Down
186 changes: 173 additions & 13 deletions crates/dig-node-service/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,10 @@ pub const IDENTITY_DIR_ENV: &str = "DIG_IDENTITY_DIR";
/// The env var that overrides the node's content CACHE dir (read by `dig_node_core`).
pub const CACHE_DIR_ENV: &str = "DIG_NODE_CACHE";

/// The env var that overrides the base BOTH wallet roots hang off. Re-exported from the crate
/// that owns the layout rather than re-spelled here, so the two can never drift apart.
pub use dig_wallet::WALLET_BASE_ENV;

/// PURE decision core (no I/O): the identity + cache directory overrides a SERVICE run must
/// adopt, given whether it is a service, the resolved machine `state_dir`, and whether the
/// operator has already set each override explicitly.
Expand All @@ -195,12 +199,38 @@ pub const CACHE_DIR_ENV: &str = "DIG_NODE_CACHE";
/// the process environment, and so the ONE place that mutates the environment is a single
/// documented call early in startup.
///
/// # The wallet base, and the one condition on it (dig-node#491)
///
/// The wallet's own resolver (`DIG_WALLET_BASE`, then `%LOCALAPPDATA%`, then `$HOME`) misses every
/// rung inside the shipped systemd unit: it sets no `User=`, so systemd sets no `$HOME`, and no
/// `WorkingDirectory=`, so the working directory is `/`. The seed was therefore CREATED at
/// `/DigWallet/seed.bin`, and the write SUCCEEDED — the unit runs as root and `ProtectSystem=full`
/// leaves `/` writable — so the node came up with a working wallet at the filesystem root and
/// nothing anywhere said so.
///
/// `legacy_wallet_present` is what keeps that fix from being worse than the bug. The node's
/// operator wallet holds real $DIG for mirror-coin collateral, so anchoring a host that ALREADY
/// has a wallet at the old location would leave the funded file unreferenced and mint a fresh
/// empty one beside it. This is a live risk on Windows in particular, where `%LOCALAPPDATA%` IS
/// set for a LocalSystem service, so existing installs already hold a seed under the systemprofile
/// path. Existing hosts therefore keep their old base; only a host with no wallet yet adopts the
/// anchored one, and moving key material is left to a deliberate operator-run migration.
///
/// This is NOT the "derive the wallet base from the node's cache dir" change
/// [`crate::wallet_env`] argues against. That one would re-root the seed onto a cache directory
/// this function has ALREADY moved, so it would find nothing there and mint a fresh wallet on
/// every existing install. The difference is the whole design: this is an independent explicit
/// base, adopted only where there is nothing to orphan. An intra-doc link rather than a code
/// span, so rustdoc fails if the module the argument lives in ever moves.
///
/// An override the operator set explicitly is never replaced — their choice outranks ours.
pub fn service_data_dir_overrides(
is_service: bool,
state_dir: &Path,
identity_dir_set: bool,
cache_dir_set: bool,
wallet_base_set: bool,
legacy_wallet_present: bool,
) -> Vec<(&'static str, PathBuf)> {
if !is_service {
return Vec::new();
Expand All @@ -212,6 +242,9 @@ pub fn service_data_dir_overrides(
if !cache_dir_set {
out.push((CACHE_DIR_ENV, state_dir.join("cache")));
}
if !wallet_base_set && !legacy_wallet_present {
Comment thread
MichaelTaylor3d marked this conversation as resolved.
out.push((WALLET_BASE_ENV, state_dir.to_path_buf()));
}
out
}

Expand All @@ -226,11 +259,33 @@ pub fn anchor_service_data_dirs() {
return;
}
let dir = state_dir();
// Asked BEFORE the override is written, and answered by the crate that owns the layout, so the
// question is genuinely "what would this build have opened a moment ago" rather than a guess
// at the path. `legacy_wallet_present` treats an undeterminable answer as PRESENT, which is
// the direction that cannot strand a funded wallet.
let legacy_wallet = dig_wallet::legacy_wallet_present();
// Gated on the SAME conjunction the anchoring decision uses, not on `legacy_wallet` alone.
// `service_data_dir_overrides` leaves the wallet base alone when the operator has already set
// `DIG_WALLET_BASE`, so with that variable set the service opens the operator's base and NOT
// the legacy path - and a warning naming the legacy path as "the one it keeps opening" would
// be a false statement about where live key material sits, inviting a migration or a deletion
// of the wrong file.
if legacy_wallet && !env_is_set(WALLET_BASE_ENV) {
// The PATH only. Never the contents, and never a hint at them.
tracing::warn!(
Comment thread
MichaelTaylor3d marked this conversation as resolved.
seed = %dig_wallet::legacy_seed_path().display(),
state_dir = %dir.display(),
"a wallet already exists at the pre-#491 location, so this service keeps opening it \
rather than the machine state dir; move it deliberately if you want it anchored"
);
}
let overrides = service_data_dir_overrides(
true,
&dir,
env_is_set(IDENTITY_DIR_ENV),
env_is_set(CACHE_DIR_ENV),
env_is_set(WALLET_BASE_ENV),
legacy_wallet,
);
for (key, value) in overrides {
// Create it up front so the first write does not race, and so a failure surfaces here
Expand Down Expand Up @@ -981,8 +1036,14 @@ mod tests {
fn a_service_run_anchors_both_identity_and_cache_under_the_machine_state_dir() {
// The exact failure this prevents: the packaged unit sets ProtectHome=true, so a seed
// written under $HOME fails EROFS and the peer network never starts.
let overrides =
service_data_dir_overrides(true, Path::new("/var/lib/dig-node"), false, false);
let overrides = service_data_dir_overrides(
true,
Path::new("/var/lib/dig-node"),
false,
false,
true,
true,
);
assert_eq!(
overrides,
vec![
Expand All @@ -995,37 +1056,136 @@ mod tests {
);
}

// -- The wallet base (#491) ---------------------------------------------------------------
//
// On the shipped systemd unit the wallet's own chain resolves nothing (no `User=` so no
// `$HOME`, and no `LOCALAPPDATA` on Linux) and collapses to the working directory, which for a
// system unit is `/`. The seed was created at `/DigWallet/seed.bin` and the write SUCCEEDED,
// so the node has been running with a wallet at the filesystem root.

#[test]
fn a_service_with_no_wallet_yet_anchors_the_wallet_base_at_the_state_dir() {
let overrides = service_data_dir_overrides(
true,
Path::new("/var/lib/dig-node"),
true,
true,
false,
false,
);
assert_eq!(
overrides,
vec![(WALLET_BASE_ENV, PathBuf::from("/var/lib/dig-node"))],
"the BASE, not the wallet directory: the seed and the device key both hang off it"
);
}

#[test]
fn a_service_that_already_has_a_wallet_keeps_opening_the_old_one() {
// The safety-critical direction. The node's operator wallet holds real $DIG for
// mirror-coin collateral, so re-rooting an existing install would leave the funded seed
// unreferenced and mint a fresh empty wallet in its place. Existing Windows service
// installs are the live case: `%LOCALAPPDATA%` IS set for LocalSystem, so they already
// hold a seed under the systemprofile path.
assert!(
service_data_dir_overrides(
true,
Path::new("/var/lib/dig-node"),
true,
true,
false,
true
)
.is_empty(),
"no wallet override may be emitted while a wallet exists at the legacy path"
);
}

#[test]
fn an_operator_set_wallet_base_outranks_the_anchor() {
// Even with nothing to orphan: the operator's explicit choice wins, exactly as it does for
// the identity and cache dirs.
assert!(service_data_dir_overrides(
true,
Path::new("/var/lib/dig-node"),
true,
true,
true,
false
)
.is_empty());
}

#[test]
fn an_operator_set_wallet_base_wins_even_with_a_legacy_wallet_on_disk() {
// The state the start-up warning used to mis-narrate. With BOTH a stale seed at the legacy
// path AND an operator-set `DIG_WALLET_BASE`, the anchor emits nothing because the operator
// already placed the base - so the service opens the OPERATOR's base, not the legacy path.
// The warning in `anchor_service_data_dirs` is gated on the same conjunction for that
// reason: narrating the legacy path here would name the wrong file as the live one.
assert!(
service_data_dir_overrides(
true,
Path::new("/var/lib/dig-node"),
true,
true,
true,
true
)
.is_empty(),
"an explicit wallet base outranks the legacy-wallet guard as well as the anchor"
);
}

#[test]
fn a_cli_run_is_left_entirely_alone() {
// The CLI legitimately lives under the user's home and shares that identity with the
// user's other DIG tools; anchoring it machine-wide would change where an existing
// user's key is looked up.
assert!(
service_data_dir_overrides(false, Path::new("/var/lib/dig-node"), false, false)
.is_empty()
);
// user's key is looked up — and for the wallet base that would mean a CLI reading a
// different seed from the one it read yesterday.
assert!(service_data_dir_overrides(
false,
Path::new("/var/lib/dig-node"),
false,
false,
false,
false
)
.is_empty());
}

#[test]
fn an_operator_set_override_is_never_replaced() {
let overrides =
service_data_dir_overrides(true, Path::new("/var/lib/dig-node"), true, false);
let overrides = service_data_dir_overrides(
true,
Path::new("/var/lib/dig-node"),
true,
false,
true,
true,
);
assert_eq!(
overrides,
vec![(CACHE_DIR_ENV, PathBuf::from("/var/lib/dig-node/cache"))],
"only the unset one is filled in"
);
assert!(
service_data_dir_overrides(true, Path::new("/var/lib/dig-node"), true, true).is_empty()
);
assert!(service_data_dir_overrides(
true,
Path::new("/var/lib/dig-node"),
true,
true,
true,
true
)
.is_empty());
}

#[test]
fn the_anchored_dirs_are_inside_the_state_dir_whatever_it_resolved_to() {
// A user-level service that could not create /var/lib falls back to a per-user dir; the
// anchor must follow it rather than hardcoding the machine path.
let legacy = Path::new("/home/u/.local/share/DigNode");
for (_, path) in service_data_dir_overrides(true, legacy, false, false) {
for (_, path) in service_data_dir_overrides(true, legacy, false, false, false, false) {
assert!(
path.starts_with(legacy),
"{} must live under the resolved state dir",
Expand Down
42 changes: 28 additions & 14 deletions crates/dig-node-service/src/wallet_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
//! Two independent resolvers decide "the per-user base directory" inside one `dig-node` process,
//! and they can disagree:
//!
//! - [`dig_wallet::autoseed::user_base`] is ENV-FIRST (`LOCALAPPDATA`, then `HOME`, then `"."`), so
//! it honours an override. It owns `DigWallet/seed.bin`, `wallet.meta.json` and
//! - [`dig_wallet::autoseed::user_base`] is ENV-FIRST (`DIG_WALLET_BASE`, then `LOCALAPPDATA`, then
//! `HOME`, then `"."`), so it honours an override. It owns `DigWallet/seed.bin`, `wallet.meta.json` and
//! `DigNode/device/device.key`.
//! - [`dig_node_core::platform_user_base`] asks the OS for the Known Folder and only then falls
//! back to the environment. It owns `cache/`, `config.json` and therefore `wallet.sqlite`, the
Expand Down Expand Up @@ -130,12 +130,13 @@ pub const SPLIT_ROOTS: &str = concat!(
/// is purely the honest version of the silence this ticket is about.
pub const AMBIENT_SPLIT_ROOTS: &str = concat!(
"the wallet's per-user root and the node's per-user root resolved to DIFFERENT places, and ",
"no override caused it: neither LOCALAPPDATA nor HOME is set in this environment, so the ",
"wallet's env-first resolver fell back to the working directory while the node's asked the ",
"OS for the account's home. Both resolved roots are logged below as `wallet_base` and ",
"`node_base`, and the seed file this run will read or create is logged as `seed`. Nothing ",
"is refused and nothing is wrong with the wallet; set HOME in the service environment if you ",
"want the two halves to share one root."
"no override caused it: the wallet base is anchored by DIG_WALLET_BASE (falling back to ",
"LOCALAPPDATA, then HOME) while the node's root comes from the OS known-folder API or the ",
"account's home, so on a stock service install the two legitimately differ. Both resolved ",
"roots are logged below as `wallet_base` and `node_base`, and the seed file this run will ",
"read or create is logged as `seed`. Nothing is refused and nothing is wrong with the ",
"wallet; DIG_WALLET_BASE outranks LOCALAPPDATA and HOME, so it is the variable that decides ",
"where the wallet half lives."
);

/// The prose for the refusal.
Expand Down Expand Up @@ -361,11 +362,17 @@ mod tests {
.expect("an override splits the roots")
}

/// The stock Linux `.deb` shape: no `LOCALAPPDATA`, no `HOME`, so the wallet half collapses to
/// "." while the node half falls through `getpwuid_r` to the account home.
/// The stock Linux `.deb` shape: no `LOCALAPPDATA` and no `HOME`, so the wallet half resolves
/// from its anchored base while the node half falls through `getpwuid_r` to the account home.
/// Divergent roots with nothing overridden - the ordinary case, not a fault.
fn ambient() -> WalletRootSplit {
split_of(Path::new("."), Path::new("/root"), None, false)
.expect("divergent roots are still a split")
split_of(
Path::new("/var/lib/dig-node"),
Path::new("/root"),
None,
false,
)
.expect("divergent roots are still a split")
}

#[test]
Expand Down Expand Up @@ -534,9 +541,16 @@ mod tests {
!AMBIENT_SPLIT_ROOTS.contains(CACHE_DIR_ENV),
"DIG_NODE_CACHE does not address an ambient split"
);
assert!(AMBIENT_SPLIT_ROOTS.contains("HOME"));
assert!(
AMBIENT_SPLIT_ROOTS.contains(dig_wallet::WALLET_BASE_ENV),
"the variable that actually decides the wallet base must be named"
);
assert!(
!AMBIENT_SPLIT_ROOTS.contains("set HOME"),
"HOME is outranked by DIG_WALLET_BASE, so prescribing it is an inert remedy"
);
assert!(AMBIENT_SPLIT_ROOTS.contains("LOCALAPPDATA"));
assert!(AMBIENT_SPLIT_ROOTS.contains("seed"));
assert!(AMBIENT_SPLIT_ROOTS.contains("`seed`"));
assert!(AMBIENT_SPLIT_ROOTS.contains("Nothing is refused"));
}

Expand Down
Loading
Loading