Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
7573ac9
fix(apps): use secure scheme on Windows
Hadamcik Aug 20, 2026
d8a929d
feat(apps): allow approved image origins in CSP
Hadamcik Aug 20, 2026
6844b1a
Merge pull request #839 from Hadamcik/codex/csp-approved-network-images
Rigidity Aug 21, 2026
317e0da
Merge pull request #838 from Hadamcik/codex/windows-app-https-scheme
Rigidity Aug 21, 2026
311ab29
feat(password-gate): add testable password resolve loop
dkackman Aug 21, 2026
4b85409
feat(password-gate): wire main-window transport for password requests
dkackman Aug 21, 2026
a5682bf
fix(password-gate): allow submit_password_response in ACL, add prompt…
dkackman Aug 21, 2026
341c782
feat(password-gate): add maybe_unlock macro token and drift test
dkackman Aug 21, 2026
927177d
chore: update Cargo.lock for sage-api serde_json dev-dependency
dkackman Aug 21, 2026
93ebd0c
fix(password-gate): scan every requests/*.rs file, not a hard-coded list
dkackman Aug 21, 2026
b73821c
feat(password-gate): resolve passwords in all gated endpoint commands
dkackman Aug 22, 2026
3f90c75
feat(password-gate): prompt in main window for app bridge requests
dkackman Aug 22, 2026
a6424f2
fix(password-gate): scope the bridge gate to secret-bearing approvals
dkackman Aug 22, 2026
88388e3
fix(password-gate): restore the approval dialog after the prompt, red…
dkackman Aug 22, 2026
dc84937
feat(password-gate): force approval for protected wallets on auto-submit
dkackman Aug 22, 2026
c3053db
feat(password-gate): invert PasswordContext into a responder
dkackman Aug 22, 2026
5397d93
refactor(password-gate): drop password plumbing from React call sites
dkackman Aug 22, 2026
420b484
refactor(password-gate): drop password plumbing from WalletConnect layer
dkackman Aug 22, 2026
20819ad
fix(password-gate): stay silent when the user cancels the prompt
dkackman Aug 22, 2026
55dfcba
fix(password-gate): surface too-many-attempts and prompt-timeout errors
dkackman Aug 22, 2026
c8aa6d5
chore: fix pre-existing manual_string_new lint in sage-rpc tests
dkackman Aug 22, 2026
9a6d4c1
chore(password-gate): cargo fmt formatting fixes
dkackman Aug 22, 2026
f05044c
docs(password-gate): add design spec and implementation plan
dkackman Aug 22, 2026
fb8ef09
fix(password-gate): make the gate fingerprint-aware
dkackman Aug 22, 2026
d6338d0
fix(apps): target the gate at the approval's wallet, and stop the fli…
dkackman Aug 22, 2026
0af74b8
fix(password-gate): drop the fingerprint from the event payload
dkackman Aug 22, 2026
39d1789
fix(password-gate): reuse one request id across retry attempts
dkackman Aug 22, 2026
2fa0132
fix(i18n): translate the user-facing password gate errors
dkackman Aug 22, 2026
849f034
fix(i18n): pluralize the attempts-remaining message
dkackman Aug 22, 2026
4232573
docs(password-gate): add manual smoke test checklist
dkackman Aug 22, 2026
29ec067
fix(password-gate): prompt once per send, not twice
dkackman Aug 22, 2026
a750ae4
move generated docs
dkackman Aug 22, 2026
13ca0e2
docs: reconcile the password docs with the implementation
dkackman Aug 22, 2026
4f371ee
Merge branch 'main' into password-gate
dkackman Aug 22, 2026
1b2d18d
feat: enhance password protection for bridge approvals
dkackman Aug 23, 2026
12fee21
refactor: rename reconcileActiveKeyProtection to reconcileDriftedKeyP…
dkackman Aug 23, 2026
69b25d4
feat: implement password gating enhancements and error handling impro…
dkackman Aug 23, 2026
f64dc48
fix: correct password protection reconciliation logic in key management
dkackman Aug 23, 2026
d1285fa
feat: add reconcile drifted key protection functionality and update r…
dkackman Aug 24, 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
22 changes: 22 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ sage-wallet = { path = "./crates/sage-wallet" }
sage-assets = { path = "./crates/sage-assets" }
sage-apps = { path = "./crates/sage-apps" }
sage-rpc = { path = "./crates/sage-rpc" }
sage-password-gate = { path = "./crates/sage-password-gate" }

# Serialization
serde = "1.0.204"
Expand Down
84 changes: 82 additions & 2 deletions builtin-apps/src/system/apps/bridge-approval/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ export function App() {
const [loaded, setLoaded] = useState(false);
const [now, setNow] = useState(() => Date.now());
const [error, setError] = useState<string | null>(null);
const [password, setPassword] = useState('');
const [passwordError, setPasswordError] = useState<string | null>(null);
// Set when the host tells us an approval needs a password that its queued
// hint did not predict, so the field appears even on a stale view.
const [passwordForced, setPasswordForced] = useState(false);

async function refreshActiveRuntime() {
const active = await sage.runtimeManager.getActiveTaskbarRuntime();
Expand Down Expand Up @@ -148,24 +153,63 @@ export function App() {
? formatCountdown(activeApproval.expiresAtMs, now)
: null;

// Never carry a typed password across approvals.
useEffect(() => {
setExpanded(false);
setError(null);
setPassword('');
setPasswordError(null);
setPasswordForced(false);
}, [activeApproval?.approvalId]);

const needsPassword =
(activeApproval?.requiresPassword ?? false) || passwordForced;

async function resolve(approved: boolean) {
if (!activeApproval || working) return;
if (approved && needsPassword && password.length === 0) return;

setWorking(true);
setError(null);

try {
await sage.bridgeApprovals.resolve({
const result = await sage.bridgeApprovals.resolve({
approvalId: activeApproval.approvalId,
approved,
reason: approved ? null : 'User denied the request',
password: approved && needsPassword ? password : null,
});

switch (result.kind) {
case 'wrongPassword':
// The approval is still queued; keep the card up for another try.
setPassword('');
setPasswordError(
result.attemptsRemaining === 1
? 'Incorrect password. 1 attempt remaining.'
: `Incorrect password. ${result.attemptsRemaining} attempts remaining.`,
);
break;

case 'passwordRequired':
// The queued hint was stale — this wallet is protected after all.
setPasswordForced(true);
setPassword('');
setPasswordError('This wallet requires its password.');
break;

case 'tooManyAttempts':
setPassword('');
setPasswordError(null);
setError('Too many incorrect password attempts. Request rejected.');
break;

case 'resolved':
setPassword('');
setPasswordError(null);
break;
}

setApprovals(await sage.bridgeApprovals.listPending());
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
Expand Down Expand Up @@ -240,7 +284,7 @@ export function App() {

<button
type='button'
disabled={working}
disabled={working || (needsPassword && password.length === 0)}
onClick={() => void resolve(true)}
className='rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50'
>
Expand Down Expand Up @@ -270,6 +314,42 @@ export function App() {
expanded={expanded}
/>

{needsPassword ? (
<div className='space-y-1.5'>
<label
htmlFor='approval-password'
className='text-xs font-medium uppercase tracking-wide text-muted-foreground'
>
Wallet password
</label>

<input
id='approval-password'
type='password'
autoFocus
autoComplete='off'
spellCheck={false}
value={password}
disabled={working}
onChange={(event) => {
setPassword(event.target.value);
setPasswordError(null);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && password.length > 0) {
void resolve(true);
}
}}
className='w-full rounded-md border border-border bg-background px-3 py-2 text-sm disabled:opacity-50'
placeholder='Required to sign with this wallet'
/>

{passwordError ? (
<div className='text-xs text-destructive'>{passwordError}</div>
) : null}
</div>
) : null}

{error ? (
<div className='rounded-lg border border-destructive/40 bg-destructive/10 p-2 text-sm text-destructive'>
{error}
Expand Down
3 changes: 3 additions & 0 deletions crates/sage-api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@ serde = { workspace = true, features = ["derive"] }
tauri-specta = { workspace = true, features = ["derive"], optional = true }
specta = { workspace = true, features = ["derive", "bigdecimal"], optional = true }
utoipa = { workspace = true, optional = true }

[dev-dependencies]
serde_json = { workspace = true }
1 change: 1 addition & 0 deletions crates/sage-api/endpoints.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,5 +102,6 @@
"is_asset_owned": true,
"change_password": false,
"reconcile_key_protection": false,
"reconcile_drifted_key_protection": false,
"get_xch_usd_price": true
}
1 change: 1 addition & 0 deletions crates/sage-api/macro/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ proc-macro = true
[dependencies]
quote = { workspace = true }
convert_case = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
indexmap = { workspace = true, features = ["serde"] }
proc-macro2 = { workspace = true }
Expand Down
61 changes: 58 additions & 3 deletions crates/sage-api/macro/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,18 +80,41 @@ fn generate(input: &TokenStream, tauri: bool) -> TokenStream {
endpoints.extend(tauri_endpoints);
}

// How the host-layer password gate treats each endpoint. Endpoints absent
// from this map take no password at all. `sage-api`'s drift tests keep the
// manifest in sync with the request types and with how each endpoint's
// implementation consumes the password.
let gating: std::collections::BTreeMap<String, GateMode> =
serde_json::from_str(include_str!("../../password-gating.json"))
.expect("Invalid password gating file");

let mut output = proc_macro2::TokenStream::new();

for token in input.clone() {
convert(token, &endpoints, None, &mut output);
convert(token, &endpoints, &gating, None, &mut output);
}

output.into()
}

/// How the host-layer password gate treats an endpoint.
#[derive(Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
enum GateMode {
/// Prompt on every call, verifying against the active wallet.
Always,
/// Prompt only when `req.auto_submit` is set.
AutoSubmit,
/// Prompt on every call, verifying against `req.fingerprint`.
Fingerprint,
}

type Gating<'a> = &'a std::collections::BTreeMap<String, GateMode>;

fn convert(
tree: TokenTree,
endpoints: &IndexMap<String, bool>,
gating: Gating<'_>,
endpoint: Option<&str>,
output: &mut proc_macro2::TokenStream,
) {
Expand All @@ -115,6 +138,38 @@ fn convert(
if is_async {
output.extend(quote!(.await));
}
} else if ident == "maybe_unlock" {
match gating.get(endpoint) {
Some(GateMode::Fingerprint) => {
output.extend(quote!(
req.password = sage_password_gate::resolve_for_fingerprint(&app_handle, state.inner(), gate.inner(), req.fingerprint).await?;
));
}
Some(GateMode::Always) => {
output.extend(quote!(
req.password = sage_password_gate::resolve(&app_handle, state.inner(), gate.inner()).await?;
));
}
Some(GateMode::AutoSubmit) => {
// Without `auto_submit` the endpoint only builds the
// transaction for the confirmation dialog and never
// touches the key, so prompting here would ask twice.
// The `else` keeps the host layer the sole source of
// the password: every gated command overwrites whatever
// the caller sent, on both branches.
output.extend(quote!(if req.auto_submit {
req.password = sage_password_gate::resolve(
&app_handle,
state.inner(),
gate.inner(),
)
.await?;
} else {
req.password = None;
}));
}
None => {}
}
} else if ident.is_case(Case::Snake) {
let ident = proc_macro2::Ident::new(
&ident.replace("endpoint", &endpoint.to_case(Case::Snake)),
Expand Down Expand Up @@ -152,14 +207,14 @@ fn convert(
if repeat {
for endpoint in endpoints.keys() {
for tree in stream.clone() {
convert(tree, endpoints, Some(endpoint), output);
convert(tree, endpoints, gating, Some(endpoint), output);
}
}
} else {
let mut inner = proc_macro2::TokenStream::new();

for tree in stream {
convert(tree, endpoints, endpoint, &mut inner);
convert(tree, endpoints, gating, endpoint, &mut inner);
}

output.extend(proc_macro2::TokenStream::from(TokenStream::from(
Expand Down
34 changes: 34 additions & 0 deletions crates/sage-api/password-gating.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"add_nft_uri": "auto_submit",
"assign_nfts_to_did": "auto_submit",
"auto_combine_cat": "auto_submit",
"auto_combine_xch": "auto_submit",
"bulk_mint_nfts": "auto_submit",
"bulk_send_cat": "auto_submit",
"bulk_send_xch": "auto_submit",
"cancel_offer": "auto_submit",
"cancel_offers": "auto_submit",
"combine": "auto_submit",
"create_did": "auto_submit",
"create_transaction": "auto_submit",
"delete_key": "fingerprint",
"exercise_options": "auto_submit",
"finalize_clawback": "auto_submit",
"get_secret_key": "fingerprint",
"increase_derivation_index": "always",
"issue_cat": "auto_submit",
"make_offer": "always",
"mint_option": "auto_submit",
"multi_send": "auto_submit",
"normalize_dids": "auto_submit",
"send_cat": "auto_submit",
"send_xch": "auto_submit",
"sign_coin_spends": "always",
"sign_message_by_address": "always",
"sign_message_with_public_key": "always",
"split": "auto_submit",
"take_offer": "always",
"transfer_dids": "auto_submit",
"transfer_nfts": "auto_submit",
"transfer_options": "auto_submit"
}
Loading
Loading