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
39 changes: 37 additions & 2 deletions 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 @@ -31,7 +31,7 @@ edition = "2021"
# (`[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.24.0"
version = "0.25.0"

# 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
109 changes: 108 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -953,7 +953,10 @@ rejected with `-32030 UNAUTHORIZED` before the method runs.

**Gated wallet methods (MUST present a token).** The custody-lifecycle group (§18.20 —
`wallet.create` / `wallet.import` / `wallet.restore` / `wallet.unlock` / `wallet.lock` /
`wallet.status` / `wallet.list` / `wallet.select` / `wallet.delete`) and the mutation group (the send/spend group §18.9, the offer suite
`wallet.status` / `wallet.list` / `wallet.select` / `wallet.delete`), the node-managed unlock-auth group
(§18.24 — EVERY `auth.*` method: `auth.status` / `auth.get_method` / `auth.set_method` / `auth.set_mode` /
`auth.enroll_totp` / `auth.enroll_passkey_begin` / `auth.enroll_passkey_finish` / `auth.unlock` /
`auth.sign_unlock` / `auth.lock`), and the mutation group (the send/spend group §18.9, the offer suite
+ DID/NFT mint & transfer §18.9a, and the state-changing record-update actions §18.16) are all gated.
These methods are NEVER relayed upstream — a signing/custody request must never leave the loopback node;
an authorized call is served locally by the node-custodied wallet (or, until the wallet surface is served
Expand Down Expand Up @@ -2002,6 +2005,110 @@ Each returns a `TipOutcome` — `{ result: "tipped", txid, dig_amount, recipient
Sage stream). Each `/ws` session forwards it as a `{ "type": "tip", "tip": <ledger-entry> }` push frame,
alongside the `sync_status` + `event` frames.

## 18.24. Node-managed unlock authentication + per-transaction sign-unlock (#431/#432)

The node is the LOCAL authority that gates the node-custodied signer (§18.21). There is **NO central
server**: enrollment + verification are entirely local, credential material is encrypted at rest via
`dig-keystore`, and no auth secret is ever logged or returned over any transport. This makes signing
SAFE BY DEFAULT: the decrypted private key MUST NOT persist in memory beyond a single signature.

`crate::sage::auth::UnlockAuth` owns the auth+unlock state machine; it holds a handle to the
`WalletCustody` (§18.20) and mediates the effective signer. When an `UnlockAuth` is attached to the
served `WalletBackend` (`with_auth`), it GOVERNS `current_signer()` — the §18.21 sign/broadcast-on-behalf
path obtains a signer ONLY through the auth gate. When no `UnlockAuth` is attached (the simulator /
bring-up-injected-signer path), behaviour is unchanged (back-compat).

**Unlock mode (the ONLY policy knob).**

- **`per_transaction` (DEFAULT, secure).** A successful `unlock` grants a READ-ONLY session
(balances/history/reads). It loads NO signer — `current_signer()` is `None`. **Each signing operation
requires a fresh `sign_unlock`**: the node decrypts the seed, builds a one-shot signer, signs exactly
ONE operation, and the signer is DROPPED (not resident) immediately after. The key never persists
beyond that single operation.
- **`session_unlock_all` (OPT-OUT, convenience, OFF by default).** One `unlock` at session start builds
and HOLDS the signer for the session lifetime; `current_signer()` returns it until `lock`. Set via
`auth.set_mode`.

**Auth model — per-wallet password + NODE-LEVEL second factor.** One method is active at a time; the user
may add TOTP or a passkey on top of the password.

- The **password is PER-WALLET**: it is the at-rest KDF root that decrypts THAT wallet's seed
(`dig-keystore` Argon2id, §18.18/§18.20). Every `unlock`/`sign_unlock` requires the TARGET wallet's
password (`WalletCustody::verify_password`).
- The **second factor (TOTP / passkey) is NODE-LEVEL** — a single node authentication that authorizes the
unlock across EVERY custodied wallet (#431). Its secret is sealed at rest under a node-level device key
(`auth/node.key`, owner-only), NOT under any wallet password, so 2FA works uniformly for every wallet.
When a second factor is enrolled, `unlock`/`sign_unlock` require BOTH the target wallet's password AND
the node-level factor. A `Credential` carries the password plus, per the active method, a TOTP code or a
WebAuthn assertion; verification requires every factor the active method mandates. (This is an honest,
strong-at-rest design for a keyless local node; true passwordless replacement via WebAuthn PRF or a
TPM-sealed key is a scoped follow-up.)

- **`password` (default).** Verified by decrypting the addressed wallet's seed.
- **`totp` (RFC-6238, `totp-rs`).** `auth.enroll_totp` generates a fresh node-level secret, seals it under
`auth/node.key`, sets the method to `totp`, and returns the base32 secret + `otpauth://` URI EXACTLY
ONCE. Thereafter `unlock`/`sign_unlock` require the target wallet's password AND a current 6-digit code
(±1 step skew). **A code is ONE-TIME-USE** (RFC-6238 §5.2): the last-accepted time-step is persisted and
a code at a step `<=` it is rejected as a REPLAY (`401`). The check-and-advance is ATOMIC — the
find-step → compare-to-last → advance runs under a single exclusive lock — so two CONCURRENT verifies of
the same code cannot both pass (the replay window holds under concurrency, not just sequentially).
- **`passkey` (WebAuthn).** `auth.enroll_passkey_begin`/`finish` register a node-level credential.
Thereafter `unlock`/`sign_unlock` require the password AND a valid assertion. The real `webauthn-rs`
ceremony is finalized with the paired-extension origin (#433 follow-up) — it fails closed until then, so
the active method never becomes `passkey` on this node.

**Enrolling or replacing a factor re-verifies the CURRENT factor.** `auth.enroll_totp` /
`auth.enroll_passkey_begin` / `auth.set_method` and switching mode to `session_unlock_all` all run the
FULL current-factor verification (`verify(id, cred)` — password AND, when a second factor is already
active, the live code/assertion) BEFORE rotating/weakening. So an attacker holding the paired token + a
stolen password — the exact threat 2FA backstops — cannot rotate the factor to their own authenticator or
silently downgrade the posture. Password-only enrollment is permitted ONLY while the active method is
`password`.

**State machine + the §18.21 gate.**

- `unlock(id?, cred)` → verify per the active method → set the session READ-ONLY (`per_transaction`), or
build + hold the session signer BOUND to that wallet (`session_unlock_all`). Never returns a signer or
key. A wrong/expired/replayed credential is denied (`401`), leaves the state unchanged, loads nothing.
- `sign_unlock(id?, cred)` → verify (FRESH) → decrypt the target wallet's seed, build a one-shot signer
BOUND to that wallet, and ARM it for exactly ONE signing operation.
- **Grants/session signers are BOUND to their wallet id (§18.20a multi-wallet).** `current_signer()` for a
signing op returns the armed grant (or the held session signer) ONLY when its bound wallet id equals the
node's currently-ACTIVE wallet; otherwise it fails closed — a grant armed for wallet A can never sign
when B is active. `current_signer()` resolves: bring-up-injected signer (tests) → the wallet-matched auth
gate signer → (only when NO auth is attached) the legacy held custody signer.
- **Signing dispatch is SERIALIZED + the one-shot grant is consumed panic-safely.** Every key-touching
signing method (`send_xch`/`send_cat`/`combine`/`split`/`sign_coin_spends`/`submit_transaction`/
`make_offer`/`take_offer`/mint/transfer/… AND `tip.manual`/`tip.dev_tick`/`tip.notify_consumed`, which
sign a $DIG tip) is dispatched under a signing mutex held from before the handler THROUGH grant
consumption, so two concurrent signing calls can never both observe one armed grant (a gate TOCTOU) —
ONE `sign_unlock` authorizes EXACTLY ONE signature. Consumption is via an RAII guard that runs on normal
return AND on a panic unwind, so a panicking handler can never leave the key armed + reusable.
- `lock()` → clear the read-only session, drop the session signer AND any armed grant.

**No sibling resident key over the paired boundary.** When the auth gate is attached it is the ONLY
signer-loading path: `wallet.create`/`import`/`restore` verify-and-persist but leave NO resident custody
signer (the custody session is locked immediately after provisioning), and `wallet.unlock` is redirected
to `auth.unlock`/`auth.sign_unlock` rather than loading a session-long resident key.

**Zeroize / residency invariant (adversarial-verified).** In `per_transaction` mode no signer is resident
between signatures: after a `sign_unlock` + one signing operation the one-shot signer is dropped and
`current_signer()` returns `None` — a fresh `sign_unlock` is required for the next signature. Decrypted
mnemonic material is held only in `zeroize::Zeroizing` for the duration of a build and dropped
immediately. (`chia-bls` 0.26 `SecretKey` does not itself zeroize-on-drop; the delivered guarantee is
non-retention — the signer allocation is dropped promptly — plus zeroization of the mnemonic buffer.
Byte-level scrub of the derived scalar via a key wrapper is a scoped follow-up.)

**WS/RPC surface (paired-token gated, §7.12).** Every `auth.*` method requires the master control token
OR a valid paired token, on every transport (`POST /{method}`, `/ws`, mTLS `9257`). Methods:
`auth.status` (mode, method, session state, whether a sign-grant is armed) · `auth.get_method` /
`auth.set_method` (switch active method — `password` resets to password-only) · `auth.set_mode`
(`per_transaction` | `session_unlock_all`) · `auth.enroll_totp` · `auth.enroll_passkey_begin` /
`auth.enroll_passkey_finish` · `auth.unlock` (read-only session) · `auth.sign_unlock` (per-transaction,
authorizes exactly one signature) · `auth.lock`. Auth secrets are NEVER returned except the one-time TOTP
enrollment secret/URI at `enroll_totp` (needed to provision the authenticator). No auth material crosses
as a wallet/`control.*` result.

## 19. Peer network — NAT traversal, discovery, address book, and content location

The standalone `dig-node` binary runs an L7 peer network (the in-process FFI/browser host does not —
Expand Down
48 changes: 46 additions & 2 deletions crates/dig-node-service/src/wallet_authz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ use crate::control::ct_eq;
/// new custody method is gated the moment it lands under `wallet.*` — no per-method allowlist.
pub const CUSTODY_PREFIX: &str = "wallet.";

/// The node-managed unlock-auth namespace prefix (§18.24, #431/#432): `auth.status`, `auth.unlock`,
/// `auth.sign_unlock`, `auth.set_mode`, `auth.set_method`, `auth.enroll_totp`, `auth.enroll_passkey_*`,
/// `auth.lock`, `auth.get_method`. EVERY `auth.*` method is paired-token gated (§7.12) — even the
/// reads reveal the auth posture (mode/method/session state), and `unlock`/`sign_unlock` gate the
/// node-custodied signer — so a new auth method is gated the moment it lands under `auth.*`.
pub const AUTH_PREFIX: &str = "auth.";

/// Wallet MUTATION methods that MUST be authorized (§7.12): they sign, spend, broadcast, or change
/// persisted wallet state. Sourced from the dig-wallet Sage surface (§18.9/§18.9a/§18.16/§18.17).
const GATED_WALLET_MUTATIONS: &[&str] = &[
Expand Down Expand Up @@ -83,7 +90,8 @@ const GATED_WALLET_MUTATIONS: &[&str] = &[
/// The authorization class of a JSON-RPC method w.r.t. the wallet surface (§7.12).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WalletMethodClass {
/// A custody-lifecycle method (`wallet.*`, §18.20) — GATED.
/// A custody-lifecycle method (`wallet.*`, §18.20) or a node-managed unlock-auth method
/// (`auth.*`, §18.24) — GATED.
Custody,
/// A wallet MUTATION (sign/spend/offer/mint/transfer + state-changing actions) — GATED.
Mutation,
Expand All @@ -95,7 +103,7 @@ pub enum WalletMethodClass {

/// Classify a method against the wallet-authorization policy. PURE.
pub fn classify(method: &str) -> WalletMethodClass {
if method.starts_with(CUSTODY_PREFIX) {
if method.starts_with(CUSTODY_PREFIX) || method.starts_with(AUTH_PREFIX) {
WalletMethodClass::Custody
} else if GATED_WALLET_MUTATIONS.contains(&method) {
WalletMethodClass::Mutation
Expand Down Expand Up @@ -167,6 +175,42 @@ mod tests {
}
}

#[test]
fn auth_methods_are_gated_and_no_token_is_denied() {
for m in [
"auth.status",
"auth.get_method",
"auth.set_method",
"auth.set_mode",
"auth.enroll_totp",
"auth.enroll_passkey_begin",
"auth.enroll_passkey_finish",
"auth.unlock",
"auth.sign_unlock",
"auth.lock",
] {
assert_eq!(classify(m), WalletMethodClass::Custody, "{m}");
assert!(requires_authorization(m), "{m} must be gated");
// No token / wrong token → denied; master or paired → allowed.
assert!(
!authorize(m, None, MASTER, is_paired),
"{m}: no token denied"
);
assert!(
!authorize(m, Some("nope"), MASTER, is_paired),
"{m}: wrong token denied"
);
assert!(
authorize(m, Some(MASTER), MASTER, is_paired),
"{m}: master ok"
);
assert!(
authorize(m, Some(PAIRED), MASTER, is_paired),
"{m}: paired ok"
);
}
}

#[test]
fn spend_sign_and_offer_methods_are_gated_mutations() {
for m in [
Expand Down
12 changes: 11 additions & 1 deletion crates/dig-wallet/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "dig-wallet"
version = "0.10.0"
version = "0.11.0"
edition = "2021"
license = "GPL-2.0-only"
description = "DIG Browser built-in Chia wallet sidecar: a local axum server (using digstore-chain + chia-wallet-sdk over coinset.org) that serves a Sage-mirroring wallet UI. Native Rust so BLS signing works; the browser opens it at 127.0.0.1."
Expand Down Expand Up @@ -93,6 +93,16 @@ dig-keystore = "0.3"
tokio-stream = { version = "0.1", features = ["sync"] }
futures-core = "0.3"

# -- #431/#432 node-managed unlock auth (§18.24) ----------------------------------------
# TOTP 2FA (RFC-6238) as an optional required second factor on the password unlock. Pure
# Rust (no OpenSSL): `otpauth` gives the `otpauth://` provisioning URI, `gen_secret` the
# random secret generator. The secret is sealed at rest under the wallet password via the
# `dig-keystore` `opaque` container (§18.18) — never stored in plaintext, never logged.
# (WebAuthn/passkey — SPEC §18.24 — is enrolled behind the same interface; its real
# `webauthn-rs` ceremony is finalized with the extension origin in the follow-up, since
# webauthn-rs pulls OpenSSL and the RP/origin is defined by the paired extension #433.)
totp-rs = { version = "5", features = ["otpauth", "gen_secret"] }

[dev-dependencies]
tempfile = "3"
# K1Pair — real secp256k1 test key pairs for the vault config / K1 pubkey parse tests.
Expand Down
Loading
Loading