@wlfi-agent/cli 1.4.12 → 1.4.14
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 +3968 -0
- package/Cargo.toml +50 -0
- package/README.md +426 -6
- package/crates/vault-cli-admin/Cargo.toml +26 -0
- package/crates/vault-cli-admin/src/io_utils.rs +500 -0
- package/crates/vault-cli-admin/src/main.rs +3990 -0
- package/crates/vault-cli-admin/src/shared_config.rs +624 -0
- package/crates/vault-cli-admin/src/tui/amounts.rs +180 -0
- package/crates/vault-cli-admin/src/tui/token_rpc.rs +250 -0
- package/crates/vault-cli-admin/src/tui/utils.rs +82 -0
- package/crates/vault-cli-admin/src/tui.rs +3410 -0
- package/crates/vault-cli-agent/Cargo.toml +24 -0
- package/crates/vault-cli-agent/src/io_utils.rs +576 -0
- package/crates/vault-cli-agent/src/main.rs +833 -0
- package/crates/vault-cli-daemon/Cargo.toml +28 -0
- package/crates/vault-cli-daemon/src/bin/wlfi-agent-system-keychain.rs +216 -0
- package/crates/vault-cli-daemon/src/main.rs +644 -0
- package/crates/vault-cli-daemon/src/relay_sync.rs +894 -0
- package/crates/vault-cli-daemon/tests/system_keychain_helper_acl.rs +167 -0
- package/crates/vault-daemon/Cargo.toml +32 -0
- package/crates/vault-daemon/src/daemon_parts/api_impl_and_utils.rs +1041 -0
- package/crates/vault-daemon/src/daemon_parts/core_helpers.rs +1256 -0
- package/crates/vault-daemon/src/daemon_parts/types_api_rpc.rs +622 -0
- package/crates/vault-daemon/src/lib.rs +54 -0
- package/crates/vault-daemon/src/persistence.rs +441 -0
- package/crates/vault-daemon/src/tests.rs +237 -0
- package/crates/vault-daemon/src/tests_parts/part1.rs +1224 -0
- package/crates/vault-daemon/src/tests_parts/part2.rs +1021 -0
- package/crates/vault-daemon/src/tests_parts/part3.rs +835 -0
- package/crates/vault-daemon/src/tests_parts/part4.rs +604 -0
- package/crates/vault-domain/Cargo.toml +20 -0
- package/crates/vault-domain/src/action.rs +849 -0
- package/crates/vault-domain/src/address.rs +51 -0
- package/crates/vault-domain/src/approval.rs +90 -0
- package/crates/vault-domain/src/constants.rs +4 -0
- package/crates/vault-domain/src/error.rs +54 -0
- package/crates/vault-domain/src/keys.rs +71 -0
- package/crates/vault-domain/src/lib.rs +42 -0
- package/crates/vault-domain/src/nonce.rs +102 -0
- package/crates/vault-domain/src/policy.rs +172 -0
- package/crates/vault-domain/src/request.rs +53 -0
- package/crates/vault-domain/src/scope.rs +24 -0
- package/crates/vault-domain/src/session.rs +50 -0
- package/crates/vault-domain/src/signature.rs +34 -0
- package/crates/vault-domain/src/tests.rs +651 -0
- package/crates/vault-domain/src/u128_as_decimal_string.rs +44 -0
- package/crates/vault-policy/Cargo.toml +17 -0
- package/crates/vault-policy/src/engine.rs +301 -0
- package/crates/vault-policy/src/error.rs +81 -0
- package/crates/vault-policy/src/lib.rs +17 -0
- package/crates/vault-policy/src/report.rs +34 -0
- package/crates/vault-policy/src/tests.rs +891 -0
- package/crates/vault-policy/src/tests_explain.rs +78 -0
- package/crates/vault-sdk-agent/Cargo.toml +21 -0
- package/crates/vault-sdk-agent/src/lib.rs +711 -0
- package/crates/vault-signer/Cargo.toml +25 -0
- package/crates/vault-signer/src/lib.rs +731 -0
- package/crates/vault-signer/tests/secure_enclave_acl.rs +54 -0
- package/crates/vault-transport-unix/Cargo.toml +24 -0
- package/crates/vault-transport-unix/src/lib.rs +1640 -0
- package/crates/vault-transport-xpc/Cargo.toml +25 -0
- package/crates/vault-transport-xpc/src/client_codec_api.rs +635 -0
- package/crates/vault-transport-xpc/src/lib.rs +680 -0
- package/crates/vault-transport-xpc/src/tests.rs +818 -0
- package/crates/vault-transport-xpc/tests/e2e_flow.rs +773 -0
- package/dist/cli.cjs +35088 -0
- package/dist/cli.cjs.map +1 -0
- package/package.json +49 -43
- package/packages/cache/.turbo/turbo-build.log +52 -0
- package/packages/cache/dist/chunk-2QFWMUXT.cjs +43 -0
- package/packages/cache/dist/chunk-2QFWMUXT.cjs.map +1 -0
- package/packages/cache/dist/chunk-4U63TZTQ.js +43 -0
- package/packages/cache/dist/chunk-4U63TZTQ.js.map +1 -0
- package/packages/cache/dist/chunk-ALQ6H7KG.cjs +404 -0
- package/packages/cache/dist/chunk-ALQ6H7KG.cjs.map +1 -0
- package/packages/cache/dist/chunk-FGJEEF5N.js +404 -0
- package/packages/cache/dist/chunk-FGJEEF5N.js.map +1 -0
- package/packages/cache/dist/chunk-UYNEHZHB.cjs +45 -0
- package/packages/cache/dist/chunk-UYNEHZHB.cjs.map +1 -0
- package/packages/cache/dist/chunk-VXVMPG3W.js +45 -0
- package/packages/cache/dist/chunk-VXVMPG3W.js.map +1 -0
- package/packages/cache/dist/client/index.cjs +11 -0
- package/packages/cache/dist/client/index.cjs.map +1 -0
- package/packages/cache/dist/client/index.d.cts +15 -0
- package/packages/cache/dist/client/index.d.ts +15 -0
- package/packages/cache/dist/client/index.js +11 -0
- package/packages/cache/dist/client/index.js.map +1 -0
- package/packages/cache/dist/errors/index.cjs +11 -0
- package/packages/cache/dist/errors/index.cjs.map +1 -0
- package/packages/cache/dist/errors/index.d.cts +26 -0
- package/packages/cache/dist/errors/index.d.ts +26 -0
- package/packages/cache/dist/errors/index.js +11 -0
- package/packages/cache/dist/errors/index.js.map +1 -0
- package/packages/cache/dist/index.cjs +29 -0
- package/packages/cache/dist/index.cjs.map +1 -0
- package/packages/cache/dist/index.d.cts +4 -0
- package/packages/cache/dist/index.d.ts +4 -0
- package/packages/cache/dist/index.js +29 -0
- package/packages/cache/dist/index.js.map +1 -0
- package/packages/cache/dist/service/index.cjs +15 -0
- package/packages/cache/dist/service/index.cjs.map +1 -0
- package/packages/cache/dist/service/index.d.cts +184 -0
- package/packages/cache/dist/service/index.d.ts +184 -0
- package/packages/cache/dist/service/index.js +15 -0
- package/packages/cache/dist/service/index.js.map +1 -0
- package/packages/cache/node_modules/.bin/jiti +17 -0
- package/packages/cache/node_modules/.bin/tsc +17 -0
- package/packages/cache/node_modules/.bin/tsserver +17 -0
- package/packages/cache/node_modules/.bin/tsup +17 -0
- package/packages/cache/node_modules/.bin/tsup-node +17 -0
- package/packages/cache/node_modules/.bin/tsx +17 -0
- package/packages/cache/node_modules/.bin/vitest +17 -0
- package/packages/cache/package.json +48 -0
- package/packages/cache/src/client/index.ts +56 -0
- package/packages/cache/src/errors/index.ts +53 -0
- package/packages/cache/src/index.ts +3 -0
- package/packages/cache/src/service/index.test.ts +263 -0
- package/packages/cache/src/service/index.ts +678 -0
- package/packages/cache/tsconfig.json +13 -0
- package/packages/cache/tsup.config.ts +13 -0
- package/packages/cache/vitest.config.ts +16 -0
- package/packages/config/.turbo/turbo-build.log +18 -0
- package/packages/config/dist/index.cjs +1037 -0
- package/packages/config/dist/index.cjs.map +1 -0
- package/packages/config/dist/index.d.ts +131 -0
- package/packages/config/node_modules/.bin/jiti +17 -0
- package/packages/config/node_modules/.bin/tsc +17 -0
- package/packages/config/node_modules/.bin/tsserver +17 -0
- package/packages/config/node_modules/.bin/tsup +17 -0
- package/packages/config/node_modules/.bin/tsup-node +17 -0
- package/packages/config/node_modules/.bin/tsx +17 -0
- package/packages/config/package.json +21 -0
- package/packages/config/src/index.js +1 -0
- package/packages/config/src/index.ts +1282 -0
- package/packages/config/tsconfig.json +4 -0
- package/packages/rpc/.turbo/turbo-build.log +32 -0
- package/packages/rpc/dist/_esm-BCLXDO2R.cjs +3660 -0
- package/packages/rpc/dist/_esm-BCLXDO2R.cjs.map +1 -0
- package/packages/rpc/dist/ccip-OWJLAW55.cjs +16 -0
- package/packages/rpc/dist/ccip-OWJLAW55.cjs.map +1 -0
- package/packages/rpc/dist/chunk-APQIFZ3B.cjs +6247 -0
- package/packages/rpc/dist/chunk-APQIFZ3B.cjs.map +1 -0
- package/packages/rpc/dist/chunk-CDO2GWRD.cjs +410 -0
- package/packages/rpc/dist/chunk-CDO2GWRD.cjs.map +1 -0
- package/packages/rpc/dist/chunk-QGTNTFJ7.cjs +2249 -0
- package/packages/rpc/dist/chunk-QGTNTFJ7.cjs.map +1 -0
- package/packages/rpc/dist/chunk-TZDTAHWR.cjs +44 -0
- package/packages/rpc/dist/chunk-TZDTAHWR.cjs.map +1 -0
- package/packages/rpc/dist/index.cjs +7342 -0
- package/packages/rpc/dist/index.cjs.map +1 -0
- package/packages/rpc/dist/index.d.ts +3857 -0
- package/packages/rpc/dist/secp256k1-WCNM675D.cjs +18 -0
- package/packages/rpc/dist/secp256k1-WCNM675D.cjs.map +1 -0
- package/packages/rpc/node_modules/.bin/jiti +17 -0
- package/packages/rpc/node_modules/.bin/tsc +17 -0
- package/packages/rpc/node_modules/.bin/tsserver +17 -0
- package/packages/rpc/node_modules/.bin/tsup +17 -0
- package/packages/rpc/node_modules/.bin/tsup-node +17 -0
- package/packages/rpc/node_modules/.bin/tsx +17 -0
- package/packages/rpc/package.json +25 -0
- package/packages/rpc/src/index.ts +206 -0
- package/packages/rpc/tsconfig.json +4 -0
- package/packages/typescript/base.json +36 -0
- package/packages/typescript/nextjs.json +17 -0
- package/packages/typescript/package.json +10 -0
- package/packages/ui/.turbo/turbo-build.log +44 -0
- package/packages/ui/dist/chunk-MOAFBKSA.js +11 -0
- package/packages/ui/dist/chunk-MOAFBKSA.js.map +1 -0
- package/packages/ui/dist/components/badge.d.ts +12 -0
- package/packages/ui/dist/components/badge.js +31 -0
- package/packages/ui/dist/components/badge.js.map +1 -0
- package/packages/ui/dist/components/button.d.ts +13 -0
- package/packages/ui/dist/components/button.js +40 -0
- package/packages/ui/dist/components/button.js.map +1 -0
- package/packages/ui/dist/components/card.d.ts +10 -0
- package/packages/ui/dist/components/card.js +39 -0
- package/packages/ui/dist/components/card.js.map +1 -0
- package/packages/ui/dist/components/input.d.ts +5 -0
- package/packages/ui/dist/components/input.js +28 -0
- package/packages/ui/dist/components/input.js.map +1 -0
- package/packages/ui/dist/components/label.d.ts +5 -0
- package/packages/ui/dist/components/label.js +13 -0
- package/packages/ui/dist/components/label.js.map +1 -0
- package/packages/ui/dist/components/separator.d.ts +5 -0
- package/packages/ui/dist/components/separator.js +13 -0
- package/packages/ui/dist/components/separator.js.map +1 -0
- package/packages/ui/dist/components/textarea.d.ts +5 -0
- package/packages/ui/dist/components/textarea.js +27 -0
- package/packages/ui/dist/components/textarea.js.map +1 -0
- package/packages/ui/dist/tailwind.d.ts +56 -0
- package/packages/ui/dist/tailwind.js +60 -0
- package/packages/ui/dist/tailwind.js.map +1 -0
- package/packages/ui/dist/utils/cn.d.ts +5 -0
- package/packages/ui/dist/utils/cn.js +7 -0
- package/packages/ui/dist/utils/cn.js.map +1 -0
- package/packages/ui/node_modules/.bin/jiti +17 -0
- package/packages/ui/node_modules/.bin/tsc +17 -0
- package/packages/ui/node_modules/.bin/tsserver +17 -0
- package/packages/ui/node_modules/.bin/tsup +17 -0
- package/packages/ui/node_modules/.bin/tsup-node +17 -0
- package/packages/ui/node_modules/.bin/tsx +17 -0
- package/packages/ui/package.json +69 -0
- package/packages/ui/src/components/badge.tsx +27 -0
- package/packages/ui/src/components/button.tsx +40 -0
- package/packages/ui/src/components/card.tsx +31 -0
- package/packages/ui/src/components/input.tsx +21 -0
- package/packages/ui/src/components/label.tsx +6 -0
- package/packages/ui/src/components/separator.tsx +6 -0
- package/packages/ui/src/components/textarea.tsx +20 -0
- package/packages/ui/src/globals.css +70 -0
- package/packages/ui/src/tailwind.ts +56 -0
- package/packages/ui/src/utils/cn.ts +6 -0
- package/packages/ui/tsconfig.json +20 -0
- package/packages/ui/tsup.config.ts +20 -0
- package/pnpm-workspace.yaml +4 -0
- package/scripts/install-rust-binaries.mjs +84 -0
- package/scripts/launchd/install-user-daemon.sh +358 -0
- package/scripts/launchd/run-vault-daemon.sh +5 -0
- package/scripts/launchd/run-wlfi-agent-daemon.sh +73 -0
- package/scripts/launchd/uninstall-user-daemon.sh +103 -0
- package/src/cli.ts +2121 -0
- package/src/lib/admin-guard.js +1 -0
- package/src/lib/admin-guard.ts +185 -0
- package/src/lib/admin-passthrough.ts +33 -0
- package/src/lib/admin-reset.ts +751 -0
- package/src/lib/admin-setup.ts +1612 -0
- package/src/lib/agent-auth-clear.js +1 -0
- package/src/lib/agent-auth-clear.ts +58 -0
- package/src/lib/agent-auth-forwarding.js +1 -0
- package/src/lib/agent-auth-forwarding.ts +149 -0
- package/src/lib/agent-auth-migrate.js +1 -0
- package/src/lib/agent-auth-migrate.ts +150 -0
- package/src/lib/agent-auth-revoke.ts +103 -0
- package/src/lib/agent-auth-rotate.ts +107 -0
- package/src/lib/agent-auth-token.js +1 -0
- package/src/lib/agent-auth-token.ts +25 -0
- package/src/lib/agent-auth.ts +89 -0
- package/src/lib/asset-broadcast.js +1 -0
- package/src/lib/asset-broadcast.ts +285 -0
- package/src/lib/bootstrap-artifacts.js +1 -0
- package/src/lib/bootstrap-artifacts.ts +205 -0
- package/src/lib/bootstrap-credentials.js +1 -0
- package/src/lib/bootstrap-credentials.ts +832 -0
- package/src/lib/config-amounts.js +1 -0
- package/src/lib/config-amounts.ts +189 -0
- package/src/lib/config-mutation.ts +27 -0
- package/src/lib/fs-trust.js +1 -0
- package/src/lib/fs-trust.ts +537 -0
- package/src/lib/keychain.js +1 -0
- package/src/lib/keychain.ts +225 -0
- package/src/lib/local-admin-access.ts +106 -0
- package/src/lib/network-selection.js +1 -0
- package/src/lib/network-selection.ts +71 -0
- package/src/lib/passthrough-security.js +1 -0
- package/src/lib/passthrough-security.ts +114 -0
- package/src/lib/rpc-guard.js +1 -0
- package/src/lib/rpc-guard.ts +7 -0
- package/src/lib/rust-spawn-options.js +1 -0
- package/src/lib/rust-spawn-options.ts +98 -0
- package/src/lib/rust.js +1 -0
- package/src/lib/rust.ts +143 -0
- package/src/lib/signed-tx.js +1 -0
- package/src/lib/signed-tx.ts +116 -0
- package/src/lib/status-repair-cli.ts +116 -0
- package/src/lib/sudo.js +1 -0
- package/src/lib/sudo.ts +172 -0
- package/src/lib/vault-password-forwarding.js +1 -0
- package/src/lib/vault-password-forwarding.ts +155 -0
- package/src/lib/wallet-profile.js +1 -0
- package/src/lib/wallet-profile.ts +332 -0
- package/src/lib/wallet-repair.js +1 -0
- package/src/lib/wallet-repair.ts +304 -0
- package/src/lib/wallet-setup.js +1 -0
- package/src/lib/wallet-setup.ts +1466 -0
- package/src/lib/wallet-status.js +1 -0
- package/src/lib/wallet-status.ts +640 -0
- package/tsconfig.base.json +17 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +25 -0
- package/turbo.json +41 -0
- package/LICENSE.md +0 -1
- package/dist/wlfa/index.cjs +0 -250
- package/dist/wlfa/index.d.cts +0 -1
- package/dist/wlfa/index.d.ts +0 -1
- package/dist/wlfa/index.js +0 -250
- package/dist/wlfc/index.cjs +0 -1894
- package/dist/wlfc/index.d.cts +0 -1
- package/dist/wlfc/index.d.ts +0 -1
- package/dist/wlfc/index.js +0 -1894
|
@@ -0,0 +1,1041 @@
|
|
|
1
|
+
#[async_trait]
|
|
2
|
+
impl<B> KeyManagerDaemonApi for InMemoryDaemon<B>
|
|
3
|
+
where
|
|
4
|
+
B: VaultSignerBackend,
|
|
5
|
+
{
|
|
6
|
+
async fn issue_lease(&self, vault_password: &str) -> Result<Lease, DaemonError> {
|
|
7
|
+
let _state_guard = self.state_persist_guard.lock().await;
|
|
8
|
+
let backup = self.backup_state_if_persistent()?;
|
|
9
|
+
self.authenticate_password(vault_password)?;
|
|
10
|
+
let now = OffsetDateTime::now_utc();
|
|
11
|
+
let expires_at = now.checked_add(self.config.lease_ttl).ok_or_else(|| {
|
|
12
|
+
DaemonError::InvalidConfig("lease_ttl causes timestamp overflow".to_string())
|
|
13
|
+
})?;
|
|
14
|
+
let lease = Lease {
|
|
15
|
+
lease_id: Uuid::new_v4(),
|
|
16
|
+
issued_at: now,
|
|
17
|
+
expires_at,
|
|
18
|
+
};
|
|
19
|
+
{
|
|
20
|
+
let mut leases = self.leases.write().map_err(|_| DaemonError::LockPoisoned)?;
|
|
21
|
+
// Retain only currently valid leases so corrupted/future-dated entries
|
|
22
|
+
// cannot permanently consume capacity.
|
|
23
|
+
leases.retain(|_, existing_lease| existing_lease.is_valid_at(now));
|
|
24
|
+
if leases.len() >= self.config.max_active_leases {
|
|
25
|
+
return Err(DaemonError::TooManyActiveLeases);
|
|
26
|
+
}
|
|
27
|
+
leases.insert(lease.lease_id, lease.clone());
|
|
28
|
+
}
|
|
29
|
+
self.persist_or_revert(backup)?;
|
|
30
|
+
Ok(lease)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async fn add_policy(
|
|
34
|
+
&self,
|
|
35
|
+
session: &AdminSession,
|
|
36
|
+
policy: SpendingPolicy,
|
|
37
|
+
) -> Result<(), DaemonError> {
|
|
38
|
+
let _state_guard = self.state_persist_guard.lock().await;
|
|
39
|
+
let backup = self.backup_state_if_persistent()?;
|
|
40
|
+
self.authenticate(session, OffsetDateTime::now_utc())?;
|
|
41
|
+
validate_policy(&policy)?;
|
|
42
|
+
|
|
43
|
+
self.policies
|
|
44
|
+
.write()
|
|
45
|
+
.map_err(|_| DaemonError::LockPoisoned)?
|
|
46
|
+
.insert(policy.id, policy);
|
|
47
|
+
|
|
48
|
+
self.persist_or_revert(backup)?;
|
|
49
|
+
Ok(())
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async fn list_policies(
|
|
53
|
+
&self,
|
|
54
|
+
session: &AdminSession,
|
|
55
|
+
) -> Result<Vec<SpendingPolicy>, DaemonError> {
|
|
56
|
+
self.authenticate(session, OffsetDateTime::now_utc())?;
|
|
57
|
+
let mut policies: Vec<SpendingPolicy> = self
|
|
58
|
+
.policies
|
|
59
|
+
.read()
|
|
60
|
+
.map_err(|_| DaemonError::LockPoisoned)?
|
|
61
|
+
.values()
|
|
62
|
+
.cloned()
|
|
63
|
+
.collect();
|
|
64
|
+
policies.sort_by(|a, b| a.priority.cmp(&b.priority).then_with(|| a.id.cmp(&b.id)));
|
|
65
|
+
Ok(policies)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async fn disable_policy(
|
|
69
|
+
&self,
|
|
70
|
+
session: &AdminSession,
|
|
71
|
+
policy_id: Uuid,
|
|
72
|
+
) -> Result<(), DaemonError> {
|
|
73
|
+
let _state_guard = self.state_persist_guard.lock().await;
|
|
74
|
+
let backup = self.backup_state_if_persistent()?;
|
|
75
|
+
self.authenticate(session, OffsetDateTime::now_utc())?;
|
|
76
|
+
{
|
|
77
|
+
let mut policies = self
|
|
78
|
+
.policies
|
|
79
|
+
.write()
|
|
80
|
+
.map_err(|_| DaemonError::LockPoisoned)?;
|
|
81
|
+
let policy = policies
|
|
82
|
+
.get_mut(&policy_id)
|
|
83
|
+
.ok_or(DaemonError::UnknownPolicy(policy_id))?;
|
|
84
|
+
policy.enabled = false;
|
|
85
|
+
}
|
|
86
|
+
self.persist_or_revert(backup)?;
|
|
87
|
+
Ok(())
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async fn create_vault_key(
|
|
91
|
+
&self,
|
|
92
|
+
session: &AdminSession,
|
|
93
|
+
request: KeyCreateRequest,
|
|
94
|
+
) -> Result<VaultKey, DaemonError> {
|
|
95
|
+
let _state_guard = self.state_persist_guard.lock().await;
|
|
96
|
+
let backup = self.backup_state_if_persistent()?;
|
|
97
|
+
self.authenticate(session, OffsetDateTime::now_utc())?;
|
|
98
|
+
|
|
99
|
+
let vault_key = self.signer_backend.create_vault_key(request).await?;
|
|
100
|
+
|
|
101
|
+
self.vault_keys
|
|
102
|
+
.write()
|
|
103
|
+
.map_err(|_| DaemonError::LockPoisoned)?
|
|
104
|
+
.insert(vault_key.id, vault_key.clone());
|
|
105
|
+
|
|
106
|
+
self.persist_or_revert(backup)?;
|
|
107
|
+
Ok(vault_key)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async fn export_vault_private_key(
|
|
111
|
+
&self,
|
|
112
|
+
session: &AdminSession,
|
|
113
|
+
vault_key_id: Uuid,
|
|
114
|
+
) -> Result<Option<String>, DaemonError> {
|
|
115
|
+
self.authenticate(session, OffsetDateTime::now_utc())?;
|
|
116
|
+
|
|
117
|
+
if !self
|
|
118
|
+
.vault_keys
|
|
119
|
+
.read()
|
|
120
|
+
.map_err(|_| DaemonError::LockPoisoned)?
|
|
121
|
+
.contains_key(&vault_key_id)
|
|
122
|
+
{
|
|
123
|
+
return Err(DaemonError::UnknownVaultKey(vault_key_id));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let mut exported = self
|
|
127
|
+
.signer_backend
|
|
128
|
+
.export_persistable_key_material(&[vault_key_id])
|
|
129
|
+
.map_err(DaemonError::Signer)?;
|
|
130
|
+
Ok(exported.remove(&vault_key_id))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async fn create_agent_key(
|
|
134
|
+
&self,
|
|
135
|
+
session: &AdminSession,
|
|
136
|
+
vault_key_id: Uuid,
|
|
137
|
+
attachment: PolicyAttachment,
|
|
138
|
+
) -> Result<AgentCredentials, DaemonError> {
|
|
139
|
+
let _state_guard = self.state_persist_guard.lock().await;
|
|
140
|
+
let backup = self.backup_state_if_persistent()?;
|
|
141
|
+
let now = OffsetDateTime::now_utc();
|
|
142
|
+
self.authenticate(session, now)?;
|
|
143
|
+
|
|
144
|
+
if !self
|
|
145
|
+
.vault_keys
|
|
146
|
+
.read()
|
|
147
|
+
.map_err(|_| DaemonError::LockPoisoned)?
|
|
148
|
+
.contains_key(&vault_key_id)
|
|
149
|
+
{
|
|
150
|
+
return Err(DaemonError::UnknownVaultKey(vault_key_id));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if let PolicyAttachment::PolicySet(ids) = &attachment {
|
|
154
|
+
if ids.is_empty() {
|
|
155
|
+
return Err(DaemonError::InvalidPolicyAttachment(
|
|
156
|
+
"policy set cannot be empty".to_string(),
|
|
157
|
+
));
|
|
158
|
+
}
|
|
159
|
+
let policies = self
|
|
160
|
+
.policies
|
|
161
|
+
.read()
|
|
162
|
+
.map_err(|_| DaemonError::LockPoisoned)?;
|
|
163
|
+
for id in ids {
|
|
164
|
+
if !policies.contains_key(id) {
|
|
165
|
+
return Err(DaemonError::UnknownPolicy(*id));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let agent_key = AgentKey {
|
|
171
|
+
id: Uuid::new_v4(),
|
|
172
|
+
vault_key_id,
|
|
173
|
+
policies: attachment,
|
|
174
|
+
created_at: now,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
self.agent_keys
|
|
178
|
+
.write()
|
|
179
|
+
.map_err(|_| DaemonError::LockPoisoned)?
|
|
180
|
+
.insert(agent_key.id, agent_key.clone());
|
|
181
|
+
|
|
182
|
+
let auth_token = generate_agent_auth_token();
|
|
183
|
+
self.agent_auth_tokens
|
|
184
|
+
.write()
|
|
185
|
+
.map_err(|_| DaemonError::LockPoisoned)?
|
|
186
|
+
.insert(agent_key.id, hash_agent_auth_token(&auth_token));
|
|
187
|
+
|
|
188
|
+
self.persist_or_revert(backup)?;
|
|
189
|
+
Ok(AgentCredentials {
|
|
190
|
+
agent_key,
|
|
191
|
+
auth_token,
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async fn rotate_agent_auth_token(
|
|
196
|
+
&self,
|
|
197
|
+
session: &AdminSession,
|
|
198
|
+
agent_key_id: Uuid,
|
|
199
|
+
) -> Result<String, DaemonError> {
|
|
200
|
+
self.authenticate(session, OffsetDateTime::now_utc())?;
|
|
201
|
+
let _signing_guard = self.signing_guard.lock().await;
|
|
202
|
+
let _state_guard = self.state_persist_guard.lock().await;
|
|
203
|
+
let backup = self.backup_state_if_persistent()?;
|
|
204
|
+
|
|
205
|
+
if !self
|
|
206
|
+
.agent_keys
|
|
207
|
+
.read()
|
|
208
|
+
.map_err(|_| DaemonError::LockPoisoned)?
|
|
209
|
+
.contains_key(&agent_key_id)
|
|
210
|
+
{
|
|
211
|
+
return Err(DaemonError::UnknownAgentKey(agent_key_id));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let auth_token = generate_agent_auth_token();
|
|
215
|
+
self.agent_auth_tokens
|
|
216
|
+
.write()
|
|
217
|
+
.map_err(|_| DaemonError::LockPoisoned)?
|
|
218
|
+
.insert(agent_key_id, hash_agent_auth_token(&auth_token));
|
|
219
|
+
self.persist_or_revert(backup)?;
|
|
220
|
+
Ok(auth_token)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async fn revoke_agent_key(
|
|
224
|
+
&self,
|
|
225
|
+
session: &AdminSession,
|
|
226
|
+
agent_key_id: Uuid,
|
|
227
|
+
) -> Result<(), DaemonError> {
|
|
228
|
+
self.authenticate(session, OffsetDateTime::now_utc())?;
|
|
229
|
+
let _signing_guard = self.signing_guard.lock().await;
|
|
230
|
+
let _state_guard = self.state_persist_guard.lock().await;
|
|
231
|
+
let backup = self.backup_state_if_persistent()?;
|
|
232
|
+
|
|
233
|
+
let removed = self
|
|
234
|
+
.agent_keys
|
|
235
|
+
.write()
|
|
236
|
+
.map_err(|_| DaemonError::LockPoisoned)?
|
|
237
|
+
.remove(&agent_key_id)
|
|
238
|
+
.is_some();
|
|
239
|
+
if !removed {
|
|
240
|
+
return Err(DaemonError::UnknownAgentKey(agent_key_id));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
self.agent_auth_tokens
|
|
244
|
+
.write()
|
|
245
|
+
.map_err(|_| DaemonError::LockPoisoned)?
|
|
246
|
+
.remove(&agent_key_id);
|
|
247
|
+
self.spend_log
|
|
248
|
+
.write()
|
|
249
|
+
.map_err(|_| DaemonError::LockPoisoned)?
|
|
250
|
+
.retain(|event| event.agent_key_id != agent_key_id);
|
|
251
|
+
self.nonce_reservations
|
|
252
|
+
.write()
|
|
253
|
+
.map_err(|_| DaemonError::LockPoisoned)?
|
|
254
|
+
.retain(|_, reservation| reservation.agent_key_id != agent_key_id);
|
|
255
|
+
self.persist_or_revert(backup)?;
|
|
256
|
+
Ok(())
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async fn list_manual_approval_requests(
|
|
260
|
+
&self,
|
|
261
|
+
session: &AdminSession,
|
|
262
|
+
) -> Result<Vec<ManualApprovalRequest>, DaemonError> {
|
|
263
|
+
self.authenticate(session, OffsetDateTime::now_utc())?;
|
|
264
|
+
let mut requests = self
|
|
265
|
+
.manual_approval_requests
|
|
266
|
+
.read()
|
|
267
|
+
.map_err(|_| DaemonError::LockPoisoned)?
|
|
268
|
+
.values()
|
|
269
|
+
.cloned()
|
|
270
|
+
.collect::<Vec<_>>();
|
|
271
|
+
requests.sort_by(|left, right| right.created_at.cmp(&left.created_at));
|
|
272
|
+
Ok(requests)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async fn decide_manual_approval_request(
|
|
276
|
+
&self,
|
|
277
|
+
session: &AdminSession,
|
|
278
|
+
approval_request_id: Uuid,
|
|
279
|
+
decision: ManualApprovalDecision,
|
|
280
|
+
rejection_reason: Option<String>,
|
|
281
|
+
) -> Result<ManualApprovalRequest, DaemonError> {
|
|
282
|
+
self.authenticate(session, OffsetDateTime::now_utc())?;
|
|
283
|
+
let _state_guard = self.state_persist_guard.lock().await;
|
|
284
|
+
let backup = self.backup_state_if_persistent()?;
|
|
285
|
+
let now = OffsetDateTime::now_utc();
|
|
286
|
+
|
|
287
|
+
let updated = {
|
|
288
|
+
let mut requests = self
|
|
289
|
+
.manual_approval_requests
|
|
290
|
+
.write()
|
|
291
|
+
.map_err(|_| DaemonError::LockPoisoned)?;
|
|
292
|
+
let request = requests.get_mut(&approval_request_id).ok_or(
|
|
293
|
+
DaemonError::UnknownManualApprovalRequest(approval_request_id),
|
|
294
|
+
)?;
|
|
295
|
+
request.updated_at = now;
|
|
296
|
+
match decision {
|
|
297
|
+
ManualApprovalDecision::Approve => {
|
|
298
|
+
request.status = ManualApprovalStatus::Approved;
|
|
299
|
+
request.rejection_reason = None;
|
|
300
|
+
}
|
|
301
|
+
ManualApprovalDecision::Reject => {
|
|
302
|
+
request.status = ManualApprovalStatus::Rejected;
|
|
303
|
+
request.rejection_reason = rejection_reason.and_then(|value| {
|
|
304
|
+
let trimmed = value.trim().to_string();
|
|
305
|
+
(!trimmed.is_empty()).then_some(trimmed)
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
request.clone()
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
self.persist_or_revert(backup)?;
|
|
313
|
+
Ok(updated)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async fn set_relay_config(
|
|
317
|
+
&self,
|
|
318
|
+
session: &AdminSession,
|
|
319
|
+
relay_url: Option<String>,
|
|
320
|
+
frontend_url: Option<String>,
|
|
321
|
+
) -> Result<RelayConfig, DaemonError> {
|
|
322
|
+
self.authenticate(session, OffsetDateTime::now_utc())?;
|
|
323
|
+
let _state_guard = self.state_persist_guard.lock().await;
|
|
324
|
+
let backup = self.backup_state_if_persistent()?;
|
|
325
|
+
|
|
326
|
+
let normalized = normalize_optional_url("relay_url", relay_url)?;
|
|
327
|
+
let normalized_frontend = normalize_optional_url("frontend_url", frontend_url)?;
|
|
328
|
+
{
|
|
329
|
+
let mut relay_config = self
|
|
330
|
+
.relay_config
|
|
331
|
+
.write()
|
|
332
|
+
.map_err(|_| DaemonError::LockPoisoned)?;
|
|
333
|
+
relay_config.relay_url = normalized;
|
|
334
|
+
relay_config.frontend_url = normalized_frontend;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let relay_config = self
|
|
338
|
+
.relay_config
|
|
339
|
+
.read()
|
|
340
|
+
.map_err(|_| DaemonError::LockPoisoned)?
|
|
341
|
+
.clone();
|
|
342
|
+
self.persist_or_revert(backup)?;
|
|
343
|
+
Ok(relay_config)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async fn get_relay_config(&self, session: &AdminSession) -> Result<RelayConfig, DaemonError> {
|
|
347
|
+
self.authenticate(session, OffsetDateTime::now_utc())?;
|
|
348
|
+
Ok(self
|
|
349
|
+
.relay_config
|
|
350
|
+
.read()
|
|
351
|
+
.map_err(|_| DaemonError::LockPoisoned)?
|
|
352
|
+
.clone())
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async fn evaluate_for_agent(
|
|
356
|
+
&self,
|
|
357
|
+
request: SignRequest,
|
|
358
|
+
) -> Result<PolicyEvaluation, DaemonError> {
|
|
359
|
+
let mut request = request;
|
|
360
|
+
let result = {
|
|
361
|
+
let now = OffsetDateTime::now_utc();
|
|
362
|
+
let (_, _, policy_evaluation) = self.evaluate_authorized_request(&request, now)?;
|
|
363
|
+
Ok(policy_evaluation)
|
|
364
|
+
};
|
|
365
|
+
request.zeroize_secrets();
|
|
366
|
+
result
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async fn explain_for_agent(
|
|
370
|
+
&self,
|
|
371
|
+
request: SignRequest,
|
|
372
|
+
) -> Result<PolicyExplanation, DaemonError> {
|
|
373
|
+
let mut request = request;
|
|
374
|
+
let result = {
|
|
375
|
+
let now = OffsetDateTime::now_utc();
|
|
376
|
+
let (_, _, policy_explanation) = self.explain_authorized_request(&request, now)?;
|
|
377
|
+
Ok(policy_explanation)
|
|
378
|
+
};
|
|
379
|
+
request.zeroize_secrets();
|
|
380
|
+
result
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async fn reserve_nonce(
|
|
384
|
+
&self,
|
|
385
|
+
request: NonceReservationRequest,
|
|
386
|
+
) -> Result<NonceReservation, DaemonError> {
|
|
387
|
+
let mut request = request;
|
|
388
|
+
let result = async {
|
|
389
|
+
let _signing_guard = self.signing_guard.lock().await;
|
|
390
|
+
let _state_guard = self.state_persist_guard.lock().await;
|
|
391
|
+
let backup = self.backup_state_if_persistent()?;
|
|
392
|
+
let now = OffsetDateTime::now_utc();
|
|
393
|
+
self.validate_request_timestamps(request.requested_at, request.expires_at, now)?;
|
|
394
|
+
if request.chain_id == 0 {
|
|
395
|
+
return Err(DaemonError::InvalidNonceReservation(
|
|
396
|
+
"chain_id must be greater than zero".to_string(),
|
|
397
|
+
));
|
|
398
|
+
}
|
|
399
|
+
let agent_key =
|
|
400
|
+
self.authenticate_agent(request.agent_key_id, &request.agent_auth_token)?;
|
|
401
|
+
self.register_replay_id(request.request_id, request.expires_at, now)?;
|
|
402
|
+
self.prune_nonce_reservations(now)?;
|
|
403
|
+
|
|
404
|
+
let max_lease_expires = now + self.config.nonce_reservation_ttl;
|
|
405
|
+
let lease_expires = if request.expires_at < max_lease_expires {
|
|
406
|
+
request.expires_at
|
|
407
|
+
} else {
|
|
408
|
+
max_lease_expires
|
|
409
|
+
};
|
|
410
|
+
if lease_expires <= now {
|
|
411
|
+
return Err(DaemonError::InvalidNonceReservation(
|
|
412
|
+
"nonce reservation would be immediately expired".to_string(),
|
|
413
|
+
));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
let nonce = {
|
|
417
|
+
let mut nonce_heads = self
|
|
418
|
+
.nonce_heads
|
|
419
|
+
.write()
|
|
420
|
+
.map_err(|_| DaemonError::LockPoisoned)?;
|
|
421
|
+
let chain_heads = nonce_heads.entry(agent_key.vault_key_id).or_default();
|
|
422
|
+
let head = chain_heads
|
|
423
|
+
.entry(request.chain_id)
|
|
424
|
+
.or_insert(request.min_nonce);
|
|
425
|
+
if *head < request.min_nonce {
|
|
426
|
+
*head = request.min_nonce;
|
|
427
|
+
}
|
|
428
|
+
let nonce = *head;
|
|
429
|
+
*head = head.checked_add(1).ok_or_else(|| {
|
|
430
|
+
DaemonError::InvalidNonceReservation("nonce allocation overflow".to_string())
|
|
431
|
+
})?;
|
|
432
|
+
nonce
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
let reservation = NonceReservation {
|
|
436
|
+
reservation_id: Uuid::new_v4(),
|
|
437
|
+
agent_key_id: request.agent_key_id,
|
|
438
|
+
vault_key_id: agent_key.vault_key_id,
|
|
439
|
+
chain_id: request.chain_id,
|
|
440
|
+
nonce,
|
|
441
|
+
issued_at: now,
|
|
442
|
+
expires_at: lease_expires,
|
|
443
|
+
};
|
|
444
|
+
self.nonce_reservations
|
|
445
|
+
.write()
|
|
446
|
+
.map_err(|_| DaemonError::LockPoisoned)?
|
|
447
|
+
.insert(reservation.reservation_id, reservation.clone());
|
|
448
|
+
|
|
449
|
+
self.persist_or_revert(backup)?;
|
|
450
|
+
Ok(reservation)
|
|
451
|
+
}
|
|
452
|
+
.await;
|
|
453
|
+
request.zeroize_secrets();
|
|
454
|
+
result
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async fn release_nonce(&self, request: NonceReleaseRequest) -> Result<(), DaemonError> {
|
|
458
|
+
let mut request = request;
|
|
459
|
+
let result = async {
|
|
460
|
+
let _signing_guard = self.signing_guard.lock().await;
|
|
461
|
+
let _state_guard = self.state_persist_guard.lock().await;
|
|
462
|
+
let backup = self.backup_state_if_persistent()?;
|
|
463
|
+
let now = OffsetDateTime::now_utc();
|
|
464
|
+
self.validate_request_timestamps(request.requested_at, request.expires_at, now)?;
|
|
465
|
+
self.authenticate_agent(request.agent_key_id, &request.agent_auth_token)?;
|
|
466
|
+
self.register_replay_id(request.request_id, request.expires_at, now)?;
|
|
467
|
+
self.prune_nonce_reservations(now)?;
|
|
468
|
+
|
|
469
|
+
let released = {
|
|
470
|
+
let mut reservations = self
|
|
471
|
+
.nonce_reservations
|
|
472
|
+
.write()
|
|
473
|
+
.map_err(|_| DaemonError::LockPoisoned)?;
|
|
474
|
+
let Some(existing) = reservations.get(&request.reservation_id) else {
|
|
475
|
+
return Err(DaemonError::UnknownNonceReservation(request.reservation_id));
|
|
476
|
+
};
|
|
477
|
+
if existing.agent_key_id != request.agent_key_id {
|
|
478
|
+
return Err(DaemonError::AgentAuthenticationFailed);
|
|
479
|
+
}
|
|
480
|
+
let released = existing.clone();
|
|
481
|
+
reservations.remove(&request.reservation_id);
|
|
482
|
+
released
|
|
483
|
+
};
|
|
484
|
+
self.reclaim_unused_nonce_heads(&[released])?;
|
|
485
|
+
self.persist_or_revert(backup)?;
|
|
486
|
+
Ok(())
|
|
487
|
+
}
|
|
488
|
+
.await;
|
|
489
|
+
request.zeroize_secrets();
|
|
490
|
+
result
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async fn sign_for_agent(&self, request: SignRequest) -> Result<Signature, DaemonError> {
|
|
494
|
+
let mut request = request;
|
|
495
|
+
let result = async {
|
|
496
|
+
let _signing_guard = self.signing_guard.lock().await;
|
|
497
|
+
let _state_guard = self.state_persist_guard.lock().await;
|
|
498
|
+
let backup = self.backup_state_if_persistent()?;
|
|
499
|
+
let now = OffsetDateTime::now_utc();
|
|
500
|
+
let retention_start = now - Duration::days(8);
|
|
501
|
+
|
|
502
|
+
self.spend_log
|
|
503
|
+
.write()
|
|
504
|
+
.map_err(|_| DaemonError::LockPoisoned)?
|
|
505
|
+
.retain(|event| event.at >= retention_start);
|
|
506
|
+
|
|
507
|
+
let (agent_key, payload_action, policy_explanation) =
|
|
508
|
+
self.explain_authorized_request(&request, now)?;
|
|
509
|
+
let approved_manual_request_id = match policy_explanation.decision {
|
|
510
|
+
PolicyDecision::Allow => None,
|
|
511
|
+
PolicyDecision::Deny(PolicyError::ManualApprovalRequired { policy_id, .. }) => {
|
|
512
|
+
let payload_hash = payload_hash_hex(&request.payload);
|
|
513
|
+
match self.resolve_manual_approval_request(
|
|
514
|
+
&agent_key,
|
|
515
|
+
&payload_action,
|
|
516
|
+
&payload_hash,
|
|
517
|
+
vec![policy_id],
|
|
518
|
+
now,
|
|
519
|
+
)? {
|
|
520
|
+
ManualApprovalResolution::Approved(request_id) => request_id,
|
|
521
|
+
ManualApprovalResolution::Pending {
|
|
522
|
+
approval_request_id,
|
|
523
|
+
relay_config,
|
|
524
|
+
} => {
|
|
525
|
+
let frontend_url = self
|
|
526
|
+
.relay_private_key_hex
|
|
527
|
+
.read()
|
|
528
|
+
.ok()
|
|
529
|
+
.and_then(|value| {
|
|
530
|
+
manual_approval_capability_token(&value, approval_request_id)
|
|
531
|
+
.ok()
|
|
532
|
+
})
|
|
533
|
+
.and_then(|approval_capability| {
|
|
534
|
+
manual_approval_frontend_url(
|
|
535
|
+
&relay_config,
|
|
536
|
+
approval_request_id,
|
|
537
|
+
&approval_capability,
|
|
538
|
+
)
|
|
539
|
+
});
|
|
540
|
+
self.persist_or_revert(backup)?;
|
|
541
|
+
return Err(DaemonError::ManualApprovalRequired {
|
|
542
|
+
approval_request_id,
|
|
543
|
+
relay_url: relay_config.relay_url.clone(),
|
|
544
|
+
frontend_url,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
ManualApprovalResolution::Rejected(approval_request_id) => {
|
|
548
|
+
return Err(DaemonError::ManualApprovalRejected {
|
|
549
|
+
approval_request_id,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
PolicyDecision::Deny(err) => return Err(DaemonError::Policy(err)),
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
self.register_replay_id(request.request_id, request.expires_at, now)?;
|
|
558
|
+
|
|
559
|
+
let vault_key = self
|
|
560
|
+
.vault_keys
|
|
561
|
+
.read()
|
|
562
|
+
.map_err(|_| DaemonError::LockPoisoned)?
|
|
563
|
+
.get(&agent_key.vault_key_id)
|
|
564
|
+
.cloned()
|
|
565
|
+
.ok_or(DaemonError::UnknownVaultKey(agent_key.vault_key_id))?;
|
|
566
|
+
|
|
567
|
+
if let AgentAction::BroadcastTx { tx } = &payload_action {
|
|
568
|
+
self.consume_nonce_reservation(
|
|
569
|
+
request.agent_key_id,
|
|
570
|
+
agent_key.vault_key_id,
|
|
571
|
+
tx.chain_id,
|
|
572
|
+
tx.nonce,
|
|
573
|
+
now,
|
|
574
|
+
)?;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
let signature = match &payload_action {
|
|
578
|
+
AgentAction::BroadcastTx { tx } => {
|
|
579
|
+
if tx.tx_type != 0x02 {
|
|
580
|
+
return Err(DaemonError::Signer(SignerError::Unsupported(format!(
|
|
581
|
+
"broadcast transaction type 0x{:02x} is unsupported for signing",
|
|
582
|
+
tx.tx_type
|
|
583
|
+
))));
|
|
584
|
+
}
|
|
585
|
+
self.sign_broadcast_eip1559(&vault_key, tx).await?
|
|
586
|
+
}
|
|
587
|
+
AgentAction::Permit2Permit { .. }
|
|
588
|
+
| AgentAction::Eip3009TransferWithAuthorization { .. }
|
|
589
|
+
| AgentAction::Eip3009ReceiveWithAuthorization { .. } => {
|
|
590
|
+
self.sign_typed_data_action(&vault_key, &payload_action)
|
|
591
|
+
.await?
|
|
592
|
+
}
|
|
593
|
+
_ => {
|
|
594
|
+
self.signer_backend
|
|
595
|
+
.sign_payload(agent_key.vault_key_id, &request.payload)
|
|
596
|
+
.await?
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
if let Some(approval_request_id) = approved_manual_request_id {
|
|
601
|
+
self.complete_manual_approval_request(approval_request_id, now)?;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
let event = SpendEvent {
|
|
605
|
+
agent_key_id: request.agent_key_id,
|
|
606
|
+
chain_id: payload_action.chain_id(),
|
|
607
|
+
asset: payload_action.asset(),
|
|
608
|
+
recipient: payload_action.recipient(),
|
|
609
|
+
amount_wei: payload_action.amount_wei(),
|
|
610
|
+
at: now,
|
|
611
|
+
};
|
|
612
|
+
self.spend_log
|
|
613
|
+
.write()
|
|
614
|
+
.map_err(|_| DaemonError::LockPoisoned)?
|
|
615
|
+
.push(event);
|
|
616
|
+
self.persist_or_revert(backup)?;
|
|
617
|
+
|
|
618
|
+
Ok(signature)
|
|
619
|
+
}
|
|
620
|
+
.await;
|
|
621
|
+
request.zeroize_secrets();
|
|
622
|
+
result
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
fn normalize_optional_url(
|
|
627
|
+
label: &str,
|
|
628
|
+
value: Option<String>,
|
|
629
|
+
) -> Result<Option<String>, DaemonError> {
|
|
630
|
+
let Some(value) = value else {
|
|
631
|
+
return Ok(None);
|
|
632
|
+
};
|
|
633
|
+
let trimmed = value.trim();
|
|
634
|
+
if trimmed.is_empty() {
|
|
635
|
+
return Ok(None);
|
|
636
|
+
}
|
|
637
|
+
let parsed = reqwest::Url::parse(trimmed).map_err(|err| {
|
|
638
|
+
DaemonError::InvalidRelayConfig(format!("{label} must be a valid URL: {err}"))
|
|
639
|
+
})?;
|
|
640
|
+
if !parsed.username().is_empty() || parsed.password().is_some() {
|
|
641
|
+
return Err(DaemonError::InvalidRelayConfig(format!(
|
|
642
|
+
"{label} must not include embedded username or password"
|
|
643
|
+
)));
|
|
644
|
+
}
|
|
645
|
+
if parsed.query().is_some() {
|
|
646
|
+
return Err(DaemonError::InvalidRelayConfig(format!(
|
|
647
|
+
"{label} must not include a query string"
|
|
648
|
+
)));
|
|
649
|
+
}
|
|
650
|
+
if parsed.fragment().is_some() {
|
|
651
|
+
return Err(DaemonError::InvalidRelayConfig(format!(
|
|
652
|
+
"{label} must not include a fragment"
|
|
653
|
+
)));
|
|
654
|
+
}
|
|
655
|
+
let host = parsed.host_str().ok_or_else(|| {
|
|
656
|
+
DaemonError::InvalidRelayConfig(format!("{label} must include a hostname"))
|
|
657
|
+
})?;
|
|
658
|
+
match parsed.scheme() {
|
|
659
|
+
"https" => Ok(Some(trimmed.to_string())),
|
|
660
|
+
"http" if host.eq_ignore_ascii_case("localhost") => Ok(Some(trimmed.to_string())),
|
|
661
|
+
"http" => {
|
|
662
|
+
let is_loopback = host
|
|
663
|
+
.parse::<std::net::IpAddr>()
|
|
664
|
+
.map(|ip| ip.is_loopback())
|
|
665
|
+
.unwrap_or(false);
|
|
666
|
+
if is_loopback {
|
|
667
|
+
Ok(Some(trimmed.to_string()))
|
|
668
|
+
} else {
|
|
669
|
+
Err(DaemonError::InvalidRelayConfig(format!(
|
|
670
|
+
"{label} must use https unless it targets localhost or a loopback address"
|
|
671
|
+
)))
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
_ => Err(DaemonError::InvalidRelayConfig(format!(
|
|
675
|
+
"{label} must use http or https"
|
|
676
|
+
))),
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
fn validate_admin_password(password: &str) -> Result<(), DaemonError> {
|
|
681
|
+
if password.trim().is_empty() {
|
|
682
|
+
return Err(DaemonError::InvalidConfig(
|
|
683
|
+
"admin_password must not be empty or whitespace".to_string(),
|
|
684
|
+
));
|
|
685
|
+
}
|
|
686
|
+
if password.as_bytes().len() > MAX_AUTH_SECRET_BYTES {
|
|
687
|
+
return Err(DaemonError::InvalidConfig(format!(
|
|
688
|
+
"admin_password must not exceed {MAX_AUTH_SECRET_BYTES} bytes"
|
|
689
|
+
)));
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
Ok(())
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
fn hash_password(password: &str, config: &DaemonConfig) -> Result<String, DaemonError> {
|
|
696
|
+
let salt = SaltString::generate(&mut PasswordOsRng);
|
|
697
|
+
|
|
698
|
+
let params = ParamsBuilder::new()
|
|
699
|
+
.m_cost(config.argon2_memory_kib)
|
|
700
|
+
.t_cost(config.argon2_time_cost)
|
|
701
|
+
.p_cost(config.argon2_parallelism)
|
|
702
|
+
.build()
|
|
703
|
+
.map_err(|err| DaemonError::PasswordHash(format!("invalid argon2 params: {err}")))?;
|
|
704
|
+
|
|
705
|
+
Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params)
|
|
706
|
+
.hash_password(password.as_bytes(), &salt)
|
|
707
|
+
.map(|hash| hash.to_string())
|
|
708
|
+
.map_err(|err| DaemonError::PasswordHash(format!("hashing failed: {err}")))
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
fn validate_config(config: &DaemonConfig) -> Result<(), DaemonError> {
|
|
712
|
+
if config.lease_ttl <= Duration::ZERO {
|
|
713
|
+
return Err(DaemonError::InvalidConfig(
|
|
714
|
+
"lease_ttl must be greater than zero".to_string(),
|
|
715
|
+
));
|
|
716
|
+
}
|
|
717
|
+
if config.max_active_leases == 0 {
|
|
718
|
+
return Err(DaemonError::InvalidConfig(
|
|
719
|
+
"max_active_leases must be greater than zero".to_string(),
|
|
720
|
+
));
|
|
721
|
+
}
|
|
722
|
+
if config.max_sign_payload_bytes == 0 {
|
|
723
|
+
return Err(DaemonError::InvalidConfig(
|
|
724
|
+
"max_sign_payload_bytes must be greater than zero".to_string(),
|
|
725
|
+
));
|
|
726
|
+
}
|
|
727
|
+
if config.max_request_ttl <= Duration::ZERO {
|
|
728
|
+
return Err(DaemonError::InvalidConfig(
|
|
729
|
+
"max_request_ttl must be greater than zero".to_string(),
|
|
730
|
+
));
|
|
731
|
+
}
|
|
732
|
+
if config.max_request_clock_skew < Duration::ZERO {
|
|
733
|
+
return Err(DaemonError::InvalidConfig(
|
|
734
|
+
"max_request_clock_skew must be non-negative".to_string(),
|
|
735
|
+
));
|
|
736
|
+
}
|
|
737
|
+
if config.nonce_reservation_ttl <= Duration::ZERO {
|
|
738
|
+
return Err(DaemonError::InvalidConfig(
|
|
739
|
+
"nonce_reservation_ttl must be greater than zero".to_string(),
|
|
740
|
+
));
|
|
741
|
+
}
|
|
742
|
+
if config.max_failed_admin_auth_attempts == 0 {
|
|
743
|
+
return Err(DaemonError::InvalidConfig(
|
|
744
|
+
"max_failed_admin_auth_attempts must be greater than zero".to_string(),
|
|
745
|
+
));
|
|
746
|
+
}
|
|
747
|
+
if config.admin_auth_lockout <= Duration::ZERO {
|
|
748
|
+
return Err(DaemonError::InvalidConfig(
|
|
749
|
+
"admin_auth_lockout must be greater than zero".to_string(),
|
|
750
|
+
));
|
|
751
|
+
}
|
|
752
|
+
if config.argon2_memory_kib == 0 {
|
|
753
|
+
return Err(DaemonError::InvalidConfig(
|
|
754
|
+
"argon2_memory_kib must be greater than zero".to_string(),
|
|
755
|
+
));
|
|
756
|
+
}
|
|
757
|
+
if config.argon2_time_cost == 0 {
|
|
758
|
+
return Err(DaemonError::InvalidConfig(
|
|
759
|
+
"argon2_time_cost must be greater than zero".to_string(),
|
|
760
|
+
));
|
|
761
|
+
}
|
|
762
|
+
if config.argon2_parallelism == 0 {
|
|
763
|
+
return Err(DaemonError::InvalidConfig(
|
|
764
|
+
"argon2_parallelism must be greater than zero".to_string(),
|
|
765
|
+
));
|
|
766
|
+
}
|
|
767
|
+
Ok(())
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
fn map_domain_to_signer_error(err: vault_domain::DomainError) -> DaemonError {
|
|
771
|
+
DaemonError::Signer(SignerError::Unsupported(format!(
|
|
772
|
+
"action cannot be signed: {err}"
|
|
773
|
+
)))
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
fn parse_verifying_key(public_key_hex: &str) -> Result<VerifyingKey, DaemonError> {
|
|
777
|
+
let bytes =
|
|
778
|
+
hex::decode(public_key_hex.strip_prefix("0x").unwrap_or(public_key_hex)).map_err(|_| {
|
|
779
|
+
DaemonError::Signer(SignerError::Internal(
|
|
780
|
+
"vault public key is not valid hex".to_string(),
|
|
781
|
+
))
|
|
782
|
+
})?;
|
|
783
|
+
VerifyingKey::from_sec1_bytes(&bytes).map_err(|err| {
|
|
784
|
+
DaemonError::Signer(SignerError::Internal(format!(
|
|
785
|
+
"vault public key is not valid secp256k1 SEC1 bytes: {err}"
|
|
786
|
+
)))
|
|
787
|
+
})
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
fn validate_loaded_state(state: &PersistedDaemonState) -> Result<(), DaemonError> {
|
|
791
|
+
for policy in state.policies.values() {
|
|
792
|
+
validate_policy(policy)?;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
for signer_key_id in state.software_signer_private_keys.keys() {
|
|
796
|
+
if !state.vault_keys.contains_key(signer_key_id) {
|
|
797
|
+
return Err(DaemonError::Persistence(format!(
|
|
798
|
+
"loaded state contains signer key material for unknown vault key {}",
|
|
799
|
+
signer_key_id
|
|
800
|
+
)));
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
for agent_key in state.agent_keys.values() {
|
|
805
|
+
if !state.vault_keys.contains_key(&agent_key.vault_key_id) {
|
|
806
|
+
return Err(DaemonError::Persistence(format!(
|
|
807
|
+
"loaded state references unknown vault key {}",
|
|
808
|
+
agent_key.vault_key_id
|
|
809
|
+
)));
|
|
810
|
+
}
|
|
811
|
+
if let PolicyAttachment::PolicySet(policy_ids) = &agent_key.policies {
|
|
812
|
+
if policy_ids.is_empty() {
|
|
813
|
+
return Err(DaemonError::Persistence(format!(
|
|
814
|
+
"loaded state contains empty policy attachment for agent {}",
|
|
815
|
+
agent_key.id
|
|
816
|
+
)));
|
|
817
|
+
}
|
|
818
|
+
for policy_id in policy_ids {
|
|
819
|
+
if !state.policies.contains_key(policy_id) {
|
|
820
|
+
return Err(DaemonError::Persistence(format!(
|
|
821
|
+
"loaded state references unknown policy {} for agent {}",
|
|
822
|
+
policy_id, agent_key.id
|
|
823
|
+
)));
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
for agent_key_id in state.agent_auth_tokens.keys() {
|
|
830
|
+
if !state.agent_keys.contains_key(agent_key_id) {
|
|
831
|
+
return Err(DaemonError::Persistence(format!(
|
|
832
|
+
"loaded state contains auth token for unknown agent {}",
|
|
833
|
+
agent_key_id
|
|
834
|
+
)));
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
for vault_key_id in state.nonce_heads.keys() {
|
|
839
|
+
if !state.vault_keys.contains_key(vault_key_id) {
|
|
840
|
+
return Err(DaemonError::Persistence(format!(
|
|
841
|
+
"loaded state contains nonce head for unknown vault key {}",
|
|
842
|
+
vault_key_id
|
|
843
|
+
)));
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
for reservation in state.nonce_reservations.values() {
|
|
848
|
+
let Some(agent_key) = state.agent_keys.get(&reservation.agent_key_id) else {
|
|
849
|
+
return Err(DaemonError::Persistence(format!(
|
|
850
|
+
"loaded nonce reservation {} references unknown agent {}",
|
|
851
|
+
reservation.reservation_id, reservation.agent_key_id
|
|
852
|
+
)));
|
|
853
|
+
};
|
|
854
|
+
if reservation.vault_key_id != agent_key.vault_key_id {
|
|
855
|
+
return Err(DaemonError::Persistence(format!(
|
|
856
|
+
"loaded nonce reservation {} vault key mismatch for agent {}",
|
|
857
|
+
reservation.reservation_id, reservation.agent_key_id
|
|
858
|
+
)));
|
|
859
|
+
}
|
|
860
|
+
if reservation.chain_id == 0 {
|
|
861
|
+
return Err(DaemonError::Persistence(format!(
|
|
862
|
+
"loaded nonce reservation {} has invalid chain_id 0",
|
|
863
|
+
reservation.reservation_id
|
|
864
|
+
)));
|
|
865
|
+
}
|
|
866
|
+
if reservation.expires_at <= reservation.issued_at {
|
|
867
|
+
return Err(DaemonError::Persistence(format!(
|
|
868
|
+
"loaded nonce reservation {} has invalid timestamps",
|
|
869
|
+
reservation.reservation_id
|
|
870
|
+
)));
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
for request in state.manual_approval_requests.values() {
|
|
875
|
+
let Some(agent_key) = state.agent_keys.get(&request.agent_key_id) else {
|
|
876
|
+
return Err(DaemonError::Persistence(format!(
|
|
877
|
+
"loaded manual approval request {} references unknown agent {}",
|
|
878
|
+
request.id, request.agent_key_id
|
|
879
|
+
)));
|
|
880
|
+
};
|
|
881
|
+
if request.vault_key_id != agent_key.vault_key_id {
|
|
882
|
+
return Err(DaemonError::Persistence(format!(
|
|
883
|
+
"loaded manual approval request {} vault key mismatch for agent {}",
|
|
884
|
+
request.id, request.agent_key_id
|
|
885
|
+
)));
|
|
886
|
+
}
|
|
887
|
+
if request.chain_id == 0 {
|
|
888
|
+
return Err(DaemonError::Persistence(format!(
|
|
889
|
+
"loaded manual approval request {} has invalid chain_id 0",
|
|
890
|
+
request.id
|
|
891
|
+
)));
|
|
892
|
+
}
|
|
893
|
+
if request.request_payload_hash_hex.trim().is_empty() {
|
|
894
|
+
return Err(DaemonError::Persistence(format!(
|
|
895
|
+
"loaded manual approval request {} has empty payload hash",
|
|
896
|
+
request.id
|
|
897
|
+
)));
|
|
898
|
+
}
|
|
899
|
+
if request.updated_at < request.created_at {
|
|
900
|
+
return Err(DaemonError::Persistence(format!(
|
|
901
|
+
"loaded manual approval request {} has invalid timestamps",
|
|
902
|
+
request.id
|
|
903
|
+
)));
|
|
904
|
+
}
|
|
905
|
+
if matches!(request.status, ManualApprovalStatus::Completed)
|
|
906
|
+
&& request.completed_at.is_none()
|
|
907
|
+
{
|
|
908
|
+
return Err(DaemonError::Persistence(format!(
|
|
909
|
+
"loaded manual approval request {} is completed without completed_at",
|
|
910
|
+
request.id
|
|
911
|
+
)));
|
|
912
|
+
}
|
|
913
|
+
for policy_id in &request.triggered_by_policy_ids {
|
|
914
|
+
let Some(policy) = state.policies.get(policy_id) else {
|
|
915
|
+
return Err(DaemonError::Persistence(format!(
|
|
916
|
+
"loaded manual approval request {} references unknown policy {}",
|
|
917
|
+
request.id, policy_id
|
|
918
|
+
)));
|
|
919
|
+
};
|
|
920
|
+
if !matches!(policy.policy_type, vault_domain::PolicyType::ManualApproval) {
|
|
921
|
+
return Err(DaemonError::Persistence(format!(
|
|
922
|
+
"loaded manual approval request {} references non-manual policy {}",
|
|
923
|
+
request.id, policy_id
|
|
924
|
+
)));
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
let relay_url = normalize_optional_url("relay_url", state.relay_config.relay_url.clone())?;
|
|
930
|
+
if relay_url != state.relay_config.relay_url {
|
|
931
|
+
return Err(DaemonError::Persistence(
|
|
932
|
+
"loaded relay configuration must be normalized".to_string(),
|
|
933
|
+
));
|
|
934
|
+
}
|
|
935
|
+
let frontend_url =
|
|
936
|
+
normalize_optional_url("frontend_url", state.relay_config.frontend_url.clone())?;
|
|
937
|
+
if frontend_url != state.relay_config.frontend_url {
|
|
938
|
+
return Err(DaemonError::Persistence(
|
|
939
|
+
"loaded relay frontend configuration must be normalized".to_string(),
|
|
940
|
+
));
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
let private_key_bytes = hex::decode(
|
|
944
|
+
state.relay_private_key_hex.trim().trim_start_matches("0x"),
|
|
945
|
+
)
|
|
946
|
+
.map_err(|err| {
|
|
947
|
+
DaemonError::Persistence(format!("loaded relay private key is invalid hex: {err}"))
|
|
948
|
+
})?;
|
|
949
|
+
if private_key_bytes.len() != 32 {
|
|
950
|
+
return Err(DaemonError::Persistence(
|
|
951
|
+
"loaded relay private key must be 32 bytes".to_string(),
|
|
952
|
+
));
|
|
953
|
+
}
|
|
954
|
+
let mut private_key = [0u8; 32];
|
|
955
|
+
private_key.copy_from_slice(&private_key_bytes);
|
|
956
|
+
let secret = x25519_dalek::StaticSecret::from(private_key);
|
|
957
|
+
let public = x25519_dalek::PublicKey::from(&secret);
|
|
958
|
+
let expected_public_key_hex = hex::encode(public.as_bytes());
|
|
959
|
+
if state.relay_config.daemon_public_key_hex != expected_public_key_hex {
|
|
960
|
+
return Err(DaemonError::Persistence(
|
|
961
|
+
"loaded relay public key does not match stored private key".to_string(),
|
|
962
|
+
));
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
let daemon_id_bytes = hex::decode(
|
|
966
|
+
state
|
|
967
|
+
.relay_config
|
|
968
|
+
.daemon_id_hex
|
|
969
|
+
.trim()
|
|
970
|
+
.trim_start_matches("0x"),
|
|
971
|
+
)
|
|
972
|
+
.map_err(|err| DaemonError::Persistence(format!("loaded daemon id is invalid hex: {err}")))?;
|
|
973
|
+
if daemon_id_bytes.len() != 32 {
|
|
974
|
+
return Err(DaemonError::Persistence(
|
|
975
|
+
"loaded daemon id must be 32 bytes".to_string(),
|
|
976
|
+
));
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
Ok(())
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
fn validate_policy(policy: &SpendingPolicy) -> Result<(), DaemonError> {
|
|
983
|
+
if policy.max_amount_wei == 0 {
|
|
984
|
+
return Err(DaemonError::InvalidPolicy(
|
|
985
|
+
"max_amount_wei must be greater than zero".to_string(),
|
|
986
|
+
));
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
if matches!(
|
|
990
|
+
&policy.recipients,
|
|
991
|
+
vault_domain::EntityScope::Set(values) if values.is_empty()
|
|
992
|
+
) {
|
|
993
|
+
return Err(DaemonError::InvalidPolicy(
|
|
994
|
+
"recipient set scope must not be empty".to_string(),
|
|
995
|
+
));
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
if matches!(
|
|
999
|
+
&policy.assets,
|
|
1000
|
+
vault_domain::EntityScope::Set(values) if values.is_empty()
|
|
1001
|
+
) {
|
|
1002
|
+
return Err(DaemonError::InvalidPolicy(
|
|
1003
|
+
"asset set scope must not be empty".to_string(),
|
|
1004
|
+
));
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
if matches!(
|
|
1008
|
+
&policy.networks,
|
|
1009
|
+
vault_domain::EntityScope::Set(values)
|
|
1010
|
+
if values.is_empty() || values.iter().any(|chain_id| *chain_id == 0)
|
|
1011
|
+
) {
|
|
1012
|
+
return Err(DaemonError::InvalidPolicy(
|
|
1013
|
+
"network set scope must not be empty and must not contain chain_id 0".to_string(),
|
|
1014
|
+
));
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
Ok(())
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
fn generate_agent_auth_token() -> String {
|
|
1021
|
+
format!("{}.{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
fn hash_agent_auth_token(token: &str) -> [u8; 32] {
|
|
1025
|
+
let digest = Sha256::digest(token.as_bytes());
|
|
1026
|
+
let mut bytes = [0u8; 32];
|
|
1027
|
+
bytes.copy_from_slice(&digest);
|
|
1028
|
+
bytes
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
|
|
1032
|
+
if left.len() != right.len() {
|
|
1033
|
+
return false;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
let mut diff = 0u8;
|
|
1037
|
+
for (left, right) in left.iter().zip(right.iter()) {
|
|
1038
|
+
diff |= left ^ right;
|
|
1039
|
+
}
|
|
1040
|
+
diff == 0
|
|
1041
|
+
}
|