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.1"
version = "0.252.2"

# 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
65 changes: 64 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1539,7 +1539,14 @@ mutation or custody method is ever open.
### 7.3a. The daemon state dir — location, ACL, threat model (#501)

The state dir holds ONLY the control/auth state — the control token (§7.3) and the paired-token
store (`paired-tokens.json`, §7.11). The bulk per-user `.dig` cache and `config.json` (§3.5–3.6) do
store (`paired-tokens.json`, §7.11). The CLIENT-side paired token (§7.11a) is NOT in this dir: it
lives at `<per_user_base>/DigNode/client-token`, owned by the invoking user, so an unprivileged
client can hold a token without any machine-wide path being widened. `<per_user_base>` is the
platform per-user base — `$HOME` on Unix/macOS, `%LOCALAPPDATA%` on Windows. It MUST be resolved
directly from that base and MUST NOT be resolved through the cache resolver, whose unwritable-dir
fallback is a PID-keyed directory under the system temp dir: a world-writable temp directory is
never an acceptable location for a bearer credential. When no per-user base can be resolved, the
client MUST REFUSE to store the token rather than degrade to the current working directory. The bulk per-user `.dig` cache and `config.json` (§3.5–3.6) do
NOT move; they stay per-user (shared with the browser/digstore, #96).

**Resolution order** (the daemon and every operator CLI MUST resolve this identically, so it MUST
Expand Down Expand Up @@ -2294,6 +2301,62 @@ and REVOCABLE. All token comparisons are constant-time.
client_name, created_ms }] }`, restricted (dir ACL), atomic writes. The auth gate accepts the master token OR any token in
this store (except for the pairing-administration methods).

### 7.11a. Client-side pairing and the CLI token ladder (#403)

An MV3 extension is not the only client that cannot read `<state_dir>/control-token`. On a `.deb`
install that file is `0600 root:root` and its directory `0700 root:root` (§7.3a), so an ORDINARY OS
USER driving the CLI is in exactly the extension's position: the node is running, the user is on the
machine, and every token-gated `control.*` verb is out of reach. The remedy MUST NOT be to widen the
mode — the master token is the master capability, and it authorizes pairing administration and
chain authority over the wallet replica (§7.11, §18.16).

The unreadable-master-token remedy (§7.3) is read ONLY by a caller holding neither token, i.e. by
definition an unprivileged one. On Unix it MUST therefore name `dign pair connect` — the verb THAT
reader can run, unelevated — in addition to the operator's `sudo dign pair approve <pairing_id>`.
Naming only the elevated half directs the one audience that sees the message to a command it cannot
execute.

**`dig-node pair connect [--client-name NAME]`** is the CLIENT half of the §7.11 handshake, and the
one `pair` verb that requires NO token. It MUST:

1. call the OPEN `pairing.request { client_name }` (never the gated client — a requester holds no
token by definition, and routing an open method through the gated path fails with an elevation
remedy for a question the node answers to anyone);
2. DISPLAY the returned `pairing_code` and name the operator's command
(`sudo dign pair approve <pairing_id>`), so the compare-codes consent step of §7.11 is performed;
3. poll the OPEN `pairing.poll { pairing_id }` to a TERMINAL state, bounded by the server's own
`expires_ms` rather than by a local retry count, and terminate on expiry rather than spin;
4. on `status: "approved"`, persist the delivered token to the INVOKING USER's own per-user base
(`<per_user_base>/DigNode/client-token`, §7.3a). The file MUST be created EXCLUSIVELY and
owner-only in a single step (`O_CREAT|O_EXCL` at mode `0600` on Unix), never written first and
restricted afterwards: a write-then-chmod leaves the token readable under the process umask for
a window, and a plain write FOLLOWS a symlink planted at that path — which discloses the token,
or clobbers an arbitrary file, as the invoking user. Replacing this user's OWN existing store on
a re-pair is permitted and MUST unlink it rather than write through it.

`client_name` is bounded by the same 64-character REFUSAL bound the node applies (§7.11): an
over-long name MUST be refused, never shortened. A name this client shortened is a name the client
partly wrote, and the operator approves what they are shown.

**The token ladder.** Every CLI `control.*` call selects its token in this fixed order:

1. the MASTER control token, when this account can read it;
2. otherwise this user's paired token from `<per_user_base>/DigNode/client-token`, when present (an unresolvable per-user base counts as "no paired token", not an error);
3. otherwise the master read's own error, VERBATIM — its kind (which sets the CLI exit code) and its
remedy text (§7.3) MUST both survive unchanged, because that sentence is what tells the user how
to become able to act.

Rung 1 MUST NOT consult the per-user store: a user who can read the master token never reads a
paired one. The selection MUST be expressed as a function of the two read OUTCOMES rather than as a
compile-time platform branch, because the unprivileged case cannot be reproduced by a test process
that is privileged, and a `cfg!` branch is exercised only on the platform that compiles it.

**What pairing a CLI client does NOT grant.** The paired token remains SCOPED and REVOCABLE
(§7.11): it cannot drive `control.pairing.*` and it cannot drive `control.chiaPeers.add`/`.remove`.
Pairing a user therefore never confers pairing administration or chain authority, and no file mode
changes anywhere in the flow. Revocation is unchanged: `sudo dign pair revoke <token_id>` invalidates
it on the very next request.

### 7.12. Paired-token authorization for wallet methods (#370)

The pairing framework (§7.11) authorizes `control.*` mutations. The thin-client model (epic #365)
Expand Down
17 changes: 16 additions & 1 deletion crates/dig-node-service/src/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,7 @@ fn remedy_for_unreadable_token(path: &Path, dir: &str, unix: bool) -> String {
);
if unix {
format!(
"{elevated}. For a program that must keep running as an ordinary user (the dig-app Agent on a server), do NOT widen the mode on this file — it is the master capability. Pair a scoped, revocable token for that client instead: `sudo dign pair` LISTS the pending requests and `sudo dign pair approve <pairing_id>` approves one -- the bare verb only lists, it approves nothing -- and the token the client receives cannot mint or revoke pairings and cannot grant chain authority. Revoke it any time with `sudo dign pair revoke <token_id>`."
"{elevated}. If you cannot elevate, you do not have to: run `dign pair connect` as THIS account to request a scoped token of your own, then ask the operator to approve it. Do NOT widen the mode on this file — it is the master capability. On the operator's side: `sudo dign pair` LISTS the pending requests and `sudo dign pair approve <pairing_id>` approves one -- the bare verb only lists, it approves nothing -- and the token the client receives cannot mint or revoke pairings and cannot grant chain authority. Revoke it any time with `sudo dign pair revoke <token_id>`."
)
} else {
format!(
Expand Down Expand Up @@ -6669,6 +6669,21 @@ mod tests {
unix.contains("revoke"),
"a grant with no stated revocation is a permanent one: {unix}"
);
// dig-node#403 -- this message is rung 3 of the ladder, so the ONLY reader who ever sees
// it is one holding neither token: an unprivileged user who by construction cannot run a
// `sudo` verb. Naming only the operator's half sends that reader to find an
// administrator for a step they can perform themselves. The verb is asserted UNPREFIXED
// (`contains("sudo dign pair connect")` would be the same dead end wearing the right
// words), and the operator half is asserted above and below rather than replaced -- a fix
// that swapped one audience's guidance for the other's is not a fix.
assert!(
unix.contains("run `dign pair connect`"),
"the unprivileged reader must be told the verb THEY can run: {unix}"
);
assert!(
!unix.contains("sudo dign pair connect"),
"`pair connect` needs no elevation; prefixing it recreates the dead end: {unix}"
);
assert!(
!unix.contains("uninstall"),
"reinstalling does not grant read access on Unix; advising it is the dead end: {unix}"
Expand Down
21 changes: 20 additions & 1 deletion crates/dig-node-service/src/control_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@
//! mutating CLI control is gated by the same capability as the WS, not an unauthenticated side
//! door. A node running as a service under another OS account surfaces the precise
//! service-vs-user remedy from `load_token_readonly` (elevate / grant read ACL / start the node).
//!
//! # The ladder (#403) -- a paired token when the master token is out of reach
//!
//! On a `.deb` install the master token is `0600 root:root` (#501), so an ordinary user's read is
//! DENIED. Rather than widen that mode -- it is the master capability -- the client falls through
//! to the scoped token an operator approved for this account (`dign pair connect`, then
//! `sudo dign pair approve <pairing_id>`; see [`crate::paired_client`]). That token is strictly
//! less powerful: it cannot administer pairings and carries no chain authority over the wallet
//! replica. With NEITHER token the master read's rich remedy is returned unchanged, because it is
//! the message that tells the user what to do next.

use serde_json::{json, Value};

Expand Down Expand Up @@ -43,7 +53,16 @@ fn build_control_client() -> reqwest::Result<reqwest::Client> {
/// ("is the node running?"), a JSON-RPC `error` → `Other` (the node's own message).
pub fn call_control(config: &Config, method: &str, params: Value) -> std::io::Result<Value> {
let addr = config.bind_addr();
let token = control::load_token_readonly()?;
// The #403 ladder: the master token when this account can read it, else this user's paired
// token, else the master read's own remedy verbatim. Rung 2 is a thunk, so a user who can read
// the master token never touches the per-user store.
let token = crate::paired_client::select_token(control::load_token_readonly(), || {
Comment thread
MichaelTaylor3d marked this conversation as resolved.
// An unresolvable per-user base is "this account has no paired token", not an error of
// its own: rung 3's master remedy is the message the user needs either way.
crate::paired_client::paired_token_path()
.ok()
.and_then(|p| crate::paired_client::load_paired_token(&p))
})?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
Expand Down
26 changes: 26 additions & 0 deletions crates/dig-node-service/src/entrypoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,14 @@ enum PairCommand {
/// The token id from `dig-node pair`.
token_id: String,
},
/// Ask this node for a scoped control token for YOUR account, then wait for the operator
/// to approve it. Needs no elevation and no master token.
Connect {
/// The label the operator sees when approving. Defaults to `dign CLI (<user>)`.
/// Refused, never shortened, above 64 characters.
#[arg(long)]
client_name: Option<String>,
},
}

impl Command {
Expand Down Expand Up @@ -876,6 +884,7 @@ pub fn run() -> std::process::ExitCode {
None | Some(PairCommand::List) => PairAction::List,
Some(PairCommand::Approve { pairing_id }) => PairAction::Approve { pairing_id },
Some(PairCommand::Revoke { token_id }) => PairAction::Revoke { token_id },
Some(PairCommand::Connect { client_name }) => PairAction::Connect { client_name },
};
render(pair::run(&config, pair_action), action, json)
}
Expand Down Expand Up @@ -1467,6 +1476,7 @@ mod tests {
None | Some(PairCommand::List) => PairAction::List,
Some(PairCommand::Approve { pairing_id }) => PairAction::Approve { pairing_id },
Some(PairCommand::Revoke { token_id }) => PairAction::Revoke { token_id },
Some(PairCommand::Connect { client_name }) => PairAction::Connect { client_name },
},
_ => panic!("expected a pair command from {argv:?}"),
};
Expand All @@ -1482,6 +1492,22 @@ mod tests {
),
"`pair approve <id>` must approve, and must carry the id through"
);
// #403: `connect` is a DISTINCT verb, and it must never be reachable by accident from the
// bare noun -- the bare noun is the operator's read-only listing.
assert!(
matches!(
pair_action(&["dig-node", "pair", "connect"]),
PairAction::Connect { client_name: None }
),
"`pair connect` with no flag must default its own label"
);
assert!(
matches!(
pair_action(&["dig-node", "pair", "connect", "--client-name", "Agent"]),
PairAction::Connect { client_name: Some(ref n) } if n == "Agent"
),
"`--client-name` must reach the action verbatim -- the operator approves what they see"
);
}

/// **`dign mirror bond-states --after` sends the cursor to the node.**
Expand Down
4 changes: 4 additions & 0 deletions crates/dig-node-service/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ pub mod network_info;
/// handler argument, then opens the user's default browser at the resolving URL. See [`open`].
pub mod open;
pub mod pair;
/// The CLIENT half of the #280 pairing handshake (#403): the token ladder + the per-user
/// paired-token store, so an unprivileged user can drive `control.*` without widening a mode.
/// See [`paired_client`].
pub mod paired_client;
pub mod pairing;
/// `control.peers.ping` (dig_ecosystem#1985): the connection-ladder diagnostic — dial one peer a
/// tier at a time and report WHICH tier reached it. See [`peer_ping`].
Expand Down
Loading
Loading