@wlfi-agent/cli 1.4.14 → 1.4.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -0
- package/Cargo.toml +1 -1
- package/README.md +10 -2
- package/crates/vault-cli-admin/src/main.rs +21 -2
- package/crates/vault-cli-admin/src/tui.rs +634 -129
- package/crates/vault-cli-daemon/Cargo.toml +1 -0
- package/crates/vault-cli-daemon/src/bin/wlfi-agent-system-keychain.rs +122 -8
- package/crates/vault-cli-daemon/src/main.rs +24 -4
- package/crates/vault-cli-daemon/src/relay_sync.rs +155 -35
- package/crates/vault-cli-daemon/tests/system_keychain_helper_acl.rs +23 -18
- package/crates/vault-daemon/src/daemon_parts/api_impl_and_utils.rs +6 -0
- package/crates/vault-daemon/src/daemon_parts/types_api_rpc.rs +6 -0
- package/crates/vault-daemon/src/tests.rs +2 -2
- package/crates/vault-daemon/src/tests_parts/part4.rs +110 -0
- package/crates/vault-transport-unix/src/lib.rs +22 -3
- package/crates/vault-transport-xpc/src/lib.rs +20 -2
- package/dist/cli.cjs +20842 -25552
- package/dist/cli.cjs.map +1 -1
- package/package.json +18 -18
- package/packages/cache/.turbo/turbo-build.log +20 -20
- package/packages/cache/coverage/base.css +224 -0
- package/packages/cache/coverage/block-navigation.js +87 -0
- package/packages/cache/coverage/clover.xml +585 -0
- package/packages/cache/coverage/coverage-final.json +5 -0
- package/packages/cache/coverage/favicon.png +0 -0
- package/packages/cache/coverage/index.html +161 -0
- package/packages/cache/coverage/prettify.css +1 -0
- package/packages/cache/coverage/prettify.js +2 -0
- package/packages/cache/coverage/sort-arrow-sprite.png +0 -0
- package/packages/cache/coverage/sorter.js +210 -0
- package/packages/cache/coverage/src/client/index.html +116 -0
- package/packages/cache/coverage/src/client/index.ts.html +253 -0
- package/packages/cache/coverage/src/errors/index.html +116 -0
- package/packages/cache/coverage/src/errors/index.ts.html +244 -0
- package/packages/cache/coverage/src/index.html +116 -0
- package/packages/cache/coverage/src/index.ts.html +94 -0
- package/packages/cache/coverage/src/service/index.html +116 -0
- package/packages/cache/coverage/src/service/index.ts.html +2212 -0
- package/packages/cache/dist/{chunk-ALQ6H7KG.cjs → chunk-QF4XKEIA.cjs} +189 -45
- package/packages/cache/dist/chunk-QF4XKEIA.cjs.map +1 -0
- package/packages/cache/dist/{chunk-FGJEEF5N.js → chunk-QNK6GOTI.js} +182 -38
- package/packages/cache/dist/chunk-QNK6GOTI.js.map +1 -0
- package/packages/cache/dist/index.cjs +2 -2
- package/packages/cache/dist/index.js +1 -1
- package/packages/cache/dist/service/index.cjs +2 -2
- package/packages/cache/dist/service/index.d.cts +2 -0
- package/packages/cache/dist/service/index.d.ts +2 -0
- package/packages/cache/dist/service/index.js +1 -1
- package/packages/cache/node_modules/.bin/jiti +0 -0
- package/packages/cache/node_modules/.bin/tsc +0 -0
- package/packages/cache/node_modules/.bin/tsserver +0 -0
- package/packages/cache/node_modules/.bin/tsup +0 -0
- package/packages/cache/node_modules/.bin/tsup-node +0 -0
- package/packages/cache/node_modules/.bin/tsx +0 -0
- package/packages/cache/node_modules/.bin/vitest +0 -0
- package/packages/cache/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/packages/cache/src/service/index.test.ts +575 -0
- package/packages/cache/src/service/index.ts +234 -51
- package/packages/config/.turbo/turbo-build.log +17 -18
- package/packages/config/dist/index.cjs +0 -0
- package/packages/config/node_modules/.bin/jiti +0 -0
- package/packages/config/node_modules/.bin/tsc +2 -2
- package/packages/config/node_modules/.bin/tsserver +2 -2
- package/packages/config/node_modules/.bin/tsup +2 -2
- package/packages/config/node_modules/.bin/tsup-node +2 -2
- package/packages/config/node_modules/.bin/tsx +0 -0
- package/packages/rpc/.turbo/turbo-build.log +31 -32
- package/packages/rpc/dist/_esm-BCLXDO2R.cjs +0 -0
- package/packages/rpc/dist/ccip-OWJLAW55.cjs +0 -0
- package/packages/rpc/dist/chunk-APQIFZ3B.cjs +0 -0
- package/packages/rpc/dist/chunk-CDO2GWRD.cjs +0 -0
- package/packages/rpc/dist/chunk-QGTNTFJ7.cjs +0 -0
- package/packages/rpc/dist/chunk-TZDTAHWR.cjs +0 -0
- package/packages/rpc/dist/index.cjs +0 -0
- package/packages/rpc/dist/secp256k1-WCNM675D.cjs +0 -0
- package/packages/rpc/node_modules/.bin/jiti +0 -0
- package/packages/rpc/node_modules/.bin/tsc +2 -2
- package/packages/rpc/node_modules/.bin/tsserver +2 -2
- package/packages/rpc/node_modules/.bin/tsup +2 -2
- package/packages/rpc/node_modules/.bin/tsup-node +2 -2
- package/packages/rpc/node_modules/.bin/tsx +0 -0
- package/packages/ui/.turbo/turbo-build.log +43 -44
- package/packages/ui/node_modules/.bin/jiti +0 -0
- package/packages/ui/node_modules/.bin/tsc +0 -0
- package/packages/ui/node_modules/.bin/tsserver +0 -0
- package/packages/ui/node_modules/.bin/tsup +0 -0
- package/packages/ui/node_modules/.bin/tsup-node +0 -0
- package/packages/ui/node_modules/.bin/tsx +0 -0
- package/scripts/install-rust-binaries.mjs +164 -58
- package/scripts/launchd/install-user-daemon.sh +0 -0
- package/scripts/launchd/run-vault-daemon.sh +0 -0
- package/scripts/launchd/run-wlfi-agent-daemon.sh +0 -0
- package/scripts/launchd/uninstall-user-daemon.sh +0 -0
- package/src/cli.ts +51 -39
- package/src/lib/admin-passthrough.js +1 -0
- package/src/lib/admin-reset.js +1 -0
- package/src/lib/admin-reset.ts +26 -16
- package/src/lib/admin-setup.js +1 -0
- package/src/lib/admin-setup.ts +32 -20
- package/src/lib/agent-auth-revoke.js +1 -0
- package/src/lib/agent-auth-rotate.js +1 -0
- package/src/lib/agent-auth.js +1 -0
- package/src/lib/config-mutation.js +1 -0
- package/src/lib/launchd-assets.js +1 -0
- package/src/lib/launchd-assets.ts +29 -0
- package/src/lib/local-admin-access.js +1 -0
- package/src/lib/rust.ts +1 -1
- package/src/lib/status-repair-cli.js +1 -0
- package/packages/cache/dist/chunk-ALQ6H7KG.cjs.map +0 -1
- package/packages/cache/dist/chunk-FGJEEF5N.js.map +0 -1
|
@@ -24,5 +24,6 @@ vault-signer = { path = "../vault-signer" }
|
|
|
24
24
|
vault-transport-unix = { path = "../vault-transport-unix" }
|
|
25
25
|
|
|
26
26
|
[target.'cfg(target_os = "macos")'.dependencies]
|
|
27
|
+
core-foundation.workspace = true
|
|
27
28
|
security-framework.workspace = true
|
|
28
29
|
security-framework-sys.workspace = true
|
|
@@ -165,14 +165,18 @@ fn replace_generic_password(
|
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
168
|
+
add_generic_password_restricted_to_creator(
|
|
169
|
+
&keychain,
|
|
170
|
+
&service,
|
|
171
|
+
&account,
|
|
172
|
+
password.as_bytes(),
|
|
173
|
+
)
|
|
174
|
+
.with_context(|| {
|
|
175
|
+
format!(
|
|
176
|
+
"failed to store generic password for service '{}' and account '{}'",
|
|
177
|
+
service, account
|
|
178
|
+
)
|
|
179
|
+
})?;
|
|
176
180
|
Ok(())
|
|
177
181
|
})();
|
|
178
182
|
|
|
@@ -180,6 +184,116 @@ fn replace_generic_password(
|
|
|
180
184
|
result
|
|
181
185
|
}
|
|
182
186
|
|
|
187
|
+
#[cfg(target_os = "macos")]
|
|
188
|
+
fn add_generic_password_restricted_to_creator(
|
|
189
|
+
keychain: &security_framework::os::macos::keychain::SecKeychain,
|
|
190
|
+
service: &str,
|
|
191
|
+
account: &str,
|
|
192
|
+
password: &[u8],
|
|
193
|
+
) -> Result<()> {
|
|
194
|
+
use core_foundation::array::{CFArray, CFArrayRef};
|
|
195
|
+
use core_foundation::base::{CFType, TCFType};
|
|
196
|
+
use core_foundation::string::CFString;
|
|
197
|
+
use security_framework::base::Error as SecurityError;
|
|
198
|
+
use security_framework::os::macos::access::SecAccess;
|
|
199
|
+
use security_framework::os::macos::keychain_item::SecKeychainItem;
|
|
200
|
+
use security_framework_sys::base::{
|
|
201
|
+
SecAccessRef, SecKeychainAttribute, SecKeychainAttributeList, SecKeychainItemRef,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const K_SEC_GENERIC_PASSWORD_ITEM_CLASS: u32 = u32::from_be_bytes(*b"genp");
|
|
205
|
+
const K_SEC_SERVICE_ITEM_ATTR: u32 = u32::from_be_bytes(*b"svce");
|
|
206
|
+
const K_SEC_ACCOUNT_ITEM_ATTR: u32 = u32::from_be_bytes(*b"acct");
|
|
207
|
+
|
|
208
|
+
unsafe extern "C" {
|
|
209
|
+
fn SecAccessCreate(
|
|
210
|
+
descriptor: core_foundation::string::CFStringRef,
|
|
211
|
+
trustedlist: CFArrayRef,
|
|
212
|
+
access_ref: *mut SecAccessRef,
|
|
213
|
+
) -> core_foundation::base::OSStatus;
|
|
214
|
+
|
|
215
|
+
fn SecKeychainItemCreateFromContent(
|
|
216
|
+
item_class: u32,
|
|
217
|
+
attr_list: *mut SecKeychainAttributeList,
|
|
218
|
+
length: u32,
|
|
219
|
+
data: *const libc::c_void,
|
|
220
|
+
keychain_ref: security_framework_sys::base::SecKeychainRef,
|
|
221
|
+
initial_access: SecAccessRef,
|
|
222
|
+
item_ref: *mut SecKeychainItemRef,
|
|
223
|
+
) -> core_foundation::base::OSStatus;
|
|
224
|
+
|
|
225
|
+
fn SecTrustedApplicationCreateFromPath(
|
|
226
|
+
path: *const libc::c_char,
|
|
227
|
+
app: *mut *mut libc::c_void,
|
|
228
|
+
) -> core_foundation::base::OSStatus;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let descriptor = CFString::from("wlfi-agent-system-keychain");
|
|
232
|
+
let mut trusted_app_ref: *mut libc::c_void = std::ptr::null_mut();
|
|
233
|
+
let trusted_app_status =
|
|
234
|
+
unsafe { SecTrustedApplicationCreateFromPath(std::ptr::null(), &mut trusted_app_ref) };
|
|
235
|
+
if trusted_app_status != 0 {
|
|
236
|
+
return Err(SecurityError::from_code(trusted_app_status))
|
|
237
|
+
.context("failed to create trusted-application ACL entry for helper");
|
|
238
|
+
}
|
|
239
|
+
let trusted_app = unsafe { CFType::wrap_under_create_rule(trusted_app_ref.cast()) };
|
|
240
|
+
let trusted_apps = CFArray::from_CFTypes(&[trusted_app]);
|
|
241
|
+
|
|
242
|
+
let mut access_ref: SecAccessRef = std::ptr::null_mut();
|
|
243
|
+
let access_status = unsafe {
|
|
244
|
+
SecAccessCreate(
|
|
245
|
+
descriptor.as_concrete_TypeRef(),
|
|
246
|
+
trusted_apps.as_concrete_TypeRef(),
|
|
247
|
+
&mut access_ref,
|
|
248
|
+
)
|
|
249
|
+
};
|
|
250
|
+
if access_status != 0 {
|
|
251
|
+
return Err(SecurityError::from_code(access_status))
|
|
252
|
+
.context("failed to create keychain item access rules");
|
|
253
|
+
}
|
|
254
|
+
let access = unsafe { SecAccess::wrap_under_create_rule(access_ref) };
|
|
255
|
+
|
|
256
|
+
let mut service_bytes = service.as_bytes().to_vec();
|
|
257
|
+
let mut account_bytes = account.as_bytes().to_vec();
|
|
258
|
+
let mut attributes = [
|
|
259
|
+
SecKeychainAttribute {
|
|
260
|
+
tag: K_SEC_SERVICE_ITEM_ATTR,
|
|
261
|
+
length: service_bytes.len() as u32,
|
|
262
|
+
data: service_bytes.as_mut_ptr().cast(),
|
|
263
|
+
},
|
|
264
|
+
SecKeychainAttribute {
|
|
265
|
+
tag: K_SEC_ACCOUNT_ITEM_ATTR,
|
|
266
|
+
length: account_bytes.len() as u32,
|
|
267
|
+
data: account_bytes.as_mut_ptr().cast(),
|
|
268
|
+
},
|
|
269
|
+
];
|
|
270
|
+
let mut attribute_list = SecKeychainAttributeList {
|
|
271
|
+
count: attributes.len() as u32,
|
|
272
|
+
attr: attributes.as_mut_ptr(),
|
|
273
|
+
};
|
|
274
|
+
let mut item_ref: SecKeychainItemRef = std::ptr::null_mut();
|
|
275
|
+
|
|
276
|
+
let create_status = unsafe {
|
|
277
|
+
SecKeychainItemCreateFromContent(
|
|
278
|
+
K_SEC_GENERIC_PASSWORD_ITEM_CLASS,
|
|
279
|
+
&mut attribute_list,
|
|
280
|
+
password.len() as u32,
|
|
281
|
+
password.as_ptr().cast(),
|
|
282
|
+
keychain.as_CFTypeRef() as security_framework_sys::base::SecKeychainRef,
|
|
283
|
+
access.as_concrete_TypeRef(),
|
|
284
|
+
&mut item_ref,
|
|
285
|
+
)
|
|
286
|
+
};
|
|
287
|
+
let _item =
|
|
288
|
+
(!item_ref.is_null()).then(|| unsafe { SecKeychainItem::wrap_under_create_rule(item_ref) });
|
|
289
|
+
if create_status != 0 {
|
|
290
|
+
return Err(SecurityError::from_code(create_status))
|
|
291
|
+
.context("failed to create restricted generic password item");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
Ok(())
|
|
295
|
+
}
|
|
296
|
+
|
|
183
297
|
#[cfg(not(target_os = "macos"))]
|
|
184
298
|
fn replace_generic_password(
|
|
185
299
|
_keychain: PathBuf,
|
|
@@ -160,6 +160,7 @@ async fn main() -> Result<()> {
|
|
|
160
160
|
);
|
|
161
161
|
eprintln!("==> press Ctrl+C to stop");
|
|
162
162
|
|
|
163
|
+
let signer_backend_label = relay_signer_backend_label(cli.signer_backend);
|
|
163
164
|
match cli.signer_backend {
|
|
164
165
|
SignerBackendKind::SecureEnclave => {
|
|
165
166
|
let daemon = Arc::new(
|
|
@@ -172,7 +173,7 @@ async fn main() -> Result<()> {
|
|
|
172
173
|
.context("failed to initialize daemon")?,
|
|
173
174
|
);
|
|
174
175
|
let relay_task =
|
|
175
|
-
relay_sync::spawn_relay_sync_task(Arc::clone(&daemon),
|
|
176
|
+
relay_sync::spawn_relay_sync_task(Arc::clone(&daemon), signer_backend_label);
|
|
176
177
|
vault_password.zeroize();
|
|
177
178
|
server
|
|
178
179
|
.run_until_shutdown(daemon, async {
|
|
@@ -193,7 +194,7 @@ async fn main() -> Result<()> {
|
|
|
193
194
|
.context("failed to initialize daemon")?,
|
|
194
195
|
);
|
|
195
196
|
let relay_task =
|
|
196
|
-
relay_sync::spawn_relay_sync_task(Arc::clone(&daemon),
|
|
197
|
+
relay_sync::spawn_relay_sync_task(Arc::clone(&daemon), signer_backend_label);
|
|
197
198
|
vault_password.zeroize();
|
|
198
199
|
server
|
|
199
200
|
.run_until_shutdown(daemon, async {
|
|
@@ -208,6 +209,13 @@ async fn main() -> Result<()> {
|
|
|
208
209
|
Ok(())
|
|
209
210
|
}
|
|
210
211
|
|
|
212
|
+
fn relay_signer_backend_label(backend: SignerBackendKind) -> &'static str {
|
|
213
|
+
match backend {
|
|
214
|
+
SignerBackendKind::SecureEnclave => "secure-enclave",
|
|
215
|
+
SignerBackendKind::Software => "software",
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
211
219
|
fn validate_signer_backend_runtime(backend: SignerBackendKind) -> Result<()> {
|
|
212
220
|
#[cfg(not(target_os = "macos"))]
|
|
213
221
|
{
|
|
@@ -506,8 +514,8 @@ fn lock_path(path: &Path) -> PathBuf {
|
|
|
506
514
|
#[cfg(test)]
|
|
507
515
|
mod tests {
|
|
508
516
|
use super::{
|
|
509
|
-
ensure_file_parent,
|
|
510
|
-
validate_password, Cli,
|
|
517
|
+
ensure_file_parent, relay_signer_backend_label, resolve_allowed_peer_euids_with_sudo_uid,
|
|
518
|
+
resolve_vault_password, validate_password, Cli, SignerBackendKind,
|
|
511
519
|
};
|
|
512
520
|
use clap::Parser;
|
|
513
521
|
use std::collections::BTreeSet;
|
|
@@ -608,6 +616,18 @@ mod tests {
|
|
|
608
616
|
assert_eq!(resolved.agent, BTreeSet::from([0, 22, 33]));
|
|
609
617
|
}
|
|
610
618
|
|
|
619
|
+
#[test]
|
|
620
|
+
fn relay_signer_backend_label_matches_runtime_backend() {
|
|
621
|
+
assert_eq!(
|
|
622
|
+
relay_signer_backend_label(SignerBackendKind::SecureEnclave),
|
|
623
|
+
"secure-enclave"
|
|
624
|
+
);
|
|
625
|
+
assert_eq!(
|
|
626
|
+
relay_signer_backend_label(SignerBackendKind::Software),
|
|
627
|
+
"software"
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
611
631
|
#[cfg(unix)]
|
|
612
632
|
#[test]
|
|
613
633
|
fn assert_allowed_directory_owner_rejects_non_root_owner_for_root_runtime() {
|
|
@@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize};
|
|
|
11
11
|
use tokio::task::JoinHandle;
|
|
12
12
|
use tokio::time::{self, MissedTickBehavior};
|
|
13
13
|
use uuid::Uuid;
|
|
14
|
-
use vault_daemon::{InMemoryDaemon, RelayRegistrationSnapshot};
|
|
14
|
+
use vault_daemon::{DaemonError, InMemoryDaemon, RelayRegistrationSnapshot};
|
|
15
15
|
use vault_domain::{
|
|
16
16
|
manual_approval_capability_hash, manual_approval_capability_token, AgentAction, AssetId,
|
|
17
17
|
EntityScope, ManualApprovalDecision, ManualApprovalStatus, PolicyType, SpendingPolicy,
|
|
@@ -178,6 +178,72 @@ struct ProcessedFeedback {
|
|
|
178
178
|
status: &'static str,
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
fn manual_approval_feedback(
|
|
182
|
+
approval_request_id: Uuid,
|
|
183
|
+
status: ManualApprovalStatus,
|
|
184
|
+
note: Option<&str>,
|
|
185
|
+
message: String,
|
|
186
|
+
) -> ProcessedFeedback {
|
|
187
|
+
let mut details = BTreeMap::new();
|
|
188
|
+
details.insert("approvalRequestId".to_string(), approval_request_id.to_string());
|
|
189
|
+
details.insert(
|
|
190
|
+
"manualApprovalStatus".to_string(),
|
|
191
|
+
map_approval_status(status).to_string(),
|
|
192
|
+
);
|
|
193
|
+
if let Some(note) = note.filter(|value| !value.trim().is_empty()) {
|
|
194
|
+
details.insert("note".to_string(), note.to_string());
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
ProcessedFeedback {
|
|
198
|
+
details: Some(details),
|
|
199
|
+
message: Some(message),
|
|
200
|
+
status: "applied",
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
fn manual_approval_error_feedback(
|
|
205
|
+
approval_request_id: Uuid,
|
|
206
|
+
decision: ManualApprovalDecision,
|
|
207
|
+
note: Option<&str>,
|
|
208
|
+
error: &DaemonError,
|
|
209
|
+
) -> ProcessedFeedback {
|
|
210
|
+
if let DaemonError::ManualApprovalRequestNotPending { status, .. } = error {
|
|
211
|
+
let same_decision_already_applied = matches!(
|
|
212
|
+
(decision, status),
|
|
213
|
+
(
|
|
214
|
+
ManualApprovalDecision::Approve,
|
|
215
|
+
ManualApprovalStatus::Approved | ManualApprovalStatus::Completed,
|
|
216
|
+
) | (ManualApprovalDecision::Reject, ManualApprovalStatus::Rejected)
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
if same_decision_already_applied {
|
|
220
|
+
return manual_approval_feedback(
|
|
221
|
+
approval_request_id,
|
|
222
|
+
*status,
|
|
223
|
+
note,
|
|
224
|
+
format!(
|
|
225
|
+
"manual approval {} was already applied to {}",
|
|
226
|
+
match decision {
|
|
227
|
+
ManualApprovalDecision::Approve => "approve",
|
|
228
|
+
ManualApprovalDecision::Reject => "reject",
|
|
229
|
+
},
|
|
230
|
+
approval_request_id
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
ProcessedFeedback {
|
|
237
|
+
details: None,
|
|
238
|
+
message: Some(error.to_string()),
|
|
239
|
+
status: if matches!(decision, ManualApprovalDecision::Reject) {
|
|
240
|
+
"rejected"
|
|
241
|
+
} else {
|
|
242
|
+
"failed"
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
181
247
|
pub fn spawn_relay_sync_task<B>(
|
|
182
248
|
daemon: Arc<InMemoryDaemon<B>>,
|
|
183
249
|
signer_backend: &'static str,
|
|
@@ -528,38 +594,25 @@ where
|
|
|
528
594
|
)
|
|
529
595
|
.await
|
|
530
596
|
{
|
|
531
|
-
Ok(request) =>
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
)),
|
|
551
|
-
status: "applied",
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
Err(error) => ProcessedFeedback {
|
|
555
|
-
details: None,
|
|
556
|
-
message: Some(error.to_string()),
|
|
557
|
-
status: if matches!(decision, ManualApprovalDecision::Reject) {
|
|
558
|
-
"rejected"
|
|
559
|
-
} else {
|
|
560
|
-
"failed"
|
|
561
|
-
},
|
|
562
|
-
},
|
|
597
|
+
Ok(request) => manual_approval_feedback(
|
|
598
|
+
request.id,
|
|
599
|
+
request.status,
|
|
600
|
+
payload.note.as_deref(),
|
|
601
|
+
format!(
|
|
602
|
+
"manual approval {} applied to {}",
|
|
603
|
+
match decision {
|
|
604
|
+
ManualApprovalDecision::Approve => "approve",
|
|
605
|
+
ManualApprovalDecision::Reject => "reject",
|
|
606
|
+
},
|
|
607
|
+
request.id
|
|
608
|
+
),
|
|
609
|
+
),
|
|
610
|
+
Err(error) => manual_approval_error_feedback(
|
|
611
|
+
approval_request_id,
|
|
612
|
+
decision,
|
|
613
|
+
payload.note.as_deref(),
|
|
614
|
+
&error,
|
|
615
|
+
),
|
|
563
616
|
}
|
|
564
617
|
}
|
|
565
618
|
|
|
@@ -775,14 +828,18 @@ fn format_time(value: OffsetDateTime) -> Result<String> {
|
|
|
775
828
|
|
|
776
829
|
#[cfg(test)]
|
|
777
830
|
mod tests {
|
|
778
|
-
use super::{approval_metadata, resolve_relay_daemon_token_with};
|
|
831
|
+
use super::{approval_metadata, manual_approval_error_feedback, resolve_relay_daemon_token_with};
|
|
779
832
|
use std::collections::BTreeMap;
|
|
780
833
|
use std::fs;
|
|
781
834
|
use std::path::Path;
|
|
782
835
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
783
836
|
use time::OffsetDateTime;
|
|
784
837
|
use uuid::Uuid;
|
|
785
|
-
use
|
|
838
|
+
use vault_daemon::DaemonError;
|
|
839
|
+
use vault_domain::{
|
|
840
|
+
AgentAction, AssetId, ManualApprovalDecision, ManualApprovalRequest,
|
|
841
|
+
ManualApprovalStatus,
|
|
842
|
+
};
|
|
786
843
|
|
|
787
844
|
#[test]
|
|
788
845
|
fn approval_metadata_includes_admin_reissue_token_and_public_hash_for_pending_requests() {
|
|
@@ -891,4 +948,67 @@ mod tests {
|
|
|
891
948
|
|
|
892
949
|
assert_eq!(token, None);
|
|
893
950
|
}
|
|
951
|
+
|
|
952
|
+
#[test]
|
|
953
|
+
fn manual_approval_error_feedback_marks_replayed_approve_updates_as_applied() {
|
|
954
|
+
let approval_request_id =
|
|
955
|
+
Uuid::parse_str("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa").expect("uuid");
|
|
956
|
+
|
|
957
|
+
let feedback = manual_approval_error_feedback(
|
|
958
|
+
approval_request_id,
|
|
959
|
+
ManualApprovalDecision::Approve,
|
|
960
|
+
Some("approved earlier"),
|
|
961
|
+
&DaemonError::ManualApprovalRequestNotPending {
|
|
962
|
+
approval_request_id,
|
|
963
|
+
status: ManualApprovalStatus::Completed,
|
|
964
|
+
},
|
|
965
|
+
);
|
|
966
|
+
|
|
967
|
+
assert_eq!(feedback.status, "applied");
|
|
968
|
+
assert_eq!(
|
|
969
|
+
feedback
|
|
970
|
+
.details
|
|
971
|
+
.as_ref()
|
|
972
|
+
.and_then(|details| details.get("manualApprovalStatus")),
|
|
973
|
+
Some(&"completed".to_string())
|
|
974
|
+
);
|
|
975
|
+
assert_eq!(
|
|
976
|
+
feedback
|
|
977
|
+
.details
|
|
978
|
+
.as_ref()
|
|
979
|
+
.and_then(|details| details.get("note")),
|
|
980
|
+
Some(&"approved earlier".to_string())
|
|
981
|
+
);
|
|
982
|
+
assert!(
|
|
983
|
+
feedback
|
|
984
|
+
.message
|
|
985
|
+
.as_deref()
|
|
986
|
+
.is_some_and(|message| message.contains("already applied"))
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
#[test]
|
|
991
|
+
fn manual_approval_error_feedback_keeps_conflicting_approve_updates_failed() {
|
|
992
|
+
let approval_request_id =
|
|
993
|
+
Uuid::parse_str("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb").expect("uuid");
|
|
994
|
+
|
|
995
|
+
let feedback = manual_approval_error_feedback(
|
|
996
|
+
approval_request_id,
|
|
997
|
+
ManualApprovalDecision::Approve,
|
|
998
|
+
None,
|
|
999
|
+
&DaemonError::ManualApprovalRequestNotPending {
|
|
1000
|
+
approval_request_id,
|
|
1001
|
+
status: ManualApprovalStatus::Rejected,
|
|
1002
|
+
},
|
|
1003
|
+
);
|
|
1004
|
+
|
|
1005
|
+
assert_eq!(feedback.status, "failed");
|
|
1006
|
+
assert!(feedback.details.is_none());
|
|
1007
|
+
assert!(
|
|
1008
|
+
feedback
|
|
1009
|
+
.message
|
|
1010
|
+
.as_deref()
|
|
1011
|
+
.is_some_and(|message| message.contains("already Rejected"))
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
894
1014
|
}
|
|
@@ -6,6 +6,12 @@ mod macos {
|
|
|
6
6
|
use std::process::{Command, Output, Stdio};
|
|
7
7
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
8
8
|
|
|
9
|
+
use security_framework::os::macos::keychain::SecKeychain;
|
|
10
|
+
use security_framework_sys::base::errSecAuthFailed;
|
|
11
|
+
|
|
12
|
+
const ERR_SEC_AUTH_FAILED: i32 = errSecAuthFailed;
|
|
13
|
+
const ERR_SEC_INTERACTION_NOT_ALLOWED: i32 = -25308;
|
|
14
|
+
|
|
9
15
|
fn unique_temp_dir() -> PathBuf {
|
|
10
16
|
let unique = SystemTime::now()
|
|
11
17
|
.duration_since(UNIX_EPOCH)
|
|
@@ -57,7 +63,7 @@ mod macos {
|
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
#[test]
|
|
60
|
-
fn
|
|
66
|
+
fn helper_owned_items_are_not_readable_without_interaction() {
|
|
61
67
|
let helper = PathBuf::from(env!("CARGO_BIN_EXE_wlfi-agent-system-keychain"));
|
|
62
68
|
let temp_dir = unique_temp_dir();
|
|
63
69
|
let keychain_path = temp_dir.join("acl-test.keychain-db");
|
|
@@ -117,23 +123,22 @@ mod macos {
|
|
|
117
123
|
String::from_utf8_lossy(&replace.stderr)
|
|
118
124
|
);
|
|
119
125
|
|
|
120
|
-
let
|
|
121
|
-
"
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
"
|
|
135
|
-
|
|
136
|
-
);
|
|
126
|
+
let _interaction_guard =
|
|
127
|
+
SecKeychain::disable_user_interaction().expect("disable keychain UI for test process");
|
|
128
|
+
let keychain = SecKeychain::open(&keychain_path).expect("open test keychain");
|
|
129
|
+
let non_helper_read = keychain.find_generic_password(service, account);
|
|
130
|
+
match non_helper_read {
|
|
131
|
+
Ok((password, _)) => panic!(
|
|
132
|
+
"untrusted process unexpectedly read helper-owned password: {}",
|
|
133
|
+
String::from_utf8_lossy(password.as_ref())
|
|
134
|
+
),
|
|
135
|
+
Err(error)
|
|
136
|
+
if matches!(
|
|
137
|
+
error.code(),
|
|
138
|
+
ERR_SEC_INTERACTION_NOT_ALLOWED | ERR_SEC_AUTH_FAILED
|
|
139
|
+
) => {}
|
|
140
|
+
Err(error) => panic!("unexpected keychain error for untrusted read: {error:?}"),
|
|
141
|
+
}
|
|
137
142
|
|
|
138
143
|
let helper_read = run(
|
|
139
144
|
helper.to_str().expect("helper path utf-8"),
|
|
@@ -292,6 +292,12 @@ where
|
|
|
292
292
|
let request = requests.get_mut(&approval_request_id).ok_or(
|
|
293
293
|
DaemonError::UnknownManualApprovalRequest(approval_request_id),
|
|
294
294
|
)?;
|
|
295
|
+
if request.status != ManualApprovalStatus::Pending {
|
|
296
|
+
return Err(DaemonError::ManualApprovalRequestNotPending {
|
|
297
|
+
approval_request_id,
|
|
298
|
+
status: request.status,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
295
301
|
request.updated_at = now;
|
|
296
302
|
match decision {
|
|
297
303
|
ManualApprovalDecision::Approve => {
|
|
@@ -81,6 +81,12 @@ pub enum DaemonError {
|
|
|
81
81
|
/// Existing manual approval request was rejected.
|
|
82
82
|
#[error("manual approval request {approval_request_id} was rejected")]
|
|
83
83
|
ManualApprovalRejected { approval_request_id: Uuid },
|
|
84
|
+
/// Manual approval request was already resolved and cannot be decided again.
|
|
85
|
+
#[error("manual approval request {approval_request_id} is already {status:?}")]
|
|
86
|
+
ManualApprovalRequestNotPending {
|
|
87
|
+
approval_request_id: Uuid,
|
|
88
|
+
status: ManualApprovalStatus,
|
|
89
|
+
},
|
|
84
90
|
/// Policy engine denied request.
|
|
85
91
|
#[error("policy check failed: {0}")]
|
|
86
92
|
Policy(#[from] PolicyError),
|
|
@@ -8,8 +8,8 @@ use serde_json::to_vec;
|
|
|
8
8
|
use uuid::Uuid;
|
|
9
9
|
use vault_domain::{
|
|
10
10
|
AgentAction, AgentCredentials, AssetId, BroadcastTx, EntityScope, EvmAddress, KeySource, Lease,
|
|
11
|
-
|
|
12
|
-
SpendingPolicy,
|
|
11
|
+
ManualApprovalDecision, ManualApprovalStatus, NonceReleaseRequest, NonceReservationRequest,
|
|
12
|
+
PolicyAttachment, PolicyType, SignRequest, SpendingPolicy,
|
|
13
13
|
};
|
|
14
14
|
use vault_signer::SoftwareSignerBackend;
|
|
15
15
|
|
|
@@ -602,3 +602,113 @@ async fn persistent_store_rejects_group_readable_state_file() {
|
|
|
602
602
|
.expect("restore state file permissions for cleanup");
|
|
603
603
|
std::fs::remove_file(&state_path).expect("cleanup state file");
|
|
604
604
|
}
|
|
605
|
+
|
|
606
|
+
#[tokio::test]
|
|
607
|
+
async fn manual_approval_requests_cannot_be_decided_after_resolution() {
|
|
608
|
+
let daemon = InMemoryDaemon::new(
|
|
609
|
+
"vault-password",
|
|
610
|
+
SoftwareSignerBackend::default(),
|
|
611
|
+
DaemonConfig::default(),
|
|
612
|
+
)
|
|
613
|
+
.expect("daemon");
|
|
614
|
+
|
|
615
|
+
let lease = daemon.issue_lease("vault-password").await.expect("lease");
|
|
616
|
+
let session = AdminSession {
|
|
617
|
+
vault_password: "vault-password".to_string(),
|
|
618
|
+
lease,
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
daemon
|
|
622
|
+
.add_policy(
|
|
623
|
+
&session,
|
|
624
|
+
SpendingPolicy::new_manual_approval(
|
|
625
|
+
1,
|
|
626
|
+
1,
|
|
627
|
+
1_000_000_000_000_000_000,
|
|
628
|
+
EntityScope::All,
|
|
629
|
+
EntityScope::All,
|
|
630
|
+
EntityScope::All,
|
|
631
|
+
)
|
|
632
|
+
.expect("manual approval policy"),
|
|
633
|
+
)
|
|
634
|
+
.await
|
|
635
|
+
.expect("add policy");
|
|
636
|
+
|
|
637
|
+
let key = daemon
|
|
638
|
+
.create_vault_key(&session, KeyCreateRequest::Generate)
|
|
639
|
+
.await
|
|
640
|
+
.expect("key");
|
|
641
|
+
let agent_credentials = daemon
|
|
642
|
+
.create_agent_key(&session, key.id, PolicyAttachment::AllPolicies)
|
|
643
|
+
.await
|
|
644
|
+
.expect("agent");
|
|
645
|
+
|
|
646
|
+
let request = sign_request(
|
|
647
|
+
&agent_credentials,
|
|
648
|
+
AgentAction::Transfer {
|
|
649
|
+
chain_id: 1,
|
|
650
|
+
token: "0x1000000000000000000000000000000000000000"
|
|
651
|
+
.parse()
|
|
652
|
+
.expect("token"),
|
|
653
|
+
to: "0x2000000000000000000000000000000000000000"
|
|
654
|
+
.parse()
|
|
655
|
+
.expect("recipient"),
|
|
656
|
+
amount_wei: 42,
|
|
657
|
+
},
|
|
658
|
+
);
|
|
659
|
+
let approval_request_id = match daemon.sign_for_agent(request.clone()).await {
|
|
660
|
+
Err(DaemonError::ManualApprovalRequired {
|
|
661
|
+
approval_request_id, ..
|
|
662
|
+
}) => approval_request_id,
|
|
663
|
+
other => panic!("expected manual approval request, got {other:?}"),
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
daemon
|
|
667
|
+
.decide_manual_approval_request(
|
|
668
|
+
&session,
|
|
669
|
+
approval_request_id,
|
|
670
|
+
ManualApprovalDecision::Approve,
|
|
671
|
+
None,
|
|
672
|
+
)
|
|
673
|
+
.await
|
|
674
|
+
.expect("approve request");
|
|
675
|
+
|
|
676
|
+
let err = daemon
|
|
677
|
+
.decide_manual_approval_request(
|
|
678
|
+
&session,
|
|
679
|
+
approval_request_id,
|
|
680
|
+
ManualApprovalDecision::Reject,
|
|
681
|
+
Some("late rejection".to_string()),
|
|
682
|
+
)
|
|
683
|
+
.await
|
|
684
|
+
.expect_err("resolved request must reject a second decision");
|
|
685
|
+
assert!(matches!(
|
|
686
|
+
err,
|
|
687
|
+
DaemonError::ManualApprovalRequestNotPending {
|
|
688
|
+
approval_request_id: id,
|
|
689
|
+
status: ManualApprovalStatus::Approved,
|
|
690
|
+
} if id == approval_request_id
|
|
691
|
+
));
|
|
692
|
+
|
|
693
|
+
daemon
|
|
694
|
+
.sign_for_agent(request)
|
|
695
|
+
.await
|
|
696
|
+
.expect("approved request should sign");
|
|
697
|
+
|
|
698
|
+
let err = daemon
|
|
699
|
+
.apply_relay_manual_approval_decision(
|
|
700
|
+
"vault-password",
|
|
701
|
+
approval_request_id,
|
|
702
|
+
ManualApprovalDecision::Reject,
|
|
703
|
+
Some("too late".to_string()),
|
|
704
|
+
)
|
|
705
|
+
.await
|
|
706
|
+
.expect_err("completed request must reject relay retries");
|
|
707
|
+
assert!(matches!(
|
|
708
|
+
err,
|
|
709
|
+
DaemonError::ManualApprovalRequestNotPending {
|
|
710
|
+
approval_request_id: id,
|
|
711
|
+
status: ManualApprovalStatus::Completed,
|
|
712
|
+
} if id == approval_request_id
|
|
713
|
+
));
|
|
714
|
+
}
|