Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a3ab560
feat(auth): hand a browser its key from dg, one warning on every phra…
PastaPastaPasta Oct 4, 2026
d803b27
fix(auth): never leave a browser key live and unheld after a dg handoff
PastaPastaPasta Oct 4, 2026
cbc5875
fix(dg): warn that a browser key request may come from a look-alike page
PastaPastaPasta Oct 4, 2026
8289ef5
Merge remote-tracking branch 'origin/master' into feat/e6-phrase-handoff
PastaPastaPasta Oct 4, 2026
60e5ba4
feat(web): Devices & keys page, a new-key alert on sign-in, and a per…
PastaPastaPasta Oct 4, 2026
b7e48e9
fix(web): disable only Forge keys, never alert on keys this browser a…
PastaPastaPasta Oct 4, 2026
4822a86
Merge remote-tracking branch 'origin/master' into feat/e6-phrase-handoff
PastaPastaPasta Oct 4, 2026
cf60bf5
Merge branch 'feat/e6-phrase-handoff' into feat/e6-devices-keys
PastaPastaPasta Oct 4, 2026
935e6a1
Merge remote-tracking branch 'origin/master' into feat/e6-phrase-handoff
PastaPastaPasta Oct 4, 2026
998b05f
Merge branch 'feat/e6-phrase-handoff' into feat/e6-devices-keys
PastaPastaPasta Oct 4, 2026
f970afa
Merge remote-tracking branch 'origin/master' into feat/e6-phrase-handoff
PastaPastaPasta Oct 4, 2026
fbfd8b3
Merge branch 'feat/e6-phrase-handoff' into feat/e6-devices-keys
PastaPastaPasta Oct 4, 2026
8f13855
Merge remote-tracking branch 'origin/master' into feat/e6-phrase-handoff
PastaPastaPasta Oct 4, 2026
cda7abf
Merge branch 'feat/e6-phrase-handoff' into feat/e6-devices-keys
PastaPastaPasta Oct 4, 2026
dcc0eba
Merge remote-tracking branch 'origin/master' into feat/e6-phrase-handoff
PastaPastaPasta Oct 4, 2026
80bb7f6
Merge branch 'feat/e6-phrase-handoff' into feat/e6-devices-keys
PastaPastaPasta Oct 4, 2026
f7d8716
fix(web): Devices & keys copy passes the copy lint, and says why the …
PastaPastaPasta Oct 4, 2026
dbbe71a
Merge remote-tracking branch 'origin/master' into feat/e6-phrase-handoff
PastaPastaPasta Oct 4, 2026
9d76814
Merge branch 'feat/e6-phrase-handoff' into feat/e6-devices-keys
PastaPastaPasta Oct 4, 2026
182a68f
Merge remote-tracking branch 'origin/master' into feat/e6-phrase-handoff
PastaPastaPasta Oct 4, 2026
9f72353
merge: origin/master into feat/e6-phrase-handoff
PastaPastaPasta Oct 5, 2026
5ee925b
fix(auth): take a dg key only for the identity the page asked about, …
PastaPastaPasta Oct 5, 2026
faab35d
fix(auth): report a vault another tab changed as such, not as a stage…
PastaPastaPasta Oct 5, 2026
6a3da91
Merge branch 'feat/e6-phrase-handoff' into feat/e6-devices-keys
PastaPastaPasta Oct 5, 2026
3ff8ed2
merge: origin/master into feat/e6-phrase-handoff
PastaPastaPasta Oct 5, 2026
078c75d
merge: origin/master into feat/e6-phrase-handoff
PastaPastaPasta Oct 5, 2026
15adb37
Merge branch 'feat/e6-phrase-handoff' into feat/e6-devices-keys
PastaPastaPasta Oct 5, 2026
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
309 changes: 306 additions & 3 deletions crates/dg/src/auth/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ pub enum KeysCommand {
/// A limited key is what this computer then signs with (default: 0.25 DASH / 180
/// days, bound to dash-forge; `--replace <id>` disables the old one in the same update), or
/// with --encryption the ENCRYPTION key private repositories need. Needs the master key once.
/// For a key to hand to CI use `dg auth export --new-key`.
/// For a key to hand to CI use `dg auth export --new-key`. With `--for-browser <request>`
/// the key is for a browser tab instead: dg prints it sealed to that tab, so the recovery
/// phrase never goes into a web page.
Add(AddArgs),
/// Disable a key on Platform. Needs the master key once.
Disable {
Expand Down Expand Up @@ -54,13 +56,27 @@ impl KeysCommand {
#[derive(Debug, clap::Args)]
pub struct AddArgs {
/// Add an ENCRYPTION key (for private repositories) instead of a limited key.
#[arg(long, conflicts_with_all = ["budget", "expires", "replace"])]
#[arg(long, conflicts_with_all = ["budget", "expires", "replace", "for_browser"])]
pub encryption: bool,
/// Register a limited key for a browser tab and print it sealed to that tab: paste the
/// `dfkr1:` request the browser shows (Sign in → Use dg, or Renew). This computer keeps
/// signing with its own key. Default limits are the browser's: 0.05 DASH for 90 days.
#[arg(long, value_name = "REQUEST", conflicts_with = "keep_current")]
pub for_browser: Option<String>,
/// With --for-browser: also hand over the identity's encryption key, so the browser opens
/// private repositories.
#[arg(long, requires = "for_browser")]
pub with_encryption_key: bool,
/// With --for-browser: the identity the browser asked a key for. dg refuses, before anything
/// is signed, to make the key for another identity.
#[arg(long, value_name = "IDENTITY_ID", requires = "for_browser")]
pub for_identity: Option<String>,
/// Bound to the `dash-forge` contract group (the only binding dg makes; kept for the
/// spec's command line).
#[arg(long, value_name = "GROUP", default_value = "dash-forge", value_parser = ["dash-forge"])]
pub bound: String,
/// Disable this limited key in the same update (default: the one this computer signs with).
/// Disable this limited key in the same update (default: the one this computer signs with;
/// none with --for-browser).
#[arg(long, value_name = "KEY_ID")]
pub replace: Option<u32>,
/// Keep the key this computer signs with now valid on Platform (it is no longer stored here).
Expand Down Expand Up @@ -240,7 +256,283 @@ async fn add_encryption(
Ok(())
}

/// The browser key defaults (`forge-web/lib/auth/limited-key.ts` `BROWSER_KEY_DEFAULTS`).
const BROWSER_KEY_BUDGET_DASH: f64 = 0.05;
const BROWSER_KEY_DAYS: u64 = 90;

/// The flag that selects `network`, a network key (`testnet`, `mainnet`, `devnet-<name>`).
fn network_flag(network: &str) -> String {
match network.strip_prefix("devnet-") {
Some(name) => format!("--devnet-name {name}"),
None => format!("--network {network}"),
}
}

/// The browser's `dfkr1:` request, refused when it is not one or is for another network.
fn browser_request(ctx: &Ctx, text: &str) -> Result<forge_core::browser_key::Request> {
let request = forge_core::browser_key::parse_request(text).map_err(|e| {
UserError::new(codes::USAGE, "that is not a browser key request")
.cause(e.to_string())
.fix("copy the whole command the browser shows, including the dfkr1:… request")
})?;
let network = ctx.network_label();
if request.network != network {
return Err(
UserError::new(codes::USAGE, "the browser is on another network")
.cause(format!(
"the request is from a site on {}, and dg is on {network}",
request.network
))
.fix(format!(
"run it again with {}",
network_flag(&request.network)
))
.note("nothing was sent")
.into(),
);
}
Ok(request)
}

/// The encryption key `--with-encryption-key` hands over: the highest-id one `sources` (the
/// master source and the key dg signs with) open.
/// Checked before anything is paid for, so a key dg cannot hand over never leaves the browser
/// with a signing key and a promise.
fn handover_encryption_key(
ctx: &Ctx,
identity: &forge_core::platform::LoadedIdentity,
sources: &[&forge_core::keystore::BridgeIdentity],
) -> Result<forge_core::browser_key::EncryptionKey> {
let storage = super::StorageArgs {
insecure_plaintext: false,
signing_only: false,
};
let key = super::encryption_to_store(ctx, &storage, identity, sources)
.into_iter()
.max_by_key(|k| k.id)
.ok_or_else(|| {
UserError::new(codes::KEY_CANNOT_SIGN, "no encryption key to hand over")
.cause("this identity has no encryption key that the recovery words or the --master file open")
.fix("run it without --with-encryption-key, or add one first: `dg auth keys add --encryption`")
.note("nothing was sent")
})?;
Ok(forge_core::browser_key::EncryptionKey {
key_id: key.id,
private_key_hex: key.private_key_hex.expose().to_string(),
})
}

/// The longest a browser key may live (the web app's longest choice, TS-06).
const BROWSER_KEY_MAX_DAYS: u64 = 365;

/// The master identity for `--for-browser`: `--master`, else the identity dg is signed in as,
/// else the recovery words (only when dg is not signed in at all: a key file that fails to
/// open is an error, not a reason to ask for the words). Refused when it is not `for_identity`.
async fn browser_master(
ctx: &Ctx,
client: &forge_core::platform::PlatformClient,
args: &AddArgs,
) -> Result<(
forge_core::keystore::BridgeIdentity,
Option<forge_core::keystore::BridgeIdentity>,
)> {
let current = match ctx.identity_path {
Some(_) => Some(ctx.load_bridge()?),
None => None,
};
let current_id = current.as_ref().map(|b| b.identity_id.clone());
let master = match (&current_id, &args.master) {
(None, None) => {
eprintln!("{}", super::MASTER_PROMPT);
let words = super::read_mnemonic()?;
super::identity_from_words(ctx, client, &words).await?
}
_ => master_identity(
ctx,
args.master.as_deref(),
current_id.as_deref().unwrap_or(""),
)?,
};
if let Some(want) = &args.for_identity {
if *want != master.identity_id {
return Err(
UserError::new(codes::KEY_CANNOT_SIGN, "that is a different identity")
.cause(format!(
"the browser asked for a key of {want}, and dg would sign for {}",
master.identity_id
))
.fix(format!("pass --master with the identity file of {want}"))
.note("nothing was sent")
.into(),
);
}
}
Ok((master, current))
}

/// The key a reply carries, from the `dfk1:` text `register_limited_key` hands its `persist`.
fn handoff_payload(
network: &str,
dfk1: &forge_core::keystore::Secret,
replaced: Option<u32>,
encryption: Option<&forge_core::browser_key::EncryptionKey>,
) -> Result<forge_core::browser_key::Payload> {
let bridge = forge_core::keystore::BridgeIdentity::from_dfk1(dfk1.expose())?;
let key = bridge.doc_op_key()?;
Ok(forge_core::browser_key::Payload {
v: 1,
network: network.to_string(),
identity_id: bridge.identity_id.clone(),
key_id: key.id,
wif: key.private_key_wif.expose().to_string(),
replaced_key_id: replaced,
encryption_key: encryption.map(|e| forge_core::browser_key::EncryptionKey {
key_id: e.key_id,
private_key_hex: e.private_key_hex.clone(),
}),
})
}

/// `dg auth keys add --for-browser <request>` (TS-06): register a limited key for a browser tab
/// and print it sealed to the tab's one-time key (`forge_core::browser_key`). Nothing is stored
/// here; this computer's own key is untouched unless `--replace` names it.
async fn add_for_browser(ctx: &Ctx, args: &AddArgs, request: &str) -> Result<()> {
let request = browser_request(ctx, request)?;
let network = ctx.network_label();
let client = ctx.connect().await?;
let (master, current) = browser_master(ctx, &client, args).await?;
let identity = client.fetch_signer(&master).await?;
let encryption = if args.with_encryption_key {
let sources: Vec<_> = std::iter::once(&master).chain(current.as_ref()).collect();
Some(handover_encryption_key(ctx, &identity, &sources)?)
} else {
None
};
let spec = key_spec(ctx, &args.limits, BROWSER_KEY_BUDGET_DASH, BROWSER_KEY_DAYS)?;
if spec.expires_at_ms > super::expiry_ms(BROWSER_KEY_MAX_DAYS) {
return Err(crate::errors::usage(format!(
"a browser key lives at most {BROWSER_KEY_MAX_DAYS} days; pass --expires 365d or less"
)));
}
let checked = super::check_group(ctx, &client, &spec.group, args.limits.strict_group()).await?;
super::explain_new_key(ctx, &master.identity_id, &spec, "for a browser", &checked);
explain_handover(ctx, args.replace, encryption.as_ref());
ctx.confirm_or_cancel("Add the key?")?;
// The reply is sealed before anything is sent (as `register_limited_key` stores a key before
// it registers it), and again if the key lands under another id.
let seal = |dfk1: &forge_core::keystore::Secret| -> Result<(u32, String)> {
let payload = handoff_payload(&network, dfk1, args.replace, encryption.as_ref())?;
Ok((
payload.key_id,
forge_core::browser_key::seal(&request, &payload)?,
))
};
let mut sealed: Option<(u32, String, forge_core::keystore::Secret)> = None;
let registered = super::register_limited_key(
ctx,
&client,
&master,
&spec,
args.replace,
&checked,
&mut |t| {
let (id, reply) = seal(t)?;
sealed = Some((id, reply, t.clone()));
Ok(())
},
)
.await;
let (id, reply, unconfirmed) = match (registered, sealed) {
(Ok(id), Some((sealed_id, reply, _))) if id == sealed_id => (id, reply, None),
(Ok(id), _) => {
anyhow::bail!("internal error: key #{id} registered, but its reply was not sealed")
}
// The update errored but may have landed (a broadcast that timed out, a node behind):
// a key that is on chain is handed over, or nobody would hold it.
(Err(e), Some((_, _, dfk1))) => {
let Some(landed) = landed_key(ctx, &client, &network, &dfk1).await else {
return Err(e);
};
let (id, reply) = seal(&landed)?;
(id, reply, Some(format!("{e:#}")))
}
(Err(e), None) => return Err(e),
};
ctx.emit(
json!({
"status": "added",
"keyId": id,
"budgetCredits": spec.budget_credits,
"expiresAt": spec.expires_at_ms,
"replacedKeyId": args.replace,
"encryptionKeyId": encryption.as_ref().map(|e| e.key_id),
"reply": reply,
"warning": unconfirmed,
}),
|| {
if let Some(why) = &unconfirmed {
eprintln!("note: the update reported an error ({why}), but key #{id} is on chain");
}
eprintln!("✓ limited key #{id} added for the browser");
if let Some(old) = args.replace {
eprintln!(" key #{old} disabled");
}
eprintln!(
"Paste this line into the browser. It opens only in the tab that asked for it:"
);
println!("{reply}");
},
);
Ok(())
}

/// The key in `dfk1` under the id it is live with on chain (a `dfk1:` text again), if it landed.
async fn landed_key(
ctx: &Ctx,
client: &forge_core::platform::PlatformClient,
network: &str,
dfk1: &forge_core::keystore::Secret,
) -> Option<forge_core::keystore::Secret> {
let bridge = forge_core::keystore::BridgeIdentity::from_dfk1(dfk1.expose()).ok()?;
let wif = bridge.doc_op_key().ok()?.private_key_wif.clone();
let identity = client.fetch_identity(&bridge.identity_id).await.ok()?;
let id = identity.key_id_for(wif.expose(), ctx.network())?;
Some(forge_core::keystore::dfk1(
network,
&bridge.identity_id,
id,
wif.expose(),
))
}

/// What else the update does (human mode): the key it disables, the encryption key it hands over.
fn explain_handover(
ctx: &Ctx,
replace: Option<u32>,
encryption: Option<&forge_core::browser_key::EncryptionKey>,
) {
if ctx.json {
return;
}
// Anyone can show a request: a look-alike page could ask for this command too.
eprintln!(
" run this only for a Forge page you opened yourself: the key can write to Forge as you"
);
if let Some(old) = replace {
eprintln!(" key #{old} is disabled in the same update");
}
if let Some(e) = encryption {
eprintln!(
" the browser also gets encryption key #{}: it opens your private repositories",
e.key_id
);
}
}

async fn add(ctx: &Ctx, args: &AddArgs) -> Result<()> {
if let Some(request) = &args.for_browser {
return add_for_browser(ctx, args, request).await;
}
let current = ctx.load_bridge()?;
let client = ctx.connect().await?;
let master = master_identity(ctx, args.master.as_deref(), &current.identity_id)?;
Expand Down Expand Up @@ -380,3 +672,14 @@ async fn disable(ctx: &Ctx, id: u32, master: Option<&std::path::Path>, force: bo
);
Ok(())
}

#[cfg(test)]
mod tests {
use super::network_flag;

#[test]
fn the_network_flag_matches_the_browser_command() {
assert_eq!(network_flag("devnet-sakura"), "--devnet-name sakura");
assert_eq!(network_flag("testnet"), "--network testnet");
}
}
6 changes: 4 additions & 2 deletions crates/dg/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,8 @@ pub fn read_mnemonic() -> Result<Secret> {
.into(),
);
}
// The same words before every phrase prompt, here and in the web app (TS-06).
eprintln!("{}", forge_core::browser_key::RECOVERY_PHRASE_WARNING);
let words =
rpassword::prompt_password("Recovery words (12, hidden as you type): ").map_err(|e| {
// Ctrl-D or Ctrl-C: the user left, nothing was written (E803), not "no terminal".
Expand All @@ -315,7 +317,7 @@ pub fn read_mnemonic() -> Result<Secret> {
/// What [`master_identity`] says before it asks for the recovery words: a user who signed in
/// with an identity file has no words at hand, and `--master <file>` takes that file instead
/// (L-34).
const MASTER_PROMPT: &str =
pub(crate) const MASTER_PROMPT: &str =
"This needs your master key once. It is used for this one signature and not stored.\n\
Type your 12-word recovery phrase below, or press Ctrl-C and run the command again with \
--master <identity file> (the file from the bridge, or a `dg auth new --backup-file`).";
Expand Down Expand Up @@ -360,7 +362,7 @@ pub fn master_identity(

/// The identity's master identity loaded when only words are available and the id is unknown
/// yet: derive, then find the identity by its master key on chain.
async fn identity_from_words(
pub(crate) async fn identity_from_words(
ctx: &Ctx,
client: &PlatformClient,
words: &Secret,
Expand Down
Loading
Loading