Compare commits

...

136 Commits

Author SHA1 Message Date
Clippy Bot
cc21036448 fix(clippy): apply auto-fixable linting suggestions 2026-06-23 18:47:23 +00:00
CleverWild
4fd75701c7 ci(clippy): add auto-fix bot pipeline 2026-06-23 20:41:41 +02:00
CleverWild
b843105533 fix(user-agent): zombie sessions #74
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline was successful
ci/woodpecker/push/server-test Pipeline was successful
2026-06-18 16:29:43 +02:00
a8e4a710f1 Merge pull request 'security(server): bind grant revocation state (revoked_at) to integrity hash' (#83) from security-hash-revoke_at into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline was successful
ci/woodpecker/push/server-test Pipeline was successful
ci/woodpecker/push/useragent-analyze Pipeline failed
Reviewed-on: #83
2026-06-11 09:44:28 +00:00
CleverWild
d99c87c473 fix: lints
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-09 21:07:01 +02:00
CleverWild
303120c9ac Merge branch 'main' into security-hash-revoke_at
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-09 20:58:20 +02:00
CleverWild
32f317384d security(evm): remove client-controlled wallet_access_id from grant revocation
Some checks failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-09 19:36:44 +02:00
CleverWild
4bb2c062dc feat(evm): add wallet_access_id to grant deletion requests and revocation logic
Some checks failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-09 19:16:21 +02:00
CleverWild
b0a3f37cea refactor(evm): implement revoke_grant method for grant revocation 2026-06-09 19:11:39 +02:00
CleverWild
58a72da46c Merge branch 'security-hash-revoke_at' of ssh://git.markettakers.org:22222/MarketTakers/arbiter into security-hash-revoke_at
Some checks failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-09 19:10:57 +02:00
CleverWild
e287459b10 revert(server): bind grant revocation state (revoked_at) to integrity hash 2026-06-09 18:45:30 +02:00
CleverWild
3c482da917 fix(smlang::statemachine): macro invocation requires inner types to be public
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-06-08 18:00:52 +02:00
Skipper
3f801abdff housekeeping(server): deps upgrade + diesel migration to AsyncFnOnce
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-05-01 11:22:40 +02:00
Skipper
2b44570ab4 fix(server): MacOS build version
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-19 13:47:47 +02:00
Skipper
1f9b253433 housekeeping(server): removed unused deps 2026-04-19 13:46:49 +02:00
Skipper
a1c3ffd2d1 refactor: rename to to better reflect meaning
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-19 13:41:50 +02:00
Skipper
fd25de32a1 docs: move to folder and update to new challenge payload 2026-04-18 15:17:18 +02:00
Skipper
9ab074170b merge: feat-lints into main
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
ci/woodpecker/push/useragent-analyze Pipeline failed
2026-04-18 15:04:33 +02:00
18b8a3bbf5 Merge pull request 'refactor-integrity-check' (#90) from refactor-integrity-check into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline was successful
ci/woodpecker/push/server-test Pipeline was successful
ci/woodpecker/push/useragent-analyze Pipeline failed
Reviewed-on: #90
2026-04-18 11:54:30 +00:00
Skipper
38cf1b98b9 housekeeping(server): clippy warns fix
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-18 13:53:11 +02:00
Skipper
9cf87b2058 merge: refactor-integrity-check into main
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-18 13:46:28 +02:00
Skipper
929d50b589 housekeeping(server): clean too-broad visibility markers and organize imports
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-18 13:30:09 +02:00
Skipper
70acfc99b5 merge: refactor-integrity-check into main 2026-04-18 13:19:13 +02:00
28f84d03ab Merge pull request 'housekeeping(server): dependencies upgrade' (#89) from push-zmvtzuwrnyyv into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline was successful
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #89
2026-04-17 19:20:50 +00:00
Skipper
4a8e51ef32 docs: updated to new auth challenge format and removed stale TOCTOU race condition note
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-17 18:25:55 +02:00
Skipper
9ee86afc19 fix(useragent): now using new challenge format 2026-04-17 18:19:51 +02:00
Skipper
790026e93b fix(server::tests): api surface of auth challenge changed 2026-04-17 17:58:22 +02:00
Skipper
0e09afda5d refactor(server::{useragent::auth, client::auth}): use random based + timestamp nonce instead of monotonic counter in database 2026-04-17 17:44:42 +02:00
Skipper
51e6571d80 refactor(server): now keeps track of useragents, instead of 2026-04-17 00:00:43 +02:00
Skipper
3b828d5874 refactor(server::grpc::vault_gate): standard approach using / traits 2026-04-16 22:15:18 +02:00
Skipper
a6f94e3115 fix(server): sending fixed vault state when on stage 2026-04-16 19:36:41 +02:00
hdbg
f49e995c2f WIP: kameo::messages wiring for transport generalization
Some checks failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-16 17:18:46 +02:00
Skipper
e88df432fb housekeeping(server): dependencies upgrade
Some checks failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
2026-04-14 19:10:07 +02:00
hdbg
87ee0fe87b feat(user-agent): add VaultGate for sealed vault authentication 2026-04-12 11:53:05 +02:00
CleverWild
41b3fc5d39 fix(lints): remove unstable ones
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-10 01:00:21 +02:00
CleverWild
f6a0c32b9d feat: rustc and clippy linting
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-10 00:42:43 +02:00
hdbg
205227a3df fix(server::integrity): vault now differentias between expected/unexpected states for commands more granularly 2026-04-08 18:21:48 +02:00
hdbg
a4070e7df7 fix(useragent): unsafe, but working implementation of ml-dsa 2026-04-08 17:43:51 +02:00
hdbg
6b8da567dd fix(server::user_agent): useragents now self-sign themselves on bootstrap 2026-04-08 17:40:45 +02:00
hdbg
1585f90cae refactor(server): reorganized client/user_agent actors into separate module peers and added event MessageBus 2026-04-08 12:34:16 +02:00
CleverWild
5a34463228 security(server): bind grant revocation state (revoked_at) to integrity hash
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-08 12:09:54 +02:00
62dff3f810 Merge pull request 'refactor(hashing): introduce Hashable derive macro and migrate server types' (#82) from hashing-proc-macro into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #82
Reviewed-by: Stas <business@jexter.tech>
2026-04-08 00:18:40 +00:00
CleverWild
6e22f368c9 refactor(hashing): introduce Hashable derive macro and migrate server types
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-08 01:32:59 +02:00
f3cf6a9438 Merge pull request 'Post-quantum crypto and better useragent security' (#80) from push-xrxykvkuxpsv into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #80
2026-04-07 19:26:54 +00:00
hdbg
a9f9fc2a9d housekeeping(server): fixed clippy warns
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-07 16:28:47 +02:00
hdbg
d22ab49e3d refactor(server): moved shared module crypto into arbiter-crypto 2026-04-07 16:24:51 +02:00
hdbg
a845181ef6 docs: ml-dsa scheme everywhere
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-07 15:02:32 +02:00
hdbg
0d424f3afc refactor(server): migrated auth to ml-dsa 2026-04-07 14:55:31 +02:00
hdbg
1497884ce6 fix(server::bootsrapper): token compare is now constant-time
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 18:33:47 +02:00
hdbg
b3464cf8a6 tests(server::client::auth): integrity envelope insertion for valid paths
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 18:24:13 +02:00
hdbg
46d1318b6f feat(server): add integrity verification for client keys 2026-04-06 18:13:11 +02:00
9c80d51d45 Merge pull request 'fix(server): replaced postcard-based integrity fingerprint with custom trait providing order-independent hashing' (#77) from push-opwuyuwxknyo into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #77
2026-04-06 15:42:47 +00:00
hdbg
33456a644d tests(server): property-based testing for ordering independency for hash
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-06 17:40:41 +02:00
hdbg
5bc0c42cc7 fix(server): replaced postcard-based integrity fingerprint with custom trait providing order-independent hashing 2026-04-06 16:25:32 +02:00
hdbg
f6b62ab884 fix(server): added chain_id check and covered check_shared_constraints with unit tests
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 12:57:18 +02:00
hdbg
2dd5a3f32f tests(server): initial cargo-mutants
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 12:03:56 +02:00
hdbg
1aca9d4007 fix(server): simplify hash function for debug profile 2026-04-05 22:50:28 +02:00
5ee1b49c43 Merge pull request 'feat(server): integrity envelope engine for EVM grants with HMAC verification' (#51) from integrity-envelope into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #51
2026-04-05 16:26:51 +00:00
hdbg
00745bb381 tests(server): fixed for new integrity checks
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-05 14:49:02 +02:00
hdbg
b122aa464c refactor(server): rework envelopes and integrity check
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
2026-04-05 14:17:00 +02:00
hdbg
9fab945a00 fix(server): remove stale mentions of miette
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
2026-04-05 10:45:24 +02:00
CleverWild
aeed664e9a chore: inline integrity proto types
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
2026-04-05 10:44:21 +02:00
CleverWild
4057c1fc12 feat(server): integrity envelope engine for EVM grants with HMAC verification 2026-04-05 10:44:21 +02:00
hdbg
f5eb51978d docs: add recovery operators and multi-operator details 2026-04-05 08:27:24 +00:00
hdbg
d997e0f843 docs: add multi-operator governance section 2026-04-05 08:27:24 +00:00
hdbg
7aca281a81 merge: @main into client-integrity-verification
Some checks failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/push/useragent-analyze Pipeline failed
ci/woodpecker/push/server-test Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/pr/server-audit Pipeline was successful
2026-04-05 10:25:46 +02:00
0daad1dd37 Merge branch 'main' into push-zmyvyloztluy
Some checks failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-test Pipeline was successful
2026-04-05 07:57:31 +00:00
9ea474e1b2 fix(server): use LOCALHOST const instead of hard-coded ip value
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-04 14:14:15 +00:00
CleverWild
c6f440fdad fix(client): evm-feature's code for new proto
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-04 14:10:44 +00:00
e17c25a604 ci(server-test): ensure that all features are compiling
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
2026-04-04 14:06:02 +00:00
hdbg
01b12515bd housekeeping(server): fixed clippy warns
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-04 14:33:48 +02:00
hdbg
4a50daa7ea refactor(user-agent): remove backfill pubkey integrity tags
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-04 14:32:00 +02:00
hdbg
352ee3ee63 fix(server): previously, user agent auth accepted invalid signatures
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-04 14:28:07 +02:00
hdbg
dd51d756da refactor(server): separate crypto by purpose and moved outside of actor into separate module 2026-04-04 14:21:52 +02:00
CleverWild
0bb6e596ac feat(auth): implement attestation status verification for public keys
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-04 12:10:45 +02:00
hdbg
083ff66af2 refactor(server): removed miette out of server
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-04 12:10:34 +02:00
CleverWild
881f16bb1a fix(keyholder): comment drift 2026-04-04 12:02:50 +02:00
CleverWild
78895bca5b refactor(keyholder): generalize derive_useragent_integrity_key and compute_useragent_pubkey_integrity_tag corespondenly to derive_integrity_key and compute_integrity_tag 2026-04-04 12:00:39 +02:00
1495fbe754 Merge pull request 'refactor(protocol): split into domain-based nesting' (#45) from push-zwvktknttnmw into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
ci/woodpecker/push/useragent-analyze Pipeline failed
Reviewed-on: #45
2026-04-04 08:24:16 +00:00
ab8cf877d7 Merge branch 'main' into push-zwvktknttnmw
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-03 20:34:37 +00:00
hdbg
146f7a419e housekeeping: updated docs to match current impl state 2026-04-03 22:26:25 +02:00
hdbg
0362044b83 housekeeping(server): fixed clippy warns
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-03 22:20:07 +02:00
72618c186f Merge pull request 'feat(evm): implement EVM sign transaction handling in client and user agent' (#38) from feat--self-signed-transactions into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #38
Reviewed-by: Stas <business@jexter.tech>
2026-04-03 22:20:07 +02:00
hdbg
e47ccc3108 fix(useragent): upgraded to new protocol changes 2026-04-03 22:20:07 +02:00
90d8ae3c6c Merge pull request 'fix-security' (#42) from fix-security into main
Reviewed-on: #42
Reviewed-by: Stas <business@jexter.tech>
2026-04-03 22:20:07 +02:00
4af172e49a Merge branch 'main' into feat--self-signed-transactions 2026-04-03 22:20:07 +02:00
hdbg
bc45b9b9ce merge: @main into refactor-proto 2026-04-03 22:20:07 +02:00
CleverWild
5bce9fd68e chore: bump mise deps 2026-04-03 22:20:07 +02:00
CleverWild
63a4875fdb fix(keyholder): remove dead overwritten select in try_unseal query 2026-04-03 22:20:07 +02:00
hdbg
d5ec303b9a merge: main 2026-04-03 22:20:07 +02:00
hdbg
82b5b85f52 refactor(proto): nest client protocol and extract shared schemas 2026-04-03 22:20:07 +02:00
hdbg
e2d8b7841b style(dashboard): format code and add title margin 2026-04-03 22:20:07 +02:00
CleverWild
8feda7990c fix(auth): reject invalid challenge signatures instead of transitioning to AuthOk 2026-04-03 22:20:07 +02:00
hdbg
16f0e67d02 refactor(proto): scope client and user-agent schemas and extract shared types 2026-04-03 22:20:07 +02:00
hdbg
b5507e7d0f feat(grants-create): add configurable grant authorization fields 2026-04-03 22:20:07 +02:00
CleverWild
0388fa2c8b fix(server): enforce volumetric cap using past + current transfer value 2026-04-03 22:20:07 +02:00
hdbg
cfe01ba1ad refactor(server, protocol): split big message files into smaller and domain-based 2026-04-03 22:20:07 +02:00
hdbg
59c7091cba refactor(useragent::evm::grants): split into more files & flutter_form_builder usage 2026-04-03 22:20:07 +02:00
hdbg
523bf783ac refactor(grpc): extract user agent request handlers into separate functions 2026-04-03 22:20:07 +02:00
hdbg
643f251419 fix(useragent::dashboard): screen pushed twice due to improper listen hook 2026-04-03 22:20:07 +02:00
hdbg
bce6ecd409 refactor(grants): wrap grant list in SingleChildScrollView 2026-04-03 22:20:07 +02:00
hdbg
f32728a277 style(dashboard): remove const from _CalloutBell and add title to nav rail 2026-04-03 22:20:07 +02:00
hdbg
32743741e1 refactor(useragent): moved shared CreamPanel and StatePanel into generic widgets 2026-04-03 22:20:07 +02:00
hdbg
54b2183be5 feat(evm): add EVM grants screen with create UI and list 2026-04-03 22:20:07 +02:00
hdbg
ca35b9fed7 refactor(proto): restructure wallet access messages for improved data organization 2026-04-03 22:20:07 +02:00
hdbg
27428f709a refactor(server::evm): removed repetetive errors and error variants 2026-04-03 22:20:07 +02:00
hdbg
78006e90f2 refactor(useragent::evm::table): broke down into more widgets 2026-04-03 22:20:07 +02:00
hdbg
29cc4d9e5b refactor(useragent::evm): moved out header into general widget 2026-04-03 22:20:07 +02:00
hdbg
7f8b9cc63e feat(useragent): vibe-coded access list 2026-04-03 22:20:07 +02:00
CleverWild
a02ef68a70 feat(auth): add seal-key-derived pubkey integrity tags with auth enforcement and unseal backfill
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
2026-03-30 00:17:04 +02:00
hdbg
e5be55e141 style(dashboard): format code and add title margin
Some checks failed
ci/woodpecker/push/useragent-analyze Pipeline failed
2026-03-29 10:54:02 +02:00
hdbg
8f0eb7130b feat(grants-create): add configurable grant authorization fields 2026-03-29 00:37:58 +01:00
hdbg
94fe04a6a4 refactor(useragent::evm::grants): split into more files & flutter_form_builder usage 2026-03-29 00:37:58 +01:00
hdbg
976c11902c fix(useragent::dashboard): screen pushed twice due to improper listen hook 2026-03-29 00:37:58 +01:00
hdbg
c8d2662a36 refactor(grants): wrap grant list in SingleChildScrollView 2026-03-29 00:37:58 +01:00
hdbg
ac5fedddd1 style(dashboard): remove const from _CalloutBell and add title to nav rail 2026-03-29 00:37:58 +01:00
hdbg
0c2d4986a2 refactor(useragent): moved shared CreamPanel and StatePanel into generic widgets 2026-03-29 00:37:58 +01:00
hdbg
a3203936d2 feat(evm): add EVM grants screen with create UI and list 2026-03-29 00:37:58 +01:00
hdbg
fb1c0ec130 refactor(proto): restructure wallet access messages for improved data organization 2026-03-29 00:37:58 +01:00
hdbg
2a21758369 refactor(server::evm): removed repetetive errors and error variants 2026-03-29 00:37:58 +01:00
hdbg
1abb5fa006 refactor(useragent::evm::table): broke down into more widgets 2026-03-29 00:37:58 +01:00
hdbg
e1b1c857fa refactor(useragent::evm): moved out header into general widget 2026-03-29 00:37:58 +01:00
hdbg
4216007af3 feat(useragent): vibe-coded access list 2026-03-29 00:37:58 +01:00
CleverWild
6987e5f70f feat(evm): implement EVM sign transaction handling in client and user agent
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
2026-03-26 19:57:48 +01:00
hdbg
bbf8a8019c feat(evm): add wallet access grant/revoke functionality
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
ci/woodpecker/push/useragent-analyze Pipeline failed
2026-03-25 16:33:55 +01:00
hdbg
ac04495480 refactor(server): grpc wire conversion 2026-03-25 15:25:24 +01:00
hdbg
eb25d31361 fix(useragent::nav): incorrect ordering led to mismatched routing 2026-03-24 20:25:53 +01:00
hdbg
056ff3470b fix(tls, client): added proper errors to client & schema to connect url; added localhost wildcard for self-signed setup 2026-03-24 20:22:13 +01:00
hdbg
c0b08e84cc feat(useragent): callouts feature for approving new things 2026-03-24 20:22:13 +01:00
hdbg
ddd6e7910f test: add test_connect binary for client connection testing 2026-03-22 17:45:33 +01:00
hdbg
d9b3694cab feat(useragent): add SDK clients table screen 2026-03-22 17:40:48 +01:00
hdbg
4ebe7b6fc4 merge: new flow into main 2026-03-22 12:50:55 +01:00
hdbg
8043cdf8d8 feat(server): re-introduce client approval flow 2026-03-22 12:18:18 +01:00
hdbg
51674bb39c refactor(actors): rename MessageRouter to FlowCoordinator 2026-03-21 13:12:06 +01:00
hdbg
cd07ab7a78 refactor(server): renamed 'wallet_visibility' to 'wallet_access' 2026-03-21 13:06:25 +01:00
hdbg
cfa6e068eb feat(client): add client metadata and wallet visibility support 2026-03-20 20:41:00 +01:00
449 changed files with 79264 additions and 52816 deletions

View File

@@ -0,0 +1,11 @@
---
name: Widget decomposition and provider subscriptions
description: Prefer splitting screens into multiple focused files/widgets; each widget subscribes to its own relevant providers
type: feedback
---
Split screens into multiple smaller widgets across multiple files. Each widget should subscribe only to the providers it needs (`ref.watch` at lowest possible level), rather than having one large screen widget that watches everything and passes data down as parameters.
**Why:** Reduces unnecessary rebuilds; improves readability; each file has one clear responsibility.
**How to apply:** When building a new screen, identify which sub-widgets need their own provider subscriptions and extract them into separate files (e.g., `widgets/grant_card.dart` watches enrichment providers itself, rather than the screen doing it and passing resolved strings down).

11
.gitignore vendored
View File

@@ -1,5 +1,6 @@
target/ target/
scripts/__pycache__/ scripts/__pycache__/
.DS_Store .DS_Store
.cargo/config.toml .cargo/config.toml
.vscode/ .vscode/
docs/superpowers

View File

@@ -1,26 +1,26 @@
when: when:
- event: pull_request - event: pull_request
path: path:
include: ['.woodpecker/server-*.yaml', 'server/**'] include: ['.woodpecker/server-*.yaml', 'server/**']
- event: push - event: push
branch: main branch: main
path: path:
include: ['.woodpecker/server-*.yaml', 'server/**'] include: ['.woodpecker/server-*.yaml', 'server/**']
steps: steps:
- name: audit - name: audit
image: jdxcode/mise:latest image: jdxcode/mise:latest
directory: server directory: server
environment: environment:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
CARGO_TARGET_DIR: /usr/local/cargo/target CARGO_TARGET_DIR: /usr/local/cargo/target
CARGO_HOME: /usr/local/cargo/registry CARGO_HOME: /usr/local/cargo/registry
volumes: volumes:
- cargo-target:/usr/local/cargo/target - cargo-target:/usr/local/cargo/target
- cargo-registry:/usr/local/cargo/registry - cargo-registry:/usr/local/cargo/registry
commands: commands:
- apt-get update && apt-get install -y pkg-config - apt-get update && apt-get install -y pkg-config
# Install only the necessary Rust toolchain and test runner to speed up the CI # Install only the necessary Rust toolchain and test runner to speed up the CI
- mise install rust - mise install rust
- mise install cargo:cargo-audit - mise install cargo:cargo-audit
- mise exec cargo:cargo-audit -- cargo audit - mise exec cargo:cargo-audit -- cargo audit

View File

@@ -0,0 +1,45 @@
when:
- event: push
branch: main
path:
include: ['.woodpecker/server-*.yaml', 'server/**']
steps:
- name: clippy-fix
image: jdxcode/mise:latest
directory: server
environment:
CARGO_TERM_COLOR: always
CARGO_TARGET_DIR: /usr/local/cargo/target
CARGO_HOME: /usr/local/cargo/registry
GITEA_TOKEN:
from_secret: GITEA_TOKEN
GITEA_URL:
from_secret: GITEA_URL
volumes:
- cargo-target:/usr/local/cargo/target
- cargo-registry:/usr/local/cargo/registry
commands:
- apt-get update && apt-get install -y pkg-config curl
- mise install rust
- mise install protoc
- mise exec rust -- cargo clippy --fix --allow-dirty --all
- |
cd ..
if git diff --quiet HEAD; then
echo "No machine-applicable clippy fixes found, nothing to do."
exit 0
fi
git config user.email "bot@arbiter"
git config user.name "Clippy Bot"
git add -A
git commit -m "fix(clippy): apply auto-fixable linting suggestions"
git config http.extraHeader "Authorization: token ${GITEA_TOKEN}"
git push --force origin HEAD:refs/heads/bot/clippy-fixes
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST "${GITEA_URL}/api/v1/repos/${CI_REPO}/pulls" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"title\":\"fix(clippy): apply auto-fixable linting suggestions\",\"head\":\"bot/clippy-fixes\",\"base\":\"main\",\"body\":\"Automated clippy fixes generated by CI bot.\"}")
echo "Gitea API response: ${HTTP_STATUS}"
[ "$HTTP_STATUS" = "201" ] || [ "$HTTP_STATUS" = "409" ] || [ "$HTTP_STATUS" = "422" ] || exit 1

View File

@@ -1,25 +1,25 @@
when: when:
- event: pull_request - event: pull_request
path: path:
include: ['.woodpecker/server-*.yaml', 'server/**'] include: ['.woodpecker/server-*.yaml', 'server/**']
- event: push - event: push
branch: main branch: main
path: path:
include: ['.woodpecker/server-*.yaml', 'server/**'] include: ['.woodpecker/server-*.yaml', 'server/**']
steps: steps:
- name: lint - name: lint
image: jdxcode/mise:latest image: jdxcode/mise:latest
directory: server directory: server
environment: environment:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
CARGO_TARGET_DIR: /usr/local/cargo/target CARGO_TARGET_DIR: /usr/local/cargo/target
CARGO_HOME: /usr/local/cargo/registry CARGO_HOME: /usr/local/cargo/registry
volumes: volumes:
- cargo-target:/usr/local/cargo/target - cargo-target:/usr/local/cargo/target
- cargo-registry:/usr/local/cargo/registry - cargo-registry:/usr/local/cargo/registry
commands: commands:
- apt-get update && apt-get install -y pkg-config - apt-get update && apt-get install -y pkg-config
- mise install rust - mise install rust
- mise install protoc - mise install protoc
- mise exec rust -- cargo clippy --all -- -D warnings - mise exec rust -- cargo clippy --all -- -D warnings

View File

@@ -1,27 +1,27 @@
when: when:
- event: pull_request - event: pull_request
path: path:
include: ['.woodpecker/server-*.yaml', 'server/**'] include: ['.woodpecker/server-*.yaml', 'server/**']
- event: push - event: push
branch: main branch: main
path: path:
include: ['.woodpecker/server-*.yaml', 'server/**'] include: ['.woodpecker/server-*.yaml', 'server/**']
steps: steps:
- name: test - name: test
image: jdxcode/mise:latest image: jdxcode/mise:latest
directory: server directory: server
environment: environment:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
CARGO_TARGET_DIR: /usr/local/cargo/target CARGO_TARGET_DIR: /usr/local/cargo/target
CARGO_HOME: /usr/local/cargo/registry CARGO_HOME: /usr/local/cargo/registry
volumes: volumes:
- cargo-target:/usr/local/cargo/target - cargo-target:/usr/local/cargo/target
- cargo-registry:/usr/local/cargo/registry - cargo-registry:/usr/local/cargo/registry
commands: commands:
- apt-get update && apt-get install -y pkg-config - apt-get update && apt-get install -y pkg-config
# Install only the necessary Rust toolchain and test runner to speed up the CI # Install only the necessary Rust toolchain and test runner to speed up the CI
- mise install rust - mise install rust
- mise install protoc - mise install protoc
- mise install cargo:cargo-nextest - mise install cargo:cargo-nextest
- mise exec cargo:cargo-nextest -- cargo nextest run --no-fail-fast - mise exec cargo:cargo-nextest -- cargo nextest run --no-fail-fast --all-features

View File

@@ -1,26 +1,26 @@
when: when:
- event: pull_request - event: pull_request
path: path:
include: ['.woodpecker/server-*.yaml', 'server/**'] include: ['.woodpecker/server-*.yaml', 'server/**']
- event: push - event: push
branch: main branch: main
path: path:
include: ['.woodpecker/server-*.yaml', 'server/**'] include: ['.woodpecker/server-*.yaml', 'server/**']
steps: steps:
- name: vet - name: vet
image: jdxcode/mise:latest image: jdxcode/mise:latest
directory: server directory: server
environment: environment:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
CARGO_TARGET_DIR: /usr/local/cargo/target CARGO_TARGET_DIR: /usr/local/cargo/target
CARGO_HOME: /usr/local/cargo/registry CARGO_HOME: /usr/local/cargo/registry
volumes: volumes:
- cargo-target:/usr/local/cargo/target - cargo-target:/usr/local/cargo/target
- cargo-registry:/usr/local/cargo/registry - cargo-registry:/usr/local/cargo/registry
commands: commands:
- apt-get update && apt-get install -y pkg-config - apt-get update && apt-get install -y pkg-config
# Install only the necessary Rust toolchain and test runner to speed up the CI # Install only the necessary Rust toolchain and test runner to speed up the CI
- mise install rust - mise install rust
- mise install cargo:cargo-vet - mise install cargo:cargo-vet
- mise exec cargo:cargo-vet -- cargo vet - mise exec cargo:cargo-vet -- cargo vet

View File

@@ -1,18 +1,18 @@
when: when:
- event: pull_request - event: pull_request
path: path:
include: ['.woodpecker/useragent-*.yaml', 'useragent/**'] include: ['.woodpecker/useragent-*.yaml', 'useragent/**']
- event: push - event: push
branch: main branch: main
path: path:
include: ['.woodpecker/useragent-*.yaml', 'useragent/**'] include: ['.woodpecker/useragent-*.yaml', 'useragent/**']
steps: steps:
- name: analyze - name: analyze
image: jdxcode/mise:latest image: jdxcode/mise:latest
commands: commands:
- mise install flutter - mise install flutter
- mise install protoc - mise install protoc
# Reruns codegen to catch protocol drift # Reruns codegen to catch protocol drift
- mise codegen - mise codegen
- cd useragent/ && flutter analyze - cd useragent/ && flutter analyze

277
AGENTS.md
View File

@@ -1,128 +1,149 @@
# AGENTS.md # AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project Overview ## Project Overview
Arbiter is a **permissioned signing service** for cryptocurrency wallets. It consists of: Arbiter is a **permissioned signing service** for cryptocurrency wallets. It consists of:
- **`server/`** — Rust gRPC daemon that holds encrypted keys and enforces policies - **`server/`** — Rust gRPC daemon that holds encrypted keys and enforces policies
- **`useragent/`** — Flutter desktop app (macOS/Windows) with a Rust backend via Rinf - **`operator/`** — Flutter desktop app (macOS/Windows) with a Rust backend via Rinf
- **`protobufs/`** — Protocol Buffer definitions shared between server and client - **`protobufs/`** — Protocol Buffer definitions shared between server and client
The vault never exposes key material; it only produces signatures when requests satisfy configured policies. The vault never exposes key material; it only produces signatures when requests satisfy configured policies.
## Toolchain Setup ## Toolchain Setup
Tools are managed via [mise](https://mise.jdx.dev/). Install all required tools: Tools are managed via [mise](https://mise.jdx.dev/). Install all required tools:
```sh ```sh
mise install mise install
``` ```
Key versions: Rust 1.93.0 (with clippy), Flutter 3.38.9-stable, protoc 29.6, diesel_cli 2.3.6 (sqlite). Key versions: Rust 1.93.0 (with clippy), Flutter 3.38.9-stable, protoc 29.6, diesel_cli 2.3.6 (sqlite).
## Server (Rust workspace at `server/`) ## Server (Rust workspace at `server/`)
### Crates ### Crates
| Crate | Purpose | | Crate | Purpose |
|---|---| |---|---|
| `arbiter-proto` | Generated gRPC stubs + protobuf types; compiled from `protobufs/*.proto` via `tonic-prost-build` | | `arbiter-proto` | Generated gRPC stubs + protobuf types; compiled from `protobufs/*.proto` via `tonic-prost-build` |
| `arbiter-server` | Main daemon — actors, DB, EVM policy engine, gRPC service implementation | | `arbiter-server` | Main daemon — actors, DB, EVM policy engine, gRPC service implementation |
| `arbiter-useragent` | Rust client library for the user agent side of the gRPC protocol | | `arbiter-operator` | Rust client library for the operator side of the gRPC protocol |
| `arbiter-client` | Rust client library for SDK clients | | `arbiter-client` | Rust client library for SDK clients |
### Common Commands ### Common Commands
```sh ```sh
cd server cd server
# Build # Build
cargo build cargo build
# Run the server daemon # Run the server daemon
cargo run -p arbiter-server cargo run -p arbiter-server
# Run all tests (preferred over cargo test) # Run all tests (preferred over cargo test)
cargo nextest run cargo nextest run
# Run a single test # Run a single test
cargo nextest run <test_name> cargo nextest run <test_name>
# Lint # Lint
cargo clippy cargo clippy
# Security audit # Security audit
cargo audit cargo audit
# Check unused dependencies # Check unused dependencies
cargo shear cargo shear
# Run snapshot tests and update snapshots # Run snapshot tests and update snapshots
cargo insta review cargo insta review
``` ```
### Architecture ### Architecture
The server is actor-based using the **kameo** crate. All long-lived state lives in `GlobalActors`: The server is actor-based using the **kameo** crate. All long-lived state lives in `GlobalActors`:
- **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run. - **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run.
- **`KeyHolder`** — Holds the encrypted root key and manages the Sealed/Unsealed vault state machine. On unseal, decrypts the root key into a `memsafe` hardened memory cell. - **`Vault`** — Holds the encrypted root key and manages the Sealed/Unsealed vault state machine. On unseal, decrypts the root key into a `memsafe` hardened memory cell.
- **`MessageRouter`** — Coordinates streaming messages between user agents and SDK clients. - **`FlowCoordinator`** — Coordinates cross-connection flow between operators and SDK clients.
- **`EvmActor`** — Handles EVM transaction policy enforcement and signing. - **`EvmActor`** — Handles EVM transaction policy enforcement and signing.
Per-connection actors live under `actors/user_agent/` and `actors/client/`, each with `auth` (challenge-response authentication) and `session` (post-auth operations) sub-modules. Per-connection actors live under `actors/operator/` and `actors/client/`, each with `auth` (challenge-response authentication) and `session` (post-auth operations) sub-modules.
**Database:** SQLite via `diesel-async` + `bb8` connection pool. Schema managed by embedded Diesel migrations in `crates/arbiter-server/migrations/`. DB file lives at `~/.arbiter/arbiter.sqlite`. Tests use a temp-file DB via `db::create_test_pool()`. **Database:** SQLite via `diesel-async` + `bb8` connection pool. Schema managed by embedded Diesel migrations in `crates/arbiter-server/migrations/`. DB file lives at `~/.arbiter/arbiter.sqlite`. Tests use a temp-file DB via `db::create_test_pool()`.
**Cryptography:** **Cryptography:**
- Authentication: ed25519 (challenge-response, nonce-tracked per peer) - Authentication: ed25519 (challenge-response, nonce-tracked per peer)
- Encryption at rest: XChaCha20-Poly1305 (versioned via `scheme` field for transparent migration on unseal) - Encryption at rest: XChaCha20-Poly1305 (versioned via `scheme` field for transparent migration on unseal)
- Password KDF: Argon2 - Password KDF: Argon2
- Unseal transport: X25519 ephemeral key exchange - Unseal transport: X25519 ephemeral key exchange
- TLS: self-signed certificate (aws-lc-rs backend), fingerprint distributed via `ArbiterUrl` - TLS: self-signed certificate (aws-lc-rs backend), fingerprint distributed via `ArbiterUrl`
**Protocol:** gRPC with Protocol Buffers. The `ArbiterUrl` type encodes host, port, CA cert, and bootstrap token into a single shareable string (printed to console on first run). **Protocol:** gRPC with Protocol Buffers. The `ArbiterUrl` type encodes host, port, CA cert, and bootstrap token into a single shareable string (printed to console on first run).
### Proto Regeneration ### Proto Regeneration
When `.proto` files in `protobufs/` change, rebuild to regenerate: When `.proto` files in `protobufs/` change, rebuild to regenerate:
```sh ```sh
cd server && cargo build -p arbiter-proto cd server && cargo build -p arbiter-proto
``` ```
### Database Migrations ### Database Migrations
```sh ```sh
# Create a new migration # Create a new migration
diesel migration generate <name> --migration-dir crates/arbiter-server/migrations diesel migration generate <name> --migration-dir crates/arbiter-server/migrations
# Run migrations manually (server also runs them on startup) # Run migrations manually (server also runs them on startup)
diesel migration run --migration-dir crates/arbiter-server/migrations diesel migration run --migration-dir crates/arbiter-server/migrations
``` ```
## User Agent (Flutter + Rinf at `useragent/`) ### Code Conventions
The Flutter app uses [Rinf](https://rinf.cunarist.org) to call Rust code. The Rust logic lives in `useragent/native/hub/` as a separate crate that uses `arbiter-useragent` for the gRPC client. **`#[must_use]` Attribute:**
Apply the `#[must_use]` attribute to return types of functions where the return value is critical and should not be accidentally ignored. This is commonly used for:
Communication between Dart and Rust uses typed **signals** defined in `useragent/native/hub/src/signals/`. After modifying signal structs, regenerate Dart bindings:
- Methods that return `bool` indicating success/failure or validation state
```sh - Any function where ignoring the return value indicates a logic error
cd useragent && rinf gen
``` Do not apply `#[must_use]` redundantly to items (types or functions) that are already annotated with `#[must_use]`.
### Common Commands Example:
```sh ```rust
cd useragent #[must_use]
pub fn verify(&self, nonce: i32, context: &[u8], signature: &Signature) -> bool {
# Run the app (macOS or Windows) // verification logic
flutter run }
```
# Regenerate Rust↔Dart signal bindings
rinf gen This forces callers to either use the return value or explicitly ignore it with `let _ = ...;`, preventing silent failures.
# Analyze Dart code ## Operator (Flutter + Rinf at `operator/`)
flutter analyze
``` The Flutter app uses [Rinf](https://rinf.cunarist.org) to call Rust code. The Rust logic lives in `operator/native/hub/` as a separate crate that uses `arbiter-operator` for the gRPC client.
The Rinf Rust entry point is `useragent/native/hub/src/lib.rs`. It spawns actors defined in `useragent/native/hub/src/actors/` which handle Dart↔server communication via signals. Communication between Dart and Rust uses typed **signals** defined in `operator/native/hub/src/signals/`. After modifying signal structs, regenerate Dart bindings:
```sh
cd operator && rinf gen
```
### Common Commands
```sh
cd operator
# Run the app (macOS or Windows)
flutter run
# Regenerate Rust↔Dart signal bindings
rinf gen
# Analyze Dart code
flutter analyze
```
The Rinf Rust entry point is `operator/native/hub/src/lib.rs`. It spawns actors defined in `operator/native/hub/src/actors/` which handle Dart↔server communication via signals.

View File

@@ -1,155 +0,0 @@
# Arbiter
Arbiter is a permissioned signing service for cryptocurrency wallets. It runs as a background service on the user's machine with an optional client application for vault management.
**Core principle:** The vault NEVER exposes key material. It only produces signatures when a request satisfies the configured policies.
---
## 1. Peer Types
Arbiter distinguishes two kinds of peers:
- **User Agent** — A client application used by the owner to manage the vault (create wallets, approve SDK clients, configure policies).
- **SDK Client** — A consumer of signing capabilities, typically an automation tool. In the future, this could include a browser-based wallet.
---
## 2. Authentication
### 2.1 Challenge-Response
All peers authenticate via public-key cryptography using a challenge-response protocol:
1. The peer sends its public key and requests a challenge.
2. The server looks up the key in its database. If found, it increments the nonce and returns a challenge (replay-attack protection).
3. The peer signs the challenge with its private key and sends the signature back.
4. The server verifies the signature:
- **Pass:** The connection is considered authenticated.
- **Fail:** The server closes the connection.
### 2.2 User Agent Bootstrap
On first run — when no User Agents are registered — the server generates a one-time bootstrap token. It is made available in two ways:
- **Local setup:** Written to `~/.arbiter/bootstrap_token` for automatic discovery by a co-located User Agent.
- **Remote setup:** Printed to the server's console output.
The first User Agent must present this token alongside the standard challenge-response to complete registration.
### 2.3 SDK Client Registration
There is no bootstrap mechanism for SDK clients. They must be explicitly approved by an already-registered User Agent.
---
## 3. Server Identity
The server proves its identity using TLS with a self-signed certificate. The TLS private key is generated on first run and is long-term; no rotation mechanism exists yet due to the complexity of multi-peer coordination.
Peers verify the server by its **public key fingerprint**:
- **User Agent (local):** Receives the fingerprint automatically through the bootstrap token.
- **User Agent (remote) / SDK Client:** Must receive the fingerprint out-of-band.
> A streamlined setup mechanism using a single connection string is planned but not yet implemented.
---
## 4. Key Management
### 4.1 Key Hierarchy
There are three layers of keys:
| Key | Encrypts | Encrypted by |
|---|---|---|
| **User key** (password) | Root key | — (derived from user input) |
| **Root key** | Wallet keys | User key |
| **Wallet keys** | — (used for signing) | Root key |
This layered design enables:
- **Password rotation** without re-encrypting every wallet key (only the root key is re-encrypted).
- **Root key rotation** without requiring the user to change their password.
### 4.2 Encryption at Rest
The database stores everything in encrypted form using symmetric AEAD. The encryption scheme is versioned to support transparent migration — when the vault unseals, Arbiter automatically re-encrypts any entries that are behind the current scheme version. See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the specific scheme and versioning mechanism.
---
## 5. Vault Lifecycle
### 5.1 Sealed State
On boot, the root key is encrypted and the server cannot perform any signing operations. This state is called **Sealed**.
### 5.2 Unseal Flow
To transition to the **Unsealed** state, a User Agent must provide the password:
1. The User Agent initiates an unseal request.
2. The server generates a one-time key pair and returns the public key.
3. The User Agent encrypts the user's password with this one-time public key and sends the ciphertext to the server.
4. The server decrypts and verifies the password:
- **Success:** The root key is decrypted and placed into a hardened memory cell. The server transitions to `Unsealed`. Any entries pending encryption scheme migration are re-encrypted.
- **Failure:** The server returns an error indicating the password is incorrect.
### 5.3 Memory Protection
Once unsealed, the root key must be protected in memory against:
- Memory dumps
- Page swaps to disk
- Hibernation files
See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the current and planned memory protection approaches.
---
## 6. Permission Engine
### 6.1 Fundamental Rules
- SDK clients have **no access by default**.
- Access is granted **explicitly** by a User Agent.
- Grants are scoped to **specific wallets** and governed by **policies**.
Each blockchain requires its own policy system due to differences in static transaction analysis. Currently, only EVM is supported; Solana support is planned.
Arbiter is also responsible for ensuring that **transaction nonces are never reused**.
### 6.2 EVM Policies
Every EVM grant is scoped to a specific **wallet** and **chain ID**.
#### 6.2.1 Transaction Sub-Grants
Arbiter maintains an ever-expanding database of known contracts and their ABIs. Based on contract knowledge, transaction requests fall into three categories:
**1. Known contract (ABI available)**
The transaction can be decoded and presented with semantic meaning. For example: *"Client X wants to transfer Y USDT to address Z."*
Available restrictions:
- Volume limits (e.g., "no more than 10,000 tokens ever")
- Rate limits (e.g., "no more than 100 tokens per hour")
**2. Unknown contract (no ABI)**
The transaction cannot be decoded, so its effects are opaque — it could do anything, including draining all tokens. The user is warned, and if approved, access is granted to all interactions with the contract (matched by the `to` field).
Available restrictions:
- Transaction count limits (e.g., "no more than 100 transactions ever")
- Rate limits (e.g., "no more than 5 transactions per hour")
**3. Plain ether transfer (no calldata)**
These transactions have no `calldata` and therefore cannot interact with contracts. They can be subject to the same volume and rate restrictions as above.
#### 6.2.2 Global Limits
In addition to sub-grant-specific restrictions, the following limits can be applied across all grant types:
- **Gas limit** — Maximum gas per transaction.
- **Time-window restrictions** — e.g., signing allowed only 08:0020:00 on Mondays and Thursdays.

129
CLAUDE.md
View File

@@ -1,128 +1 @@
# CLAUDE.md Refer to @AGENTS.md for instructions.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Arbiter is a **permissioned signing service** for cryptocurrency wallets. It consists of:
- **`server/`** — Rust gRPC daemon that holds encrypted keys and enforces policies
- **`useragent/`** — Flutter desktop app (macOS/Windows) with a Rust backend via Rinf
- **`protobufs/`** — Protocol Buffer definitions shared between server and client
The vault never exposes key material; it only produces signatures when requests satisfy configured policies.
## Toolchain Setup
Tools are managed via [mise](https://mise.jdx.dev/). Install all required tools:
```sh
mise install
```
Key versions: Rust 1.93.0 (with clippy), Flutter 3.38.9-stable, protoc 29.6, diesel_cli 2.3.6 (sqlite).
## Server (Rust workspace at `server/`)
### Crates
| Crate | Purpose |
|---|---|
| `arbiter-proto` | Generated gRPC stubs + protobuf types; compiled from `protobufs/*.proto` via `tonic-prost-build` |
| `arbiter-server` | Main daemon — actors, DB, EVM policy engine, gRPC service implementation |
| `arbiter-useragent` | Rust client library for the user agent side of the gRPC protocol |
| `arbiter-client` | Rust client library for SDK clients |
### Common Commands
```sh
cd server
# Build
cargo build
# Run the server daemon
cargo run -p arbiter-server
# Run all tests (preferred over cargo test)
cargo nextest run
# Run a single test
cargo nextest run <test_name>
# Lint
cargo clippy
# Security audit
cargo audit
# Check unused dependencies
cargo shear
# Run snapshot tests and update snapshots
cargo insta review
```
### Architecture
The server is actor-based using the **kameo** crate. All long-lived state lives in `GlobalActors`:
- **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run.
- **`KeyHolder`** — Holds the encrypted root key and manages the Sealed/Unsealed vault state machine. On unseal, decrypts the root key into a `memsafe` hardened memory cell.
- **`MessageRouter`** — Coordinates streaming messages between user agents and SDK clients.
- **`EvmActor`** — Handles EVM transaction policy enforcement and signing.
Per-connection actors live under `actors/user_agent/` and `actors/client/`, each with `auth` (challenge-response authentication) and `session` (post-auth operations) sub-modules.
**Database:** SQLite via `diesel-async` + `bb8` connection pool. Schema managed by embedded Diesel migrations in `crates/arbiter-server/migrations/`. DB file lives at `~/.arbiter/arbiter.sqlite`. Tests use a temp-file DB via `db::create_test_pool()`.
**Cryptography:**
- Authentication: ed25519 (challenge-response, nonce-tracked per peer)
- Encryption at rest: XChaCha20-Poly1305 (versioned via `scheme` field for transparent migration on unseal)
- Password KDF: Argon2
- Unseal transport: X25519 ephemeral key exchange
- TLS: self-signed certificate (aws-lc-rs backend), fingerprint distributed via `ArbiterUrl`
**Protocol:** gRPC with Protocol Buffers. The `ArbiterUrl` type encodes host, port, CA cert, and bootstrap token into a single shareable string (printed to console on first run).
### Proto Regeneration
When `.proto` files in `protobufs/` change, rebuild to regenerate:
```sh
cd server && cargo build -p arbiter-proto
```
### Database Migrations
```sh
# Create a new migration
diesel migration generate <name> --migration-dir crates/arbiter-server/migrations
# Run migrations manually (server also runs them on startup)
diesel migration run --migration-dir crates/arbiter-server/migrations
```
## User Agent (Flutter + Rinf at `useragent/`)
The Flutter app uses [Rinf](https://rinf.cunarist.org) to call Rust code. The Rust logic lives in `useragent/native/hub/` as a separate crate that uses `arbiter-useragent` for the gRPC client.
Communication between Dart and Rust uses typed **signals** defined in `useragent/native/hub/src/signals/`. After modifying signal structs, regenerate Dart bindings:
```sh
cd useragent && rinf gen
```
### Common Commands
```sh
cd useragent
# Run the app (macOS or Windows)
flutter run
# Regenerate Rust↔Dart signal bindings
rinf gen
# Analyze Dart code
flutter analyze
```
The Rinf Rust entry point is `useragent/native/hub/src/lib.rs`. It spawns actors defined in `useragent/native/hub/src/actors/` which handle Dart↔server communication via signals.

380
LICENSE
View File

@@ -1,190 +1,190 @@
Apache License Apache License
Version 2.0, January 2004 Version 2.0, January 2004
http://www.apache.org/licenses/ http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions. 1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, "License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document. and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by "Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License. the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all "Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition, control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the "control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity. outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity "You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License. exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, "Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation including but not limited to software source code, documentation
source, and configuration files. source, and configuration files.
"Object" form shall mean any form resulting from mechanical "Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation, not limited to compiled object code, generated documentation,
and conversions to other media types. and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or "Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work copyright notice that is included in or attached to the work
(an example is provided in the Appendix below). (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object "Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of, separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof. the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including "Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted" the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems, communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution." designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity "Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work. subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of 2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual, this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of, copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form. Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of 3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual, this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made, (except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work, use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s) Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate granted to You under this License for that Work shall terminate
as of the date such litigation is filed. as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the 4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You modifications, and in Source or Object form, provided that You
meet the following conditions: meet the following conditions:
(a) You must give any other recipients of the Work or (a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices (b) You must cause any modified files to carry prominent notices
stating that You changed the files; and stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works (c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work, attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of excluding those notices that do not pertain to any part of
the Derivative Works; and the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its (d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or, documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed that such additional attribution notices cannot be construed
as modifying the License. as modifying the License.
You may add Your own copyright statement to Your modifications and You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use, for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License. the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, 5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions. this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions. with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade 6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor, names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file. origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or 7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS, Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License. risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, 8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise, whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special, liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill, Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages. has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing 9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer, the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity, and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify, of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability. of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS END OF TERMS AND CONDITIONS
Copyright 2026 MarketTakers Copyright 2026 MarketTakers
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
You may obtain a copy of the License at You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.

View File

@@ -1,13 +1,13 @@
# Arbiter # Arbiter
> Policy-first multi-client wallet daemon, allowing permissioned transactions across blockchains > Policy-first multi-client wallet daemon, allowing permissioned transactions across blockchains
## Security warning ## Security warning
Arbiter can't meaningfully protect against host compromise. Potential attack flow: Arbiter can't meaningfully protect against host compromise. Potential attack flow:
- Attacker steals TLS keys from database - Attacker steals TLS keys from database
- Pretends to be server; just accepts user agent challenge solutions - Pretends to be server; just accepts operator challenge solutions
- Pretend to be in sealed state and performing DH with client - Pretend to be in sealed state and performing DH with client
- Steals user password and derives seal key - Steals user password and derives seal key
While this attack is highly targetive, it's still possible. While this attack is highly targetive, it's still possible.
> This software is experimental. Do not use with funds you cannot afford to lose. > This software is experimental. Do not use with funds you cannot afford to lose.

View File

@@ -1,31 +1,31 @@
Extension Discovery Cache Extension Discovery Cache
========================= =========================
This folder is used by `package:extension_discovery` to cache lists of This folder is used by `package:extension_discovery` to cache lists of
packages that contains extensions for other packages. packages that contains extensions for other packages.
DO NOT USE THIS FOLDER DO NOT USE THIS FOLDER
---------------------- ----------------------
* Do not read (or rely) the contents of this folder. * Do not read (or rely) the contents of this folder.
* Do write to this folder. * Do write to this folder.
If you're interested in the lists of extensions stored in this folder use the If you're interested in the lists of extensions stored in this folder use the
API offered by package `extension_discovery` to get this information. API offered by package `extension_discovery` to get this information.
If this package doesn't work for your use-case, then don't try to read the If this package doesn't work for your use-case, then don't try to read the
contents of this folder. It may change, and will not remain stable. contents of this folder. It may change, and will not remain stable.
Use package `extension_discovery` Use package `extension_discovery`
--------------------------------- ---------------------------------
If you want to access information from this folder. If you want to access information from this folder.
Feel free to delete this folder Feel free to delete this folder
------------------------------- -------------------------------
Files in this folder act as a cache, and the cache is discarded if the files Files in this folder act as a cache, and the cache is discarded if the files
are older than the modification time of `.dart_tool/package_config.json`. are older than the modification time of `.dart_tool/package_config.json`.
Hence, it should never be necessary to clear this cache manually, if you find a Hence, it should never be necessary to clear this cache manually, if you find a
need to do please file a bug. need to do please file a bug.

View File

@@ -1,178 +1,178 @@
{ {
"configVersion": 2, "configVersion": 2,
"packages": [ "packages": [
{ {
"name": "async", "name": "async",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/async-2.13.0", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/async-2.13.0",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.4" "languageVersion": "3.4"
}, },
{ {
"name": "boolean_selector", "name": "boolean_selector",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/boolean_selector-2.1.2", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/boolean_selector-2.1.2",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.1" "languageVersion": "3.1"
}, },
{ {
"name": "characters", "name": "characters",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/characters-1.4.0", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/characters-1.4.0",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.4" "languageVersion": "3.4"
}, },
{ {
"name": "clock", "name": "clock",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/clock-1.1.2", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/clock-1.1.2",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.4" "languageVersion": "3.4"
}, },
{ {
"name": "collection", "name": "collection",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/collection-1.19.1", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/collection-1.19.1",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.4" "languageVersion": "3.4"
}, },
{ {
"name": "cupertino_icons", "name": "cupertino_icons",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/cupertino_icons-1.0.8", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/cupertino_icons-1.0.8",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.1" "languageVersion": "3.1"
}, },
{ {
"name": "fake_async", "name": "fake_async",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/fake_async-1.3.3", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/fake_async-1.3.3",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.3" "languageVersion": "3.3"
}, },
{ {
"name": "flutter", "name": "flutter",
"rootUri": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable/packages/flutter", "rootUri": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable/packages/flutter",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.8" "languageVersion": "3.8"
}, },
{ {
"name": "flutter_lints", "name": "flutter_lints",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/flutter_lints-6.0.0", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/flutter_lints-6.0.0",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.8" "languageVersion": "3.8"
}, },
{ {
"name": "flutter_test", "name": "flutter_test",
"rootUri": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable/packages/flutter_test", "rootUri": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable/packages/flutter_test",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.8" "languageVersion": "3.8"
}, },
{ {
"name": "leak_tracker", "name": "leak_tracker",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/leak_tracker-11.0.2", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/leak_tracker-11.0.2",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.2" "languageVersion": "3.2"
}, },
{ {
"name": "leak_tracker_flutter_testing", "name": "leak_tracker_flutter_testing",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/leak_tracker_flutter_testing-3.0.10", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/leak_tracker_flutter_testing-3.0.10",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.2" "languageVersion": "3.2"
}, },
{ {
"name": "leak_tracker_testing", "name": "leak_tracker_testing",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/leak_tracker_testing-3.0.2", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/leak_tracker_testing-3.0.2",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.2" "languageVersion": "3.2"
}, },
{ {
"name": "lints", "name": "lints",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/lints-6.1.0", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/lints-6.1.0",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.8" "languageVersion": "3.8"
}, },
{ {
"name": "matcher", "name": "matcher",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/matcher-0.12.17", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/matcher-0.12.17",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.4" "languageVersion": "3.4"
}, },
{ {
"name": "material_color_utilities", "name": "material_color_utilities",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/material_color_utilities-0.11.1", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/material_color_utilities-0.11.1",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "2.17" "languageVersion": "2.17"
}, },
{ {
"name": "meta", "name": "meta",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/meta-1.17.0", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/meta-1.17.0",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.5" "languageVersion": "3.5"
}, },
{ {
"name": "path", "name": "path",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/path-1.9.1", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/path-1.9.1",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.4" "languageVersion": "3.4"
}, },
{ {
"name": "sky_engine", "name": "sky_engine",
"rootUri": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable/bin/cache/pkg/sky_engine", "rootUri": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable/bin/cache/pkg/sky_engine",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.8" "languageVersion": "3.8"
}, },
{ {
"name": "source_span", "name": "source_span",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/source_span-1.10.2", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/source_span-1.10.2",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.1" "languageVersion": "3.1"
}, },
{ {
"name": "stack_trace", "name": "stack_trace",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/stack_trace-1.12.1", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/stack_trace-1.12.1",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.4" "languageVersion": "3.4"
}, },
{ {
"name": "stream_channel", "name": "stream_channel",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/stream_channel-2.1.4", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/stream_channel-2.1.4",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.3" "languageVersion": "3.3"
}, },
{ {
"name": "string_scanner", "name": "string_scanner",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/string_scanner-1.4.1", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/string_scanner-1.4.1",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.1" "languageVersion": "3.1"
}, },
{ {
"name": "term_glyph", "name": "term_glyph",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/term_glyph-1.2.2", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/term_glyph-1.2.2",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.1" "languageVersion": "3.1"
}, },
{ {
"name": "test_api", "name": "test_api",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/test_api-0.7.7", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/test_api-0.7.7",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.5" "languageVersion": "3.5"
}, },
{ {
"name": "vector_math", "name": "vector_math",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/vector_math-2.2.0", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/vector_math-2.2.0",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.1" "languageVersion": "3.1"
}, },
{ {
"name": "vm_service", "name": "vm_service",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/vm_service-15.0.2", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/vm_service-15.0.2",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.5" "languageVersion": "3.5"
}, },
{ {
"name": "app", "name": "app",
"rootUri": "../", "rootUri": "../",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.10" "languageVersion": "3.10"
} }
], ],
"generator": "pub", "generator": "pub",
"generatorVersion": "3.10.8", "generatorVersion": "3.10.8",
"flutterRoot": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable", "flutterRoot": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable",
"flutterVersion": "3.38.9", "flutterVersion": "3.38.9",
"pubCache": "file:///Users/kaska/.pub-cache" "pubCache": "file:///Users/kaska/.pub-cache"
} }

View File

@@ -1,230 +1,230 @@
{ {
"roots": [ "roots": [
"app" "app"
], ],
"packages": [ "packages": [
{ {
"name": "app", "name": "app",
"version": "1.0.0+1", "version": "1.0.0+1",
"dependencies": [ "dependencies": [
"cupertino_icons", "cupertino_icons",
"flutter" "flutter"
], ],
"devDependencies": [ "devDependencies": [
"flutter_lints", "flutter_lints",
"flutter_test" "flutter_test"
] ]
}, },
{ {
"name": "flutter_lints", "name": "flutter_lints",
"version": "6.0.0", "version": "6.0.0",
"dependencies": [ "dependencies": [
"lints" "lints"
] ]
}, },
{ {
"name": "flutter_test", "name": "flutter_test",
"version": "0.0.0", "version": "0.0.0",
"dependencies": [ "dependencies": [
"clock", "clock",
"collection", "collection",
"fake_async", "fake_async",
"flutter", "flutter",
"leak_tracker_flutter_testing", "leak_tracker_flutter_testing",
"matcher", "matcher",
"meta", "meta",
"path", "path",
"stack_trace", "stack_trace",
"stream_channel", "stream_channel",
"test_api", "test_api",
"vector_math" "vector_math"
] ]
}, },
{ {
"name": "cupertino_icons", "name": "cupertino_icons",
"version": "1.0.8", "version": "1.0.8",
"dependencies": [] "dependencies": []
}, },
{ {
"name": "flutter", "name": "flutter",
"version": "0.0.0", "version": "0.0.0",
"dependencies": [ "dependencies": [
"characters", "characters",
"collection", "collection",
"material_color_utilities", "material_color_utilities",
"meta", "meta",
"sky_engine", "sky_engine",
"vector_math" "vector_math"
] ]
}, },
{ {
"name": "lints", "name": "lints",
"version": "6.1.0", "version": "6.1.0",
"dependencies": [] "dependencies": []
}, },
{ {
"name": "stream_channel", "name": "stream_channel",
"version": "2.1.4", "version": "2.1.4",
"dependencies": [ "dependencies": [
"async" "async"
] ]
}, },
{ {
"name": "meta", "name": "meta",
"version": "1.17.0", "version": "1.17.0",
"dependencies": [] "dependencies": []
}, },
{ {
"name": "collection", "name": "collection",
"version": "1.19.1", "version": "1.19.1",
"dependencies": [] "dependencies": []
}, },
{ {
"name": "leak_tracker_flutter_testing", "name": "leak_tracker_flutter_testing",
"version": "3.0.10", "version": "3.0.10",
"dependencies": [ "dependencies": [
"flutter", "flutter",
"leak_tracker", "leak_tracker",
"leak_tracker_testing", "leak_tracker_testing",
"matcher", "matcher",
"meta" "meta"
] ]
}, },
{ {
"name": "vector_math", "name": "vector_math",
"version": "2.2.0", "version": "2.2.0",
"dependencies": [] "dependencies": []
}, },
{ {
"name": "stack_trace", "name": "stack_trace",
"version": "1.12.1", "version": "1.12.1",
"dependencies": [ "dependencies": [
"path" "path"
] ]
}, },
{ {
"name": "clock", "name": "clock",
"version": "1.1.2", "version": "1.1.2",
"dependencies": [] "dependencies": []
}, },
{ {
"name": "fake_async", "name": "fake_async",
"version": "1.3.3", "version": "1.3.3",
"dependencies": [ "dependencies": [
"clock", "clock",
"collection" "collection"
] ]
}, },
{ {
"name": "path", "name": "path",
"version": "1.9.1", "version": "1.9.1",
"dependencies": [] "dependencies": []
}, },
{ {
"name": "matcher", "name": "matcher",
"version": "0.12.17", "version": "0.12.17",
"dependencies": [ "dependencies": [
"async", "async",
"meta", "meta",
"stack_trace", "stack_trace",
"term_glyph", "term_glyph",
"test_api" "test_api"
] ]
}, },
{ {
"name": "test_api", "name": "test_api",
"version": "0.7.7", "version": "0.7.7",
"dependencies": [ "dependencies": [
"async", "async",
"boolean_selector", "boolean_selector",
"collection", "collection",
"meta", "meta",
"source_span", "source_span",
"stack_trace", "stack_trace",
"stream_channel", "stream_channel",
"string_scanner", "string_scanner",
"term_glyph" "term_glyph"
] ]
}, },
{ {
"name": "sky_engine", "name": "sky_engine",
"version": "0.0.0", "version": "0.0.0",
"dependencies": [] "dependencies": []
}, },
{ {
"name": "material_color_utilities", "name": "material_color_utilities",
"version": "0.11.1", "version": "0.11.1",
"dependencies": [ "dependencies": [
"collection" "collection"
] ]
}, },
{ {
"name": "characters", "name": "characters",
"version": "1.4.0", "version": "1.4.0",
"dependencies": [] "dependencies": []
}, },
{ {
"name": "async", "name": "async",
"version": "2.13.0", "version": "2.13.0",
"dependencies": [ "dependencies": [
"collection", "collection",
"meta" "meta"
] ]
}, },
{ {
"name": "leak_tracker_testing", "name": "leak_tracker_testing",
"version": "3.0.2", "version": "3.0.2",
"dependencies": [ "dependencies": [
"leak_tracker", "leak_tracker",
"matcher", "matcher",
"meta" "meta"
] ]
}, },
{ {
"name": "leak_tracker", "name": "leak_tracker",
"version": "11.0.2", "version": "11.0.2",
"dependencies": [ "dependencies": [
"clock", "clock",
"collection", "collection",
"meta", "meta",
"path", "path",
"vm_service" "vm_service"
] ]
}, },
{ {
"name": "term_glyph", "name": "term_glyph",
"version": "1.2.2", "version": "1.2.2",
"dependencies": [] "dependencies": []
}, },
{ {
"name": "string_scanner", "name": "string_scanner",
"version": "1.4.1", "version": "1.4.1",
"dependencies": [ "dependencies": [
"source_span" "source_span"
] ]
}, },
{ {
"name": "source_span", "name": "source_span",
"version": "1.10.2", "version": "1.10.2",
"dependencies": [ "dependencies": [
"collection", "collection",
"path", "path",
"term_glyph" "term_glyph"
] ]
}, },
{ {
"name": "boolean_selector", "name": "boolean_selector",
"version": "2.1.2", "version": "2.1.2",
"dependencies": [ "dependencies": [
"source_span", "source_span",
"string_scanner" "string_scanner"
] ]
}, },
{ {
"name": "vm_service", "name": "vm_service",
"version": "15.0.2", "version": "15.0.2",
"dependencies": [] "dependencies": []
} }
], ],
"configVersion": 1 "configVersion": 1
} }

View File

@@ -1,11 +1,11 @@
// This is a generated file; do not edit or check into version control. // This is a generated file; do not edit or check into version control.
FLUTTER_ROOT=/Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable FLUTTER_ROOT=/Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable
FLUTTER_APPLICATION_PATH=/Users/kaska/Documents/Projects/Major/arbiter/app FLUTTER_APPLICATION_PATH=/Users/kaska/Documents/Projects/Major/arbiter/app
COCOAPODS_PARALLEL_CODE_SIGN=true COCOAPODS_PARALLEL_CODE_SIGN=true
FLUTTER_BUILD_DIR=build FLUTTER_BUILD_DIR=build
FLUTTER_BUILD_NAME=1.0.0 FLUTTER_BUILD_NAME=1.0.0
FLUTTER_BUILD_NUMBER=1 FLUTTER_BUILD_NUMBER=1
DART_OBFUSCATION=false DART_OBFUSCATION=false
TRACK_WIDGET_CREATION=true TRACK_WIDGET_CREATION=true
TREE_SHAKE_ICONS=false TREE_SHAKE_ICONS=false
PACKAGE_CONFIG=.dart_tool/package_config.json PACKAGE_CONFIG=.dart_tool/package_config.json

View File

@@ -1,12 +1,12 @@
#!/bin/sh #!/bin/sh
# This is a generated file; do not edit or check into version control. # This is a generated file; do not edit or check into version control.
export "FLUTTER_ROOT=/Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable" export "FLUTTER_ROOT=/Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable"
export "FLUTTER_APPLICATION_PATH=/Users/kaska/Documents/Projects/Major/arbiter/app" export "FLUTTER_APPLICATION_PATH=/Users/kaska/Documents/Projects/Major/arbiter/app"
export "COCOAPODS_PARALLEL_CODE_SIGN=true" export "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_BUILD_DIR=build" export "FLUTTER_BUILD_DIR=build"
export "FLUTTER_BUILD_NAME=1.0.0" export "FLUTTER_BUILD_NAME=1.0.0"
export "FLUTTER_BUILD_NUMBER=1" export "FLUTTER_BUILD_NUMBER=1"
export "DART_OBFUSCATION=false" export "DART_OBFUSCATION=false"
export "TRACK_WIDGET_CREATION=true" export "TRACK_WIDGET_CREATION=true"
export "TREE_SHAKE_ICONS=false" export "TREE_SHAKE_ICONS=false"
export "PACKAGE_CONFIG=.dart_tool/package_config.json" export "PACKAGE_CONFIG=.dart_tool/package_config.json"

334
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,334 @@
# Arbiter
Arbiter is a permissioned signing service for cryptocurrency wallets. It runs as a background service on the user's machine with an optional client application for vault management.
**Core principle:** The vault NEVER exposes key material. It only produces signatures when a request satisfies the configured policies.
---
## 1. Peer Types
Arbiter distinguishes two kinds of peers:
- **Operator** — A client application used by the owner to manage the vault (create wallets, approve SDK clients, configure policies).
- **SDK Client** — A consumer of signing capabilities, typically an automation tool. In the future, this could include a browser-based wallet.
- **Recovery Operator** — A dormant recovery participant with narrowly scoped authority used only for custody recovery and operator replacement.
---
## 2. Authentication
### 2.1 Challenge-Response
All peers authenticate via public-key cryptography using a challenge-response protocol:
1. The peer sends its public key and requests a challenge.
2. The server looks up the key in its database. If found, it generates a fresh challenge from random bytes plus the current timestamp.
3. The peer signs the canonical challenge payload with its private key and sends the signature back.
4. The server verifies the signature:
- **Pass:** The connection is considered authenticated.
- **Fail:** The server closes the connection.
Authentication challenges are per-connection, ephemeral values. They are not persisted in the peer tables, and peer records store no challenge state.
### 2.2 Operator Bootstrap
On first run — when no Operators are registered — the server generates a one-time bootstrap token. It is made available in two ways:
- **Local setup:** Written to `~/.arbiter/bootstrap_token` for automatic discovery by a co-located Operator.
- **Remote setup:** Printed to the server's console output.
The first Operator must present this token alongside the standard challenge-response to complete registration.
### 2.3 SDK Client Registration
There is no bootstrap mechanism for SDK clients. They must be explicitly approved by an already-registered Operator.
---
## 3. Multi-Operator Governance
When more than one Operator is registered, the vault is treated as having multiple operators. In that mode, sensitive actions are governed by voting rather than by a single operator decision.
### 3.1 Voting Rules
Voting is based on the total number of registered operators:
- **1 operator:** no vote is needed; the single operator decides directly.
- **2 operators:** full consensus is required; both operators must approve.
- **3 or more operators:** quorum is `floor(N / 2) + 1`.
For a decision to count, the operator's approval or rejection must be signed by that operator's associated key. Unsigned votes, or votes that fail signature verification, are ignored.
Examples:
- **3 operators:** 2 approvals required
- **4 operators:** 3 approvals required
### 3.2 Actions Requiring a Vote
In multi-operator mode, a successful vote is required for:
- approving new SDK clients
- granting an SDK client visibility to a wallet
- approving a one-off transaction
- approving creation of a persistent grant
- approving operator replacement
- approving server updates
- updating Shamir secret-sharing parameters
### 3.3 Special Rule for Key Rotation
Key rotation always requires full quorum, regardless of the normal voting threshold.
This is stricter than ordinary governance actions because rotating the root key requires every operator to participate in coordinated share refresh/update steps. The root key itself is not redistributed directly, but each operator's share material must be changed consistently.
### 3.4 Root Key Custody
When the vault has multiple operators, the vault root key is protected using Shamir secret sharing.
The vault root key is encrypted in a way that requires reconstruction from user-held shares rather than from a single shared password.
For ordinary operators, the Shamir threshold matches the ordinary governance quorum. For example:
- **2 operators:** `2-of-2`
- **3 operators:** `2-of-3`
- **4 operators:** `3-of-4`
In practice, the Shamir share set also includes Recovery Operator shares. This means the effective Shamir parameters are computed over the combined share pool while keeping the same threshold. For example:
- **3 ordinary operators + 2 recovery shares:** `2-of-5`
This ensures that the normal custody threshold follows the ordinary operator quorum, while still allowing dormant recovery shares to exist for break-glass recovery flows.
### 3.5 Recovery Operators
Recovery Operators are a separate peer type from ordinary vault operators.
Their role is intentionally narrow. They can only:
- participate in unsealing the vault
- vote for operator replacement
Recovery Operators do not participate in routine governance such as approving SDK clients, granting wallet visibility, approving transactions, creating grants, approving server updates, or changing Shamir parameters.
### 3.6 Sleeping and Waking Recovery Operators
By default, Recovery Operators are **sleeping** and do not participate in any active flow.
Any ordinary operator may request that Recovery Operators **wake up**.
Any ordinary operator may also cancel a pending wake-up request.
This creates a dispute window before recovery powers become active. The default wake-up delay is **14 days**.
Recovery Operators are therefore part of the break-glass recovery path rather than the normal operating quorum.
The high-level recovery flow is:
```mermaid
sequenceDiagram
autonumber
actor Op as Ordinary Operator
participant Server
actor Other as Other Operator
actor Rec as Recovery Operator
Op->>Server: Request recovery wake-up
Server-->>Op: Wake-up pending
Note over Server: Default dispute window: 14 days
alt Wake-up cancelled during dispute window
Other->>Server: Cancel wake-up
Server-->>Op: Recovery cancelled
Server-->>Rec: Stay sleeping
else No cancellation for 14 days
Server-->>Rec: Wake up
Rec->>Server: Join recovery flow
critical Recovery authority
Rec->>Server: Participate in unseal
Rec->>Server: Vote on operator replacement
end
Server-->>Op: Recovery mode active
end
```
### 3.7 Committee Formation
There are two ways to form a multi-operator committee:
- convert an existing single-operator vault by adding new operators
- bootstrap an unbootstrapped vault directly into multi-operator mode
In both cases, committee formation is a coordinated process. Arbiter does not allow multi-operator custody to emerge implicitly from unrelated registrations.
### 3.8 Bootstrapping an Unbootstrapped Vault into Multi-Operator Mode
When an unbootstrapped vault is initialized as a multi-operator vault, the setup proceeds as follows:
1. An operator connects to the unbootstrapped vault using an Operator and the bootstrap token.
2. During bootstrap setup, that operator declares:
- the total number of ordinary operators
- the total number of Recovery Operators
3. The vault enters **multi-bootstrap mode**.
4. While in multi-bootstrap mode:
- every ordinary operator must connect with an Operator using the bootstrap token
- every Recovery Operator must also connect using the bootstrap token
- each participant is registered individually
- each participant's share is created and protected with that participant's credentials
5. The vault is considered fully bootstrapped only after all declared operator and recovery-share registrations have completed successfully.
This means the operator and recovery set is fixed at bootstrap completion time, based on the counts declared when multi-bootstrap mode was entered.
### 3.9 Special Bootstrap Constraint for Two-Operator Vaults
If a vault is declared with exactly **2 ordinary operators**, Arbiter requires at least **1 Recovery Operator** to be configured during bootstrap.
This prevents the worst-case custody failure in which a `2-of-2` operator set becomes permanently unrecoverable after loss of a single operator.
---
## 4. Server Identity
The server proves its identity using TLS with a self-signed certificate. The TLS private key is generated on first run and is long-term; no rotation mechanism exists yet due to the complexity of multi-peer coordination.
Peers verify the server by its **public key fingerprint**:
- **Operator (local):** Receives the fingerprint automatically through the bootstrap token.
- **Operator (remote) / SDK Client:** Must receive the fingerprint out-of-band.
> A streamlined setup mechanism using a single connection string is planned but not yet implemented.
---
## 5. Key Management
### 5.1 Key Hierarchy
There are three layers of keys:
| Key | Encrypts | Encrypted by |
|---|---|---|
| **User key** (password) | Root key | — (derived from user input) |
| **Root key** | Wallet keys | User key |
| **Wallet keys** | — (used for signing) | Root key |
This layered design enables:
- **Password rotation** without re-encrypting every wallet key (only the root key is re-encrypted).
- **Root key rotation** without requiring the user to change their password.
### 5.2 Encryption at Rest
The database stores everything in encrypted form using symmetric AEAD. The encryption scheme is versioned to support transparent migration — when the vault unseals, Arbiter automatically re-encrypts any entries that are behind the current scheme version. See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the specific scheme and versioning mechanism.
---
## 6. Vault Lifecycle
### 6.1 Sealed State
On boot, the root key is encrypted and the server cannot perform any signing operations. This state is called **Sealed**.
### 6.2 Unseal Flow
To transition to the **Unsealed** state, an Operator must provide the password:
1. The Operator initiates an unseal request.
2. The server generates a one-time key pair and returns the public key.
3. The Operator encrypts the user's password with this one-time public key and sends the ciphertext to the server.
4. The server decrypts and verifies the password:
- **Success:** The root key is decrypted and placed into a hardened memory cell. The server transitions to `Unsealed`. Any entries pending encryption scheme migration are re-encrypted.
- **Failure:** The server returns an error indicating the password is incorrect.
### 6.3 Memory Protection
Once unsealed, the root key must be protected in memory against:
- Memory dumps
- Page swaps to disk
- Hibernation files
See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the current and planned memory protection approaches.
---
## 7. Permission Engine
### 7.1 Fundamental Rules
- SDK clients have **no access by default**.
- Access is granted **explicitly** by an Operator.
- Grants are scoped to **specific wallets** and governed by **policies**.
Each blockchain requires its own policy system due to differences in static transaction analysis. Currently, only EVM is supported; Solana support is planned.
Arbiter is also responsible for ensuring that **transaction nonces are never reused**.
### 7.2 EVM Policies
Every EVM grant is scoped to a specific **wallet** and **chain ID**.
#### 7.2.0 Transaction Signing Sequence
The high-level interaction order is:
```mermaid
sequenceDiagram
autonumber
actor SDK as SDK Client
participant Server
participant operator as Operator
SDK->>Server: SignTransactionRequest
Server->>Server: Resolve wallet and wallet visibility
alt Visibility approval required
Server->>operator: Ask for wallet visibility approval
operator-->>Server: Vote result
end
Server->>Server: Evaluate transaction
Server->>Server: Load grant and limits context
alt Grant approval required
Server->>operator: Ask for execution / grant approval
operator-->>Server: Vote result
opt Create persistent grant
Server->>Server: Create and store grant
end
Server->>Server: Retry evaluation
end
critical Final authorization path
Server->>Server: Check limits and record execution
Server-->>Server: Signature or evaluation error
end
Server-->>SDK: Signature or error
```
#### 7.2.1 Transaction Sub-Grants
Arbiter maintains an ever-expanding database of known contracts and their ABIs. Based on contract knowledge, transaction requests fall into three categories:
**1. Known contract (ABI available)**
The transaction can be decoded and presented with semantic meaning. For example: *"Client X wants to transfer Y USDT to address Z."*
Available restrictions:
- Volume limits (e.g., "no more than 10,000 tokens ever")
- Rate limits (e.g., "no more than 100 tokens per hour")
**2. Unknown contract (no ABI)**
The transaction cannot be decoded, so its effects are opaque — it could do anything, including draining all tokens. The user is warned, and if approved, access is granted to all interactions with the contract (matched by the `to` field).
Available restrictions:
- Transaction count limits (e.g., "no more than 100 transactions ever")
- Rate limits (e.g., "no more than 5 transactions per hour")
**3. Plain ether transfer (no calldata)**
These transactions have no `calldata` and therefore cannot interact with contracts. They can be subject to the same volume and rate restrictions as above.
#### 7.2.2 Global Limits
In addition to sub-grant-specific restrictions, the following limits can be applied across all grant types:
- **Gas limit** — Maximum gas per transaction.
- **Time-window restrictions** — e.g., signing allowed only 08:0020:00 on Mondays and Thursdays.

View File

@@ -1,183 +1,226 @@
# Implementation Details # Implementation Details
This document covers concrete technology choices and dependencies. For the architectural design, see [ARCHITECTURE.md](ARCHITECTURE.md). This document covers concrete technology choices and dependencies. For the architectural design, see [ARCHITECTURE.md](ARCHITECTURE.md).
--- ---
## Client Connection Flow ## Client Connection Flow
### Authentication Result Semantics ### Authentication Result Semantics
Authentication no longer uses an implicit success-only response shape. Both `client` and `user-agent` return explicit auth status enums over the wire. Authentication no longer uses an implicit success-only response shape. Both `client` and `operator` return explicit auth status enums over the wire.
- **Client:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `APPROVAL_DENIED`, `NO_USER_AGENTS_ONLINE`, or `INTERNAL` - **Client:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `APPROVAL_DENIED`, `NO_OPERATORS_ONLINE`, or `INTERNAL`
- **User-agent:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `BOOTSTRAP_REQUIRED`, `TOKEN_INVALID`, or `INTERNAL` - **Operator:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `BOOTSTRAP_REQUIRED`, `TOKEN_INVALID`, or `INTERNAL`
This makes transport-level failures and actor/domain-level auth failures distinct: This makes transport-level failures and actor/domain-level auth failures distinct:
- **Transport/protocol failures** are surfaced as stream/status errors - **Transport/protocol failures** are surfaced as stream/status errors
- **Authentication failures** are surfaced as successful protocol responses carrying an explicit auth status - **Authentication failures** are surfaced as successful protocol responses carrying an explicit auth status
Clients are expected to handle these status codes directly and present the concrete failure reason to the user. Clients are expected to handle these status codes directly and present the concrete failure reason to the user.
### New Client Approval ### New Client Approval
When a client whose public key is not yet in the database connects, all connected user agents are asked to approve the connection. The first agent to respond determines the outcome; remaining requests are cancelled via a watch channel. When a client whose public key is not yet in the database connects, all connected operators are asked to approve the connection. The first operator to respond determines the outcome; remaining requests are cancelled via a watch channel.
```mermaid ```mermaid
flowchart TD flowchart TD
A([Client connects]) --> B[Receive AuthChallengeRequest] A([Client connects]) --> B[Receive AuthChallengeRequest]
B --> C{pubkey in DB?} B --> C{pubkey in DB?}
C -- yes --> D[Read nonce\nIncrement nonce in DB] C -- yes --> G[Generate AuthChallenge]
D --> G
C -- no --> E[Ask all Operators:\nClientConnectionRequest]
C -- no --> E[Ask all UserAgents:\nClientConnectionRequest] E --> F{First response}
E --> F{First response} F -- denied --> Z([Reject connection])
F -- denied --> Z([Reject connection]) F -- approved --> F2[Cancel remaining\nOperator requests]
F -- approved --> F2[Cancel remaining\nUserAgent requests] F2 --> F3[INSERT client]
F2 --> F3[INSERT client\nnonce = 1] F3 --> G
F3 --> G[Send AuthChallenge\nwith nonce]
G --> H[Send AuthChallenge\ntimestamp + random bytes]
G --> H[Receive AuthChallengeSolution] H --> I[Receive AuthChallengeSolution]
H --> I{Signature valid?} I --> K{Signature valid?}
I -- no --> Z K -- no --> Z
I -- yes --> J([Session started]) K -- yes --> J([Session started])
``` ```
### Known Issue: Concurrent Registration Race (TOCTOU) Auth challenges are generated from fresh random bytes plus a nanosecond timestamp. The server keeps the issued challenge only in the in-flight authentication state for that connection, then verifies the signature against the same canonical challenge payload.
Two connections presenting the same previously-unknown public key can race through the approval flow simultaneously: The authentication schema stores peer identity, not replay counters:
1. Both check the DB → neither is registered. - `program_client` stores the SDK client's public key, metadata binding, and timestamps.
2. Both request approval from user agents → both receive approval. - `operator_client` stores the Operator public key and timestamps.
3. Both `INSERT` the client record → the second insert silently overwrites the first, resetting the nonce. - Neither table stores an authentication nonce, and challenge generation does not update either table.
This means the first connection's nonce is invalidated by the second, causing its challenge verification to fail. A fix requires either serialising new-client registration (e.g. an in-memory lock keyed on pubkey) or replacing the separate check + insert with an `INSERT OR IGNORE` / upsert guarded by a unique constraint on `public_key`. ---
### Nonce Semantics ## Cryptography
The `program_client.nonce` column stores the **next usable nonce** — i.e. it is always one ahead of the nonce last issued in a challenge. ### Authentication
- **Client protocol:** ML-DSA
- **New client:** inserted with `nonce = 1`; the first challenge is issued with `nonce = 0`.
- **Existing client:** the current DB value is read and used as the challenge nonce, then immediately incremented within the same exclusive transaction, preventing replay. ### User-Agent Authentication
--- Operator authentication supports multiple signature schemes because platform-provided "hardware-bound" keys do not expose a uniform algorithm across operating systems and hardware.
## Cryptography - **Supported schemes:** ML-DSA
- **Why:** Secure Enclave (MacOS) support them natively, on other platforms we could emulate while they roll-out
### Authentication
- **Signature scheme:** ed25519 ### Encryption at Rest
- **Scheme:** Symmetric AEAD — currently **XChaCha20-Poly1305**
### Encryption at Rest - **Version tracking:** Each `aead_encrypted` database entry carries a `scheme` field denoting the version, enabling transparent migration on unseal
- **Scheme:** Symmetric AEAD — currently **XChaCha20-Poly1305**
- **Version tracking:** Each `aead_encrypted` database entry carries a `scheme` field denoting the version, enabling transparent migration on unseal ### Server Identity
- **Transport:** TLS with a self-signed certificate
### Server Identity - **Key type:** Generated on first run; long-term (no rotation mechanism yet)
- **Transport:** TLS with a self-signed certificate
- **Key type:** Generated on first run; long-term (no rotation mechanism yet) ---
--- ## Communication
## Communication - **Protocol:** gRPC with Protocol Buffers
- **Request/response matching:** multiplexed over a single bidirectional stream using per-connection request IDs
- **Protocol:** gRPC with Protocol Buffers - **Server identity distribution:** `ServerInfo` protobuf struct containing the TLS public key fingerprint
- **Request/response matching:** multiplexed over a single bidirectional stream using per-connection request IDs - **Future consideration:** grpc-web lacks bidirectional stream support, so a browser-based wallet may require protojson over WebSocket
- **Server identity distribution:** `ServerInfo` protobuf struct containing the TLS public key fingerprint
- **Future consideration:** grpc-web lacks bidirectional stream support, so a browser-based wallet may require protojson over WebSocket ### Request Multiplexing
### Request Multiplexing Both `client` and `operator` connections support multiple in-flight requests over one gRPC bidi stream.
Both `client` and `user-agent` connections support multiple in-flight requests over one gRPC bidi stream. - Every request carries a monotonically increasing request ID
- Every normal response echoes the request ID it corresponds to
- Every request carries a monotonically increasing request ID - Out-of-band server messages omit the response ID entirely
- Every normal response echoes the request ID it corresponds to - The server rejects already-seen request IDs at the transport adapter boundary before business logic sees the message
- Out-of-band server messages omit the response ID entirely
- The server rejects already-seen request IDs at the transport adapter boundary before business logic sees the message This keeps request correlation entirely in transport/client connection code while leaving actor and domain handlers unaware of request IDs.
This keeps request correlation entirely in transport/client connection code while leaving actor and domain handlers unaware of request IDs. ---
--- ## EVM Policy Engine
## EVM Policy Engine ### Overview
### Overview The EVM engine classifies incoming transactions, enforces grant constraints, and records executions. It is the sole path through which a wallet key is used for signing.
The EVM engine classifies incoming transactions, enforces grant constraints, and records executions. It is the sole path through which a wallet key is used for signing. The central abstraction is the `Policy` trait. Each implementation handles one semantic transaction category and owns its own database tables for grant storage and transaction logging.
The central abstraction is the `Policy` trait. Each implementation handles one semantic transaction category and owns its own database tables for grant storage and transaction logging. ### Transaction Evaluation Flow
### Transaction Evaluation Flow `Engine::evaluate_transaction` runs the following steps in order:
`Engine::evaluate_transaction` runs the following steps in order: 1. **Classify** — Each registered policy's `analyze(context)` inspects the transaction fields (`chain`, `to`, `value`, `calldata`). The first one returning `Some(meaning)` wins. If none match, the transaction is rejected as `UnsupportedTransactionType`.
2. **Find grant**`Policy::try_find_grant` queries for a non-revoked grant covering this wallet, client, chain, and target address.
1. **Classify** — Each registered policy's `analyze(context)` inspects the transaction fields (`chain`, `to`, `value`, `calldata`). The first one returning `Some(meaning)` wins. If none match, the transaction is rejected as `UnsupportedTransactionType`. 3. **Check shared constraints**`check_shared_constraints` runs in the engine before any policy-specific logic. It enforces the validity window, gas fee caps, and transaction count rate limit (see below).
2. **Find grant**`Policy::try_find_grant` queries for a non-revoked grant covering this wallet, client, chain, and target address. 4. **Evaluate**`Policy::evaluate` checks the decoded meaning against the grant's policy-specific constraints and returns any violations.
3. **Check shared constraints**`check_shared_constraints` runs in the engine before any policy-specific logic. It enforces the validity window, gas fee caps, and transaction count rate limit (see below). 5. **Record** — If `RunKind::Execution` and there are no violations, the engine writes to `evm_transaction_log` and calls `Policy::record_transaction` for any policy-specific logging (e.g., token transfer volume).
4. **Evaluate**`Policy::evaluate` checks the decoded meaning against the grant's policy-specific constraints and returns any violations.
5. **Record** — If `RunKind::Execution` and there are no violations, the engine writes to `evm_transaction_log` and calls `Policy::record_transaction` for any policy-specific logging (e.g., token transfer volume). The detailed branch structure is shown below:
### Policy Trait ```mermaid
flowchart TD
| Method | Purpose | A[SDK Client sends sign transaction request] --> B[Server resolves wallet]
|---|---| B --> C{Wallet exists?}
| `analyze` | Pure — classifies a transaction into a typed `Meaning`, or `None` if this policy doesn't apply |
| `evaluate` | Checks the `Meaning` against a `Grant`; returns a list of `EvalViolation`s | C -- No --> Z1[Return wallet not found error]
| `create_grant` | Inserts policy-specific rows; returns the specific grant ID | C -- Yes --> D[Check SDK client wallet visibility]
| `try_find_grant` | Finds a matching non-revoked grant for the given `EvalContext` |
| `find_all_grants` | Returns all non-revoked grants (used for listing) | D --> E{Wallet visible to SDK client?}
| `record_transaction` | Persists policy-specific data after execution | E -- No --> F[Start wallet visibility voting flow]
F --> G{Vote approved?}
`analyze` and `evaluate` are intentionally separate: classification is pure and cheap, while evaluation may involve DB queries (e.g., fetching past transfer volume). G -- No --> Z2[Return wallet access denied error]
G -- Yes --> H[Persist wallet visibility]
### Registered Policies E -- Yes --> I[Classify transaction meaning]
H --> I
**EtherTransfer** — plain ETH transfers (empty calldata)
I --> J{Meaning supported?}
- Grant requires: allowlist of recipient addresses + one volumetric rate limit (max ETH over a time window) J -- No --> Z3[Return unsupported transaction error]
- Violations: recipient not in allowlist, cumulative ETH volume exceeded J -- Yes --> K[Find matching grant]
**TokenTransfer** — ERC-20 `transfer(address,uint256)` calls K --> L{Grant exists?}
L -- Yes --> M[Check grant limits]
- Recognised by ABI-decoding the `transfer(address,uint256)` selector against a static registry of known token contracts (`arbiter_tokens_registry`) L -- No --> N[Start execution or grant voting flow]
- Grant requires: token contract address, optional recipient restriction, zero or more volumetric rate limits
- Violations: recipient mismatch, any volumetric limit exceeded N --> O{Operator decision}
O -- Reject --> Z4[Return no matching grant error]
### Grant Model O -- Allow once --> M
O -- Create grant --> P[Create grant with user-selected limits]
Every grant has two layers: P --> Q[Persist grant]
Q --> M
- **Shared (`evm_basic_grant`)** — wallet, chain, validity period, gas fee caps, transaction count rate limit. One row per grant regardless of type.
- **Specific** — policy-owned tables (`evm_ether_transfer_grant`, `evm_token_transfer_grant`, etc.) holding type-specific configuration. M --> R{Limits exceeded?}
R -- Yes --> Z5[Return evaluation error]
`find_all_grants` uses a `#[diesel::auto_type]` base join between the specific and shared tables, then batch-loads related rows (targets, volume limits) in two additional queries to avoid N+1. R -- No --> S[Record transaction in logs]
S --> T[Produce signature]
The engine exposes `list_all_grants` which collects across all policy types into `Vec<Grant<SpecificGrant>>` via a blanket `From<Grant<S>> for Grant<SpecificGrant>` conversion. T --> U[Return signature to SDK client]
### Shared Constraints (enforced by the engine) note1[Limit checks include volume, count, and gas constraints.]
note2[Grant lookup depends on classified meaning, such as ether transfer or token transfer.]
These are checked centrally in `check_shared_constraints` before policy evaluation:
K -. uses .-> note2
| Constraint | Fields | Behaviour | M -. checks .-> note1
|---|---|---| ```
| Validity window | `valid_from`, `valid_until` | Emits `InvalidTime` if current time is outside the range |
| Gas fee cap | `max_gas_fee_per_gas`, `max_priority_fee_per_gas` | Emits `GasLimitExceeded` if either cap is breached | ### Policy Trait
| Tx count rate limit | `rate_limit` (`count` + `window`) | Counts rows in `evm_transaction_log` within the window; emits `RateLimitExceeded` if at or above the limit |
| Method | Purpose |
--- |---|---|
| `analyze` | Pure — classifies a transaction into a typed `Meaning`, or `None` if this policy doesn't apply |
### Known Limitations | `evaluate` | Checks the `Meaning` against a `Grant`; returns a list of `EvalViolation`s |
| `create_grant` | Inserts policy-specific rows; returns the specific grant ID |
- **Only EIP-1559 transactions are supported.** Legacy and EIP-2930 types are rejected outright. | `try_find_grant` | Finds a matching non-revoked grant for the given `EvalContext` |
- **No opaque-calldata (unknown contract) grant type.** The architecture describes a category for unrecognised contracts, but no policy implements it yet. Any transaction that is not a plain ETH transfer or a known ERC-20 transfer is unconditionally rejected. | `find_all_grants` | Returns all non-revoked grants (used for listing) |
- **Token registry is static.** Tokens are recognised only if they appear in the hard-coded `arbiter_tokens_registry` crate. There is no mechanism to register additional contracts at runtime. | `record_transaction` | Persists policy-specific data after execution |
- **Nonce management is not implemented.** The architecture lists nonce deduplication as a core responsibility, but no nonce tracking or enforcement exists yet.
`analyze` and `evaluate` are intentionally separate: classification is pure and cheap, while evaluation may involve DB queries (e.g., fetching past transfer volume).
---
### Registered Policies
## Memory Protection
**EtherTransfer** — plain ETH transfers (empty calldata)
The unsealed root key must be held in a hardened memory cell resistant to dumps, page swaps, and hibernation.
- Grant requires: allowlist of recipient addresses + one volumetric rate limit (max ETH over a time window)
- **Current:** Using the `memsafe` crate as an interim solution - Violations: recipient not in allowlist, cumulative ETH volume exceeded
- **Planned:** Custom implementation based on `mlock` (Unix) and `VirtualProtect` (Windows)
**TokenTransfer** — ERC-20 `transfer(address,uint256)` calls
- Recognised by ABI-decoding the `transfer(address,uint256)` selector against a static registry of known token contracts (`arbiter_tokens_registry`)
- Grant requires: token contract address, optional recipient restriction, zero or more volumetric rate limits
- Violations: recipient mismatch, any volumetric limit exceeded
### Grant Model
Every grant has two layers:
- **Shared (`evm_basic_grant`)** — wallet, chain, validity period, gas fee caps, transaction count rate limit. One row per grant regardless of type.
- **Specific** — policy-owned tables (`evm_ether_transfer_grant`, `evm_token_transfer_grant`) holding type-specific configuration.
`find_all_grants` uses a `#[diesel::auto_type]` base join between the specific and shared tables, then batch-loads related rows (targets, volume limits) in two additional queries to avoid N+1.
The engine exposes `list_all_grants` which collects across all policy types into `Vec<Grant<SpecificGrant>>` via a blanket `From<Grant<S>> for Grant<SpecificGrant>` conversion.
### Shared Constraints (enforced by the engine)
These are checked centrally in `check_shared_constraints` before policy evaluation:
| Constraint | Fields | Behaviour |
|---|---|---|
| Validity window | `valid_from`, `valid_until` | Emits `InvalidTime` if current time is outside the range |
| Gas fee cap | `max_gas_fee_per_gas`, `max_priority_fee_per_gas` | Emits `GasLimitExceeded` if either cap is breached |
| Tx count rate limit | `rate_limit` (`count` + `window`) | Counts rows in `evm_transaction_log` within the window; emits `RateLimitExceeded` if at or above the limit |
---
### Known Limitations
- **Only EIP-1559 transactions are supported.** Legacy and EIP-2930 types are rejected outright.
- **No opaque-calldata (unknown contract) grant type.** The architecture describes a category for unrecognised contracts, but no policy implements it yet. Any transaction that is not a plain ETH transfer or a known ERC-20 transfer is unconditionally rejected.
- **Token registry is static.** Tokens are recognised only if they appear in the hard-coded `arbiter_tokens_registry` crate. There is no mechanism to register additional contracts at runtime.
---
## Memory Protection
The unsealed root key must be held in a hardened memory cell resistant to dumps, page swaps, and hibernation.
- **Current:** A dedicated memory-protection abstraction is in place, with `memsafe` used behind that abstraction today
- **Planned:** Additional backends can be introduced behind the same abstraction, including a custom implementation based on `mlock` (Unix) and `VirtualProtect` (Windows)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,821 @@
# Grant Grid View Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an "EVM Grants" dashboard tab that displays all grants as enriched cards (type, chain, wallet address, client name) with per-card revoke support.
**Architecture:** A new `walletAccessListProvider` fetches wallet accesses with their DB row IDs. The screen (`grants.dart`) watches only `evmGrantsProvider` for top-level state. Each `GrantCard` widget (its own file) watches enrichment providers (`walletAccessListProvider`, `evmProvider`, `sdkClientsProvider`) and the revoke mutation directly — keeping rebuilds scoped to the card. The screen is registered as a dashboard tab in `AdaptiveScaffold`.
**Tech Stack:** Flutter, Riverpod (`riverpod_annotation` + `build_runner` codegen), `sizer` (adaptive sizing), `auto_route`, Protocol Buffers (Dart), `Palette` design tokens.
---
## File Map
| File | Action | Responsibility |
|---|---|---|
| `operator/lib/theme/palette.dart` | Modify | Add `Palette.token` (indigo accent for token-transfer cards) |
| `operator/lib/features/connection/evm/wallet_access.dart` | Modify | Add `listAllWalletAccesses()` function |
| `operator/lib/providers/sdk_clients/wallet_access_list.dart` | Create | `WalletAccessListProvider` — fetches full wallet access list with IDs |
| `operator/lib/screens/dashboard/evm/grants/widgets/grant_card.dart` | Create | `GrantCard` widget — watches enrichment providers + revoke mutation; one card per grant |
| `operator/lib/screens/dashboard/evm/grants/grants.dart` | Create | `EvmGrantsScreen` — watches `evmGrantsProvider`; handles loading/error/empty/data states; renders `GrantCard` list |
| `operator/lib/router.dart` | Modify | Register `EvmGrantsRoute` in dashboard children |
| `operator/lib/screens/dashboard.dart` | Modify | Add Grants entry to `routes` list and `NavigationDestination` list |
---
## Task 1: Add `Palette.token`
**Files:**
- Modify: `operator/lib/theme/palette.dart`
- [ ] **Step 1: Add the color**
Replace the contents of `operator/lib/theme/palette.dart` with:
```dart
import 'package:flutter/material.dart';
class Palette {
static const ink = Color(0xFF15263C);
static const coral = Color(0xFFE26254);
static const cream = Color(0xFFFFFAF4);
static const line = Color(0x1A15263C);
static const token = Color(0xFF5C6BC0);
}
```
- [ ] **Step 2: Verify**
```sh
cd operator && flutter analyze lib/theme/palette.dart
```
Expected: no issues.
- [ ] **Step 3: Commit**
```sh
jj describe -m "feat(theme): add Palette.token for token-transfer grant cards"
jj new
```
---
## Task 2: Add `listAllWalletAccesses` feature function
**Files:**
- Modify: `operator/lib/features/connection/evm/wallet_access.dart`
`readClientWalletAccess` (existing) filters the list to one client's wallet IDs and returns `Set<int>`. This new function returns the complete unfiltered list with row IDs so the grant cards can resolve wallet_access_id → wallet + client.
- [ ] **Step 1: Append function**
Add at the bottom of `operator/lib/features/connection/evm/wallet_access.dart`:
```dart
Future<List<SdkClientWalletAccess>> listAllWalletAccesses(
Connection connection,
) async {
final response = await connection.ask(
OperatorRequest(listWalletAccess: Empty()),
);
if (!response.hasListWalletAccessResponse()) {
throw Exception(
'Expected list wallet access response, got ${response.whichPayload()}',
);
}
return response.listWalletAccessResponse.accesses.toList(growable: false);
}
```
Each returned `SdkClientWalletAccess` has:
- `.id` — the `evm_wallet_access` row ID (same value as `wallet_access_id` in a `GrantEntry`)
- `.access.walletId` — the EVM wallet DB ID
- `.access.sdkClientId` — the SDK client DB ID
- [ ] **Step 2: Verify**
```sh
cd operator && flutter analyze lib/features/connection/evm/wallet_access.dart
```
Expected: no issues.
- [ ] **Step 3: Commit**
```sh
jj describe -m "feat(evm): add listAllWalletAccesses feature function"
jj new
```
---
## Task 3: Create `WalletAccessListProvider`
**Files:**
- Create: `operator/lib/providers/sdk_clients/wallet_access_list.dart`
- Generated: `operator/lib/providers/sdk_clients/wallet_access_list.g.dart`
Mirrors the structure of `EvmGrants` in `providers/evm/evm_grants.dart` — class-based `@riverpod` with a `refresh()` method.
- [ ] **Step 1: Write the provider**
Create `operator/lib/providers/sdk_clients/wallet_access_list.dart`:
```dart
import 'package:arbiter/features/connection/evm/wallet_access.dart';
import 'package:arbiter/proto/operator.pb.dart';
import 'package:arbiter/providers/connection/connection_manager.dart';
import 'package:mtcore/markettakers.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'wallet_access_list.g.dart';
@riverpod
class WalletAccessList extends _$WalletAccessList {
@override
Future<List<SdkClientWalletAccess>?> build() async {
final connection = await ref.watch(connectionManagerProvider.future);
if (connection == null) {
return null;
}
try {
return await listAllWalletAccesses(connection);
} catch (e, st) {
talker.handle(e, st);
rethrow;
}
}
Future<void> refresh() async {
final connection = await ref.read(connectionManagerProvider.future);
if (connection == null) {
state = const AsyncData(null);
return;
}
state = const AsyncLoading();
state = await AsyncValue.guard(() => listAllWalletAccesses(connection));
}
}
```
- [ ] **Step 2: Run code generation**
```sh
cd operator && dart run build_runner build --delete-conflicting-outputs
```
Expected: `operator/lib/providers/sdk_clients/wallet_access_list.g.dart` created. No errors.
- [ ] **Step 3: Verify**
```sh
cd operator && flutter analyze lib/providers/sdk_clients/
```
Expected: no issues.
- [ ] **Step 4: Commit**
```sh
jj describe -m "feat(providers): add WalletAccessListProvider"
jj new
```
---
## Task 4: Create `GrantCard` widget
**Files:**
- Create: `operator/lib/screens/dashboard/evm/grants/widgets/grant_card.dart`
This widget owns all per-card logic: enrichment lookups, revoke action, and rebuild scope. The screen only passes it a `GrantEntry` — the card fetches everything else itself.
**Key types:**
- `GrantEntry` (from `proto/evm.pb.dart`): `.id`, `.shared.walletAccessId`, `.shared.chainId`, `.specific.whichGrant()`
- `SpecificGrant_Grant.etherTransfer` / `.tokenTransfer` — enum values for the oneof
- `SdkClientWalletAccess` (from `proto/operator.pb.dart`): `.id`, `.access.walletId`, `.access.sdkClientId`
- `WalletEntry` (from `proto/evm.pb.dart`): `.id`, `.address` (List<int>)
- `SdkClientEntry` (from `proto/operator.pb.dart`): `.id`, `.info.name`
- `revokeEvmGrantMutation``Mutation<void>` (global; all revoke buttons disable together while any revoke is in flight)
- `executeRevokeEvmGrant(ref, grantId: int)``Future<void>`
- [ ] **Step 1: Write the widget**
Create `operator/lib/screens/dashboard/evm/grants/widgets/grant_card.dart`:
```dart
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/proto/operator.pb.dart';
import 'package:arbiter/providers/evm/evm.dart';
import 'package:arbiter/providers/evm/evm_grants.dart';
import 'package:arbiter/providers/sdk_clients/list.dart';
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
String _shortAddress(List<int> bytes) {
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
return '0x${hex.substring(0, 6)}...${hex.substring(hex.length - 4)}';
}
String _formatError(Object error) {
final message = error.toString();
if (message.startsWith('Exception: ')) {
return message.substring('Exception: '.length);
}
return message;
}
class GrantCard extends ConsumerWidget {
const GrantCard({super.key, required this.grant});
final GrantEntry grant;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Enrichment lookups — each watch scopes rebuilds to this card only
final walletAccesses =
ref.watch(walletAccessListProvider).asData?.value ?? const [];
final wallets = ref.watch(evmProvider).asData?.value ?? const [];
final clients = ref.watch(sdkClientsProvider).asData?.value ?? const [];
final revoking = ref.watch(revokeEvmGrantMutation) is MutationPending;
final isEther =
grant.specific.whichGrant() == SpecificGrant_Grant.etherTransfer;
final accent = isEther ? Palette.coral : Palette.token;
final typeLabel = isEther ? 'Ether' : 'Token';
final theme = Theme.of(context);
final muted = Palette.ink.withValues(alpha: 0.62);
// Resolve wallet_access_id → wallet address + client name
final accessById = <int, SdkClientWalletAccess>{
for (final a in walletAccesses) a.id: a,
};
final walletById = <int, WalletEntry>{
for (final w in wallets) w.id: w,
};
final clientNameById = <int, String>{
for (final c in clients) c.id: c.info.name,
};
final accessId = grant.shared.walletAccessId;
final access = accessById[accessId];
final wallet = access != null ? walletById[access.access.walletId] : null;
final walletLabel = wallet != null
? _shortAddress(wallet.address)
: 'Access #$accessId';
final clientLabel = () {
if (access == null) return '';
final name = clientNameById[access.access.sdkClientId] ?? '';
return name.isEmpty ? 'Client #${access.access.sdkClientId}' : name;
}();
void showError(String message) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
Future<void> revoke() async {
try {
await executeRevokeEvmGrant(ref, grantId: grant.id);
} catch (e) {
showError(_formatError(e));
}
}
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Palette.cream.withValues(alpha: 0.92),
border: Border.all(color: Palette.line),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Accent strip
Container(
width: 0.8.w,
decoration: BoxDecoration(
color: accent,
borderRadius: const BorderRadius.horizontal(
left: Radius.circular(24),
),
),
),
// Card body
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 1.6.w,
vertical: 1.4.h,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Row 1: type badge · chain · spacer · revoke button
Row(
children: [
Container(
padding: EdgeInsets.symmetric(
horizontal: 1.w,
vertical: 0.4.h,
),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
typeLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: accent,
fontWeight: FontWeight.w800,
),
),
),
SizedBox(width: 1.w),
Container(
padding: EdgeInsets.symmetric(
horizontal: 1.w,
vertical: 0.4.h,
),
decoration: BoxDecoration(
color: Palette.ink.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Chain ${grant.shared.chainId}',
style: theme.textTheme.labelSmall?.copyWith(
color: muted,
fontWeight: FontWeight.w700,
),
),
),
const Spacer(),
if (revoking)
SizedBox(
width: 1.8.h,
height: 1.8.h,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Palette.coral,
),
)
else
OutlinedButton.icon(
onPressed: revoke,
style: OutlinedButton.styleFrom(
foregroundColor: Palette.coral,
side: BorderSide(
color: Palette.coral.withValues(alpha: 0.4),
),
padding: EdgeInsets.symmetric(
horizontal: 1.w,
vertical: 0.6.h,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
icon: const Icon(Icons.block_rounded, size: 16),
label: const Text('Revoke'),
),
],
),
SizedBox(height: 0.8.h),
// Row 2: wallet address · client name
Row(
children: [
Text(
walletLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: Palette.ink,
fontFamily: 'monospace',
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 0.8.w),
child: Text(
'·',
style: theme.textTheme.bodySmall
?.copyWith(color: muted),
),
),
Expanded(
child: Text(
clientLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall
?.copyWith(color: muted),
),
),
],
),
],
),
),
),
],
),
),
);
}
}
```
- [ ] **Step 2: Verify**
```sh
cd operator && flutter analyze lib/screens/dashboard/evm/grants/widgets/grant_card.dart
```
Expected: no issues.
- [ ] **Step 3: Commit**
```sh
jj describe -m "feat(grants): add GrantCard widget with self-contained enrichment"
jj new
```
---
## Task 5: Create `EvmGrantsScreen`
**Files:**
- Create: `operator/lib/screens/dashboard/evm/grants/grants.dart`
The screen watches only `evmGrantsProvider` for top-level state (loading / error / no connection / empty / data). When there is data it renders a list of `GrantCard` widgets — each card manages its own enrichment subscriptions.
- [ ] **Step 1: Write the screen**
Create `operator/lib/screens/dashboard/evm/grants/grants.dart`:
```dart
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/providers/evm/evm_grants.dart';
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
import 'package:arbiter/router.gr.dart';
import 'package:arbiter/screens/dashboard/evm/grants/widgets/grant_card.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/page_header.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
String _formatError(Object error) {
final message = error.toString();
if (message.startsWith('Exception: ')) {
return message.substring('Exception: '.length);
}
return message;
}
// ─── State panel ──────────────────────────────────────────────────────────────
class _StatePanel extends StatelessWidget {
const _StatePanel({
required this.icon,
required this.title,
required this.body,
this.actionLabel,
this.onAction,
this.busy = false,
});
final IconData icon;
final String title;
final String body;
final String? actionLabel;
final Future<void> Function()? onAction;
final bool busy;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Palette.cream.withValues(alpha: 0.92),
border: Border.all(color: Palette.line),
),
child: Padding(
padding: EdgeInsets.all(2.8.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (busy)
SizedBox(
width: 2.8.h,
height: 2.8.h,
child: const CircularProgressIndicator(strokeWidth: 2.5),
)
else
Icon(icon, size: 34, color: Palette.coral),
SizedBox(height: 1.8.h),
Text(
title,
style: theme.textTheme.headlineSmall?.copyWith(
color: Palette.ink,
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 1.h),
Text(
body,
style: theme.textTheme.bodyLarge?.copyWith(
color: Palette.ink.withValues(alpha: 0.72),
height: 1.5,
),
),
if (actionLabel != null && onAction != null) ...[
SizedBox(height: 2.h),
OutlinedButton.icon(
onPressed: () => onAction!(),
icon: const Icon(Icons.refresh),
label: Text(actionLabel!),
),
],
],
),
),
);
}
}
// ─── Grant list ───────────────────────────────────────────────────────────────
class _GrantList extends StatelessWidget {
const _GrantList({required this.grants});
final List<GrantEntry> grants;
@override
Widget build(BuildContext context) {
return Column(
children: [
for (var i = 0; i < grants.length; i++)
Padding(
padding: EdgeInsets.only(
bottom: i == grants.length - 1 ? 0 : 1.8.h,
),
child: GrantCard(grant: grants[i]),
),
],
);
}
}
// ─── Screen ───────────────────────────────────────────────────────────────────
@RoutePage()
class EvmGrantsScreen extends ConsumerWidget {
const EvmGrantsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Screen watches only the grant list for top-level state decisions
final grantsAsync = ref.watch(evmGrantsProvider);
Future<void> refresh() async {
await Future.wait([
ref.read(evmGrantsProvider.notifier).refresh(),
ref.read(walletAccessListProvider.notifier).refresh(),
]);
}
void showMessage(String message) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
Future<void> safeRefresh() async {
try {
await refresh();
} catch (e) {
showMessage(_formatError(e));
}
}
final grantsState = grantsAsync.asData?.value;
final grants = grantsState?.grants;
final content = switch (grantsAsync) {
AsyncLoading() when grantsState == null => const _StatePanel(
icon: Icons.hourglass_top,
title: 'Loading grants',
body: 'Pulling grant registry from Arbiter.',
busy: true,
),
AsyncError(:final error) => _StatePanel(
icon: Icons.sync_problem,
title: 'Grant registry unavailable',
body: _formatError(error),
actionLabel: 'Retry',
onAction: safeRefresh,
),
AsyncData(:final value) when value == null => _StatePanel(
icon: Icons.portable_wifi_off,
title: 'No active server connection',
body: 'Reconnect to Arbiter to list EVM grants.',
actionLabel: 'Refresh',
onAction: safeRefresh,
),
_ when grants != null && grants.isEmpty => _StatePanel(
icon: Icons.policy_outlined,
title: 'No grants yet',
body: 'Create a grant to allow SDK clients to sign transactions.',
actionLabel: 'Create grant',
onAction: () => context.router.push(const CreateEvmGrantRoute()),
),
_ => _GrantList(grants: grants ?? const []),
};
return Scaffold(
body: SafeArea(
child: RefreshIndicator.adaptive(
color: Palette.ink,
backgroundColor: Colors.white,
onRefresh: safeRefresh,
child: ListView(
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
padding: EdgeInsets.fromLTRB(2.4.w, 2.4.h, 2.4.w, 3.2.h),
children: [
PageHeader(
title: 'EVM Grants',
isBusy: grantsAsync.isLoading,
actions: [
FilledButton.icon(
onPressed: () =>
context.router.push(const CreateEvmGrantRoute()),
icon: const Icon(Icons.add_rounded),
label: const Text('Create grant'),
),
SizedBox(width: 1.w),
OutlinedButton.icon(
onPressed: safeRefresh,
style: OutlinedButton.styleFrom(
foregroundColor: Palette.ink,
side: BorderSide(color: Palette.line),
padding: EdgeInsets.symmetric(
horizontal: 1.4.w,
vertical: 1.2.h,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Refresh'),
),
],
),
SizedBox(height: 1.8.h),
content,
],
),
),
),
);
}
}
```
- [ ] **Step 2: Verify**
```sh
cd operator && flutter analyze lib/screens/dashboard/evm/grants/
```
Expected: no issues.
- [ ] **Step 3: Commit**
```sh
jj describe -m "feat(grants): add EvmGrantsScreen"
jj new
```
---
## Task 6: Wire router and dashboard tab
**Files:**
- Modify: `operator/lib/router.dart`
- Modify: `operator/lib/screens/dashboard.dart`
- Regenerated: `operator/lib/router.gr.dart`
- [ ] **Step 1: Add route to `router.dart`**
Replace the contents of `operator/lib/router.dart` with:
```dart
import 'package:auto_route/auto_route.dart';
import 'router.gr.dart';
@AutoRouterConfig(generateForDir: ['lib/screens'])
class Router extends RootStackRouter {
@override
List<AutoRoute> get routes => [
AutoRoute(page: Bootstrap.page, path: '/bootstrap', initial: true),
AutoRoute(page: ServerInfoSetupRoute.page, path: '/server-info'),
AutoRoute(page: ServerConnectionRoute.page, path: '/server-connection'),
AutoRoute(page: VaultSetupRoute.page, path: '/vault'),
AutoRoute(page: ClientDetailsRoute.page, path: '/clients/:clientId'),
AutoRoute(page: CreateEvmGrantRoute.page, path: '/evm-grants/create'),
AutoRoute(
page: DashboardRouter.page,
path: '/dashboard',
children: [
AutoRoute(page: EvmRoute.page, path: 'evm'),
AutoRoute(page: ClientsRoute.page, path: 'clients'),
AutoRoute(page: EvmGrantsRoute.page, path: 'grants'),
AutoRoute(page: AboutRoute.page, path: 'about'),
],
),
];
}
```
- [ ] **Step 2: Update `dashboard.dart`**
In `operator/lib/screens/dashboard.dart`, replace the `routes` constant:
```dart
final routes = [
const EvmRoute(),
const ClientsRoute(),
const EvmGrantsRoute(),
const AboutRoute(),
];
```
And replace the `destinations` list inside `AdaptiveScaffold`:
```dart
destinations: const [
NavigationDestination(
icon: Icon(Icons.account_balance_wallet_outlined),
selectedIcon: Icon(Icons.account_balance_wallet),
label: 'Wallets',
),
NavigationDestination(
icon: Icon(Icons.devices_other_outlined),
selectedIcon: Icon(Icons.devices_other),
label: 'Clients',
),
NavigationDestination(
icon: Icon(Icons.policy_outlined),
selectedIcon: Icon(Icons.policy),
label: 'Grants',
),
NavigationDestination(
icon: Icon(Icons.info_outline),
selectedIcon: Icon(Icons.info),
label: 'About',
),
],
```
- [ ] **Step 3: Regenerate router**
```sh
cd operator && dart run build_runner build --delete-conflicting-outputs
```
Expected: `lib/router.gr.dart` updated, `EvmGrantsRoute` now available, no errors.
- [ ] **Step 4: Full project verify**
```sh
cd operator && flutter analyze
```
Expected: no issues.
- [ ] **Step 5: Commit**
```sh
jj describe -m "feat(nav): add Grants dashboard tab"
jj new
```

View File

@@ -0,0 +1,170 @@
# Grant Grid View — Design Spec
**Date:** 2026-03-28
## Overview
Add a "Grants" dashboard tab to the Flutter operator app that displays all EVM grants as a card-based grid. Each card shows a compact summary (type, chain, wallet address, client name) with a revoke action. The tab integrates into the existing `AdaptiveScaffold` navigation alongside Wallets, Clients, and About.
## Scope
- New `walletAccessListProvider` for fetching wallet access entries with their DB row IDs
- New `EvmGrantsScreen` as a dashboard tab
- Grant card widget with enriched display (type, chain, wallet, client)
- Revoke action wired to existing `executeRevokeEvmGrant` mutation
- Dashboard tab bar and router updated
- New token-transfer accent color added to `Palette`
**Out of scope:** Fixing grant creation (separate task).
---
## Data Layer
### `walletAccessListProvider`
**File:** `operator/lib/providers/sdk_clients/wallet_access_list.dart`
- `@riverpod` class, watches `connectionManagerProvider.future`
- Returns `List<SdkClientWalletAccess>?` (null when not connected)
- Each entry: `.id` (wallet_access_id), `.access.walletId`, `.access.sdkClientId`
- Exposes a `refresh()` method following the same pattern as `EvmGrants.refresh()`
### Enrichment at render time (Approach A)
The `EvmGrantsScreen` watches four providers:
1. `evmGrantsProvider` — the grant list
2. `walletAccessListProvider` — to resolve wallet_access_id → (wallet_id, sdk_client_id)
3. `evmProvider` — to resolve wallet_id → wallet address
4. `sdkClientsProvider` — to resolve sdk_client_id → client name
All lookups are in-memory Maps built inside the build method; no extra model class needed.
Fallbacks:
- Wallet address not found → `"Access #N"` where N is the wallet_access_id
- Client name not found → `"Client #N"` where N is the sdk_client_id
---
## Route Structure
```
/dashboard
/evm ← existing (Wallets tab)
/clients ← existing (Clients tab)
/grants ← NEW (Grants tab)
/about ← existing
/evm-grants/create ← existing push route (unchanged)
```
### Changes to `router.dart`
Add inside dashboard children:
```dart
AutoRoute(page: EvmGrantsRoute.page, path: 'grants'),
```
### Changes to `dashboard.dart`
Add to `routes` list:
```dart
const EvmGrantsRoute()
```
Add `NavigationDestination`:
```dart
NavigationDestination(
icon: Icon(Icons.policy_outlined),
selectedIcon: Icon(Icons.policy),
label: 'Grants',
),
```
---
## Screen: `EvmGrantsScreen`
**File:** `operator/lib/screens/dashboard/evm/grants/grants.dart`
```
Scaffold
└─ SafeArea
└─ RefreshIndicator.adaptive (refreshes evmGrantsProvider + walletAccessListProvider)
└─ ListView (BouncingScrollPhysics + AlwaysScrollableScrollPhysics)
├─ PageHeader
│ title: 'EVM Grants'
│ isBusy: evmGrantsProvider.isLoading
│ actions: [CreateGrantButton, RefreshButton]
├─ SizedBox(height: 1.8.h)
└─ <content>
```
### State handling
Matches the pattern from `EvmScreen` and `ClientsScreen`:
| State | Display |
|---|---|
| Loading (no data yet) | `_StatePanel` with spinner, "Loading grants" |
| Error | `_StatePanel` with coral icon, error message, Retry button |
| No connection | `_StatePanel`, "No active server connection" |
| Empty list | `_StatePanel`, "No grants yet", with Create Grant shortcut |
| Data | Column of `_GrantCard` widgets |
### Header actions
**CreateGrantButton:** `FilledButton.icon` with `Icons.add_rounded`, pushes `CreateEvmGrantRoute()` via `context.router.push(...)`.
**RefreshButton:** `OutlinedButton.icon` with `Icons.refresh`, calls `ref.read(evmGrantsProvider.notifier).refresh()`.
---
## Grant Card: `_GrantCard`
**Layout:**
```
Container (rounded 24, Palette.cream bg, Palette.line border)
└─ IntrinsicHeight > Row
├─ Accent strip (0.8.w wide, full height, rounded left)
└─ Padding > Column
├─ Row 1: TypeBadge + ChainChip + Spacer + RevokeButton
└─ Row 2: WalletText + "·" + ClientText
```
**Accent color by grant type:**
- Ether transfer → `Palette.coral`
- Token transfer → `Palette.token` (new entry in `Palette` — indigo, e.g. `Color(0xFF5C6BC0)`)
**TypeBadge:** Small pill container with accent color background at 15% opacity, accent-colored text. Label: `'Ether'` or `'Token'`.
**ChainChip:** Small container: `'Chain ${grant.shared.chainId}'`, muted ink color.
**WalletText:** Short hex address (`0xabc...def`) from wallet lookup, `bodySmall`, monospace font family.
**ClientText:** Client name from `sdkClientsProvider` lookup, or fallback string. `bodySmall`, muted ink.
**RevokeButton:**
- `OutlinedButton` with `Icons.block_rounded` icon, label `'Revoke'`
- `foregroundColor: Palette.coral`, `side: BorderSide(color: Palette.coral.withValues(alpha: 0.4))`
- Disabled (replaced with `CircularProgressIndicator`) while `revokeEvmGrantMutation` is pending — note: this is a single global mutation, so all revoke buttons disable while any revoke is in flight
- On press: calls `executeRevokeEvmGrant(ref, grantId: grant.id)`; shows `SnackBar` on error
---
## Adaptive Sizing
All sizing uses `sizer` units (`1.h`, `1.w`, etc.). No hardcoded pixel values.
---
## Files to Create / Modify
| File | Action |
|---|---|
| `lib/theme/palette.dart` | Modify — add `Palette.token` color |
| `lib/providers/sdk_clients/wallet_access_list.dart` | Create |
| `lib/screens/dashboard/evm/grants/grants.dart` | Create |
| `lib/router.dart` | Modify — add grants route to dashboard children |
| `lib/screens/dashboard.dart` | Modify — add tab to routes list and NavigationDestinations |

113
mise.lock
View File

@@ -1,83 +1,83 @@
# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html # @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html
[[tools.ast-grep]] [[tools.ast-grep]]
version = "0.42.0" version = "0.42.1"
backend = "aqua:ast-grep/ast-grep" backend = "aqua:ast-grep/ast-grep"
[tools.ast-grep."platforms.linux-arm64"] [tools.ast-grep."platforms.linux-arm64"]
checksum = "sha256:5c830eae8456569e2f7212434ed9c238f58dca412d76045418ed6d394a755836" checksum = "sha256:3ba383839044cf9817929435f5ce0027f91d06931e8efb32d942e58d73d92be5"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-unknown-linux-gnu.zip" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-aarch64-unknown-linux-gnu.zip"
[tools.ast-grep."platforms.linux-arm64-musl"]
checksum = "sha256:3ba383839044cf9817929435f5ce0027f91d06931e8efb32d942e58d73d92be5"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-aarch64-unknown-linux-gnu.zip"
[tools.ast-grep."platforms.linux-x64"] [tools.ast-grep."platforms.linux-x64"]
checksum = "sha256:e825a05603f0bcc4cd9076c4cc8c9abd6d008b7cd07d9aa3cc323ba4b8606651" checksum = "sha256:5de8b87cba67fc8dc3e239d54b6484802ad745a7ae3de76be4fe89661dc52657"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-unknown-linux-gnu.zip" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-unknown-linux-gnu.zip"
[tools.ast-grep."platforms.linux-x64-musl"]
checksum = "sha256:5de8b87cba67fc8dc3e239d54b6484802ad745a7ae3de76be4fe89661dc52657"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-unknown-linux-gnu.zip"
[tools.ast-grep."platforms.macos-arm64"] [tools.ast-grep."platforms.macos-arm64"]
checksum = "sha256:fc300d5293b1c770a5aece03a8a193b92e71e87cec726c28096990691a582620" checksum = "sha256:c3961d8e8a4ee0ce2d0d98c7beeb168bb331cdc766b53630118a7b6c4fd39015"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-apple-darwin.zip" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-aarch64-apple-darwin.zip"
[tools.ast-grep."platforms.macos-x64"] [tools.ast-grep."platforms.macos-x64"]
checksum = "sha256:979ffe611327056f4730a1ae71b0209b3b830f58b22c6ed194cda34f55400db2" checksum = "sha256:a038965bfd7fe44257c771cdf8918dc3467dd8ec0eef673b8b14f639b144cdbd"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-apple-darwin.zip" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-apple-darwin.zip"
[tools.ast-grep."platforms.windows-x64"] [tools.ast-grep."platforms.windows-x64"]
checksum = "sha256:55836fa1b2c65dc7d61615a4d9368622a0d2371a76d28b9a165e5a3ab6ae32a4" checksum = "sha256:fe34f631bb24c08ad146f92ca2a92971a53d179461b509fd8d32dc863bff9f83"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-pc-windows-msvc.zip" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-pc-windows-msvc.zip"
[[tools."cargo:cargo-audit"]] [[tools."cargo:cargo-audit"]]
version = "0.22.1" version = "0.22.1"
backend = "cargo:cargo-audit" backend = "cargo:cargo-audit"
[[tools."cargo:cargo-edit"]] [[tools."cargo:cargo-edit"]]
version = "0.13.9" version = "0.13.10"
backend = "cargo:cargo-edit" backend = "cargo:cargo-edit"
[[tools."cargo:cargo-features"]]
version = "1.0.0"
backend = "cargo:cargo-features"
[[tools."cargo:cargo-features-manager"]] [[tools."cargo:cargo-features-manager"]]
version = "0.11.1" version = "0.12.0"
backend = "cargo:cargo-features-manager" backend = "cargo:cargo-features-manager"
[[tools."cargo:cargo-insta"]] [[tools."cargo:cargo-insta"]]
version = "1.46.3" version = "1.47.2"
backend = "cargo:cargo-insta" backend = "cargo:cargo-insta"
[[tools."cargo:cargo-mutants"]]
version = "27.0.0"
backend = "cargo:cargo-mutants"
[[tools."cargo:cargo-nextest"]] [[tools."cargo:cargo-nextest"]]
version = "0.9.126" version = "0.9.133"
backend = "cargo:cargo-nextest" backend = "cargo:cargo-nextest"
[[tools."cargo:cargo-shear"]] [[tools."cargo:cargo-shear"]]
version = "1.9.1" version = "1.11.2"
backend = "cargo:cargo-shear" backend = "cargo:cargo-shear"
[[tools."cargo:cargo-vet"]] [[tools."cargo:cargo-vet"]]
version = "0.10.2" version = "0.10.2"
backend = "cargo:cargo-vet" backend = "cargo:cargo-vet"
[[tools."cargo:diesel-cli"]]
version = "2.3.6"
backend = "cargo:diesel-cli"
[tools."cargo:diesel-cli".options]
default-features = "false"
features = "sqlite,sqlite-bundled"
[[tools."cargo:diesel_cli"]] [[tools."cargo:diesel_cli"]]
version = "2.3.6" version = "2.3.7"
backend = "cargo:diesel_cli" backend = "cargo:diesel_cli"
[tools."cargo:diesel_cli".options] [tools."cargo:diesel_cli".options]
default-features = "false" default-features = "false"
features = "sqlite,sqlite-bundled" features = "sqlite,sqlite-bundled"
[[tools."cargo:rinf_cli"]] [[tools."cargo:flutter_rust_bridge_codegen"]]
version = "8.9.1" version = "2.12.0"
backend = "cargo:rinf_cli" backend = "cargo:flutter_rust_bridge_codegen"
[[tools.flutter]] [[tools.flutter]]
version = "3.38.9-stable" version = "3.41.7-stable"
backend = "asdf:flutter" backend = "asdf:flutter"
[[tools.protoc]] [[tools.protoc]]
@@ -88,10 +88,18 @@ backend = "aqua:protocolbuffers/protobuf/protoc"
checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79" checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-aarch_64.zip" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-aarch_64.zip"
[tools.protoc."platforms.linux-arm64-musl"]
checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-aarch_64.zip"
[tools.protoc."platforms.linux-x64"] [tools.protoc."platforms.linux-x64"]
checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323" checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-x86_64.zip" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-x86_64.zip"
[tools.protoc."platforms.linux-x64-musl"]
checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-x86_64.zip"
[tools.protoc."platforms.macos-arm64"] [tools.protoc."platforms.macos-arm64"]
checksum = "sha256:b9576b5fa1a1ef3fe13a8c91d9d8204b46545759bea5ae155cd6ba2ea4cdaeed" checksum = "sha256:b9576b5fa1a1ef3fe13a8c91d9d8204b46545759bea5ae155cd6ba2ea4cdaeed"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-osx-aarch_64.zip" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-osx-aarch_64.zip"
@@ -105,29 +113,44 @@ checksum = "sha256:1ebd7c87baffb9f1c47169b640872bf5fb1e4408079c691af527be9561d8f
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-win64.zip" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-win64.zip"
[[tools.python]] [[tools.python]]
version = "3.14.3" version = "3.14.4"
backend = "core:python" backend = "core:python"
[tools.python."platforms.linux-arm64"] [tools.python."platforms.linux-arm64"]
checksum = "sha256:be0f4dc2932f762292b27d46ea7d3e8e66ddf3969a5eb0254a229015ed402625" checksum = "sha256:b8b597fdb2f8dccdc502c11947b60a4b65eb6bce79cfa60c7ccf9b6e8352c60a"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-arm64-musl"]
checksum = "sha256:b8b597fdb2f8dccdc502c11947b60a4b65eb6bce79cfa60c7ccf9b6e8352c60a"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64"] [tools.python."platforms.linux-x64"]
checksum = "sha256:0a73413f89efd417871876c9accaab28a9d1e3cd6358fbfff171a38ec99302f0" checksum = "sha256:fe9a9c32d13870af632cbac3dfc7528ae53597e94472aa4c7d6a42e8166136cd"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64-musl"]
checksum = "sha256:fe9a9c32d13870af632cbac3dfc7528ae53597e94472aa4c7d6a42e8166136cd"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-arm64"] [tools.python."platforms.macos-arm64"]
checksum = "sha256:4703cdf18b26798fde7b49b6b66149674c25f97127be6a10dbcf29309bdcdcdb" checksum = "blake3:0314ec66e0f33ec04959583b5900bc8edae371a396aa96b8874e750d1fe936e6"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-aarch64-apple-darwin-install_only_stripped.tar.gz" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-aarch64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-x64"] [tools.python."platforms.macos-x64"]
checksum = "sha256:76f1cc26e3d262eae8ca546a93e8bded10cf0323613f7e246fea2e10a8115eb7" checksum = "sha256:d51250a32fa5d9f0799c7bcb71720c27b10a3afd4a7de288120f96085d508a5a"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-x86_64-apple-darwin-install_only_stripped.tar.gz" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-x86_64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.windows-x64"] [tools.python."platforms.windows-x64"]
checksum = "sha256:950c5f21a015c1bdd1337f233456df2470fab71e4d794407d27a84cb8b9909a0" checksum = "sha256:a976991dcd085c1bb5d9a8084823a6bc8b7f9b079d8c432574a6ddd68c3a6fe1"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-x86_64-pc-windows-msvc-install_only_stripped.tar.gz" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
provenance = "github-attestations"
[[tools.rust]] [[tools.rust]]
version = "1.93.0" version = "1.95.0"
backend = "core:rust" backend = "core:rust"

View File

@@ -1,22 +1,24 @@
[tools] [tools]
"cargo:diesel_cli" = { version = "2.3.6", features = "sqlite,sqlite-bundled", default-features = false } "cargo:diesel_cli" = { version = "2.3.7", features = "sqlite,sqlite-bundled", default-features = "false" }
"cargo:cargo-audit" = "0.22.1" "cargo:cargo-audit" = "0.22.1"
"cargo:cargo-vet" = "0.10.2" "cargo:cargo-vet" = "0.10.2"
flutter = "3.38.9-stable" flutter = "3.41.7-stable"
protoc = "29.6" protoc = "29.6"
"rust" = {version = "1.93.0", components = "clippy"} rust = { version = "1.95.0", components = "clippy,rust-analyzer" }
"cargo:cargo-features-manager" = "0.11.1" "cargo:cargo-features-manager" = "0.12.0"
"cargo:cargo-nextest" = "0.9.126" "cargo:cargo-nextest" = "0.9.133"
"cargo:cargo-shear" = "latest" "cargo:cargo-shear" = "latest"
"cargo:cargo-insta" = "1.46.3" "cargo:cargo-insta" = "1.47.2"
python = "3.14.3" python = "3.14.4"
ast-grep = "0.42.0" ast-grep = "0.42.1"
"cargo:cargo-edit" = "0.13.9" "cargo:cargo-edit" = "0.13.10"
"cargo:cargo-mutants" = "27.0.0"
[tasks.codegen] "cargo:flutter_rust_bridge_codegen" = "2.12.0"
sources = ['protobufs/*.proto']
outputs = ['useragent/lib/proto/*'] [tasks.codegen]
run = ''' sources = ['protobufs/*.proto', 'protobufs/**/*.proto']
dart pub global activate protoc_plugin && \ outputs = ['useragent/lib/proto/**']
protoc --dart_out=grpc:useragent/lib/proto --proto_path=protobufs/ protobufs/*.proto run = '''
''' dart pub global activate protoc_plugin && \
protoc --dart_out=grpc:useragent/lib/proto --proto_path=protobufs/ $(find protobufs -name '*.proto' | sort)
'''

View File

@@ -1,16 +1,16 @@
syntax = "proto3"; syntax = "proto3";
package arbiter; package arbiter;
import "client.proto"; import "client.proto";
import "user_agent.proto"; import "operator.proto";
message ServerInfo { message ServerInfo {
string version = 1; string version = 1;
bytes cert_public_key = 2; bytes cert_public_key = 2;
} }
service ArbiterService { service ArbiterService {
rpc Client(stream arbiter.client.ClientRequest) returns (stream arbiter.client.ClientResponse); rpc Client(stream arbiter.client.ClientRequest) returns (stream arbiter.client.ClientResponse);
rpc UserAgent(stream arbiter.user_agent.UserAgentRequest) returns (stream arbiter.user_agent.UserAgentResponse); rpc Operator(stream arbiter.operator.OperatorRequest) returns (stream arbiter.operator.OperatorResponse);
} }

View File

@@ -1,57 +1,25 @@
syntax = "proto3"; syntax = "proto3";
package arbiter.client; package arbiter.client;
import "evm.proto"; import "client/auth.proto";
import "google/protobuf/empty.proto"; import "client/evm.proto";
import "client/vault.proto";
message AuthChallengeRequest {
bytes pubkey = 1; message ClientRequest {
} int32 request_id = 4;
oneof payload {
message AuthChallenge { auth.Request auth = 1;
bytes pubkey = 1; vault.Request vault = 2;
int32 nonce = 2; evm.Request evm = 3;
} }
}
message AuthChallengeSolution {
bytes signature = 1; message ClientResponse {
} optional int32 request_id = 7;
oneof payload {
enum AuthResult { auth.Response auth = 1;
AUTH_RESULT_UNSPECIFIED = 0; vault.Response vault = 2;
AUTH_RESULT_SUCCESS = 1; evm.Response evm = 3;
AUTH_RESULT_INVALID_KEY = 2; }
AUTH_RESULT_INVALID_SIGNATURE = 3; }
AUTH_RESULT_APPROVAL_DENIED = 4;
AUTH_RESULT_NO_USER_AGENTS_ONLINE = 5;
AUTH_RESULT_INTERNAL = 6;
}
enum VaultState {
VAULT_STATE_UNSPECIFIED = 0;
VAULT_STATE_UNBOOTSTRAPPED = 1;
VAULT_STATE_SEALED = 2;
VAULT_STATE_UNSEALED = 3;
VAULT_STATE_ERROR = 4;
}
message ClientRequest {
int32 request_id = 4;
oneof payload {
AuthChallengeRequest auth_challenge_request = 1;
AuthChallengeSolution auth_challenge_solution = 2;
google.protobuf.Empty query_vault_state = 3;
}
}
message ClientResponse {
optional int32 request_id = 7;
oneof payload {
AuthChallenge auth_challenge = 1;
AuthResult auth_result = 2;
arbiter.evm.EvmSignTransactionResponse evm_sign_transaction = 3;
arbiter.evm.EvmAnalyzeTransactionResponse evm_analyze_transaction = 4;
VaultState vault_state = 6;
}
}

View File

@@ -0,0 +1,43 @@
syntax = "proto3";
package arbiter.client.auth;
import "shared/client.proto";
message AuthChallengeRequest {
bytes pubkey = 1;
arbiter.shared.ClientInfo client_info = 2;
}
message AuthChallenge {
uint64 timestamp_nanos = 1;
bytes random = 2;
}
message AuthChallengeSolution {
bytes signature = 1;
}
enum AuthResult {
AUTH_RESULT_UNSPECIFIED = 0;
AUTH_RESULT_SUCCESS = 1;
AUTH_RESULT_INVALID_KEY = 2;
AUTH_RESULT_INVALID_SIGNATURE = 3;
AUTH_RESULT_APPROVAL_DENIED = 4;
AUTH_RESULT_NO_OPERATORS_ONLINE = 5;
AUTH_RESULT_INTERNAL = 6;
}
message Request {
oneof payload {
AuthChallengeRequest challenge_request = 1;
AuthChallengeSolution challenge_solution = 2;
}
}
message Response {
oneof payload {
AuthChallenge challenge = 1;
AuthResult result = 2;
}
}

View File

@@ -0,0 +1,19 @@
syntax = "proto3";
package arbiter.client.evm;
import "evm.proto";
message Request {
oneof payload {
arbiter.evm.EvmSignTransactionRequest sign_transaction = 1;
arbiter.evm.EvmAnalyzeTransactionRequest analyze_transaction = 2;
}
}
message Response {
oneof payload {
arbiter.evm.EvmSignTransactionResponse sign_transaction = 1;
arbiter.evm.EvmAnalyzeTransactionResponse analyze_transaction = 2;
}
}

View File

@@ -0,0 +1,18 @@
syntax = "proto3";
package arbiter.client.vault;
import "google/protobuf/empty.proto";
import "shared/vault.proto";
message Request {
oneof payload {
google.protobuf.Empty query_state = 1;
}
}
message Response {
oneof payload {
arbiter.shared.VaultState state = 1;
}
}

View File

@@ -1,216 +1,153 @@
syntax = "proto3"; syntax = "proto3";
package arbiter.evm; package arbiter.evm;
import "google/protobuf/empty.proto"; import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto"; import "google/protobuf/timestamp.proto";
import "shared/evm.proto";
enum EvmError {
EVM_ERROR_UNSPECIFIED = 0; enum EvmError {
EVM_ERROR_VAULT_SEALED = 1; EVM_ERROR_UNSPECIFIED = 0;
EVM_ERROR_INTERNAL = 2; EVM_ERROR_VAULT_SEALED = 1;
} EVM_ERROR_INTERNAL = 2;
}
message WalletEntry {
bytes address = 1; // 20-byte Ethereum address message WalletEntry {
} int32 id = 1;
bytes address = 2; // 20-byte Ethereum address
message WalletList { }
repeated WalletEntry wallets = 1;
} message WalletList {
repeated WalletEntry wallets = 1;
message WalletCreateResponse { }
oneof result {
WalletEntry wallet = 1; message WalletCreateResponse {
EvmError error = 2; oneof result {
} WalletEntry wallet = 1;
} EvmError error = 2;
}
message WalletListResponse { }
oneof result {
WalletList wallets = 1; message WalletListResponse {
EvmError error = 2; oneof result {
} WalletList wallets = 1;
} EvmError error = 2;
}
// --- Grant types --- }
message TransactionRateLimit { // --- Grant types ---
uint32 count = 1;
int64 window_secs = 2; message TransactionRateLimit {
} uint32 count = 1;
int64 window_secs = 2;
message VolumeRateLimit { }
bytes max_volume = 1; // U256 as big-endian bytes
int64 window_secs = 2; message VolumeRateLimit {
} bytes max_volume = 1; // U256 as big-endian bytes
int64 window_secs = 2;
message SharedSettings { }
int32 wallet_id = 1;
uint64 chain_id = 2; message SharedSettings {
optional google.protobuf.Timestamp valid_from = 3; int32 wallet_access_id = 1;
optional google.protobuf.Timestamp valid_until = 4; uint64 chain_id = 2;
optional bytes max_gas_fee_per_gas = 5; // U256 as big-endian bytes optional google.protobuf.Timestamp valid_from = 3;
optional bytes max_priority_fee_per_gas = 6; // U256 as big-endian bytes optional google.protobuf.Timestamp valid_until = 4;
optional TransactionRateLimit rate_limit = 7; optional bytes max_gas_fee_per_gas = 5; // U256 as big-endian bytes
} optional bytes max_priority_fee_per_gas = 6; // U256 as big-endian bytes
optional TransactionRateLimit rate_limit = 7;
message EtherTransferSettings { }
repeated bytes targets = 1; // list of 20-byte Ethereum addresses
VolumeRateLimit limit = 2; message EtherTransferSettings {
} repeated bytes targets = 1; // list of 20-byte Ethereum addresses
VolumeRateLimit limit = 2;
message TokenTransferSettings { }
bytes token_contract = 1; // 20-byte Ethereum address
optional bytes target = 2; // 20-byte Ethereum address; absent means any recipient allowed message TokenTransferSettings {
repeated VolumeRateLimit volume_limits = 3; bytes token_contract = 1; // 20-byte Ethereum address
} optional bytes target = 2; // 20-byte Ethereum address; absent means any recipient allowed
repeated VolumeRateLimit volume_limits = 3;
message SpecificGrant { }
oneof grant {
EtherTransferSettings ether_transfer = 1; message SpecificGrant {
TokenTransferSettings token_transfer = 2; oneof grant {
} EtherTransferSettings ether_transfer = 1;
} TokenTransferSettings token_transfer = 2;
}
message EtherTransferMeaning { }
bytes to = 1; // 20-byte Ethereum address
bytes value = 2; // U256 as big-endian bytes // --- Operator grant management ---
} message EvmGrantCreateRequest {
SharedSettings shared = 1;
message TokenInfo { SpecificGrant specific = 2;
string symbol = 1; }
bytes address = 2; // 20-byte Ethereum address
uint64 chain_id = 3; message EvmGrantCreateResponse {
} oneof result {
int32 grant_id = 1;
// Mirror of token_transfers::Meaning EvmError error = 2;
message TokenTransferMeaning { }
TokenInfo token = 1; }
bytes to = 2; // 20-byte Ethereum address
bytes value = 3; // U256 as big-endian bytes message EvmGrantDeleteRequest {
} int32 grant_id = 1;
}
// Mirror of policies::SpecificMeaning
message SpecificMeaning { message EvmGrantDeleteResponse {
oneof meaning { oneof result {
EtherTransferMeaning ether_transfer = 1; google.protobuf.Empty ok = 1;
TokenTransferMeaning token_transfer = 2; EvmError error = 2;
} }
} }
// --- Eval error types --- // Basic grant info returned in grant listings
message GasLimitExceededViolation { message GrantEntry {
optional bytes max_gas_fee_per_gas = 1; // U256 as big-endian bytes int32 id = 1;
optional bytes max_priority_fee_per_gas = 2; // U256 as big-endian bytes int32 wallet_access_id = 2;
} SharedSettings shared = 3;
SpecificGrant specific = 4;
message EvalViolation { }
oneof kind {
bytes invalid_target = 1; // 20-byte Ethereum address message EvmGrantListRequest {
GasLimitExceededViolation gas_limit_exceeded = 2; optional int32 wallet_access_id = 1;
google.protobuf.Empty rate_limit_exceeded = 3; }
google.protobuf.Empty volumetric_limit_exceeded = 4;
google.protobuf.Empty invalid_time = 5; message EvmGrantListResponse {
google.protobuf.Empty invalid_transaction_type = 6; oneof result {
} EvmGrantList grants = 1;
} EvmError error = 2;
}
// Transaction was classified but no grant covers it }
message NoMatchingGrantError {
SpecificMeaning meaning = 1; message EvmGrantList {
} repeated GrantEntry grants = 1;
}
// Transaction was classified and a grant was found, but constraints were violated
message PolicyViolationsError { // --- Client transaction operations ---
SpecificMeaning meaning = 1;
repeated EvalViolation violations = 2; message EvmSignTransactionRequest {
} bytes wallet_address = 1; // 20-byte Ethereum address
bytes rlp_transaction = 2; // RLP-encoded EIP-1559 transaction (unsigned)
// top-level error returned when transaction evaluation fails }
message TransactionEvalError {
oneof kind { // oneof because signing and evaluation happen atomically — a signing failure
google.protobuf.Empty contract_creation_not_supported = 1; // is always either an eval error or an internal error, never a partial success
google.protobuf.Empty unsupported_transaction_type = 2; message EvmSignTransactionResponse {
NoMatchingGrantError no_matching_grant = 3; oneof result {
PolicyViolationsError policy_violations = 4; bytes signature = 1; // 65-byte signature: r[32] || s[32] || v[1]
} arbiter.shared.evm.TransactionEvalError eval_error = 2;
} EvmError error = 3;
}
// --- UserAgent grant management --- }
message EvmGrantCreateRequest {
int32 client_id = 1; message EvmAnalyzeTransactionRequest {
SharedSettings shared = 2; bytes wallet_address = 1; // 20-byte Ethereum address
SpecificGrant specific = 3; bytes rlp_transaction = 2; // RLP-encoded EIP-1559 transaction
} }
message EvmGrantCreateResponse { message EvmAnalyzeTransactionResponse {
oneof result { oneof result {
int32 grant_id = 1; arbiter.shared.evm.SpecificMeaning meaning = 1;
EvmError error = 2; arbiter.shared.evm.TransactionEvalError eval_error = 2;
} EvmError error = 3;
} }
}
message EvmGrantDeleteRequest {
int32 grant_id = 1;
}
message EvmGrantDeleteResponse {
oneof result {
google.protobuf.Empty ok = 1;
EvmError error = 2;
}
}
// Basic grant info returned in grant listings
message GrantEntry {
int32 id = 1;
int32 client_id = 2;
SharedSettings shared = 3;
SpecificGrant specific = 4;
}
message EvmGrantListRequest {
optional int32 wallet_id = 1;
}
message EvmGrantListResponse {
oneof result {
EvmGrantList grants = 1;
EvmError error = 2;
}
}
message EvmGrantList {
repeated GrantEntry grants = 1;
}
// --- Client transaction operations ---
message EvmSignTransactionRequest {
bytes wallet_address = 1; // 20-byte Ethereum address
bytes rlp_transaction = 2; // RLP-encoded EIP-1559 transaction (unsigned)
}
// oneof because signing and evaluation happen atomically — a signing failure
// is always either an eval error or an internal error, never a partial success
message EvmSignTransactionResponse {
oneof result {
bytes signature = 1; // 65-byte signature: r[32] || s[32] || v[1]
TransactionEvalError eval_error = 2;
EvmError error = 3;
}
}
message EvmAnalyzeTransactionRequest {
bytes wallet_address = 1; // 20-byte Ethereum address
bytes rlp_transaction = 2; // RLP-encoded EIP-1559 transaction
}
message EvmAnalyzeTransactionResponse {
oneof result {
SpecificMeaning meaning = 1;
TransactionEvalError eval_error = 2;
EvmError error = 3;
}
}

28
protobufs/operator.proto Normal file
View File

@@ -0,0 +1,28 @@
syntax = "proto3";
package arbiter.operator;
import "operator/auth.proto";
import "operator/evm.proto";
import "operator/sdk_client.proto";
import "operator/vault/vault.proto";
message OperatorRequest {
int32 id = 16;
oneof payload {
auth.Request auth = 1;
vault.Request vault = 2;
evm.Request evm = 3;
sdk_client.Request sdk_client = 4;
}
}
message OperatorResponse {
optional int32 id = 16;
oneof payload {
auth.Response auth = 1;
vault.Response vault = 2;
evm.Response evm = 3;
sdk_client.Response sdk_client = 4;
}
}

View File

@@ -0,0 +1,41 @@
syntax = "proto3";
package arbiter.operator.auth;
message AuthChallengeRequest {
bytes pubkey = 1;
optional string bootstrap_token = 2;
}
message AuthChallenge {
uint64 timestamp_nanos = 1;
bytes random = 2;
}
message AuthChallengeSolution {
bytes signature = 1;
}
enum AuthResult {
AUTH_RESULT_UNSPECIFIED = 0;
AUTH_RESULT_SUCCESS = 1;
AUTH_RESULT_INVALID_KEY = 2;
AUTH_RESULT_INVALID_SIGNATURE = 3;
AUTH_RESULT_BOOTSTRAP_REQUIRED = 4;
AUTH_RESULT_TOKEN_INVALID = 5;
AUTH_RESULT_INTERNAL = 6;
}
message Request {
oneof payload {
AuthChallengeRequest challenge_request = 1;
AuthChallengeSolution challenge_solution = 2;
}
}
message Response {
oneof payload {
AuthChallenge challenge = 1;
AuthResult result = 2;
}
}

View File

@@ -0,0 +1,33 @@
syntax = "proto3";
package arbiter.operator.evm;
import "evm.proto";
import "google/protobuf/empty.proto";
message SignTransactionRequest {
int32 client_id = 1;
arbiter.evm.EvmSignTransactionRequest request = 2;
}
message Request {
oneof payload {
google.protobuf.Empty wallet_create = 1;
google.protobuf.Empty wallet_list = 2;
arbiter.evm.EvmGrantCreateRequest grant_create = 3;
arbiter.evm.EvmGrantDeleteRequest grant_delete = 4;
arbiter.evm.EvmGrantListRequest grant_list = 5;
SignTransactionRequest sign_transaction = 6;
}
}
message Response {
oneof payload {
arbiter.evm.WalletCreateResponse wallet_create = 1;
arbiter.evm.WalletListResponse wallet_list = 2;
arbiter.evm.EvmGrantCreateResponse grant_create = 3;
arbiter.evm.EvmGrantDeleteResponse grant_delete = 4;
arbiter.evm.EvmGrantListResponse grant_list = 5;
arbiter.evm.EvmSignTransactionResponse sign_transaction = 6;
}
}

View File

@@ -0,0 +1,100 @@
syntax = "proto3";
package arbiter.operator.sdk_client;
import "shared/client.proto";
import "google/protobuf/empty.proto";
enum Error {
ERROR_UNSPECIFIED = 0;
ERROR_ALREADY_EXISTS = 1;
ERROR_NOT_FOUND = 2;
ERROR_HAS_RELATED_DATA = 3; // hard-delete blocked by FK (client has grants or transaction logs)
ERROR_INTERNAL = 4;
}
message RevokeRequest {
int32 client_id = 1;
}
message Entry {
int32 id = 1;
bytes pubkey = 2;
arbiter.shared.ClientInfo info = 3;
int32 created_at = 4;
}
message List {
repeated Entry clients = 1;
}
message RevokeResponse {
oneof result {
google.protobuf.Empty ok = 1;
Error error = 2;
}
}
message ListResponse {
oneof result {
List clients = 1;
Error error = 2;
}
}
message ConnectionRequest {
bytes pubkey = 1;
arbiter.shared.ClientInfo info = 2;
}
message ConnectionResponse {
bool approved = 1;
bytes pubkey = 2;
}
message ConnectionCancel {
bytes pubkey = 1;
}
message WalletAccess {
int32 wallet_id = 1;
int32 sdk_client_id = 2;
}
message WalletAccessEntry {
int32 id = 1;
WalletAccess access = 2;
}
message GrantWalletAccess {
repeated WalletAccess accesses = 1;
}
message RevokeWalletAccess {
repeated int32 accesses = 1;
}
message ListWalletAccessResponse {
repeated WalletAccessEntry accesses = 1;
}
message Request {
oneof payload {
ConnectionResponse connection_response = 1;
RevokeRequest revoke = 2;
google.protobuf.Empty list = 3;
GrantWalletAccess grant_wallet_access = 4;
RevokeWalletAccess revoke_wallet_access = 5;
google.protobuf.Empty list_wallet_access = 6;
}
}
message Response {
oneof payload {
ConnectionRequest connection_request = 1;
ConnectionCancel connection_cancel = 2;
RevokeResponse revoke = 3;
ListResponse list = 4;
ListWalletAccessResponse list_wallet_access = 5;
}
}

View File

@@ -0,0 +1,24 @@
syntax = "proto3";
package arbiter.operator.vault.bootstrap;
message BootstrapEncryptedKey {
bytes nonce = 1;
bytes ciphertext = 2;
bytes associated_data = 3;
}
enum BootstrapResult {
BOOTSTRAP_RESULT_UNSPECIFIED = 0;
BOOTSTRAP_RESULT_SUCCESS = 1;
BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED = 2;
BOOTSTRAP_RESULT_INVALID_KEY = 3;
}
message Request {
BootstrapEncryptedKey encrypted_key = 2;
}
message Response {
BootstrapResult result = 1;
}

View File

@@ -0,0 +1,37 @@
syntax = "proto3";
package arbiter.operator.vault.unseal;
message UnsealStart {
bytes client_pubkey = 1;
}
message UnsealStartResponse {
bytes server_pubkey = 1;
}
message UnsealEncryptedKey {
bytes nonce = 1;
bytes ciphertext = 2;
bytes associated_data = 3;
}
enum UnsealResult {
UNSEAL_RESULT_UNSPECIFIED = 0;
UNSEAL_RESULT_SUCCESS = 1;
UNSEAL_RESULT_INVALID_KEY = 2;
UNSEAL_RESULT_UNBOOTSTRAPPED = 3;
}
message Request {
oneof payload {
UnsealStart start = 1;
UnsealEncryptedKey encrypted_key = 2;
}
}
message Response {
oneof payload {
UnsealStartResponse start = 1;
UnsealResult result = 2;
}
}

View File

@@ -0,0 +1,24 @@
syntax = "proto3";
package arbiter.operator.vault;
import "google/protobuf/empty.proto";
import "shared/vault.proto";
import "operator/vault/bootstrap.proto";
import "operator/vault/unseal.proto";
message Request {
oneof payload {
google.protobuf.Empty query_state = 1;
unseal.Request unseal = 2;
bootstrap.Request bootstrap = 3;
}
}
message Response {
oneof payload {
arbiter.shared.VaultState state = 1;
unseal.Response unseal = 2;
bootstrap.Response bootstrap = 3;
}
}

View File

@@ -0,0 +1,9 @@
syntax = "proto3";
package arbiter.shared;
message ClientInfo {
string name = 1;
optional string description = 2;
optional string version = 3;
}

View File

@@ -0,0 +1,74 @@
syntax = "proto3";
package arbiter.shared.evm;
import "google/protobuf/empty.proto";
message EtherTransferMeaning {
bytes to = 1; // 20-byte Ethereum address
bytes value = 2; // U256 as big-endian bytes
}
message TokenInfo {
string symbol = 1;
bytes address = 2; // 20-byte Ethereum address
uint64 chain_id = 3;
}
// Mirror of token_transfers::Meaning
message TokenTransferMeaning {
TokenInfo token = 1;
bytes to = 2; // 20-byte Ethereum address
bytes value = 3; // U256 as big-endian bytes
}
// Mirror of policies::SpecificMeaning
message SpecificMeaning {
oneof meaning {
EtherTransferMeaning ether_transfer = 1;
TokenTransferMeaning token_transfer = 2;
}
}
message GasLimitExceededViolation {
optional bytes max_gas_fee_per_gas = 1; // U256 as big-endian bytes
optional bytes max_priority_fee_per_gas = 2; // U256 as big-endian bytes
}
message EvalViolation {
message ChainIdMismatch {
uint64 expected = 1;
uint64 actual = 2;
}
oneof kind {
bytes invalid_target = 1; // 20-byte Ethereum address
GasLimitExceededViolation gas_limit_exceeded = 2;
google.protobuf.Empty rate_limit_exceeded = 3;
google.protobuf.Empty volumetric_limit_exceeded = 4;
google.protobuf.Empty invalid_time = 5;
google.protobuf.Empty invalid_transaction_type = 6;
ChainIdMismatch chain_id_mismatch = 7;
}
}
// Transaction was classified but no grant covers it
message NoMatchingGrantError {
SpecificMeaning meaning = 1;
}
// Transaction was classified and a grant was found, but constraints were violated
message PolicyViolationsError {
SpecificMeaning meaning = 1;
repeated EvalViolation violations = 2;
}
// top-level error returned when transaction evaluation fails
message TransactionEvalError {
oneof kind {
google.protobuf.Empty contract_creation_not_supported = 1;
google.protobuf.Empty unsupported_transaction_type = 2;
NoMatchingGrantError no_matching_grant = 3;
PolicyViolationsError policy_violations = 4;
}
}

View File

@@ -0,0 +1,11 @@
syntax = "proto3";
package arbiter.shared;
enum VaultState {
VAULT_STATE_UNSPECIFIED = 0;
VAULT_STATE_UNBOOTSTRAPPED = 1;
VAULT_STATE_SEALED = 2;
VAULT_STATE_UNSEALED = 3;
VAULT_STATE_ERROR = 4;
}

View File

@@ -1,179 +0,0 @@
syntax = "proto3";
package arbiter.user_agent;
import "evm.proto";
import "google/protobuf/empty.proto";
enum KeyType {
KEY_TYPE_UNSPECIFIED = 0;
KEY_TYPE_ED25519 = 1;
KEY_TYPE_ECDSA_SECP256K1 = 2;
KEY_TYPE_RSA = 3;
}
// --- SDK client management ---
enum SdkClientError {
SDK_CLIENT_ERROR_UNSPECIFIED = 0;
SDK_CLIENT_ERROR_ALREADY_EXISTS = 1;
SDK_CLIENT_ERROR_NOT_FOUND = 2;
SDK_CLIENT_ERROR_HAS_RELATED_DATA = 3; // hard-delete blocked by FK (client has grants or transaction logs)
SDK_CLIENT_ERROR_INTERNAL = 4;
}
message SdkClientApproveRequest {
bytes pubkey = 1; // 32-byte ed25519 public key
}
message SdkClientRevokeRequest {
int32 client_id = 1;
}
message SdkClientEntry {
int32 id = 1;
bytes pubkey = 2;
int32 created_at = 3;
}
message SdkClientList {
repeated SdkClientEntry clients = 1;
}
message SdkClientApproveResponse {
oneof result {
SdkClientEntry client = 1;
SdkClientError error = 2;
}
}
message SdkClientRevokeResponse {
oneof result {
google.protobuf.Empty ok = 1;
SdkClientError error = 2;
}
}
message SdkClientListResponse {
oneof result {
SdkClientList clients = 1;
SdkClientError error = 2;
}
}
message AuthChallengeRequest {
bytes pubkey = 1;
optional string bootstrap_token = 2;
KeyType key_type = 3;
}
message AuthChallenge {
int32 nonce = 2;
reserved 1;
}
message AuthChallengeSolution {
bytes signature = 1;
}
enum AuthResult {
AUTH_RESULT_UNSPECIFIED = 0;
AUTH_RESULT_SUCCESS = 1;
AUTH_RESULT_INVALID_KEY = 2;
AUTH_RESULT_INVALID_SIGNATURE = 3;
AUTH_RESULT_BOOTSTRAP_REQUIRED = 4;
AUTH_RESULT_TOKEN_INVALID = 5;
AUTH_RESULT_INTERNAL = 6;
}
message UnsealStart {
bytes client_pubkey = 1;
}
message UnsealStartResponse {
bytes server_pubkey = 1;
}
message UnsealEncryptedKey {
bytes nonce = 1;
bytes ciphertext = 2;
bytes associated_data = 3;
}
message BootstrapEncryptedKey {
bytes nonce = 1;
bytes ciphertext = 2;
bytes associated_data = 3;
}
enum UnsealResult {
UNSEAL_RESULT_UNSPECIFIED = 0;
UNSEAL_RESULT_SUCCESS = 1;
UNSEAL_RESULT_INVALID_KEY = 2;
UNSEAL_RESULT_UNBOOTSTRAPPED = 3;
}
enum BootstrapResult {
BOOTSTRAP_RESULT_UNSPECIFIED = 0;
BOOTSTRAP_RESULT_SUCCESS = 1;
BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED = 2;
BOOTSTRAP_RESULT_INVALID_KEY = 3;
}
enum VaultState {
VAULT_STATE_UNSPECIFIED = 0;
VAULT_STATE_UNBOOTSTRAPPED = 1;
VAULT_STATE_SEALED = 2;
VAULT_STATE_UNSEALED = 3;
VAULT_STATE_ERROR = 4;
}
message SdkClientConnectionRequest {
bytes pubkey = 1;
}
message SdkClientConnectionResponse {
bool approved = 1;
}
message SdkClientConnectionCancel {}
message UserAgentRequest {
int32 id = 16;
oneof payload {
AuthChallengeRequest auth_challenge_request = 1;
AuthChallengeSolution auth_challenge_solution = 2;
UnsealStart unseal_start = 3;
UnsealEncryptedKey unseal_encrypted_key = 4;
google.protobuf.Empty query_vault_state = 5;
google.protobuf.Empty evm_wallet_create = 6;
google.protobuf.Empty evm_wallet_list = 7;
arbiter.evm.EvmGrantCreateRequest evm_grant_create = 8;
arbiter.evm.EvmGrantDeleteRequest evm_grant_delete = 9;
arbiter.evm.EvmGrantListRequest evm_grant_list = 10;
SdkClientConnectionResponse sdk_client_connection_response = 11;
SdkClientApproveRequest sdk_client_approve = 12;
SdkClientRevokeRequest sdk_client_revoke = 13;
google.protobuf.Empty sdk_client_list = 14;
BootstrapEncryptedKey bootstrap_encrypted_key = 15;
}
}
message UserAgentResponse {
optional int32 id = 16;
oneof payload {
AuthChallenge auth_challenge = 1;
AuthResult auth_result = 2;
UnsealStartResponse unseal_start_response = 3;
UnsealResult unseal_result = 4;
VaultState vault_state = 5;
arbiter.evm.WalletCreateResponse evm_wallet_create = 6;
arbiter.evm.WalletListResponse evm_wallet_list = 7;
arbiter.evm.EvmGrantCreateResponse evm_grant_create = 8;
arbiter.evm.EvmGrantDeleteResponse evm_grant_delete = 9;
arbiter.evm.EvmGrantListResponse evm_grant_list = 10;
SdkClientConnectionResponse sdk_client_connection_response = 11;
SdkClientApproveResponse sdk_client_approve_response = 12;
SdkClientRevokeResponse sdk_client_revoke_response = 13;
SdkClientListResponse sdk_client_list_response = 14;
BootstrapResult bootstrap_result = 15;
}
}

View File

@@ -1,150 +1,150 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Fetch the Uniswap default token list and emit Rust `TokenInfo` statics. Fetch the Uniswap default token list and emit Rust `TokenInfo` statics.
Usage: Usage:
python3 gen_erc20_registry.py # fetch from IPFS python3 gen_erc20_registry.py # fetch from IPFS
python3 gen_erc20_registry.py tokens.json # local file python3 gen_erc20_registry.py tokens.json # local file
python3 gen_erc20_registry.py tokens.json out.rs # custom output file python3 gen_erc20_registry.py tokens.json out.rs # custom output file
""" """
import json import json
import re import re
import sys import sys
import unicodedata import unicodedata
import urllib.request import urllib.request
UNISWAP_URL = "https://ipfs.io/ipns/tokens.uniswap.org" UNISWAP_URL = "https://ipfs.io/ipns/tokens.uniswap.org"
SOLANA_CHAIN_ID = 501000101 SOLANA_CHAIN_ID = 501000101
IDENTIFIER_RE = re.compile(r"[^A-Za-z0-9]+") IDENTIFIER_RE = re.compile(r"[^A-Za-z0-9]+")
def load_tokens(source=None): def load_tokens(source=None):
if source: if source:
with open(source) as f: with open(source) as f:
return json.load(f) return json.load(f)
req = urllib.request.Request( req = urllib.request.Request(
UNISWAP_URL, UNISWAP_URL,
headers={"Accept": "application/json", "User-Agent": "gen_tokens/1.0"}, headers={"Accept": "application/json", "User-Agent": "gen_tokens/1.0"},
) )
with urllib.request.urlopen(req, timeout=60) as resp: with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read()) return json.loads(resp.read())
def escape(s: str) -> str: def escape(s: str) -> str:
return s.replace("\\", "\\\\").replace('"', '\\"') return s.replace("\\", "\\\\").replace('"', '\\"')
def to_screaming_case(name: str) -> str: def to_screaming_case(name: str) -> str:
normalized = unicodedata.normalize("NFKD", name or "") normalized = unicodedata.normalize("NFKD", name or "")
ascii_name = normalized.encode("ascii", "ignore").decode("ascii") ascii_name = normalized.encode("ascii", "ignore").decode("ascii")
snake = IDENTIFIER_RE.sub("_", ascii_name).strip("_").upper() snake = IDENTIFIER_RE.sub("_", ascii_name).strip("_").upper()
if not snake: if not snake:
snake = "TOKEN" snake = "TOKEN"
if snake[0].isdigit(): if snake[0].isdigit():
snake = f"TOKEN_{snake}" snake = f"TOKEN_{snake}"
return snake return snake
def static_name_for_token(token: dict, used_names: set[str]) -> str: def static_name_for_token(token: dict, used_names: set[str]) -> str:
base = to_screaming_case(token.get("name", "")) base = to_screaming_case(token.get("name", ""))
if base not in used_names: if base not in used_names:
used_names.add(base) used_names.add(base)
return base return base
address = token["address"] address = token["address"]
suffix = f"{token['chainId']}_{address[2:].upper()[-8:]}" suffix = f"{token['chainId']}_{address[2:].upper()[-8:]}"
candidate = f"{base}_{suffix}" candidate = f"{base}_{suffix}"
i = 2 i = 2
while candidate in used_names: while candidate in used_names:
candidate = f"{base}_{suffix}_{i}" candidate = f"{base}_{suffix}_{i}"
i += 1 i += 1
used_names.add(candidate) used_names.add(candidate)
return candidate return candidate
def main(): def main():
source = sys.argv[1] if len(sys.argv) > 1 else None source = sys.argv[1] if len(sys.argv) > 1 else None
output = sys.argv[2] if len(sys.argv) > 2 else "generated_tokens.rs" output = sys.argv[2] if len(sys.argv) > 2 else "generated_tokens.rs"
data = load_tokens(source) data = load_tokens(source)
tokens = data["tokens"] tokens = data["tokens"]
# Deduplicate by (chainId, address) # Deduplicate by (chainId, address)
seen = set() seen = set()
unique = [] unique = []
for t in tokens: for t in tokens:
key = (t["chainId"], t["address"].lower()) key = (t["chainId"], t["address"].lower())
if key not in seen: if key not in seen:
seen.add(key) seen.add(key)
unique.append(t) unique.append(t)
unique.sort(key=lambda t: (t["chainId"], t.get("symbol", "").upper())) unique.sort(key=lambda t: (t["chainId"], t.get("symbol", "").upper()))
evm_tokens = [t for t in unique if t["chainId"] != SOLANA_CHAIN_ID] evm_tokens = [t for t in unique if t["chainId"] != SOLANA_CHAIN_ID]
ver = data["version"] ver = data["version"]
lines = [] lines = []
w = lines.append w = lines.append
w( w(
f"// Auto-generated from Uniswap token list v{ver['major']}.{ver['minor']}.{ver['patch']}" f"// Auto-generated from Uniswap token list v{ver['major']}.{ver['minor']}.{ver['patch']}"
) )
w(f"// {len(evm_tokens)} tokens") w(f"// {len(evm_tokens)} tokens")
w("// DO NOT EDIT - regenerate with gen_erc20_registry.py") w("// DO NOT EDIT - regenerate with gen_erc20_registry.py")
w("") w("")
used_static_names = set() used_static_names = set()
token_statics = [] token_statics = []
for t in evm_tokens: for t in evm_tokens:
static_name = static_name_for_token(t, used_static_names) static_name = static_name_for_token(t, used_static_names)
token_statics.append((static_name, t)) token_statics.append((static_name, t))
for static_name, t in token_statics: for static_name, t in token_statics:
addr = t["address"] addr = t["address"]
name = escape(t.get("name", "")) name = escape(t.get("name", ""))
symbol = escape(t.get("symbol", "")) symbol = escape(t.get("symbol", ""))
decimals = t.get("decimals", 18) decimals = t.get("decimals", 18)
logo = t.get("logoURI") logo = t.get("logoURI")
chain = t["chainId"] chain = t["chainId"]
logo_val = f'Some("{escape(logo)}")' if logo else "None" logo_val = f'Some("{escape(logo)}")' if logo else "None"
w(f"pub static {static_name}: TokenInfo = TokenInfo {{") w(f"pub static {static_name}: TokenInfo = TokenInfo {{")
w(f' name: "{name}",') w(f' name: "{name}",')
w(f' symbol: "{symbol}",') w(f' symbol: "{symbol}",')
w(f" decimals: {decimals},") w(f" decimals: {decimals},")
w(f' contract: address!("{addr}"),') w(f' contract: address!("{addr}"),')
w(f" chain: {chain},") w(f" chain: {chain},")
w(f" logo_uri: {logo_val},") w(f" logo_uri: {logo_val},")
w("};") w("};")
w("") w("")
w("pub static TOKENS: &[&TokenInfo] = &[") w("pub static TOKENS: &[&TokenInfo] = &[")
for static_name, _ in token_statics: for static_name, _ in token_statics:
w(f" &{static_name},") w(f" &{static_name},")
w("];") w("];")
w("") w("")
w("pub fn get_token(") w("pub fn get_token(")
w(" chain_id: alloy::primitives::ChainId,") w(" chain_id: alloy::primitives::ChainId,")
w(" address: alloy::primitives::Address,") w(" address: alloy::primitives::Address,")
w(") -> Option<&'static TokenInfo> {") w(") -> Option<&'static TokenInfo> {")
w(" match (chain_id, address) {") w(" match (chain_id, address) {")
for static_name, t in token_statics: for static_name, t in token_statics:
w( w(
f' ({t["chainId"]}, addr) if addr == address!("{t["address"]}") => Some(&{static_name}),' f' ({t["chainId"]}, addr) if addr == address!("{t["address"]}") => Some(&{static_name}),'
) )
w(" _ => None,") w(" _ => None,")
w(" }") w(" }")
w("}") w("}")
w("") w("")
with open(output, "w") as f: with open(output, "w") as f:
f.write("\n".join(lines)) f.write("\n".join(lines))
print(f"Wrote {len(token_statics)} tokens to {output}") print(f"Wrote {len(token_statics)} tokens to {output}")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -1,13 +1,13 @@
[advisories] [advisories]
# RUSTSEC-2023-0071: Marvin Attack timing side-channel in rsa crate. # RUSTSEC-2023-0071: Marvin Attack timing side-channel in rsa crate.
# No fixed version is available upstream. # No fixed version is available upstream.
# RSA support is required for Windows Hello / KeyCredentialManager # RSA support is required for Windows Hello / KeyCredentialManager
# (https://learn.microsoft.com/en-us/uwp/api/windows.security.credentials.keycredentialmanager.requestcreateasync), # (https://learn.microsoft.com/en-us/uwp/api/windows.security.credentials.keycredentialmanager.requestcreateasync),
# which only issues RSA-2048 keys. # which only issues RSA-2048 keys.
# Mitigations in place: # Mitigations in place:
# - Signing uses BlindedSigningKey (PSS+SHA-256), which applies blinding to # - Signing uses BlindedSigningKey (PSS+SHA-256), which applies blinding to
# protect the private key from timing recovery during signing. # protect the private key from timing recovery during signing.
# - RSA decryption is never performed; we only verify public-key signatures. # - RSA decryption is never performed; we only verify public-key signatures.
# - The attack requires local, high-resolution timing access against the # - The attack requires local, high-resolution timing access against the
# signing process, which is not exposed in our threat model. # signing process, which is not exposed in our threat model.
ignore = ["RUSTSEC-2023-0071"] ignore = ["RUSTSEC-2023-0071"]

View File

@@ -0,0 +1,2 @@
[env]
MACOSX_DEPLOYMENT_TARGET = "26.3"

View File

@@ -0,0 +1 @@
test_tool = "nextest"

2
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
mutants.out/
mutants.out.old/

12580
server/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,45 +1,171 @@
[workspace] [workspace]
members = [ members = [
"crates/*", "crates/*",
] ]
resolver = "3" resolver = "3"
[workspace.lints.clippy]
disallowed-methods = "deny" [workspace.dependencies]
alloy = "2.0.4"
async-trait = "0.1.89"
[workspace.dependencies] base64 = "0.22.1"
tonic = { version = "0.14.5", features = [ chrono = { version = "0.4.44", features = ["serde"] }
"deflate", futures = "0.3.32"
"gzip", k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
"tls-connect-info", kameo = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"}
"zstd", kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"}
] } hmac = "0.13.0"
tracing = "0.1.44" miette = { version = "7.6.0", features = ["fancy", "serde"] }
tokio = { version = "1.50.0", features = ["full"] } ml-dsa = { version = "0.1.0-rc.9", features = ["zeroize"] }
ed25519-dalek = { version = "3.0.0-pre.6", features = ["rand_core"] } mutants = "0.0.4"
chrono = { version = "0.4.44", features = ["serde"] } prost = "0.14.3"
rand = "0.10.0" prost-types = { version = "0.14.3", features = ["chrono"] }
rustls = { version = "0.23.37", features = ["aws-lc-rs"] } rand = "0.10.1"
smlang = "0.8.0" rcgen = { version = "0.14.7", features = [ "aws_lc_rs", "pem", "x509-parser", "zeroize" ], default-features = false }
miette = { version = "7.6.0", features = ["fancy", "serde"] } rstest = "0.26.1"
thiserror = "2.0.18" rustls = { version = "0.23.40", features = ["aws-lc-rs", "logging", "prefer-post-quantum", "std"], default-features = false }
async-trait = "0.1.89" rustls-pki-types = "1.14.1"
futures = "0.3.32" sha2 = "0.11"
tokio-stream = { version = "0.1.18", features = ["full"] } smlang = "0.8.0"
kameo = "0.19.2" thiserror = "2.0.18"
prost-types = { version = "0.14.3", features = ["chrono"] } tokio = { version = "1.52.1", features = ["full"] }
x25519-dalek = { version = "2.0.1", features = ["getrandom"] } tokio-stream = { version = "0.1.18", features = ["full"] }
rstest = "0.26.1" tonic = { version = "0.14.5", features = [ "deflate", "gzip", "tls-connect-info", "zstd" ] }
rustls-pki-types = "1.14.0" tracing = "0.1.44"
alloy = "1.7.3" x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
rcgen = { version = "0.14.7", features = [
"aws_lc_rs", [workspace.lints.rust]
"pem", missing_unsafe_on_extern = "deny"
"x509-parser", unsafe_attr_outside_unsafe = "deny"
"zeroize", unsafe_op_in_unsafe_fn = "deny"
], default-features = false } unstable_features = "deny"
k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
rsa = { version = "0.9", features = ["sha2"] } deprecated_safe_2024 = "warn"
sha2 = "0.10" ffi_unwind_calls = "warn"
spki = "0.7" linker_messages = "warn"
elided_lifetimes_in_paths = "warn"
explicit_outlives_requirements = "warn"
impl-trait-overcaptures = "warn"
impl-trait-redundant-captures = "warn"
redundant_lifetimes = "warn"
single_use_lifetimes = "warn"
unused_lifetimes = "warn"
macro_use_extern_crate = "warn"
redundant_imports = "warn"
unused_import_braces = "warn"
unused_macro_rules = "warn"
unused_qualifications = "warn"
unit_bindings = "warn"
# missing_docs = "warn" # ENABLE BY THE FIRST MAJOR VERSION!!
unnameable_types = "warn"
[workspace.lints.clippy]
derive_partial_eq_without_eq = "allow"
future_not_send = "allow"
inconsistent_struct_constructor = "allow"
inline_always = "allow"
missing_errors_doc = "allow"
missing_fields_in_debug = "allow"
missing_panics_doc = "allow"
must_use_candidate = "allow"
needless_pass_by_ref_mut = "allow"
pub_underscore_fields = "allow"
redundant_pub_crate = "allow"
uninhabited_references = "allow" # safe with unsafe_code = "forbid" and standard uninhabited pattern (match *self {})
too-many-lines = "allow" # this is a very common pattern in server code, and it's not always possible to break it down into smaller modules without hurting readability
# restriction lints
alloc_instead_of_core = "warn"
allow_attributes_without_reason = "warn"
as_conversions = "warn"
assertions_on_result_states = "warn"
cfg_not_test = "warn"
clone_on_ref_ptr = "warn"
cognitive_complexity = "warn"
create_dir = "warn"
dbg_macro = "warn"
decimal_literal_representation = "warn"
default_union_representation = "warn"
deref_by_slicing = "warn"
disallowed_script_idents = "warn"
doc_include_without_cfg = "warn"
empty_drop = "warn"
empty_enum_variants_with_brackets = "warn"
empty_structs_with_brackets = "warn"
exit = "warn"
filetype_is_file = "warn"
float_arithmetic = "warn"
float_cmp_const = "warn"
fn_to_numeric_cast_any = "warn"
get_unwrap = "warn"
if_then_some_else_none = "warn"
indexing_slicing = "warn"
infinite_loop = "warn"
inline_asm_x86_att_syntax = "warn"
inline_asm_x86_intel_syntax = "warn"
integer_division = "warn"
large_include_file = "warn"
lossy_float_literal = "warn"
map_with_unused_argument_over_ranges = "warn"
mem_forget = "warn"
missing_assert_message = "warn"
mixed_read_write_in_expression = "warn"
modulo_arithmetic = "warn"
multiple_unsafe_ops_per_block = "warn"
mutex_atomic = "warn"
mutex_integer = "warn"
needless_raw_strings = "warn"
non_ascii_literal = "warn"
non_zero_suggestions = "warn"
pathbuf_init_then_push = "warn"
pointer_format = "warn"
precedence_bits = "warn"
pub_without_shorthand = "warn"
rc_buffer = "warn"
rc_mutex = "warn"
redundant_test_prefix = "warn"
redundant_type_annotations = "warn"
ref_patterns = "warn"
renamed_function_params = "warn"
rest_pat_in_fully_bound_structs = "warn"
return_and_then = "warn"
semicolon_inside_block = "warn"
str_to_string = "warn"
string_add = "warn"
string_lit_chars_any = "warn"
string_slice = "warn"
suspicious_xor_used_as_pow = "warn"
try_err = "warn"
undocumented_unsafe_blocks = "warn"
uninlined_format_args = "warn"
unnecessary_safety_comment = "warn"
unnecessary_safety_doc = "warn"
unnecessary_self_imports = "warn"
unneeded_field_pattern = "warn"
unused_result_ok = "warn"
verbose_file_reads = "warn"
# cargo lints
negative_feature_names = "warn"
redundant_feature_names = "warn"
wildcard_dependencies = "warn"
# ENABLE BY THE FIRST MAJOR VERSION!!
# todo = "warn"
# unimplemented = "warn"
# panic = "warn"
# panic_in_result_fn = "warn"
#
# cargo_common_metadata = "warn"
# multiple_crate_versions = "warn" # a controversial option since it's really difficult to maintain
disallowed_methods = "deny"
nursery = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
type_repetition_in_bounds = "allow" # sometimes, it's better for readability this way

View File

@@ -1,9 +1,28 @@
disallowed-methods = [ disallowed-methods = [
# RSA decryption is forbidden: the rsa crate has RUSTSEC-2023-0071 (Marvin Attack). # RSA decryption is forbidden: the rsa crate has RUSTSEC-2023-0071 (Marvin Attack).
# We only use RSA for Windows Hello (KeyCredentialManager) public-key verification — decryption # We only use RSA for Windows Hello (KeyCredentialManager) public-key verification — decryption
# is never required and must not be introduced. # is never required and must not be introduced.
{ path = "rsa::RsaPrivateKey::decrypt", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). Only PSS signing/verification is permitted." }, { path = "rsa::RsaPrivateKey::decrypt", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). Only PSS signing/verification is permitted." },
{ path = "rsa::RsaPrivateKey::decrypt_blinded", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). Only PSS signing/verification is permitted." }, { path = "rsa::RsaPrivateKey::decrypt_blinded", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). Only PSS signing/verification is permitted." },
{ path = "rsa::traits::Decryptor::decrypt", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). This blocks decrypt() on rsa::{pkcs1v15,oaep}::DecryptingKey." }, { path = "rsa::traits::Decryptor::decrypt", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). This blocks decrypt() on rsa::{pkcs1v15,oaep}::DecryptingKey." },
{ path = "rsa::traits::RandomizedDecryptor::decrypt_with_rng", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). This blocks decrypt_with_rng() on rsa::{pkcs1v15,oaep}::DecryptingKey." }, { path = "rsa::traits::RandomizedDecryptor::decrypt_with_rng", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). This blocks decrypt_with_rng() on rsa::{pkcs1v15,oaep}::DecryptingKey." },
] ]
allow-indexing-slicing-in-tests = true
allow-panic-in-tests = true
check-inconsistent-struct-field-initializers = true
suppress-restriction-lint-in-const = true
allow-renamed-params-for = [
"core::convert::From",
"core::convert::TryFrom",
"core::str::FromStr",
"kameo::actor::Actor",
]
module-items-ordered-within-groupings = ["UPPER_SNAKE_CASE"]
source-item-ordering = ["enum"]
trait-assoc-item-kinds-order = [
"const",
"type",
"fn",
] # community tested standard

View File

@@ -1,26 +1,29 @@
[package] [package]
name = "arbiter-client" name = "arbiter-client"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
repository = "https://git.markettakers.org/MarketTakers/arbiter" repository = "https://git.markettakers.org/MarketTakers/arbiter"
license = "Apache-2.0" license = "Apache-2.0"
[lints] [lints]
workspace = true workspace = true
[features] [features]
evm = ["dep:alloy"] evm = ["dep:alloy"]
[dependencies] [dependencies]
arbiter-proto.path = "../arbiter-proto" arbiter-proto.path = "../arbiter-proto"
alloy = { workspace = true, optional = true } arbiter-crypto.path = "../arbiter-crypto"
tonic.workspace = true alloy = { workspace = true, optional = true }
tonic.features = ["tls-aws-lc"] tonic.workspace = true
tokio.workspace = true tonic.features = ["tls-aws-lc"]
tokio-stream.workspace = true tokio.workspace = true
ed25519-dalek.workspace = true tokio-stream.workspace = true
thiserror.workspace = true thiserror.workspace = true
http = "1.4.0" http = "1.4.0"
rustls-webpki = { version = "0.103.10", features = ["aws-lc-rs"] } rustls-webpki = { version = "0.103.13", features = ["aws-lc-rs"] }
async-trait.workspace = true async-trait.workspace = true
rand.workspace = true chrono.workspace = true
[lib]
doctest = false

View File

@@ -1,140 +1,160 @@
use arbiter_proto::{ use crate::{
format_challenge, storage::StorageError,
proto::client::{ transport::{ClientTransport, next_request_id},
AuthChallengeRequest, AuthChallengeSolution, AuthResult, ClientRequest, };
client_request::Payload as ClientRequestPayload, use arbiter_crypto::authn::{self, CLIENT_CONTEXT, SigningKey};
client_response::Payload as ClientResponsePayload, use arbiter_proto::{
}, ClientMetadata,
}; proto::{
use ed25519_dalek::Signer as _; client::{
ClientRequest,
use crate::{ auth::{
storage::StorageError, self as proto_auth, AuthChallenge, AuthChallengeRequest, AuthChallengeSolution,
transport::{ClientTransport, next_request_id}, AuthResult, request::Payload as AuthRequestPayload,
}; response::Payload as AuthResponsePayload,
},
#[derive(Debug, thiserror::Error)] client_request::Payload as ClientRequestPayload,
pub enum ConnectError { client_response::Payload as ClientResponsePayload,
#[error("Could not establish connection")] },
Connection(#[from] tonic::transport::Error), shared::ClientInfo as ProtoClientInfo,
},
#[error("Invalid server URI")] };
InvalidUri(#[from] http::uri::InvalidUri),
use chrono::DateTime;
#[error("Invalid CA certificate")]
InvalidCaCert(#[from] webpki::Error), #[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("gRPC error")] #[error("Server sent invalid auth challenge")]
Grpc(#[from] tonic::Status), InvalidChallenge,
#[error("Client approval denied by Operator")]
#[error("Auth challenge was not returned by server")] ApprovalDenied,
MissingAuthChallenge, #[error("Auth challenge was not returned by server")]
MissingAuthChallenge,
#[error("Client approval denied by User Agent")]
ApprovalDenied, #[error("No Operators online to approve client")]
NoOperatorsOnline,
#[error("No User Agents online to approve client")]
NoUserAgentsOnline, #[error("Signing key storage error")]
Storage(#[from] StorageError),
#[error("Unexpected auth response payload")]
UnexpectedAuthResponse, #[error("Unexpected auth response payload")]
UnexpectedAuthResponse,
#[error("Signing key storage error")] }
Storage(#[from] StorageError),
} fn map_auth_result(code: i32) -> AuthError {
match AuthResult::try_from(code).unwrap_or(AuthResult::Unspecified) {
fn map_auth_result(code: i32) -> ConnectError { AuthResult::ApprovalDenied => AuthError::ApprovalDenied,
match AuthResult::try_from(code).unwrap_or(AuthResult::Unspecified) { AuthResult::NoOperatorsOnline => AuthError::NoOperatorsOnline,
AuthResult::ApprovalDenied => ConnectError::ApprovalDenied, AuthResult::Unspecified
AuthResult::NoUserAgentsOnline => ConnectError::NoUserAgentsOnline, | AuthResult::Success
AuthResult::Unspecified | AuthResult::InvalidKey
| AuthResult::Success | AuthResult::InvalidSignature
| AuthResult::InvalidKey | AuthResult::Internal => AuthError::UnexpectedAuthResponse,
| AuthResult::InvalidSignature }
| AuthResult::Internal => ConnectError::UnexpectedAuthResponse, }
}
} async fn send_auth_challenge_request(
transport: &mut ClientTransport,
async fn send_auth_challenge_request( metadata: ClientMetadata,
transport: &mut ClientTransport, key: &SigningKey,
key: &ed25519_dalek::SigningKey, ) -> Result<(), AuthError> {
) -> std::result::Result<(), ConnectError> { transport
transport .send(ClientRequest {
.send(ClientRequest { request_id: next_request_id(),
request_id: next_request_id(), payload: Some(ClientRequestPayload::Auth(proto_auth::Request {
payload: Some(ClientRequestPayload::AuthChallengeRequest( payload: Some(AuthRequestPayload::ChallengeRequest(AuthChallengeRequest {
AuthChallengeRequest { pubkey: key.public_key().to_bytes(),
pubkey: key.verifying_key().to_bytes().to_vec(), client_info: Some(ProtoClientInfo {
}, name: metadata.name,
)), description: metadata.description,
}) version: metadata.version,
.await }),
.map_err(|_| ConnectError::UnexpectedAuthResponse) })),
} })),
})
async fn receive_auth_challenge( .await
transport: &mut ClientTransport, .map_err(|_| AuthError::UnexpectedAuthResponse)
) -> std::result::Result<arbiter_proto::proto::client::AuthChallenge, ConnectError> { }
let response = transport
.recv() async fn receive_auth_challenge(
.await transport: &mut ClientTransport,
.map_err(|_| ConnectError::MissingAuthChallenge)?; ) -> Result<AuthChallenge, AuthError> {
let response = transport
let payload = response.payload.ok_or(ConnectError::MissingAuthChallenge)?; .recv()
match payload { .await
ClientResponsePayload::AuthChallenge(challenge) => Ok(challenge), .map_err(|_| AuthError::MissingAuthChallenge)?;
ClientResponsePayload::AuthResult(result) => Err(map_auth_result(result)),
_ => Err(ConnectError::UnexpectedAuthResponse), let payload = response.payload.ok_or(AuthError::MissingAuthChallenge)?;
} match payload {
} ClientResponsePayload::Auth(response) => match response.payload {
Some(AuthResponsePayload::Challenge(challenge)) => Ok(challenge),
async fn send_auth_challenge_solution( Some(AuthResponsePayload::Result(result)) => Err(map_auth_result(result)),
transport: &mut ClientTransport, None => Err(AuthError::MissingAuthChallenge),
key: &ed25519_dalek::SigningKey, },
challenge: arbiter_proto::proto::client::AuthChallenge, _ => Err(AuthError::UnexpectedAuthResponse),
) -> std::result::Result<(), ConnectError> { }
let challenge_payload = format_challenge(challenge.nonce, &challenge.pubkey); }
let signature = key.sign(&challenge_payload).to_bytes().to_vec();
async fn send_auth_challenge_solution(
transport transport: &mut ClientTransport,
.send(ClientRequest { key: &SigningKey,
request_id: next_request_id(), challenge: AuthChallenge,
payload: Some(ClientRequestPayload::AuthChallengeSolution( ) -> Result<(), AuthError> {
AuthChallengeSolution { signature }, let timestamp = DateTime::from_timestamp_nanos(challenge.timestamp_nanos.cast_signed());
)), let challenge = authn::AuthChallenge {
}) nonce: *challenge
.await .random
.map_err(|_| ConnectError::UnexpectedAuthResponse) .as_array()
} .ok_or(AuthError::InvalidChallenge)?,
timestamp,
async fn receive_auth_confirmation( };
transport: &mut ClientTransport, let challenge_payload: Vec<u8> = challenge.format();
) -> std::result::Result<(), ConnectError> { let signature = key
let response = transport .sign_message(&challenge_payload, CLIENT_CONTEXT)
.recv() .map_err(|_| AuthError::UnexpectedAuthResponse)?
.await .to_bytes();
.map_err(|_| ConnectError::UnexpectedAuthResponse)?;
transport
let payload = response .send(ClientRequest {
.payload request_id: next_request_id(),
.ok_or(ConnectError::UnexpectedAuthResponse)?; payload: Some(ClientRequestPayload::Auth(proto_auth::Request {
match payload { payload: Some(AuthRequestPayload::ChallengeSolution(
ClientResponsePayload::AuthResult(result) AuthChallengeSolution { signature },
if AuthResult::try_from(result).ok() == Some(AuthResult::Success) => )),
{ })),
Ok(()) })
} .await
ClientResponsePayload::AuthResult(result) => Err(map_auth_result(result)), .map_err(|_| AuthError::UnexpectedAuthResponse)
_ => Err(ConnectError::UnexpectedAuthResponse), }
}
} async fn receive_auth_confirmation(transport: &mut ClientTransport) -> Result<(), AuthError> {
let response = transport
pub(crate) async fn authenticate( .recv()
transport: &mut ClientTransport, .await
key: &ed25519_dalek::SigningKey, .map_err(|_| AuthError::UnexpectedAuthResponse)?;
) -> std::result::Result<(), ConnectError> {
send_auth_challenge_request(transport, key).await?; let payload = response.payload.ok_or(AuthError::UnexpectedAuthResponse)?;
let challenge = receive_auth_challenge(transport).await?; match payload {
send_auth_challenge_solution(transport, key, challenge).await?; ClientResponsePayload::Auth(response) => match response.payload {
receive_auth_confirmation(transport).await Some(AuthResponsePayload::Result(result))
} if AuthResult::try_from(result).ok() == Some(AuthResult::Success) =>
{
Ok(())
}
Some(AuthResponsePayload::Result(result)) => Err(map_auth_result(result)),
_ => Err(AuthError::UnexpectedAuthResponse),
},
_ => Err(AuthError::UnexpectedAuthResponse),
}
}
pub async fn authenticate(
transport: &mut ClientTransport,
metadata: ClientMetadata,
key: &SigningKey,
) -> Result<(), AuthError> {
send_auth_challenge_request(transport, metadata, key).await?;
let challenge = receive_auth_challenge(transport).await?;
send_auth_challenge_solution(transport, key, challenge).await?;
receive_auth_confirmation(transport).await
}

View File

@@ -0,0 +1,44 @@
use arbiter_client::ArbiterClient;
use arbiter_proto::{ClientMetadata, url::ArbiterUrl};
use std::io::{self, Write};
#[tokio::main]
async fn main() {
println!("Testing connection to Arbiter server...");
print!("Enter ArbiterUrl: ");
let _ = io::stdout().flush();
let mut input = String::new();
if let Err(err) = io::stdin().read_line(&mut input) {
eprintln!("Failed to read input: {err}");
return;
}
let input = input.trim();
if input.is_empty() {
eprintln!("ArbiterUrl cannot be empty");
return;
}
let url = match ArbiterUrl::try_from(input) {
Ok(url) => url,
Err(err) => {
eprintln!("Invalid ArbiterUrl: {err}");
return;
}
};
println!("{url:#?}");
let metadata = ClientMetadata {
name: "arbiter-client test_connect".to_owned(),
description: Some("Manual connection smoke test".to_owned()),
version: Some(env!("CARGO_PKG_VERSION").to_owned()),
};
match ArbiterClient::connect(url, metadata).await {
Ok(_) => println!("Connected and authenticated successfully."),
Err(err) => eprintln!("Failed to connect: {err:#?}"),
}
}

View File

@@ -1,76 +1,101 @@
use arbiter_proto::{proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl}; #[cfg(feature = "evm")]
use std::sync::Arc; use crate::wallets::evm::ArbiterEvmWallet;
use tokio::sync::{Mutex, mpsc}; use crate::{
use tokio_stream::wrappers::ReceiverStream; StorageError,
use tonic::transport::ClientTlsConfig; auth::{AuthError, authenticate},
storage::{FileSigningKeyStorage, SigningKeyStorage},
use crate::{ transport::{BUFFER_LENGTH, ClientTransport},
auth::{ConnectError, authenticate}, };
storage::{FileSigningKeyStorage, SigningKeyStorage}, use arbiter_crypto::authn::SigningKey;
transport::{BUFFER_LENGTH, ClientTransport}, use arbiter_proto::{
}; ClientMetadata, proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl,
};
#[cfg(feature = "evm")]
use crate::wallets::evm::ArbiterEvmWallet; use std::sync::Arc;
use tokio::sync::{Mutex, mpsc};
#[derive(Debug, thiserror::Error)] use tokio_stream::wrappers::ReceiverStream;
pub enum ClientError { use tonic::transport::ClientTlsConfig;
#[error("gRPC error")]
Grpc(#[from] tonic::Status), #[derive(Debug, thiserror::Error)]
pub enum ArbiterClientError {
#[error("Connection closed by server")] #[error("Authentication error")]
ConnectionClosed, Authentication(#[from] AuthError),
}
#[error("Could not establish connection")]
pub struct ArbiterClient { Connection(#[from] tonic::transport::Error),
#[allow(dead_code)]
transport: Arc<Mutex<ClientTransport>>, #[error("gRPC error")]
} Grpc(#[from] tonic::Status),
impl ArbiterClient { #[error("Invalid CA certificate")]
pub async fn connect(url: ArbiterUrl) -> Result<Self, ConnectError> { InvalidCaCert(#[from] webpki::Error),
let storage = FileSigningKeyStorage::from_default_location()?;
Self::connect_with_storage(url, &storage).await #[error("Invalid server URI")]
} InvalidUri(#[from] http::uri::InvalidUri),
pub async fn connect_with_storage<S: SigningKeyStorage>( #[error("Storage error")]
url: ArbiterUrl, Storage(#[from] StorageError),
storage: &S, }
) -> Result<Self, ConnectError> {
let key = storage.load_or_create()?; pub struct ArbiterClient {
Self::connect_with_key(url, key).await #[expect(
} dead_code,
reason = "transport will be used in future methods for sending requests and receiving responses"
pub async fn connect_with_key( )]
url: ArbiterUrl, transport: Arc<Mutex<ClientTransport>>,
key: ed25519_dalek::SigningKey, }
) -> Result<Self, ConnectError> {
let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned(); impl ArbiterClient {
let tls = ClientTlsConfig::new().trust_anchor(anchor); pub async fn connect(
url: ArbiterUrl,
let channel = tonic::transport::Channel::from_shared(format!("{}:{}", url.host, url.port))? metadata: ClientMetadata,
.tls_config(tls)? ) -> Result<Self, ArbiterClientError> {
.connect() let storage = FileSigningKeyStorage::from_default_location()?;
.await?; Self::connect_with_storage(url, metadata, &storage).await
}
let mut client = ArbiterServiceClient::new(channel);
let (tx, rx) = mpsc::channel(BUFFER_LENGTH); pub async fn connect_with_storage<S: SigningKeyStorage>(
let response_stream = client.client(ReceiverStream::new(rx)).await?.into_inner(); url: ArbiterUrl,
metadata: ClientMetadata,
let mut transport = ClientTransport { storage: &S,
sender: tx, ) -> Result<Self, ArbiterClientError> {
receiver: response_stream, let key = storage.load_or_create()?;
}; Self::connect_with_key(url, metadata, key).await
}
authenticate(&mut transport, &key).await?;
pub async fn connect_with_key(
Ok(Self { url: ArbiterUrl,
transport: Arc::new(Mutex::new(transport)), metadata: ClientMetadata,
}) key: SigningKey,
} ) -> Result<Self, ArbiterClientError> {
let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned();
#[cfg(feature = "evm")] let tls = ClientTlsConfig::new().trust_anchor(anchor);
pub async fn evm_wallets(&self) -> Result<Vec<ArbiterEvmWallet>, ClientError> {
todo!("fetch EVM wallet list from server") let channel =
} tonic::transport::Channel::from_shared(format!("https://{}:{}", url.host, url.port))?
} .tls_config(tls)?
.connect()
.await?;
let mut client = ArbiterServiceClient::new(channel);
let (tx, rx) = mpsc::channel(BUFFER_LENGTH);
let response_stream = client.client(ReceiverStream::new(rx)).await?.into_inner();
let mut transport = ClientTransport {
sender: tx,
receiver: response_stream,
};
authenticate(&mut transport, metadata, &key).await?;
Ok(Self {
transport: Arc::new(Mutex::new(transport)),
})
}
#[cfg(feature = "evm")]
#[expect(clippy::unused_async, reason = "false positive")]
pub async fn evm_wallets(&self) -> Result<Vec<ArbiterEvmWallet>, ArbiterClientError> {
todo!("fetch EVM wallet list from server")
}
}

View File

@@ -1,12 +1,12 @@
mod auth; mod auth;
mod client; mod client;
mod storage; mod storage;
mod transport; mod transport;
pub mod wallets; pub mod wallets;
pub use auth::ConnectError; pub use auth::AuthError;
pub use client::{ArbiterClient, ClientError}; pub use client::{ArbiterClient, ArbiterClientError};
pub use storage::{FileSigningKeyStorage, SigningKeyStorage, StorageError}; pub use storage::{FileSigningKeyStorage, SigningKeyStorage, StorageError};
#[cfg(feature = "evm")] #[cfg(feature = "evm")]
pub use wallets::evm::ArbiterEvmWallet; pub use wallets::evm::{ArbiterEvmSignTransactionError, ArbiterEvmWallet};

View File

@@ -1,132 +1,134 @@
use arbiter_proto::home_path; use arbiter_crypto::authn::SigningKey;
use std::path::{Path, PathBuf}; use arbiter_proto::home_path;
#[derive(Debug, thiserror::Error)] use std::path::{Path, PathBuf};
pub enum StorageError {
#[error("I/O error")] #[derive(Debug, thiserror::Error)]
Io(#[from] std::io::Error), pub enum StorageError {
#[error("Invalid signing key length in storage: expected {expected} bytes, got {actual} bytes")]
#[error("Invalid signing key length in storage: expected {expected} bytes, got {actual} bytes")] InvalidKeyLength { expected: usize, actual: usize },
InvalidKeyLength { expected: usize, actual: usize },
} #[error("I/O error")]
Io(#[from] std::io::Error),
pub trait SigningKeyStorage { }
fn load_or_create(&self) -> std::result::Result<ed25519_dalek::SigningKey, StorageError>;
} pub trait SigningKeyStorage {
fn load_or_create(&self) -> Result<SigningKey, StorageError>;
#[derive(Debug, Clone)] }
pub struct FileSigningKeyStorage {
path: PathBuf, #[derive(Debug, Clone)]
} pub struct FileSigningKeyStorage {
path: PathBuf,
impl FileSigningKeyStorage { }
pub const DEFAULT_FILE_NAME: &str = "sdk_client_ed25519.key";
impl FileSigningKeyStorage {
pub fn new(path: impl Into<PathBuf>) -> Self { pub const DEFAULT_FILE_NAME: &str = "sdk_client_ml_dsa.key";
Self { path: path.into() }
} pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
pub fn from_default_location() -> std::result::Result<Self, StorageError> { }
Ok(Self::new(home_path()?.join(Self::DEFAULT_FILE_NAME)))
} pub fn from_default_location() -> Result<Self, StorageError> {
Ok(Self::new(home_path()?.join(Self::DEFAULT_FILE_NAME)))
fn read_key(path: &Path) -> std::result::Result<ed25519_dalek::SigningKey, StorageError> { }
let bytes = std::fs::read(path)?;
let raw: [u8; 32] = fn read_key(path: &Path) -> Result<SigningKey, StorageError> {
bytes let bytes = std::fs::read(path)?;
.try_into() let raw: [u8; 32] =
.map_err(|v: Vec<u8>| StorageError::InvalidKeyLength { bytes
expected: 32, .try_into()
actual: v.len(), .map_err(|v: Vec<u8>| StorageError::InvalidKeyLength {
})?; expected: 32,
Ok(ed25519_dalek::SigningKey::from_bytes(&raw)) actual: v.len(),
} })?;
} Ok(SigningKey::from_seed(raw))
}
impl SigningKeyStorage for FileSigningKeyStorage { }
fn load_or_create(&self) -> std::result::Result<ed25519_dalek::SigningKey, StorageError> {
if let Some(parent) = self.path.parent() { impl SigningKeyStorage for FileSigningKeyStorage {
std::fs::create_dir_all(parent)?; fn load_or_create(&self) -> Result<SigningKey, StorageError> {
} if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
if self.path.exists() { }
return Self::read_key(&self.path);
} if self.path.exists() {
return Self::read_key(&self.path);
let key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); }
let raw_key = key.to_bytes();
let key = SigningKey::generate();
// Use create_new to prevent accidental overwrite if another process creates the key first. let raw_key = key.to_seed();
match std::fs::OpenOptions::new()
.create_new(true) // Use create_new to prevent accidental overwrite if another process creates the key first.
.write(true) match std::fs::OpenOptions::new()
.open(&self.path) .create_new(true)
{ .write(true)
Ok(mut file) => { .open(&self.path)
use std::io::Write as _; {
file.write_all(&raw_key)?; Ok(mut file) => {
Ok(key) use std::io::Write as _;
} file.write_all(&raw_key)?;
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { Ok(key)
Self::read_key(&self.path) }
} Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
Err(err) => Err(StorageError::Io(err)), Self::read_key(&self.path)
} }
} Err(err) => Err(StorageError::Io(err)),
} }
}
#[cfg(test)] }
mod tests {
use super::{FileSigningKeyStorage, SigningKeyStorage, StorageError}; #[cfg(test)]
mod tests {
fn unique_temp_key_path() -> std::path::PathBuf { use super::{FileSigningKeyStorage, SigningKeyStorage, StorageError};
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) fn unique_temp_key_path() -> std::path::PathBuf {
.expect("clock should be after unix epoch") let nanos = std::time::SystemTime::now()
.as_nanos(); .duration_since(std::time::UNIX_EPOCH)
std::env::temp_dir().join(format!( .expect("clock should be after unix epoch")
"arbiter-client-key-{}-{}.bin", .as_nanos();
std::process::id(), std::env::temp_dir().join(format!(
nanos "arbiter-client-key-{}-{}.bin",
)) std::process::id(),
} nanos
))
#[test] }
fn file_storage_creates_and_reuses_key() {
let path = unique_temp_key_path(); #[test]
let storage = FileSigningKeyStorage::new(path.clone()); fn file_storage_creates_and_reuses_key() {
let path = unique_temp_key_path();
let key_a = storage let storage = FileSigningKeyStorage::new(path.clone());
.load_or_create()
.expect("first load_or_create should create key"); let key_a = storage
let key_b = storage .load_or_create()
.load_or_create() .expect("first load_or_create should create key");
.expect("second load_or_create should read same key"); let key_b = storage
.load_or_create()
assert_eq!(key_a.to_bytes(), key_b.to_bytes()); .expect("second load_or_create should read same key");
assert!(path.exists());
assert_eq!(key_a.to_seed(), key_b.to_seed());
std::fs::remove_file(path).expect("temp key file should be removable"); assert!(path.exists());
}
std::fs::remove_file(path).expect("temp key file should be removable");
#[test] }
fn file_storage_rejects_invalid_key_length() {
let path = unique_temp_key_path(); #[test]
std::fs::write(&path, [42u8; 31]).expect("should write invalid key file"); fn file_storage_rejects_invalid_key_length() {
let storage = FileSigningKeyStorage::new(path.clone()); let path = unique_temp_key_path();
std::fs::write(&path, [42u8; 31]).expect("should write invalid key file");
let err = storage let storage = FileSigningKeyStorage::new(path.clone());
.load_or_create()
.expect_err("storage should reject non-32-byte key file"); let err = storage
.load_or_create()
match err { .expect_err("storage should reject non-32-byte key file");
StorageError::InvalidKeyLength { expected, actual } => {
assert_eq!(expected, 32); match err {
assert_eq!(actual, 31); StorageError::InvalidKeyLength { expected, actual } => {
} assert_eq!(expected, 32);
other => panic!("unexpected error: {other:?}"), assert_eq!(actual, 31);
} }
other @ StorageError::Io(_) => panic!("unexpected error: {other:?}"),
std::fs::remove_file(path).expect("temp key file should be removable"); }
}
} std::fs::remove_file(path).expect("temp key file should be removable");
}
}

View File

@@ -1,48 +1,41 @@
use arbiter_proto::proto::{ use arbiter_proto::proto::client::{ClientRequest, ClientResponse};
client::{ClientRequest, ClientResponse},
}; use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::atomic::{AtomicI32, Ordering}; use tokio::sync::mpsc;
use tokio::sync::mpsc;
pub const BUFFER_LENGTH: usize = 16;
pub(crate) const BUFFER_LENGTH: usize = 16; static NEXT_REQUEST_ID: AtomicI32 = AtomicI32::new(1);
static NEXT_REQUEST_ID: AtomicI32 = AtomicI32::new(1);
pub fn next_request_id() -> i32 {
pub(crate) fn next_request_id() -> i32 { NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed)
NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed) }
}
#[derive(Debug, thiserror::Error)]
#[derive(Debug, thiserror::Error)] pub enum ClientSignError {
pub(crate) enum ClientSignError { #[error("Transport channel closed")]
#[error("Transport channel closed")] ChannelClosed,
ChannelClosed,
#[error("Connection closed by server")]
#[error("Connection closed by server")] ConnectionClosed,
ConnectionClosed, }
}
pub struct ClientTransport {
pub(crate) struct ClientTransport { pub(crate) sender: mpsc::Sender<ClientRequest>,
pub(crate) sender: mpsc::Sender<ClientRequest>, pub(crate) receiver: tonic::Streaming<ClientResponse>,
pub(crate) receiver: tonic::Streaming<ClientResponse>, }
}
impl ClientTransport {
impl ClientTransport { pub(crate) async fn send(&mut self, request: ClientRequest) -> Result<(), ClientSignError> {
pub(crate) async fn send( self.sender
&mut self, .send(request)
request: ClientRequest, .await
) -> std::result::Result<(), ClientSignError> { .map_err(|_| ClientSignError::ChannelClosed)
self.sender }
.send(request)
.await pub(crate) async fn recv(&mut self) -> Result<ClientResponse, ClientSignError> {
.map_err(|_| ClientSignError::ChannelClosed) match self.receiver.message().await {
} Ok(Some(resp)) => Ok(resp),
Ok(None) | Err(_) => Err(ClientSignError::ConnectionClosed),
pub(crate) async fn recv( }
&mut self, }
) -> std::result::Result<ClientResponse, ClientSignError> { }
match self.receiver.message().await {
Ok(Some(resp)) => Ok(resp),
Ok(None) => Err(ClientSignError::ConnectionClosed),
Err(_) => Err(ClientSignError::ConnectionClosed),
}
}
}

View File

@@ -1,89 +1,197 @@
use alloy::{ use crate::transport::{ClientTransport, next_request_id};
consensus::SignableTransaction, use arbiter_proto::proto::{
network::TxSigner, client::{
primitives::{Address, B256, ChainId, Signature}, ClientRequest,
signers::{Error, Result, Signer}, client_request::Payload as ClientRequestPayload,
}; client_response::Payload as ClientResponsePayload,
use async_trait::async_trait; evm::{
use std::sync::Arc; self as proto_evm, request::Payload as EvmRequestPayload,
use tokio::sync::Mutex; response::Payload as EvmResponsePayload,
},
use crate::transport::ClientTransport; },
evm::{
pub struct ArbiterEvmWallet { EvmSignTransactionRequest,
transport: Arc<Mutex<ClientTransport>>, evm_sign_transaction_response::Result as EvmSignTransactionResult,
address: Address, },
chain_id: Option<ChainId>, shared::evm::TransactionEvalError,
} };
impl ArbiterEvmWallet { use alloy::{
pub(crate) fn new(transport: Arc<Mutex<ClientTransport>>, address: Address) -> Self { consensus::SignableTransaction,
Self { network::TxSigner,
transport, primitives::{Address, B256, ChainId, Signature},
address, signers::{Error, Result, Signer},
chain_id: None, };
} use async_trait::async_trait;
} use std::sync::Arc;
use tokio::sync::Mutex;
pub fn address(&self) -> Address {
self.address /// A typed error payload returned by [`ArbiterEvmWallet`] transaction signing.
} ///
/// This is wrapped into `alloy::signers::Error::Other`, so consumers can downcast by [`TryFrom`] and
pub fn with_chain_id(mut self, chain_id: ChainId) -> Self { /// interpret the concrete policy evaluation failure instead of parsing strings.
self.chain_id = Some(chain_id); #[derive(Debug, thiserror::Error)]
self #[non_exhaustive]
} pub enum ArbiterEvmSignTransactionError {
#[error("transaction rejected by policy: {0:?}")]
fn validate_chain_id(&self, tx: &mut dyn SignableTransaction<Signature>) -> Result<()> { PolicyEval(TransactionEvalError),
if let Some(chain_id) = self.chain_id }
&& !tx.set_chain_id_checked(chain_id)
{ impl<'a> TryFrom<&'a Error> for &'a ArbiterEvmSignTransactionError {
return Err(Error::TransactionChainIdMismatch { type Error = ();
signer: chain_id,
tx: tx.chain_id().unwrap(), fn try_from(value: &'a Error) -> Result<Self, Self::Error> {
}); if let Error::Other(inner) = value
} && let Some(eval_error) = inner.downcast_ref()
{
Ok(()) Ok(eval_error)
} } else {
} Err(())
}
#[async_trait] }
impl Signer for ArbiterEvmWallet { }
async fn sign_hash(&self, _hash: &B256) -> Result<Signature> {
Err(Error::other( pub struct ArbiterEvmWallet {
"hash-only signing is not supported for ArbiterEvmWallet; use transaction signing", transport: Arc<Mutex<ClientTransport>>,
)) address: Address,
} chain_id: Option<ChainId>,
}
fn address(&self) -> Address {
self.address impl ArbiterEvmWallet {
} #[expect(
dead_code,
fn chain_id(&self) -> Option<ChainId> { reason = "new will be used in future methods for creating wallets with different parameters"
self.chain_id )]
} pub(crate) const fn new(transport: Arc<Mutex<ClientTransport>>, address: Address) -> Self {
Self {
fn set_chain_id(&mut self, chain_id: Option<ChainId>) { transport,
self.chain_id = chain_id; address,
} chain_id: None,
} }
}
#[async_trait]
impl TxSigner<Signature> for ArbiterEvmWallet { pub const fn address(&self) -> Address {
fn address(&self) -> Address { self.address
self.address }
}
#[must_use]
async fn sign_transaction( pub const fn with_chain_id(mut self, chain_id: ChainId) -> Self {
&self, self.chain_id = Some(chain_id);
tx: &mut dyn SignableTransaction<Signature>, self
) -> Result<Signature> { }
let _transport = self.transport.lock().await;
self.validate_chain_id(tx)?; fn validate_chain_id(&self, tx: &mut dyn SignableTransaction<Signature>) -> Result<()> {
if let Some(chain_id) = self.chain_id
Err(Error::other( && !tx.set_chain_id_checked(chain_id)
"transaction signing is not supported by current arbiter.client protocol", {
)) return Err(Error::TransactionChainIdMismatch {
} signer: chain_id,
} tx: tx.chain_id().unwrap(),
});
}
Ok(())
}
}
#[async_trait]
impl Signer for ArbiterEvmWallet {
async fn sign_hash(&self, _hash: &B256) -> Result<Signature> {
Err(Error::other(
"hash-only signing is not supported for ArbiterEvmWallet; use transaction signing",
))
}
fn address(&self) -> Address {
self.address
}
fn chain_id(&self) -> Option<ChainId> {
self.chain_id
}
fn set_chain_id(&mut self, chain_id: Option<ChainId>) {
self.chain_id = chain_id;
}
}
#[async_trait]
impl TxSigner<Signature> for ArbiterEvmWallet {
fn address(&self) -> Address {
self.address
}
async fn sign_transaction(
&self,
tx: &mut dyn SignableTransaction<Signature>,
) -> Result<Signature> {
self.validate_chain_id(tx)?;
let mut transport = self.transport.lock().await;
let request_id = next_request_id();
let rlp_transaction = tx.encoded_for_signing();
transport
.send(ClientRequest {
request_id,
payload: Some(ClientRequestPayload::Evm(proto_evm::Request {
payload: Some(EvmRequestPayload::SignTransaction(
EvmSignTransactionRequest {
wallet_address: self.address.to_vec(),
rlp_transaction,
},
)),
})),
})
.await
.map_err(|_| Error::other("failed to send evm sign transaction request"))?;
let response = transport
.recv()
.await
.map_err(|_| Error::other("failed to receive evm sign transaction response"))?;
drop(transport);
if response.request_id != Some(request_id) {
return Err(Error::other(
"received mismatched response id for evm sign transaction",
));
}
let payload = response
.payload
.ok_or_else(|| Error::other("missing evm sign transaction response payload"))?;
let ClientResponsePayload::Evm(proto_evm::Response {
payload: Some(payload),
}) = payload
else {
return Err(Error::other(
"unexpected response payload for evm sign transaction request",
));
};
let EvmResponsePayload::SignTransaction(response) = payload else {
return Err(Error::other(
"unexpected evm response payload for sign transaction request",
));
};
let result = response
.result
.ok_or_else(|| Error::other("missing evm sign transaction result"))?;
match result {
EvmSignTransactionResult::Signature(signature) => {
Signature::try_from(signature.as_slice())
.map_err(|_| Error::other("invalid signature returned by server"))
}
EvmSignTransactionResult::EvalError(eval_error) => Err(Error::other(
ArbiterEvmSignTransactionError::PolicyEval(eval_error),
)),
EvmSignTransactionResult::Error(code) => Err(Error::other(format!(
"server failed to sign transaction with error code {code}"
))),
}
}
}

View File

@@ -1,2 +1,2 @@
#[cfg(feature = "evm")] #[cfg(feature = "evm")]
pub mod evm; pub mod evm;

View File

@@ -0,0 +1 @@
/target

View File

@@ -0,0 +1,25 @@
[package]
name = "arbiter-crypto"
version = "0.1.0"
edition = "2024"
[dependencies]
ml-dsa = {workspace = true, optional = true }
rand = {workspace = true, optional = true}
memsafe = {version = "0.4.0", optional = true}
hmac.workspace = true
alloy.workspace = true
x-wing = { version = "0.1.0-rc.0", features = ["zeroize"] }
chrono.workspace = true
thiserror.workspace = true
[lints]
workspace = true
[features]
default = ["authn", "safecell"]
authn = ["dep:ml-dsa", "dep:rand"]
safecell = ["dep:memsafe"]
[lib]
doctest = false

View File

@@ -0,0 +1,2 @@
pub mod v1;
pub use v1::*;

View File

@@ -0,0 +1,252 @@
use chrono::{DateTime, Utc};
use hmac::digest::Digest;
use ml_dsa::{
EncodedVerifyingKey, Error, KeyGen, MlDsa87, Seed, Signature as MlDsaSignature,
SigningKey as MlDsaSigningKey, VerifyingKey as MlDsaVerifyingKey, signature::Keypair as _,
};
use rand::RngExt;
pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client";
pub static OPERATOR_CONTEXT: &[u8] = b"arbiter_operator";
const NONCE_SIZE: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("invalid length: expected {expected} bytes, got {actual} bytes")]
pub struct InvalidLength {
pub expected: usize,
pub actual: usize,
}
#[derive(Debug, Clone)]
pub struct AuthChallenge {
pub nonce: [u8; NONCE_SIZE],
pub timestamp: DateTime<Utc>,
}
impl AuthChallenge {
pub fn generate(rng: &mut impl rand::CryptoRng) -> Self {
let timestamp = Utc::now();
let nonce = {
let mut array = [0; NONCE_SIZE];
rng.fill(&mut array);
array
};
Self { nonce, timestamp }
}
pub fn format(&self) -> Vec<u8> {
{
let mut buffer = Vec::from(self.nonce);
let stamp = self
.timestamp
.timestamp_nanos_opt()
.expect("We would be long dead by the time this triggers :)");
buffer.extend_from_slice(stamp.to_be_bytes().as_slice());
buffer
}
}
pub fn from_parts(nonce: &[u8], timestamp: i64) -> Result<Self, InvalidLength> {
let random_nonce = nonce.as_array().ok_or(InvalidLength {
expected: NONCE_SIZE,
actual: nonce.len(),
})?;
Ok(Self {
nonce: *random_nonce,
timestamp: DateTime::from_timestamp_nanos(timestamp),
})
}
}
pub type KeyParams = MlDsa87;
#[derive(Clone, Debug, PartialEq)]
pub struct PublicKey(Box<MlDsaVerifyingKey<KeyParams>>);
impl crate::hashing::Hashable for PublicKey {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.to_bytes());
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Signature(Box<MlDsaSignature<KeyParams>>);
#[derive(Debug)]
pub struct SigningKey(Box<MlDsaSigningKey<KeyParams>>);
impl PublicKey {
pub fn to_bytes(&self) -> Vec<u8> {
self.0.encode().0.to_vec()
}
#[must_use]
pub fn verify(&self, challenge: &AuthChallenge, context: &[u8], signature: &Signature) -> bool {
let challenge = challenge.format();
self.0
.verify_with_context(&challenge, context, &signature.0)
}
}
impl Signature {
pub fn to_bytes(&self) -> Vec<u8> {
self.0.encode().0.to_vec()
}
}
impl SigningKey {
pub fn generate() -> Self {
Self(Box::new(KeyParams::key_gen(&mut rand::rng())))
}
pub fn from_seed(seed: [u8; 32]) -> Self {
Self(Box::new(KeyParams::from_seed(&Seed::from(seed))))
}
pub fn to_seed(&self) -> [u8; 32] {
self.0.to_seed().into()
}
pub fn public_key(&self) -> PublicKey {
self.0.verifying_key().into()
}
pub fn sign_message(&self, message: &[u8], context: &[u8]) -> Result<Signature, Error> {
self.0
.signing_key()
.sign_deterministic(message, context)
.map(Into::into)
}
pub fn sign_challenge(
&self,
challenge: &AuthChallenge,
context: &[u8],
) -> Result<Signature, Error> {
let challenge = challenge.format();
self.sign_message(&challenge, context)
}
}
impl From<MlDsaVerifyingKey<KeyParams>> for PublicKey {
fn from(value: MlDsaVerifyingKey<KeyParams>) -> Self {
Self(Box::new(value))
}
}
impl From<MlDsaSignature<KeyParams>> for Signature {
fn from(value: MlDsaSignature<KeyParams>) -> Self {
Self(Box::new(value))
}
}
impl From<MlDsaSigningKey<KeyParams>> for SigningKey {
fn from(value: MlDsaSigningKey<KeyParams>) -> Self {
Self(Box::new(value))
}
}
impl TryFrom<Vec<u8>> for PublicKey {
type Error = ();
fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {
Self::try_from(value.as_slice())
}
}
impl TryFrom<&'_ [u8]> for PublicKey {
type Error = ();
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
let encoded = EncodedVerifyingKey::<KeyParams>::try_from(value).map_err(|_| ())?;
Ok(Self(Box::new(MlDsaVerifyingKey::decode(&encoded))))
}
}
impl TryFrom<Vec<u8>> for Signature {
type Error = ();
fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {
Self::try_from(value.as_slice())
}
}
impl TryFrom<&'_ [u8]> for Signature {
type Error = ();
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
MlDsaSignature::try_from(value)
.map(|sig| Self(Box::new(sig)))
.map_err(|_| ())
}
}
#[cfg(test)]
mod tests {
use ml_dsa::{KeyGen, MlDsa87, signature::Keypair as _};
use crate::authn::AuthChallenge;
use super::{CLIENT_CONTEXT, PublicKey, Signature, SigningKey, OPERATOR_CONTEXT};
#[test]
fn public_key_round_trip_decodes() {
let key = MlDsa87::key_gen(&mut rand::rng());
let encoded = PublicKey::from(key.verifying_key()).to_bytes();
let decoded = PublicKey::try_from(encoded.as_slice()).expect("public key should decode");
assert_eq!(decoded, PublicKey::from(key.verifying_key()));
}
#[test]
fn signature_round_trip_decodes() {
let key = SigningKey::generate();
let signature = key
.sign_message(b"challenge", CLIENT_CONTEXT)
.expect("signature should be created");
let decoded =
Signature::try_from(signature.to_bytes().as_slice()).expect("signature should decode");
assert_eq!(decoded, signature);
}
#[test]
fn challenge_verification_uses_context_and_canonical_key_bytes() {
let key = SigningKey::generate();
let public_key = key.public_key();
let challenge = AuthChallenge::generate(&mut rand::rng());
let signature = key
.sign_challenge(&challenge, CLIENT_CONTEXT)
.expect("signature should be created");
assert!(public_key.verify(&challenge, CLIENT_CONTEXT, &signature));
assert!(!public_key.verify(&challenge, OPERATOR_CONTEXT, &signature));
}
#[test]
fn signing_key_round_trip_seed_preserves_public_key_and_signing() {
let original = SigningKey::generate();
let restored = SigningKey::from_seed(original.to_seed());
assert_eq!(restored.public_key(), original.public_key());
let challenge = AuthChallenge::generate(&mut rand::rng());
let signature = restored
.sign_challenge(&challenge, CLIENT_CONTEXT)
.expect("signature should be created");
assert!(
restored
.public_key()
.verify(&challenge, CLIENT_CONTEXT, &signature)
);
}
}

View File

@@ -0,0 +1,112 @@
use std::collections::HashSet;
pub use hmac::digest::Digest;
/// Deterministically hash a value by feeding its fields into the hasher in a consistent order.
#[diagnostic::on_unimplemented(
note = "for local types consider adding `#[derive(arbiter_macros::Hashable)]` to your `{Self}` type",
note = "for types from other crates check whether the crate offers a `Hashable` implementation"
)]
pub trait Hashable {
fn hash<H: Digest>(&self, hasher: &mut H);
}
macro_rules! impl_numeric {
($($t:ty),*) => {
$(
impl Hashable for $t {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(&self.to_be_bytes());
}
}
)*
};
}
impl_numeric!(u8, u16, u32, u64, i8, i16, i32, i64);
impl Hashable for &[u8] {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self);
}
}
impl Hashable for String {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.as_bytes());
}
}
impl<T: Hashable + PartialOrd> Hashable for Vec<T> {
fn hash<H: Digest>(&self, hasher: &mut H) {
let ref_sorted = {
let mut sorted = self.iter().collect::<Vec<_>>();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
sorted
};
for item in ref_sorted {
item.hash(hasher);
}
}
}
impl<T: Hashable + PartialOrd, S: std::hash::BuildHasher> Hashable for HashSet<T, S> {
fn hash<H: Digest>(&self, hasher: &mut H) {
let ref_sorted = {
let mut sorted = self.iter().collect::<Vec<_>>();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
sorted
};
for item in ref_sorted {
item.hash(hasher);
}
}
}
impl<T: Hashable> Hashable for Option<T> {
fn hash<H: Digest>(&self, hasher: &mut H) {
match self {
Some(value) => {
hasher.update([1]);
value.hash(hasher);
}
None => hasher.update([0]),
}
}
}
impl<T: Hashable> Hashable for Box<T> {
fn hash<H: Digest>(&self, hasher: &mut H) {
self.as_ref().hash(hasher);
}
}
impl<T: Hashable> Hashable for &T {
fn hash<H: Digest>(&self, hasher: &mut H) {
(*self).hash(hasher);
}
}
impl Hashable for alloy::primitives::Address {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.as_slice());
}
}
impl Hashable for alloy::primitives::U256 {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.to_be_bytes::<32>());
}
}
impl Hashable for chrono::Duration {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.num_seconds().to_be_bytes());
}
}
impl Hashable for chrono::DateTime<chrono::Utc> {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.timestamp_millis().to_be_bytes());
}
}

View File

@@ -0,0 +1,7 @@
#[cfg(feature = "authn")]
pub mod authn;
pub mod hashing;
#[cfg(feature = "safecell")]
pub mod safecell;
pub use x_wing;

View File

@@ -1,111 +1,118 @@
use std::ops::{Deref, DerefMut}; use memsafe::MemSafe;
use std::{any::type_name, fmt}; use std::{
any::type_name,
use memsafe::MemSafe; fmt,
ops::{Deref, DerefMut},
pub trait SafeCellHandle<T> { };
type CellRead<'a>: Deref<Target = T>
where pub trait SafeCellHandle<T> {
Self: 'a, type CellRead<'a>: Deref<Target = T>
T: 'a; where
type CellWrite<'a>: Deref<Target = T> + DerefMut<Target = T> Self: 'a,
where T: 'a;
Self: 'a, type CellWrite<'a>: Deref<Target = T> + DerefMut<Target = T>
T: 'a; where
Self: 'a,
fn new(value: T) -> Self T: 'a;
where
Self: Sized; fn new(value: T) -> Self
where
fn read(&mut self) -> Self::CellRead<'_>; Self: Sized;
fn write(&mut self) -> Self::CellWrite<'_>;
fn read(&mut self) -> Self::CellRead<'_>;
fn new_inline<F>(f: F) -> Self fn write(&mut self) -> Self::CellWrite<'_>;
where
Self: Sized, fn new_inline<F>(f: F) -> Self
T: Default, where
F: for<'a> FnOnce(&'a mut T), Self: Sized,
{ T: Default,
let mut cell = Self::new(T::default()); F: for<'a> FnOnce(&'a mut T),
{ {
let mut handle = cell.write(); let mut cell = Self::new(T::default());
f(handle.deref_mut()); {
} let mut handle = cell.write();
cell f(&mut *handle);
} }
cell
#[inline(always)] }
fn read_inline<F, R>(&mut self, f: F) -> R
where #[inline(always)]
F: FnOnce(&T) -> R, fn read_inline<F, R>(&mut self, f: F) -> R
{ where
f(&*self.read()) F: FnOnce(&T) -> R,
} {
f(&*self.read())
#[inline(always)] }
fn write_inline<F, R>(&mut self, f: F) -> R
where #[inline(always)]
F: FnOnce(&mut T) -> R, fn write_inline<F, R>(&mut self, f: F) -> R
{ where
f(&mut *self.write()) F: FnOnce(&mut T) -> R,
} {
} f(&mut *self.write())
}
pub struct MemSafeCell<T>(MemSafe<T>); }
impl<T> fmt::Debug for MemSafeCell<T> { pub struct MemSafeCell<T>(MemSafe<T>);
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MemSafeCell") impl<T> fmt::Debug for MemSafeCell<T> {
.field("inner", &format_args!("<protected {}>", type_name::<T>())) fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
.finish() f.debug_struct("MemSafeCell")
} .field("inner", &format_args!("<protected {}>", type_name::<T>()))
} .finish()
}
impl<T> SafeCellHandle<T> for MemSafeCell<T> { }
type CellRead<'a>
= memsafe::MemSafeRead<'a, T> impl<T> SafeCellHandle<T> for MemSafeCell<T> {
where type CellRead<'a>
Self: 'a, = memsafe::MemSafeRead<'a, T>
T: 'a; where
type CellWrite<'a> Self: 'a,
= memsafe::MemSafeWrite<'a, T> T: 'a;
where type CellWrite<'a>
Self: 'a, = memsafe::MemSafeWrite<'a, T>
T: 'a; where
Self: 'a,
fn new(value: T) -> Self { T: 'a;
match MemSafe::new(value) {
Ok(inner) => Self(inner), fn new(value: T) -> Self {
Err(err) => { match MemSafe::new(value) {
// If protected memory cannot be allocated, process integrity is compromised. Ok(inner) => Self(inner),
abort_memory_breach("safe cell allocation", &err) Err(err) => {
} // If protected memory cannot be allocated, process integrity is compromised.
} abort_memory_breach("safe cell allocation", &err)
} }
}
#[inline(always)] }
fn read(&mut self) -> Self::CellRead<'_> {
match self.0.read() { #[inline(always)]
Ok(inner) => inner, fn read(&mut self) -> Self::CellRead<'_> {
Err(err) => abort_memory_breach("safe cell read", &err), match self.0.read() {
} Ok(inner) => inner,
} Err(err) => abort_memory_breach("safe cell read", &err),
}
#[inline(always)] }
fn write(&mut self) -> Self::CellWrite<'_> {
match self.0.write() { #[inline(always)]
Ok(inner) => inner, fn write(&mut self) -> Self::CellWrite<'_> {
Err(err) => { match self.0.write() {
// If protected memory becomes unwritable here, treat it as a fatal memory breach. Ok(inner) => inner,
abort_memory_breach("safe cell write", &err) Err(err) => {
} // If protected memory becomes unwritable here, treat it as a fatal memory breach.
} abort_memory_breach("safe cell write", &err)
} }
} }
}
fn abort_memory_breach(action: &str, err: &memsafe::error::MemoryError) -> ! { }
eprintln!("fatal {action}: {err}");
std::process::abort(); fn abort_memory_breach(action: &str, err: &memsafe::error::MemoryError) -> ! {
} eprintln!("fatal {action}: {err}");
// SAFETY: Intentionally cause a segmentation fault to prevent further execution in a compromised state.
pub type SafeCell<T> = MemSafeCell<T>; unsafe {
let unsafe_pointer = std::ptr::null_mut::<u8>();
std::ptr::write_volatile(unsafe_pointer, 0);
}
std::process::abort();
}
pub type SafeCell<T> = MemSafeCell<T>;

View File

@@ -0,0 +1,19 @@
[package]
name = "arbiter-macros"
version = "0.1.0"
edition = "2024"
[lib]
proc-macro = true
doctest = false
[dependencies]
proc-macro2 = "1.0"
quote = "1.0"
syn = { version = "2.0", features = ["derive", "fold", "full", "visit-mut"] }
[dev-dependencies]
arbiter-crypto = { path = "../arbiter-crypto" }
[lints]
workspace = true

View File

@@ -0,0 +1,131 @@
use crate::utils::{HASHABLE_TRAIT_PATH, HMAC_DIGEST_PATH};
use proc_macro2::{Span, TokenStream, TokenTree};
use quote::quote;
use syn::{DataStruct, DeriveInput, Fields, Generics, Index, parse_quote, spanned::Spanned};
pub(crate) fn derive(input: &DeriveInput) -> TokenStream {
match &input.data {
syn::Data::Struct(struct_data) => hashable_struct(input, struct_data),
syn::Data::Enum(_) => {
syn::Error::new_spanned(input, "Hashable can currently be derived only for structs")
.to_compile_error()
}
syn::Data::Union(_) => {
syn::Error::new_spanned(input, "Hashable cannot be derived for unions")
.to_compile_error()
}
}
}
fn hashable_struct(input: &DeriveInput, struct_data: &DataStruct) -> TokenStream {
let ident = &input.ident;
let hashable_trait = HASHABLE_TRAIT_PATH.to_path();
let hmac_digest = HMAC_DIGEST_PATH.to_path();
let generics = add_hashable_bounds(input.generics.clone(), &hashable_trait);
let field_accesses = collect_field_accesses(struct_data);
let hash_calls = build_hash_calls(&field_accesses, &hashable_trait);
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
quote! {
#[automatically_derived]
impl #impl_generics #hashable_trait for #ident #ty_generics #where_clause {
fn hash<H: #hmac_digest>(&self, hasher: &mut H) {
#(#hash_calls)*
}
}
}
}
fn add_hashable_bounds(mut generics: Generics, hashable_trait: &syn::Path) -> Generics {
for type_param in generics.type_params_mut() {
type_param.bounds.push(parse_quote!(#hashable_trait));
}
generics
}
struct FieldAccess {
access: TokenStream,
span: Span,
}
fn collect_field_accesses(struct_data: &DataStruct) -> Vec<FieldAccess> {
match &struct_data.fields {
Fields::Named(fields) => {
// Keep deterministic alphabetical order for named fields.
// Do not remove this sort, because it keeps hash output stable regardless of source order.
let mut named_fields = fields
.named
.iter()
.map(|field| {
let name = field
.ident
.as_ref()
.expect("Fields::Named(fields) must have names")
.clone();
(name.to_string(), name)
})
.collect::<Vec<_>>();
named_fields.sort_by(|a, b| a.0.cmp(&b.0));
named_fields
.into_iter()
.map(|(_, name)| FieldAccess {
access: quote! { #name },
span: name.span(),
})
.collect()
}
Fields::Unnamed(fields) => fields
.unnamed
.iter()
.enumerate()
.map(|(i, field)| FieldAccess {
access: {
let index = Index::from(i);
quote! { #index }
},
span: field.ty.span(),
})
.collect(),
Fields::Unit => Vec::new(),
}
}
fn build_hash_calls(
field_accesses: &[FieldAccess],
hashable_trait: &syn::Path,
) -> Vec<TokenStream> {
field_accesses
.iter()
.map(|field| {
let access = &field.access;
let call = quote! {
#hashable_trait::hash(&self.#access, hasher);
};
respan(call, field.span)
})
.collect()
}
/// Recursively set span on all tokens, including interpolated ones.
fn respan(tokens: TokenStream, span: Span) -> TokenStream {
tokens
.into_iter()
.map(|tt| match tt {
TokenTree::Group(g) => {
let mut new = proc_macro2::Group::new(g.delimiter(), respan(g.stream(), span));
new.set_span(span);
TokenTree::Group(new)
}
mut other => {
other.set_span(span);
other
}
})
.collect()
}

View File

@@ -0,0 +1,10 @@
use syn::{DeriveInput, parse_macro_input};
mod hashable;
mod utils;
#[proc_macro_derive(Hashable)]
pub fn derive_hashable(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let input = parse_macro_input!(input as DeriveInput);
hashable::derive(&input).into()
}

View File

@@ -0,0 +1,24 @@
pub(crate) struct ToPath(pub &'static str);
impl ToPath {
pub(crate) fn to_path(&self) -> syn::Path {
syn::parse_str(self.0).expect("Invalid path")
}
}
macro_rules! ensure_path {
($path:path as $name:ident) => {
const _: () = {
#[cfg(test)]
#[expect(
unused_imports,
reason = "Ensures the path is valid and will cause a compile error if not"
)]
use $path as _;
};
pub(crate) const $name: ToPath = ToPath(stringify!($path));
};
}
ensure_path!(::arbiter_crypto::hashing::Hashable as HASHABLE_TRAIT_PATH);
ensure_path!(::arbiter_crypto::hashing::Digest as HMAC_DIGEST_PATH);

View File

@@ -1,36 +1,35 @@
[package] [package]
name = "arbiter-proto" name = "arbiter-proto"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
repository = "https://git.markettakers.org/MarketTakers/arbiter" repository = "https://git.markettakers.org/MarketTakers/arbiter"
license = "Apache-2.0" license = "Apache-2.0"
[dependencies] [dependencies]
tonic.workspace = true tonic.workspace = true
tokio.workspace = true tokio.workspace = true
futures.workspace = true futures.workspace = true
hex = "0.4.3" tonic-prost = "0.14.5"
tonic-prost = "0.14.5" prost.workspace = true
prost = "0.14.3" kameo.workspace = true
kameo.workspace = true url = "2.5.8"
url = "2.5.8" miette.workspace = true
miette.workspace = true thiserror.workspace = true
thiserror.workspace = true rustls-pki-types.workspace = true
rustls-pki-types.workspace = true base64.workspace = true
base64 = "0.22.1" prost-types.workspace = true
prost-types.workspace = true async-trait.workspace = true
tracing.workspace = true tokio-stream.workspace = true
async-trait.workspace = true
tokio-stream.workspace = true [build-dependencies]
tonic-prost-build = "0.14.5"
[build-dependencies]
tonic-prost-build = "0.14.5" [dev-dependencies]
protoc-bin-vendored = "3" rstest.workspace = true
rcgen.workspace = true
[dev-dependencies]
rstest.workspace = true [lib]
rand.workspace = true doctest = false
rcgen.workspace = true
[package.metadata.cargo-shear]
[package.metadata.cargo-shear] ignored = ["tonic-prost", "prost"]
ignored = ["tonic-prost", "prost", "kameo"]

View File

@@ -1,32 +1,21 @@
use std::path::PathBuf; use tonic_prost_build::configure;
use tonic_prost_build::configure;
static PROTOBUF_DIR: &str = "../../../protobufs";
static PROTOBUF_DIR: &str = "../../../protobufs";
fn main() -> Result<(), Box<dyn std::error::Error>> {
fn main() -> Result<(), Box<dyn std::error::Error>> { println!("cargo::rerun-if-changed={PROTOBUF_DIR}");
let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?);
let protobuf_dir = manifest_dir.join(PROTOBUF_DIR); configure()
let protoc_include = protoc_bin_vendored::include_path()?; .message_attribute(".", "#[derive(::kameo::Reply)]")
let protoc_path = protoc_bin_vendored::protoc_bin_path()?; .compile_protos(
&[
unsafe { format!("{}/arbiter.proto", PROTOBUF_DIR),
std::env::set_var("PROTOC", &protoc_path); format!("{}/operator.proto", PROTOBUF_DIR),
std::env::set_var("PROTOC_INCLUDE", &protoc_include); format!("{}/client.proto", PROTOBUF_DIR),
} format!("{}/evm.proto", PROTOBUF_DIR),
],
println!("cargo::rerun-if-changed={}", protobuf_dir.display()); &[PROTOBUF_DIR.to_string()],
)
configure() .unwrap();
.message_attribute(".", "#[derive(::kameo::Reply)]") Ok(())
.compile_well_known_types(true) }
.compile_protos(
&[
protobuf_dir.join("arbiter.proto"),
protobuf_dir.join("user_agent.proto"),
protobuf_dir.join("client.proto"),
protobuf_dir.join("evm.proto"),
],
&[protobuf_dir],
)?;
Ok(())
}

View File

@@ -1,46 +1,84 @@
pub mod transport; pub mod transport;
pub mod url; pub mod url;
use base64::{Engine, prelude::BASE64_STANDARD}; pub mod proto {
tonic::include_proto!("arbiter");
pub mod google {
pub mod protobuf { pub mod shared {
tonic::include_proto!("google.protobuf"); tonic::include_proto!("arbiter.shared");
}
} pub mod evm {
tonic::include_proto!("arbiter.shared.evm");
pub mod proto { }
tonic::include_proto!("arbiter"); }
pub mod user_agent { pub mod operator {
tonic::include_proto!("arbiter.user_agent"); tonic::include_proto!("arbiter.operator");
}
pub mod auth {
pub mod client { tonic::include_proto!("arbiter.operator.auth");
tonic::include_proto!("arbiter.client"); }
}
pub mod evm {
pub mod evm { tonic::include_proto!("arbiter.operator.evm");
tonic::include_proto!("arbiter.evm"); }
}
} pub mod sdk_client {
tonic::include_proto!("arbiter.operator.sdk_client");
pub static BOOTSTRAP_PATH: &str = "bootstrap_token"; }
pub fn home_path() -> Result<std::path::PathBuf, std::io::Error> { pub mod vault {
static ARBITER_HOME: &str = ".arbiter"; tonic::include_proto!("arbiter.operator.vault");
let home_dir = std::env::home_dir().ok_or(std::io::Error::new(
std::io::ErrorKind::PermissionDenied, pub mod bootstrap {
"can not get home directory", tonic::include_proto!("arbiter.operator.vault.bootstrap");
))?; }
let arbiter_home = home_dir.join(ARBITER_HOME); pub mod unseal {
std::fs::create_dir_all(&arbiter_home)?; tonic::include_proto!("arbiter.operator.vault.unseal");
}
Ok(arbiter_home) }
} }
pub fn format_challenge(nonce: i32, pubkey: &[u8]) -> Vec<u8> { pub mod client {
let concat_form = format!("{}:{}", nonce, BASE64_STANDARD.encode(pubkey)); tonic::include_proto!("arbiter.client");
concat_form.into_bytes()
} pub mod auth {
tonic::include_proto!("arbiter.client.auth");
}
pub mod evm {
tonic::include_proto!("arbiter.client.evm");
}
pub mod vault {
tonic::include_proto!("arbiter.client.vault");
}
}
pub mod evm {
tonic::include_proto!("arbiter.evm");
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientMetadata {
pub name: String,
pub description: Option<String>,
pub version: Option<String>,
}
pub static BOOTSTRAP_PATH: &str = "bootstrap_token";
pub fn home_path() -> Result<std::path::PathBuf, std::io::Error> {
static ARBITER_HOME: &str = ".arbiter";
let home_dir = std::env::home_dir().ok_or(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"can not get home directory",
))?;
let arbiter_home = home_dir.join(ARBITER_HOME);
std::fs::create_dir_all(&arbiter_home)?;
Ok(arbiter_home)
}

View File

@@ -1,163 +1,218 @@
//! Transport-facing abstractions shared by protocol/session code. //! Transport-facing abstractions shared by protocol/session code.
//! //!
//! This module defines a small set of transport traits that actors and other //! This module defines a small set of transport traits that actors and other
//! protocol code can depend on without knowing anything about the concrete //! protocol code can depend on without knowing anything about the concrete
//! transport underneath. //! transport underneath.
//! //!
//! The abstraction is split into: //! The abstraction is split into:
//! - [`Sender`] for outbound delivery //! - [`Sender`] for outbound delivery
//! - [`Receiver`] for inbound delivery //! - [`Receiver`] for inbound delivery
//! - [`Bi`] as the combined duplex form (`Sender + Receiver`) //! - [`Bi`] as the combined duplex form (`Sender + Receiver`)
//! //!
//! This split lets code depend only on the half it actually needs. For //! This split lets code depend only on the half it actually needs. For
//! example, some actor/session code only sends out-of-band messages, while //! example, some actor/session code only sends out-of-band messages, while
//! auth/state-machine code may need full duplex access. //! auth/state-machine code may need full duplex access.
//! //!
//! [`Bi`] remains intentionally minimal and transport-agnostic: //! [`Bi`] remains intentionally minimal and transport-agnostic:
//! - [`Receiver::recv`] yields inbound messages //! - [`Receiver::recv`] yields inbound messages
//! - [`Sender::send`] accepts outbound messages //! - [`Sender::send`] accepts outbound messages
//! //!
//! Transport-specific adapters, including protobuf or gRPC bridges, live in the //! Transport-specific adapters, including protobuf or gRPC bridges, live in the
//! crates that own those boundaries rather than in `arbiter-proto`. //! crates that own those boundaries rather than in `arbiter-proto`.
//! //!
//! [`Bi`] deliberately does not model request/response correlation. Some //! [`Bi`] deliberately does not model request/response correlation. Some
//! transports may carry multiplexed request/response traffic, some may emit //! transports may carry multiplexed request/response traffic, some may emit
//! out-of-band messages, and some may be one-message-at-a-time state machines. //! out-of-band messages, and some may be one-message-at-a-time state machines.
//! Correlation concerns such as request IDs, pending response maps, and //! Correlation concerns such as request IDs, pending response maps, and
//! out-of-band routing belong in the adapter or connection layer built on top //! out-of-band routing belong in the adapter or connection layer built on top
//! of [`Bi`], not in this abstraction itself. //! of [`Bi`], not in this abstraction itself.
//! //!
//! # Generic Ordering Rule //! # Generic Ordering Rule
//! //!
//! This module consistently uses `Inbound` first and `Outbound` second in //! This module consistently uses `Inbound` first and `Outbound` second in
//! generic parameter lists. //! generic parameter lists.
//! //!
//! For [`Receiver`], [`Sender`], and [`Bi`], this means: //! For [`Receiver`], [`Sender`], and [`Bi`], this means:
//! - `Receiver<Inbound>` //! - `Receiver<Inbound>`
//! - `Sender<Outbound>` //! - `Sender<Outbound>`
//! - `Bi<Inbound, Outbound>` //! - `Bi<Inbound, Outbound>`
//! //!
//! Concretely, for [`Bi`]: //! Concretely, for [`Bi`]:
//! - `recv() -> Option<Inbound>` //! - `recv() -> Option<Inbound>`
//! - `send(Outbound)` //! - `send(Outbound)`
//! //!
//! [`expect_message`] is a small helper for linear protocol steps: it reads one //! [`expect_message`] is a small helper for linear protocol steps: it reads one
//! inbound message from a transport and extracts a typed value from it, failing //! inbound message from a transport and extracts a typed value from it, failing
//! if the channel closes or the message shape is not what the caller expected. //! if the channel closes or the message shape is not what the caller expected.
//! //!
//! [`DummyTransport`] is a no-op implementation useful for tests and local //! [`DummyTransport`] is a no-op implementation useful for tests and local
//! actor execution where no real stream exists. //! actor execution where no real stream exists.
//! //!
//! # Design Notes //! # Design Notes
//! //!
//! - [`Bi::send`] returns [`Error`] only for transport delivery failures, such //! - [`Bi::send`] returns [`Error`] only for transport delivery failures, such
//! as a closed outbound channel. //! as a closed outbound channel.
//! - [`Bi::recv`] returns `None` when the underlying transport closes. //! - [`Bi::recv`] returns `None` when the underlying transport closes.
//! - Message translation is intentionally out of scope for this module. //! - Message translation is intentionally out of scope for this module.
use async_trait::async_trait;
use std::marker::PhantomData; use kameo::{error::Infallible, prelude::*};
use std::marker::PhantomData;
use async_trait::async_trait;
/// Errors returned by transport adapters implementing [`Bi`].
/// Errors returned by transport adapters implementing [`Bi`]. #[derive(thiserror::Error, Debug)]
#[derive(thiserror::Error, Debug)] pub enum Error {
pub enum Error { #[error("Transport channel is closed")]
#[error("Transport channel is closed")] ChannelClosed,
ChannelClosed, #[error("Unexpected message received")]
#[error("Unexpected message received")] UnexpectedMessage,
UnexpectedMessage, }
}
/// Receives one message from `transport` and extracts a value from it using
/// Receives one message from `transport` and extracts a value from it using /// `extractor`. Returns [`Error::ChannelClosed`] if the transport closes and
/// `extractor`. Returns [`Error::ChannelClosed`] if the transport closes and /// [`Error::UnexpectedMessage`] if `extractor` returns `None`.
/// [`Error::UnexpectedMessage`] if `extractor` returns `None`. pub async fn expect_message<T, Inbound, Outbound, Target, F>(
pub async fn expect_message<T, Inbound, Outbound, Target, F>( transport: &mut T,
transport: &mut T, extractor: F,
extractor: F, ) -> Result<Target, Error>
) -> Result<Target, Error> where
where T: Bi<Inbound, Outbound> + ?Sized,
T: Bi<Inbound, Outbound> + ?Sized, F: FnOnce(Inbound) -> Option<Target>,
F: FnOnce(Inbound) -> Option<Target>, {
{ let msg = transport.recv().await.ok_or(Error::ChannelClosed)?;
let msg = transport.recv().await.ok_or(Error::ChannelClosed)?; extractor(msg).ok_or(Error::UnexpectedMessage)
extractor(msg).ok_or(Error::UnexpectedMessage) }
}
#[async_trait]
#[async_trait] pub trait Sender<Outbound>: Send + Sync {
pub trait Sender<Outbound>: Send + Sync { async fn send(&mut self, item: Outbound) -> Result<(), Error>;
async fn send(&mut self, item: Outbound) -> Result<(), Error>; }
}
#[async_trait]
#[async_trait] pub trait Receiver<Inbound>: Send + Sync {
pub trait Receiver<Inbound>: Send + Sync { async fn recv(&mut self) -> Option<Inbound>;
async fn recv(&mut self) -> Option<Inbound>; }
}
/// Minimal bidirectional transport abstraction used by protocol code.
/// Minimal bidirectional transport abstraction used by protocol code. ///
/// /// `Bi<Inbound, Outbound>` is the combined duplex form of [`Sender`] and
/// `Bi<Inbound, Outbound>` is the combined duplex form of [`Sender`] and /// [`Receiver`].
/// [`Receiver`]. ///
/// /// It models a channel with:
/// It models a channel with: /// - inbound items of type `Inbound` read via [`Bi::recv`]
/// - inbound items of type `Inbound` read via [`Bi::recv`] /// - outbound items of type `Outbound` written via [`Bi::send`]
/// - outbound items of type `Outbound` written via [`Bi::send`] ///
/// /// It does not imply request/response sequencing, one-at-a-time exchange, or
/// It does not imply request/response sequencing, one-at-a-time exchange, or /// any built-in correlation mechanism between inbound and outbound items.
/// any built-in correlation mechanism between inbound and outbound items. pub trait Bi<Inbound, Outbound>: Sender<Outbound> + Receiver<Inbound> + Send + Sync {}
pub trait Bi<Inbound, Outbound>: Sender<Outbound> + Receiver<Inbound> + Send + Sync {}
#[async_trait]
pub trait SplittableBi<Inbound, Outbound>: Bi<Inbound, Outbound> { impl<T, Outbound> Sender<Outbound> for &mut T
type Sender: Sender<Outbound>; where
type Receiver: Receiver<Inbound>; T: Sender<Outbound> + ?Sized,
Outbound: Send + 'static,
fn split(self) -> (Self::Sender, Self::Receiver); {
fn from_parts(sender: Self::Sender, receiver: Self::Receiver) -> Self; async fn send(&mut self, item: Outbound) -> Result<(), Error> {
} (**self).send(item).await
}
/// No-op [`Bi`] transport for tests and manual actor usage. }
///
/// `send` drops all items and succeeds. [`Bi::recv`] never resolves and therefore #[async_trait]
/// does not busy-wait or spuriously close the stream. impl<T, Inbound> Receiver<Inbound> for &mut T
pub struct DummyTransport<Inbound, Outbound> { where
_marker: PhantomData<(Inbound, Outbound)>, T: Receiver<Inbound> + ?Sized,
} Inbound: Send + 'static,
{
impl<Inbound, Outbound> Default for DummyTransport<Inbound, Outbound> { async fn recv(&mut self) -> Option<Inbound> {
fn default() -> Self { (**self).recv().await
Self { }
_marker: PhantomData, }
}
} impl<T, Inbound, Outbound> Bi<Inbound, Outbound> for &mut T
} where
T: Bi<Inbound, Outbound> + ?Sized,
#[async_trait] Inbound: Send + 'static,
impl<Inbound, Outbound> Sender<Outbound> for DummyTransport<Inbound, Outbound> Outbound: Send + 'static,
where {
Inbound: Send + Sync + 'static, }
Outbound: Send + Sync + 'static,
{ pub trait SplittableBi<Inbound, Outbound>: Bi<Inbound, Outbound> {
async fn send(&mut self, _item: Outbound) -> Result<(), Error> { type Sender: Sender<Outbound>;
Ok(()) type Receiver: Receiver<Inbound>;
}
} fn split(self) -> (Self::Sender, Self::Receiver);
fn from_parts(sender: Self::Sender, receiver: Self::Receiver) -> Self;
#[async_trait] }
impl<Inbound, Outbound> Receiver<Inbound> for DummyTransport<Inbound, Outbound>
where /// No-op [`Bi`] transport for tests and manual actor usage.
Inbound: Send + Sync + 'static, ///
Outbound: Send + Sync + 'static, /// `send` drops all items and succeeds. [`Bi::recv`] never resolves and therefore
{ /// does not busy-wait or spuriously close the stream.
async fn recv(&mut self) -> Option<Inbound> { pub struct DummyTransport<Inbound, Outbound> {
std::future::pending::<()>().await; _marker: PhantomData<(Inbound, Outbound)>,
None }
}
} impl<Inbound, Outbound> Default for DummyTransport<Inbound, Outbound> {
fn default() -> Self {
impl<Inbound, Outbound> Bi<Inbound, Outbound> for DummyTransport<Inbound, Outbound> Self {
where _marker: PhantomData,
Inbound: Send + Sync + 'static, }
Outbound: Send + Sync + 'static, }
{ }
}
#[async_trait]
pub mod grpc; impl<Inbound, Outbound> Sender<Outbound> for DummyTransport<Inbound, Outbound>
where
Inbound: Send + Sync + 'static,
Outbound: Send + Sync + 'static,
{
async fn send(&mut self, _item: Outbound) -> Result<(), Error> {
Ok(())
}
}
#[async_trait]
impl<Inbound, Outbound> Receiver<Inbound> for DummyTransport<Inbound, Outbound>
where
Inbound: Send + Sync + 'static,
Outbound: Send + Sync + 'static,
{
async fn recv(&mut self) -> Option<Inbound> {
std::future::pending::<()>().await;
None
}
}
impl<Inbound, Outbound> Bi<Inbound, Outbound> for DummyTransport<Inbound, Outbound>
where
Inbound: Send + Sync + 'static,
Outbound: Send + Sync + 'static,
{
}
pub mod grpc;
#[derive(thiserror::Error, Debug)]
pub enum ForwardError<I> {
#[error("Transport error: {0}")]
Transport(#[from] Error),
#[error("Actor delivery error: {0}")]
Actor(SendError<I>),
}
pub async fn forward_to_actor<Transport, Inbound, Outbound, Handler>(
transport: &mut Transport,
actor: &ActorRef<Handler>,
) -> Result<(), ForwardError<Inbound>>
where
Transport: Bi<Inbound, <Outbound as Reply>::Ok>,
Handler: Actor + Message<Inbound, Reply = Outbound>,
Inbound: Send + 'static,
Outbound: Send + 'static + Reply<Error = Infallible>, // `Infallible` to enforce contract that `Outbound` carries handler-level error
{
while let Some(request) = transport.recv().await {
let resp = actor.ask(request).await.map_err(ForwardError::Actor)?;
transport.send(resp).await?
}
Err(Error::ChannelClosed.into())
}

View File

@@ -1,106 +1,106 @@
use async_trait::async_trait; use super::{Bi, Receiver, Sender};
use futures::StreamExt;
use tokio::sync::mpsc; use async_trait::async_trait;
use tokio_stream::wrappers::ReceiverStream; use futures::StreamExt;
use tokio::sync::mpsc;
use super::{Bi, Receiver, Sender}; use tokio_stream::wrappers::ReceiverStream;
pub struct GrpcSender<Outbound> { pub struct GrpcSender<Outbound> {
tx: mpsc::Sender<Result<Outbound, tonic::Status>>, tx: mpsc::Sender<Result<Outbound, tonic::Status>>,
} }
#[async_trait] #[async_trait]
impl<Outbound> Sender<Result<Outbound, tonic::Status>> for GrpcSender<Outbound> impl<Outbound> Sender<Result<Outbound, tonic::Status>> for GrpcSender<Outbound>
where where
Outbound: Send + Sync + 'static, Outbound: Send + Sync + 'static,
{ {
async fn send(&mut self, item: Result<Outbound, tonic::Status>) -> Result<(), super::Error> { async fn send(&mut self, item: Result<Outbound, tonic::Status>) -> Result<(), super::Error> {
self.tx self.tx
.send(item) .send(item)
.await .await
.map_err(|_| super::Error::ChannelClosed) .map_err(|_| super::Error::ChannelClosed)
} }
} }
pub struct GrpcReceiver<Inbound> { pub struct GrpcReceiver<Inbound> {
rx: tonic::Streaming<Inbound>, rx: tonic::Streaming<Inbound>,
} }
#[async_trait] #[async_trait]
impl<Inbound> Receiver<Result<Inbound, tonic::Status>> for GrpcReceiver<Inbound> impl<Inbound> Receiver<Result<Inbound, tonic::Status>> for GrpcReceiver<Inbound>
where where
Inbound: Send + Sync + 'static, Inbound: Send + Sync + 'static,
{ {
async fn recv(&mut self) -> Option<Result<Inbound, tonic::Status>> { async fn recv(&mut self) -> Option<Result<Inbound, tonic::Status>> {
self.rx.next().await self.rx.next().await
} }
} }
pub struct GrpcBi<Inbound, Outbound> { pub struct GrpcBi<Inbound, Outbound> {
sender: GrpcSender<Outbound>, sender: GrpcSender<Outbound>,
receiver: GrpcReceiver<Inbound>, receiver: GrpcReceiver<Inbound>,
} }
impl<Inbound, Outbound> GrpcBi<Inbound, Outbound> impl<Inbound, Outbound> GrpcBi<Inbound, Outbound>
where where
Inbound: Send + Sync + 'static, Inbound: Send + Sync + 'static,
Outbound: Send + Sync + 'static, Outbound: Send + Sync + 'static,
{ {
pub fn from_bi_stream( pub fn from_bi_stream(
receiver: tonic::Streaming<Inbound>, receiver: tonic::Streaming<Inbound>,
) -> (Self, ReceiverStream<Result<Outbound, tonic::Status>>) { ) -> (Self, ReceiverStream<Result<Outbound, tonic::Status>>) {
let (tx, rx) = mpsc::channel(10); let (tx, rx) = mpsc::channel(10);
let sender = GrpcSender { tx }; let sender = GrpcSender { tx };
let receiver = GrpcReceiver { rx: receiver }; let receiver = GrpcReceiver { rx: receiver };
let bi = GrpcBi { sender, receiver }; let bi = GrpcBi { sender, receiver };
(bi, ReceiverStream::new(rx)) (bi, ReceiverStream::new(rx))
} }
} }
#[async_trait] #[async_trait]
impl<Inbound, Outbound> Sender<Result<Outbound, tonic::Status>> for GrpcBi<Inbound, Outbound> impl<Inbound, Outbound> Sender<Result<Outbound, tonic::Status>> for GrpcBi<Inbound, Outbound>
where where
Inbound: Send + Sync + 'static, Inbound: Send + Sync + 'static,
Outbound: Send + Sync + 'static, Outbound: Send + Sync + 'static,
{ {
async fn send(&mut self, item: Result<Outbound, tonic::Status>) -> Result<(), super::Error> { async fn send(&mut self, item: Result<Outbound, tonic::Status>) -> Result<(), super::Error> {
self.sender.send(item).await self.sender.send(item).await
} }
} }
#[async_trait] #[async_trait]
impl<Inbound, Outbound> Receiver<Result<Inbound, tonic::Status>> for GrpcBi<Inbound, Outbound> impl<Inbound, Outbound> Receiver<Result<Inbound, tonic::Status>> for GrpcBi<Inbound, Outbound>
where where
Inbound: Send + Sync + 'static, Inbound: Send + Sync + 'static,
Outbound: Send + Sync + 'static, Outbound: Send + Sync + 'static,
{ {
async fn recv(&mut self) -> Option<Result<Inbound, tonic::Status>> { async fn recv(&mut self) -> Option<Result<Inbound, tonic::Status>> {
self.receiver.recv().await self.receiver.recv().await
} }
} }
impl<Inbound, Outbound> Bi<Result<Inbound, tonic::Status>, Result<Outbound, tonic::Status>> impl<Inbound, Outbound> Bi<Result<Inbound, tonic::Status>, Result<Outbound, tonic::Status>>
for GrpcBi<Inbound, Outbound> for GrpcBi<Inbound, Outbound>
where where
Inbound: Send + Sync + 'static, Inbound: Send + Sync + 'static,
Outbound: Send + Sync + 'static, Outbound: Send + Sync + 'static,
{ {
} }
impl<Inbound, Outbound> impl<Inbound, Outbound>
super::SplittableBi<Result<Inbound, tonic::Status>, Result<Outbound, tonic::Status>> super::SplittableBi<Result<Inbound, tonic::Status>, Result<Outbound, tonic::Status>>
for GrpcBi<Inbound, Outbound> for GrpcBi<Inbound, Outbound>
where where
Inbound: Send + Sync + 'static, Inbound: Send + Sync + 'static,
Outbound: Send + Sync + 'static, Outbound: Send + Sync + 'static,
{ {
type Sender = GrpcSender<Outbound>; type Sender = GrpcSender<Outbound>;
type Receiver = GrpcReceiver<Inbound>; type Receiver = GrpcReceiver<Inbound>;
fn split(self) -> (Self::Sender, Self::Receiver) { fn split(self) -> (Self::Sender, Self::Receiver) {
(self.sender, self.receiver) (self.sender, self.receiver)
} }
fn from_parts(sender: Self::Sender, receiver: Self::Receiver) -> Self { fn from_parts(sender: Self::Sender, receiver: Self::Receiver) -> Self {
GrpcBi { sender, receiver } GrpcBi { sender, receiver }
} }
} }

View File

@@ -1,128 +1,128 @@
use std::fmt::Display; use base64::{Engine as _, prelude::BASE64_URL_SAFE};
use rustls_pki_types::CertificateDer;
use base64::{Engine as _, prelude::BASE64_URL_SAFE}; use std::fmt::Display;
use rustls_pki_types::CertificateDer;
const ARBITER_URL_SCHEME: &str = "arbiter";
const ARBITER_URL_SCHEME: &str = "arbiter"; const CERT_QUERY_KEY: &str = "cert";
const CERT_QUERY_KEY: &str = "cert"; const BOOTSTRAP_TOKEN_QUERY_KEY: &str = "bootstrap_token";
const BOOTSTRAP_TOKEN_QUERY_KEY: &str = "bootstrap_token";
#[derive(Debug, Clone)]
pub struct ArbiterUrl { pub struct ArbiterUrl {
pub host: String, pub host: String,
pub port: u16, pub port: u16,
pub ca_cert: CertificateDer<'static>, pub ca_cert: CertificateDer<'static>,
pub bootstrap_token: Option<String>, pub bootstrap_token: Option<String>,
} }
impl Display for ArbiterUrl { impl Display for ArbiterUrl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut base = format!( let mut base = format!(
"{ARBITER_URL_SCHEME}://{}:{}?{CERT_QUERY_KEY}={}", "{ARBITER_URL_SCHEME}://{}:{}?{CERT_QUERY_KEY}={}",
self.host, self.host,
self.port, self.port,
BASE64_URL_SAFE.encode(&self.ca_cert) BASE64_URL_SAFE.encode(&self.ca_cert)
); );
if let Some(token) = &self.bootstrap_token { if let Some(token) = &self.bootstrap_token {
base.push_str(&format!("&{BOOTSTRAP_TOKEN_QUERY_KEY}={}", token)); base.push_str(&format!("&{BOOTSTRAP_TOKEN_QUERY_KEY}={}", token));
} }
f.write_str(&base) f.write_str(&base)
} }
} }
#[derive(Debug, thiserror::Error, miette::Diagnostic)] #[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum Error { pub enum Error {
#[error("Invalid URL scheme, expected '{ARBITER_URL_SCHEME}://'")] #[error("Invalid URL scheme, expected '{ARBITER_URL_SCHEME}://'")]
#[diagnostic( #[diagnostic(
code(arbiter::url::invalid_scheme), code(arbiter::url::invalid_scheme),
help("The URL must start with '{ARBITER_URL_SCHEME}://'") help("The URL must start with '{ARBITER_URL_SCHEME}://'")
)] )]
InvalidScheme, InvalidScheme,
#[error("Missing host in URL")] #[error("Missing host in URL")]
#[diagnostic( #[diagnostic(
code(arbiter::url::missing_host), code(arbiter::url::missing_host),
help("The URL must include a host, e.g., '{ARBITER_URL_SCHEME}://127.0.0.1:<port>'") help("The URL must include a host, e.g., '{ARBITER_URL_SCHEME}://127.0.0.1:<port>'")
)] )]
MissingHost, MissingHost,
#[error("Missing port in URL")] #[error("Missing port in URL")]
#[diagnostic( #[diagnostic(
code(arbiter::url::missing_port), code(arbiter::url::missing_port),
help("The URL must include a port, e.g., '{ARBITER_URL_SCHEME}://127.0.0.1:1234'") help("The URL must include a port, e.g., '{ARBITER_URL_SCHEME}://127.0.0.1:1234'")
)] )]
MissingPort, MissingPort,
#[error("Missing 'cert' query parameter in URL")] #[error("Missing 'cert' query parameter in URL")]
#[diagnostic( #[diagnostic(
code(arbiter::url::missing_cert), code(arbiter::url::missing_cert),
help("The URL must include a 'cert' query parameter") help("The URL must include a 'cert' query parameter")
)] )]
MissingCert, MissingCert,
#[error("Invalid base64 in 'cert' query parameter: {0}")] #[error("Invalid base64 in 'cert' query parameter: {0}")]
#[diagnostic(code(arbiter::url::invalid_cert_base64))] #[diagnostic(code(arbiter::url::invalid_cert_base64))]
InvalidCertBase64(#[from] base64::DecodeError), InvalidCertBase64(#[from] base64::DecodeError),
} }
impl<'a> TryFrom<&'a str> for ArbiterUrl { impl<'a> TryFrom<&'a str> for ArbiterUrl {
type Error = Error; type Error = Error;
fn try_from(value: &'a str) -> Result<Self, Self::Error> { fn try_from(value: &'a str) -> Result<Self, Self::Error> {
let url = url::Url::parse(value).map_err(|_| Error::InvalidScheme)?; let url = url::Url::parse(value).map_err(|_| Error::InvalidScheme)?;
if url.scheme() != ARBITER_URL_SCHEME { if url.scheme() != ARBITER_URL_SCHEME {
return Err(Error::InvalidScheme); return Err(Error::InvalidScheme);
} }
let host = url.host_str().ok_or(Error::MissingHost)?.to_string(); let host = url.host_str().ok_or(Error::MissingHost)?.to_string();
let port = url.port().ok_or(Error::MissingPort)?; let port = url.port().ok_or(Error::MissingPort)?;
let cert_str = url let cert_str = url
.query_pairs() .query_pairs()
.find(|(k, _)| k == CERT_QUERY_KEY) .find(|(k, _)| k == CERT_QUERY_KEY)
.ok_or(Error::MissingCert)? .ok_or(Error::MissingCert)?
.1; .1;
let cert = BASE64_URL_SAFE.decode(cert_str.as_ref())?; let cert = BASE64_URL_SAFE.decode(cert_str.as_ref())?;
let cert = CertificateDer::from_slice(&cert).into_owned(); let cert = CertificateDer::from_slice(&cert).into_owned();
let bootstrap_token = url let bootstrap_token = url
.query_pairs() .query_pairs()
.find(|(k, _)| k == BOOTSTRAP_TOKEN_QUERY_KEY) .find(|(k, _)| k == BOOTSTRAP_TOKEN_QUERY_KEY)
.map(|(_, v)| v.to_string()); .map(|(_, v)| v.to_string());
Ok(ArbiterUrl { Ok(ArbiterUrl {
host, host,
port, port,
ca_cert: cert, ca_cert: cert,
bootstrap_token, bootstrap_token,
}) })
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use rcgen::generate_simple_self_signed; use rcgen::generate_simple_self_signed;
use rstest::rstest; use rstest::rstest;
use super::*; use super::*;
#[rstest] #[rstest]
fn test_parsing_correctness( fn parsing_correctness(
#[values("127.0.0.1", "localhost", "192.168.1.1", "some.domain.com")] host: &str, #[values("127.0.0.1", "localhost", "192.168.1.1", "some.domain.com")] host: &str,
#[values(None, Some("token123".to_string()))] bootstrap_token: Option<String>, #[values(None, Some("token123".to_string()))] bootstrap_token: Option<String>,
) { ) {
let cert = generate_simple_self_signed(&["Arbiter CA".into()]).unwrap(); let cert = generate_simple_self_signed(&["Arbiter CA".into()]).unwrap();
let cert = cert.cert.der(); let cert = cert.cert.der();
let url = ArbiterUrl { let url = ArbiterUrl {
host: host.to_string(), host: host.to_string(),
port: 1234, port: 1234,
ca_cert: cert.clone().into_owned(), ca_cert: cert.clone().into_owned(),
bootstrap_token, bootstrap_token,
}; };
let url_str = url.to_string(); let url_str = url.to_string();
let parsed_url = ArbiterUrl::try_from(url_str.as_str()).unwrap(); let parsed_url = ArbiterUrl::try_from(url_str.as_str()).unwrap();
assert_eq!(url.host, parsed_url.host); assert_eq!(url.host, parsed_url.host);
assert_eq!(url.port, parsed_url.port); assert_eq!(url.port, parsed_url.port);
assert_eq!(url.ca_cert.to_vec(), parsed_url.ca_cert.to_vec()); assert_eq!(url.ca_cert.to_vec(), parsed_url.ca_cert.to_vec());
assert_eq!(url.bootstrap_token, parsed_url.bootstrap_token); assert_eq!(url.bootstrap_token, parsed_url.bootstrap_token);
} }
} }

View File

@@ -1,59 +1,61 @@
[package] [package]
name = "arbiter-server" name = "arbiter-server"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
repository = "https://git.markettakers.org/MarketTakers/arbiter" repository = "https://git.markettakers.org/MarketTakers/arbiter"
license = "Apache-2.0" license = "Apache-2.0"
[lints] [lints]
workspace = true workspace = true
[dependencies] [dependencies]
diesel = { version = "2.3.7", features = ["chrono", "returning_clauses_for_sqlite_3_35", "serde_json", "time", "uuid"] } diesel = { version = "2.3.9", features = ["chrono", "returning_clauses_for_sqlite_3_35", "serde_json", "time", "uuid"] }
diesel-async = { version = "0.8.0", features = [ diesel-async = { version = "0.9.0", features = [
"bb8", "bb8",
"migrations", "migrations",
"sqlite", "sqlite",
"tokio", "tokio",
] } ] }
ed25519-dalek.workspace = true arbiter-proto.path = "../arbiter-proto"
arbiter-proto.path = "../arbiter-proto" arbiter-crypto.path = "../arbiter-crypto"
tracing.workspace = true arbiter-macros.path = "../arbiter-macros"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing.workspace = true
tonic.workspace = true tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tonic.features = ["tls-aws-lc"] tonic.workspace = true
tokio.workspace = true tonic.features = ["tls-aws-lc"]
rustls.workspace = true tokio.workspace = true
smlang.workspace = true rustls.workspace = true
miette.workspace = true smlang.workspace = true
thiserror.workspace = true thiserror.workspace = true
fatality = "0.1.1" diesel_migrations = { version = "2.3.2", features = ["sqlite"] }
diesel_migrations = { version = "2.3.1", features = ["sqlite"] } async-trait.workspace = true
async-trait.workspace = true tokio-stream.workspace = true
secrecy = "0.10.3" rand.workspace = true
futures.workspace = true rcgen.workspace = true
tokio-stream.workspace = true chrono.workspace = true
dashmap = "6.1.0" kameo.workspace = true
rand.workspace = true chacha20poly1305 = { version = "0.10.1", features = ["std"] }
rcgen.workspace = true argon2 = { version = "0.5.3", features = ["zeroize"] }
chrono.workspace = true restructed = "0.2.2"
memsafe = "0.4.0" strum = { version = "0.28.0", features = ["derive"] }
zeroize = { version = "1.8.2", features = ["std", "simd"] } pem = "3.0.6"
kameo.workspace = true sha2.workspace = true
x25519-dalek.workspace = true hmac.workspace = true
chacha20poly1305 = { version = "0.10.1", features = ["std"] } alloy.workspace = true
argon2 = { version = "0.5.3", features = ["zeroize"] } prost-types.workspace = true
restructed = "0.2.2" arbiter-tokens-registry.path = "../arbiter-tokens-registry"
strum = { version = "0.28.0", features = ["derive"] } anyhow = "1.0.102"
pem = "3.0.6" mutants.workspace = true
k256.workspace = true subtle = "2.6.1"
rsa.workspace = true x25519-dalek.workspace = true
sha2.workspace = true k256.workspace = true
spki.workspace = true kameo_actors.workspace = true
alloy.workspace = true
prost-types.workspace = true [dev-dependencies]
arbiter-tokens-registry.path = "../arbiter-tokens-registry" proptest = "1.11.0"
rstest.workspace = true
[dev-dependencies] test-log = { version = "0.2", default-features = false, features = ["trace"] }
insta = "1.46.3" ml-dsa.workspace = true
test-log = { version = "0.2", default-features = false, features = ["trace"] }
[lib]
doctest = false

View File

@@ -1,9 +1,9 @@
# For documentation on how to configure this file, # For documentation on how to configure this file,
# see https://diesel.rs/guides/configuring-diesel-cli # see https://diesel.rs/guides/configuring-diesel-cli
[print_schema] [print_schema]
file = "src/db/schema.rs" file = "src/db/schema.rs"
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"] custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
[migrations_directory] [migrations_directory]
dir = "migrations" dir = "migrations"

View File

@@ -1 +1 @@
-- This file should undo anything in `up.sql` -- This file should undo anything in `up.sql`

View File

@@ -1,161 +1,206 @@
create table if not exists root_key_history ( create table if not exists root_key_history (
id INTEGER not null PRIMARY KEY, id INTEGER not null PRIMARY KEY,
-- root key stored as aead encrypted artifact, with only difference that it's decrypted by unseal key (derived from user password) -- root key stored as aead encrypted artifact, with only difference that it's decrypted by unseal key (derived from user password)
root_key_encryption_nonce blob not null default(1), -- if re-encrypted, this should be incremented. Used for encrypting root key root_key_encryption_nonce blob not null default(1), -- if re-encrypted, this should be incremented. Used for encrypting root key
data_encryption_nonce blob not null default(1), -- nonce used for encrypting with key itself data_encryption_nonce blob not null default(1), -- nonce used for encrypting with key itself
ciphertext blob not null, ciphertext blob not null,
tag blob not null, tag blob not null,
schema_version integer not null default(1), -- server would need to reencrypt, because this means that we have changed algorithm schema_version integer not null default(1), -- server would need to reencrypt, because this means that we have changed algorithm
salt blob not null -- for key deriviation salt blob not null -- for key deriviation
) STRICT; ) STRICT;
create table if not exists aead_encrypted ( create table if not exists aead_encrypted (
id INTEGER not null PRIMARY KEY, id INTEGER not null PRIMARY KEY,
current_nonce blob not null default(1), -- if re-encrypted, this should be incremented current_nonce blob not null default(1), -- if re-encrypted, this should be incremented
ciphertext blob not null, ciphertext blob not null,
tag blob not null, tag blob not null,
schema_version integer not null default(1), -- server would need to reencrypt, because this means that we have changed algorithm schema_version integer not null default(1), -- server would need to reencrypt, because this means that we have changed algorithm
associated_root_key_id integer not null references root_key_history (id) on delete RESTRICT, associated_root_key_id integer not null references root_key_history (id) on delete RESTRICT,
created_at integer not null default(unixepoch ('now')) created_at integer not null default(unixepoch ('now'))
) STRICT; ) STRICT;
create unique index if not exists uniq_nonce_per_root_key on aead_encrypted ( create unique index if not exists uniq_nonce_per_root_key on aead_encrypted (
current_nonce, current_nonce,
associated_root_key_id associated_root_key_id
); );
create table if not exists tls_history ( create table if not exists tls_history (
id INTEGER not null PRIMARY KEY, id INTEGER not null PRIMARY KEY,
cert text not null, cert text not null,
cert_key text not null, -- PEM Encoded private key cert_key text not null, -- PEM Encoded private key
ca_cert text not null, ca_cert text not null,
ca_key text not null, -- PEM Encoded private key ca_key text not null, -- PEM Encoded private key
created_at integer not null default(unixepoch ('now')) created_at integer not null default(unixepoch ('now'))
) STRICT; ) STRICT;
-- This is a singleton -- This is a singleton
create table if not exists arbiter_settings ( create table if not exists arbiter_settings (
id INTEGER not null PRIMARY KEY CHECK (id = 1), -- singleton row, id must be 1 id INTEGER not null PRIMARY KEY CHECK (id = 1), -- singleton row, id must be 1
root_key_id integer references root_key_history (id) on delete RESTRICT, -- if null, means wasn't bootstrapped yet root_key_id integer references root_key_history (id) on delete RESTRICT, -- if null, means wasn't bootstrapped yet
tls_id integer references tls_history (id) on delete RESTRICT tls_id integer references tls_history (id) on delete RESTRICT
) STRICT; ) STRICT;
insert into arbiter_settings (id) values (1) on conflict do nothing; -- ensure singleton row exists insert into arbiter_settings (id) values (1) on conflict do nothing;
-- ensure singleton row exists
create table if not exists useragent_client (
id integer not null primary key, create table if not exists operator_client (
nonce integer not null default(1), -- used for auth challenge id integer not null primary key,
public_key blob not null, public_key blob not null,
key_type integer not null default(1), -- 1=Ed25519, 2=ECDSA(secp256k1) created_at integer not null default(unixepoch ('now')),
created_at integer not null default(unixepoch ('now')), updated_at integer not null default(unixepoch ('now'))
updated_at integer not null default(unixepoch ('now')) ) STRICT;
) STRICT; create unique index if not exists uniq_operator_client_public_key on operator_client (public_key);
create table if not exists program_client ( create table if not exists client_metadata (
id integer not null primary key, id integer not null primary key,
nonce integer not null default(1), -- used for auth challenge name text not null, -- human-readable name for the client
public_key blob not null, description text, -- optional description for the client
created_at integer not null default(unixepoch ('now')), version text, -- client version for tracking and debugging
updated_at integer not null default(unixepoch ('now')) created_at integer not null default(unixepoch ('now'))
) STRICT; ) STRICT;
create table if not exists evm_wallet ( -- created to track history of changes
id integer not null primary key, create table if not exists client_metadata_history (
address blob not null, -- 20-byte Ethereum address id integer not null primary key,
aead_encrypted_id integer not null references aead_encrypted (id) on delete RESTRICT, metadata_id integer not null references client_metadata (id) on delete cascade,
created_at integer not null default(unixepoch ('now')) client_id integer not null references program_client (id) on delete cascade,
) STRICT; created_at integer not null default(unixepoch ('now'))
) STRICT;
create unique index if not exists uniq_evm_wallet_address on evm_wallet (address);
create unique index if not exists uniq_evm_wallet_aead on evm_wallet (aead_encrypted_id); create unique index if not exists uniq_metadata_binding_client on client_metadata_history (client_id);
create table if not exists evm_ether_transfer_limit ( create table if not exists program_client (
id integer not null primary key, id integer not null primary key,
window_secs integer not null, -- window duration in seconds public_key blob not null,
max_volume blob not null -- big-endian 32-byte U256 metadata_id integer not null references client_metadata (id) on delete cascade,
) STRICT; created_at integer not null default(unixepoch ('now')),
updated_at integer not null default(unixepoch ('now'))
-- Shared grant properties: client scope, timeframe, fee caps, and rate limit ) STRICT;
create table if not exists evm_basic_grant (
id integer not null primary key, create unique index if not exists program_client_public_key_unique
wallet_id integer not null references evm_wallet(id) on delete restrict, on program_client (public_key);
client_id integer not null references program_client(id) on delete restrict,
chain_id integer not null, -- EIP-155 chain ID create unique index if not exists uniq_program_client_public_key on program_client (public_key);
valid_from integer, -- unix timestamp (seconds), null = no lower bound
valid_until integer, -- unix timestamp (seconds), null = no upper bound create table if not exists evm_wallet (
max_gas_fee_per_gas blob, -- big-endian 32-byte U256, null = unlimited id integer not null primary key,
max_priority_fee_per_gas blob, -- big-endian 32-byte U256, null = unlimited address blob not null, -- 20-byte Ethereum address
rate_limit_count integer, -- max transactions in window, null = unlimited aead_encrypted_id integer not null references aead_encrypted (id) on delete RESTRICT,
rate_limit_window_secs integer, -- window duration in seconds, null = unlimited created_at integer not null default(unixepoch ('now'))
revoked_at integer, -- unix timestamp when revoked, null = still active ) STRICT;
created_at integer not null default(unixepoch('now'))
) STRICT; create unique index if not exists uniq_evm_wallet_address on evm_wallet (address);
-- Shared transaction log for all EVM grants, used for rate limit tracking and auditing create unique index if not exists uniq_evm_wallet_aead on evm_wallet (aead_encrypted_id);
create table if not exists evm_transaction_log (
id integer not null primary key, create table if not exists evm_wallet_access (
grant_id integer not null references evm_basic_grant(id) on delete restrict, id integer not null primary key,
client_id integer not null references program_client(id) on delete restrict, wallet_id integer not null references evm_wallet (id) on delete cascade,
wallet_id integer not null references evm_wallet(id) on delete restrict, client_id integer not null references program_client (id) on delete cascade,
chain_id integer not null, created_at integer not null default(unixepoch ('now'))
eth_value blob not null, -- always present on any EVM tx ) STRICT;
signed_at integer not null default(unixepoch('now'))
) STRICT; create unique index if not exists uniq_wallet_access on evm_wallet_access (wallet_id, client_id);
create index if not exists idx_evm_basic_grant_wallet_chain on evm_basic_grant(client_id, wallet_id, chain_id); create table if not exists evm_ether_transfer_limit (
id integer not null primary key,
-- =============================== window_secs integer not null, -- window duration in seconds
-- ERC20 token transfer grant max_volume blob not null -- big-endian 32-byte U256
-- =============================== ) STRICT;
create table if not exists evm_token_transfer_grant (
id integer not null primary key, -- Shared grant properties: client scope, timeframe, fee caps, and rate limit
basic_grant_id integer not null unique references evm_basic_grant(id) on delete cascade, create table if not exists evm_basic_grant (
token_contract blob not null, -- 20-byte ERC20 contract address id integer not null primary key,
receiver blob -- 20-byte recipient address or null if every recipient allowed wallet_access_id integer not null references evm_wallet_access (id) on delete restrict,
) STRICT; chain_id integer not null, -- EIP-155 chain ID
valid_from integer, -- unix timestamp (seconds), null = no lower bound
-- Per-window volume limits for token transfer grants valid_until integer, -- unix timestamp (seconds), null = no upper bound
create table if not exists evm_token_transfer_volume_limit ( max_gas_fee_per_gas blob, -- big-endian 32-byte U256, null = unlimited
id integer not null primary key, max_priority_fee_per_gas blob, -- big-endian 32-byte U256, null = unlimited
grant_id integer not null references evm_token_transfer_grant(id) on delete cascade, rate_limit_count integer, -- max transactions in window, null = unlimited
window_secs integer not null, -- window duration in seconds rate_limit_window_secs integer, -- window duration in seconds, null = unlimited
max_volume blob not null -- big-endian 32-byte U256 revoked_at integer, -- unix timestamp when revoked, null = still active
) STRICT; created_at integer not null default(unixepoch ('now'))
) STRICT;
-- Log table for token transfer grant usage
create table if not exists evm_token_transfer_log ( -- Shared transaction log for all EVM grants, used for rate limit tracking and auditing
id integer not null primary key, create table if not exists evm_transaction_log (
grant_id integer not null references evm_token_transfer_grant(id) on delete restrict, id integer not null primary key,
log_id integer not null references evm_transaction_log(id) on delete restrict, wallet_access_id integer not null references evm_wallet_access (id) on delete restrict,
chain_id integer not null, -- EIP-155 chain ID grant_id integer not null references evm_basic_grant (id) on delete restrict,
token_contract blob not null, -- 20-byte ERC20 contract address chain_id integer not null,
recipient_address blob not null, -- 20-byte recipient address eth_value blob not null, -- always present on any EVM tx
value blob not null, -- big-endian 32-byte U256 signed_at integer not null default(unixepoch ('now'))
created_at integer not null default(unixepoch('now')) ) STRICT;
) STRICT;
create index if not exists idx_evm_basic_grant_access_chain on evm_basic_grant (wallet_access_id, chain_id);
create index if not exists idx_token_transfer_log_grant on evm_token_transfer_log(grant_id);
create index if not exists idx_token_transfer_log_log_id on evm_token_transfer_log(log_id); -- ===============================
create index if not exists idx_token_transfer_log_chain on evm_token_transfer_log(chain_id); -- ERC20 token transfer grant
-- ===============================
create table if not exists evm_token_transfer_grant (
-- =============================== id integer not null primary key,
-- Ether transfer grant (uses base log) basic_grant_id integer not null unique references evm_basic_grant (id) on delete cascade,
-- =============================== token_contract blob not null, -- 20-byte ERC20 contract address
create table if not exists evm_ether_transfer_grant ( receiver blob -- 20-byte recipient address or null if every recipient allowed
id integer not null primary key, ) STRICT;
basic_grant_id integer not null unique references evm_basic_grant(id) on delete cascade,
limit_id integer not null references evm_ether_transfer_limit(id) on delete restrict -- Per-window volume limits for token transfer grants
) STRICT; create table if not exists evm_token_transfer_volume_limit (
id integer not null primary key,
-- Specific recipient addresses for an ether transfer grant grant_id integer not null references evm_token_transfer_grant (id) on delete cascade,
create table if not exists evm_ether_transfer_grant_target ( window_secs integer not null, -- window duration in seconds
id integer not null primary key, max_volume blob not null -- big-endian 32-byte U256
grant_id integer not null references evm_ether_transfer_grant(id) on delete cascade, ) STRICT;
address blob not null -- 20-byte recipient address
) STRICT; -- Log table for token transfer grant usage
create table if not exists evm_token_transfer_log (
create unique index if not exists uniq_ether_transfer_target on evm_ether_transfer_grant_target(grant_id, address); id integer not null primary key,
grant_id integer not null references evm_token_transfer_grant (id) on delete restrict,
CREATE UNIQUE INDEX program_client_public_key_unique log_id integer not null references evm_transaction_log (id) on delete restrict,
ON program_client (public_key); chain_id integer not null, -- EIP-155 chain ID
token_contract blob not null, -- 20-byte ERC20 contract address
recipient_address blob not null, -- 20-byte recipient address
value blob not null, -- big-endian 32-byte U256
created_at integer not null default(unixepoch ('now'))
) STRICT;
create index if not exists idx_token_transfer_log_grant on evm_token_transfer_log (grant_id);
create index if not exists idx_token_transfer_log_log_id on evm_token_transfer_log (log_id);
create index if not exists idx_token_transfer_log_chain on evm_token_transfer_log (chain_id);
-- ===============================
-- Ether transfer grant (uses base log)
-- ===============================
create table if not exists evm_ether_transfer_grant (
id integer not null primary key,
basic_grant_id integer not null unique references evm_basic_grant (id) on delete cascade,
limit_id integer not null references evm_ether_transfer_limit (id) on delete restrict
) STRICT;
-- Specific recipient addresses for an ether transfer grant
create table if not exists evm_ether_transfer_grant_target (
id integer not null primary key,
grant_id integer not null references evm_ether_transfer_grant (id) on delete cascade,
address blob not null -- 20-byte recipient address
) STRICT;
create unique index if not exists uniq_ether_transfer_target on evm_ether_transfer_grant_target (grant_id, address);
-- ===============================
-- Integrity Envelopes
-- ===============================
create table if not exists integrity_envelope (
id integer not null primary key,
entity_kind text not null,
entity_id blob not null,
payload_version integer not null,
key_version integer not null,
mac blob not null, -- 20-byte recipient address
signed_at integer not null default(unixepoch ('now')),
created_at integer not null default(unixepoch ('now'))
) STRICT;
create unique index if not exists uniq_integrity_envelope_entity on integrity_envelope (entity_kind, entity_id);

View File

@@ -1,97 +1,98 @@
use arbiter_proto::{BOOTSTRAP_PATH, home_path}; use crate::db::{self, DatabasePool, schema};
use diesel::QueryDsl; use arbiter_proto::{BOOTSTRAP_PATH, home_path};
use diesel_async::RunQueryDsl;
use kameo::{Actor, messages}; use diesel::QueryDsl;
use miette::Diagnostic; use diesel_async::RunQueryDsl;
use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng}; use kameo::{Actor, messages};
use thiserror::Error; use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng};
use subtle::ConstantTimeEq as _;
use crate::db::{self, DatabasePool, schema}; use thiserror::Error;
const TOKEN_LENGTH: usize = 64;
const TOKEN_LENGTH: usize = 64;
pub async fn generate_token() -> Result<String, std::io::Error> {
let rng: StdRng = make_rng(); pub async fn generate_token() -> Result<String, std::io::Error> {
let rng: StdRng = make_rng();
let token: String = rng.sample_iter(Alphanumeric).take(TOKEN_LENGTH).fold(
Default::default(), let token = rng.sample_iter(Alphanumeric).take(TOKEN_LENGTH).fold(
|mut accum, char| { String::default(),
accum += char.to_string().as_str(); |mut accum, char| {
accum accum += char.to_string().as_str();
}, accum
); },
);
tokio::fs::write(home_path()?.join(BOOTSTRAP_PATH), token.as_str()).await?;
tokio::fs::write(home_path()?.join(BOOTSTRAP_PATH), token.as_str()).await?;
Ok(token)
} Ok(token)
}
#[derive(Error, Debug, Diagnostic)]
pub enum Error { #[derive(Error, Debug)]
#[error("Database error: {0}")] pub enum Error {
#[diagnostic(code(arbiter_server::bootstrap::database))] #[error("Database error: {0}")]
Database(#[from] db::PoolError), Database(#[from] db::PoolError),
#[error("Database query error: {0}")] #[error("I/O error: {0}")]
#[diagnostic(code(arbiter_server::bootstrap::database_query))] Io(#[from] std::io::Error),
Query(#[from] diesel::result::Error),
#[error("Database query error: {0}")]
#[error("I/O error: {0}")] Query(#[from] diesel::result::Error),
#[diagnostic(code(arbiter_server::bootstrap::io))] }
Io(#[from] std::io::Error),
} #[derive(Actor)]
pub struct Bootstrapper {
#[derive(Actor)] token: Option<String>,
pub struct Bootstrapper { }
token: Option<String>,
} impl Bootstrapper {
pub async fn new(db: &DatabasePool) -> Result<Self, Error> {
impl Bootstrapper { let row_count: i64 = {
pub async fn new(db: &DatabasePool) -> Result<Self, Error> { let mut conn = db.get().await?;
let mut conn = db.get().await?;
schema::operator_client::table
let row_count: i64 = schema::useragent_client::table .count()
.count() .get_result(&mut conn)
.get_result(&mut conn) .await?
.await?; };
drop(conn); let token = if row_count == 0 {
let token = generate_token().await?;
let token = if row_count == 0 { Some(token)
let token = generate_token().await?; } else {
Some(token) None
} else { };
None
}; Ok(Self { token })
}
Ok(Self { token }) }
}
} #[messages]
impl Bootstrapper {
#[messages] #[message]
impl Bootstrapper { pub fn is_correct_token(&self, token: String) -> bool {
#[message] self.token.as_ref().is_some_and(|expected| {
pub fn is_correct_token(&self, token: String) -> bool { let expected_bytes = expected.as_bytes();
match &self.token { let token_bytes = token.as_bytes();
Some(expected) => *expected == token,
None => false, let choice = expected_bytes.ct_eq(token_bytes);
} bool::from(choice)
} })
}
#[message]
pub fn consume_token(&mut self, token: String) -> bool { #[message]
if self.is_correct_token(token) { pub fn consume_token(&mut self, token: String) -> bool {
self.token = None; if self.is_correct_token(token) {
true self.token = None;
} else { true
false } else {
} false
} }
} }
}
#[messages]
impl Bootstrapper { #[messages]
#[message] impl Bootstrapper {
pub fn get_token(&self) -> Option<String> { #[message]
self.token.clone() pub fn get_token(&self) -> Option<String> {
} self.token.clone()
} }
}

View File

@@ -1,249 +0,0 @@
use arbiter_proto::{
format_challenge,
transport::{Bi, expect_message},
};
use diesel::{
ExpressionMethods as _, OptionalExtension as _, QueryDsl as _, dsl::insert_into, update,
};
use diesel_async::RunQueryDsl as _;
use ed25519_dalek::{Signature, VerifyingKey};
use kameo::error::SendError;
use tracing::error;
use crate::{
actors::{
client::ClientConnection,
router::{self, RequestClientApproval},
},
db::{self, schema::program_client},
};
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum Error {
#[error("Database pool unavailable")]
DatabasePoolUnavailable,
#[error("Database operation failed")]
DatabaseOperationFailed,
#[error("Invalid challenge solution")]
InvalidChallengeSolution,
#[error("Client approval request failed")]
ApproveError(#[from] ApproveError),
#[error("Transport error")]
Transport,
}
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum ApproveError {
#[error("Internal error")]
Internal,
#[error("Client connection denied by user agents")]
Denied,
#[error("Upstream error: {0}")]
Upstream(router::ApprovalError),
}
#[derive(Debug, Clone)]
pub enum Inbound {
AuthChallengeRequest { pubkey: VerifyingKey },
AuthChallengeSolution { signature: Signature },
}
#[derive(Debug, Clone)]
pub enum Outbound {
AuthChallenge { pubkey: VerifyingKey, nonce: i32 },
AuthSuccess,
}
/// Atomically reads and increments the nonce for a known client.
/// Returns `None` if the pubkey is not registered.
async fn get_nonce(db: &db::DatabasePool, pubkey: &VerifyingKey) -> Result<Option<i32>, Error> {
let pubkey_bytes = pubkey.as_bytes().to_vec();
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable
})?;
conn.exclusive_transaction(|conn| {
let pubkey_bytes = pubkey_bytes.clone();
Box::pin(async move {
let Some((client_id, current_nonce)) = program_client::table
.filter(program_client::public_key.eq(&pubkey_bytes))
.select((program_client::id, program_client::nonce))
.first::<(i32, i32)>(conn)
.await
.optional()?
else {
return Result::<_, diesel::result::Error>::Ok(None);
};
update(program_client::table)
.filter(program_client::public_key.eq(&pubkey_bytes))
.set(program_client::nonce.eq(current_nonce + 1))
.execute(conn)
.await?;
let _ = client_id;
Ok(Some(current_nonce))
})
})
.await
.map_err(|e| {
error!(error = ?e, "Database error");
Error::DatabaseOperationFailed
})
}
async fn approve_new_client(
actors: &crate::actors::GlobalActors,
pubkey: VerifyingKey,
) -> Result<(), Error> {
let result = actors
.router
.ask(RequestClientApproval {
client_pubkey: pubkey,
})
.await;
match result {
Ok(true) => Ok(()),
Ok(false) => Err(Error::ApproveError(ApproveError::Denied)),
Err(SendError::HandlerError(e)) => {
error!(error = ?e, "Approval upstream error");
Err(Error::ApproveError(ApproveError::Upstream(e)))
}
Err(e) => {
error!(error = ?e, "Approval request to router failed");
Err(Error::ApproveError(ApproveError::Internal))
}
}
}
enum InsertClientResult {
Inserted,
AlreadyExists,
}
async fn insert_client(
db: &db::DatabasePool,
pubkey: &VerifyingKey,
) -> Result<InsertClientResult, Error> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i32;
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable
})?;
match insert_into(program_client::table)
.values((
program_client::public_key.eq(pubkey.as_bytes().to_vec()),
program_client::nonce.eq(1), // pre-incremented; challenge uses 0
program_client::created_at.eq(now),
program_client::updated_at.eq(now),
))
.execute(&mut conn)
.await
{
Ok(_) => {}
Err(diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::UniqueViolation,
_,
)) => return Ok(InsertClientResult::AlreadyExists),
Err(e) => {
error!(error = ?e, "Failed to insert new client");
return Err(Error::DatabaseOperationFailed);
}
}
let client_id = program_client::table
.filter(program_client::public_key.eq(pubkey.as_bytes().to_vec()))
.order(program_client::id.desc())
.select(program_client::id)
.first::<i32>(&mut conn)
.await
.map_err(|e| {
error!(error = ?e, "Failed to load inserted client id");
Error::DatabaseOperationFailed
})?;
let _ = client_id;
Ok(InsertClientResult::Inserted)
}
async fn challenge_client<T>(
transport: &mut T,
pubkey: VerifyingKey,
nonce: i32,
) -> Result<(), Error>
where
T: Bi<Inbound, Result<Outbound, Error>> + ?Sized,
{
transport
.send(Ok(Outbound::AuthChallenge { pubkey, nonce }))
.await
.map_err(|e| {
error!(error = ?e, "Failed to send auth challenge");
Error::Transport
})?;
let signature = expect_message(transport, |req: Inbound| match req {
Inbound::AuthChallengeSolution { signature } => Some(signature),
_ => None,
})
.await
.map_err(|e| {
error!(error = ?e, "Failed to receive challenge solution");
Error::Transport
})?;
let formatted = format_challenge(nonce, pubkey.as_bytes());
pubkey.verify_strict(&formatted, &signature).map_err(|_| {
error!("Challenge solution verification failed");
Error::InvalidChallengeSolution
})?;
Ok(())
}
pub async fn authenticate<T>(
props: &mut ClientConnection,
transport: &mut T,
) -> Result<VerifyingKey, Error>
where
T: Bi<Inbound, Result<Outbound, Error>> + Send + ?Sized,
{
let Some(Inbound::AuthChallengeRequest { pubkey }) = transport.recv().await
else {
return Err(Error::Transport);
};
let nonce = match get_nonce(&props.db, &pubkey).await? {
Some(nonce) => nonce,
None => {
approve_new_client(&props.actors, pubkey).await?;
match insert_client(&props.db, &pubkey).await? {
InsertClientResult::Inserted => 0,
InsertClientResult::AlreadyExists => match get_nonce(&props.db, &pubkey).await? {
Some(nonce) => nonce,
None => return Err(Error::DatabaseOperationFailed),
},
}
}
};
challenge_client(transport, pubkey, nonce).await?;
transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|e| {
error!(error = ?e, "Failed to send auth success");
Error::Transport
})?;
Ok(pubkey)
}

View File

@@ -1,38 +0,0 @@
use arbiter_proto::transport::Bi;
use kameo::actor::Spawn;
use tracing::{error, info};
use crate::{
actors::{GlobalActors, client::session::ClientSession},
db,
};
pub struct ClientConnection {
pub(crate) db: db::DatabasePool,
pub(crate) actors: GlobalActors,
}
impl ClientConnection {
pub fn new(db: db::DatabasePool, actors: GlobalActors) -> Self {
Self { db, actors }
}
}
pub mod auth;
pub mod session;
pub async fn connect_client<T>(mut props: ClientConnection, transport: &mut T)
where
T: Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> + Send + ?Sized,
{
match auth::authenticate(&mut props, transport).await {
Ok(_pubkey) => {
ClientSession::spawn(ClientSession::new(props));
info!("Client authenticated, session started");
}
Err(err) => {
let _ = transport.send(Err(err.clone())).await;
error!(?err, "Authentication failed, closing connection");
}
}
}

View File

@@ -1,71 +0,0 @@
use kameo::{Actor, messages};
use tracing::error;
use crate::{
actors::{
GlobalActors, client::ClientConnection, keyholder::KeyHolderState, router::RegisterClient,
},
db,
};
pub struct ClientSession {
props: ClientConnection,
}
impl ClientSession {
pub(crate) fn new(props: ClientConnection) -> Self {
Self { props }
}
}
#[messages]
impl ClientSession {
#[message]
pub(crate) async fn handle_query_vault_state(&mut self) -> Result<KeyHolderState, Error> {
use crate::actors::keyholder::GetState;
let vault_state = match self.props.actors.key_holder.ask(GetState {}).await {
Ok(state) => state,
Err(err) => {
error!(?err, actor = "client", "keyholder.query.failed");
return Err(Error::Internal);
}
};
Ok(vault_state)
}
}
impl Actor for ClientSession {
type Args = Self;
type Error = Error;
async fn on_start(
args: Self::Args,
this: kameo::prelude::ActorRef<Self>,
) -> Result<Self, Self::Error> {
args.props
.actors
.router
.ask(RegisterClient { actor: this })
.await
.map_err(|_| Error::ConnectionRegistrationFailed)?;
Ok(args)
}
}
impl ClientSession {
pub fn new_test(db: db::DatabasePool, actors: GlobalActors) -> Self {
let props = ClientConnection::new(db, actors);
Self { props }
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Connection registration failed")]
ConnectionRegistrationFailed,
#[error("Internal error")]
Internal,
}

View File

@@ -1,270 +1,260 @@
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature}; use crate::{
use diesel::{ actors::vault::{CreateNew, Decrypt, Vault},
ExpressionMethods, OptionalExtension as _, QueryDsl, SelectableHelper as _, dsl::insert_into, crypto::integrity,
}; db::{
use diesel_async::RunQueryDsl; DatabaseError, DatabasePool,
use kameo::{Actor, actor::ActorRef, messages}; models::{self},
use rand::{SeedableRng, rng, rngs::StdRng}; schema,
},
use crate::{ evm::{
actors::keyholder::{CreateNew, Decrypt, KeyHolder}, self, ListError, RunKind,
db::{ policies::{
self, DatabasePool, CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
models::{self, SqliteTimestamp}, ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
schema, },
}, },
evm::{ };
self, ListGrantsError, RunKind, use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
policies::{
FullGrant, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, use alloy::{
ether_transfer::EtherTransfer, token_transfers::TokenTransfer, consensus::TxEip1559, network::TxSignerSync as _, primitives::Address, signers::Signature,
}, };
}, use diesel::{
safe_cell::{SafeCell, SafeCellHandle as _}, ExpressionMethods, OptionalExtension as _, QueryDsl, SelectableHelper as _, dsl::insert_into,
}; };
use diesel_async::RunQueryDsl;
pub use crate::evm::safe_signer; use kameo::{Actor, actor::ActorRef, messages};
use rand::{SeedableRng, rng, rngs::StdRng};
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum SignTransactionError { pub use crate::evm::safe_signer;
#[error("Wallet not found")]
#[diagnostic(code(arbiter::evm::sign::wallet_not_found))] #[derive(Debug, thiserror::Error)]
WalletNotFound, pub enum SignTransactionError {
#[error("Wallet not found")]
#[error("Database error: {0}")] WalletNotFound,
#[diagnostic(code(arbiter::evm::sign::database))]
Database(#[from] diesel::result::Error), #[error("Database error: {0}")]
Database(#[from] DatabaseError),
#[error("Database pool error: {0}")]
#[diagnostic(code(arbiter::evm::sign::pool))] #[error("Vault error: {0}")]
Pool(#[from] db::PoolError), Vault(#[from] crate::actors::vault::Error),
#[error("Keyholder error: {0}")] #[error("Vault mailbox error")]
#[diagnostic(code(arbiter::evm::sign::keyholder))] VaultSend,
Keyholder(#[from] crate::actors::keyholder::Error),
#[error("Signing error: {0}")]
#[error("Keyholder mailbox error")] Signing(#[from] alloy::signers::Error),
#[diagnostic(code(arbiter::evm::sign::keyholder_send))]
KeyholderSend, #[error("Policy error: {0}")]
Vet(#[from] evm::VetError),
#[error("Signing error: {0}")] }
#[diagnostic(code(arbiter::evm::sign::signing))]
Signing(#[from] alloy::signers::Error), #[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Policy error: {0}")] #[error("Vault error: {0}")]
#[diagnostic(code(arbiter::evm::sign::vet))] Vault(#[from] crate::actors::vault::Error),
Vet(#[from] evm::VetError),
} #[error("Vault mailbox error")]
VaultSend,
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum Error { #[error("Database error: {0}")]
#[error("Keyholder error: {0}")] Database(#[from] DatabaseError),
#[diagnostic(code(arbiter::evm::keyholder))]
Keyholder(#[from] crate::actors::keyholder::Error), #[error("Integrity violation: {0}")]
Integrity(#[from] integrity::Error),
#[error("Keyholder mailbox error")] }
#[diagnostic(code(arbiter::evm::keyholder_send))]
KeyholderSend, #[derive(Actor)]
pub struct EvmActor {
#[error("Database error: {0}")] pub vault: ActorRef<Vault>,
#[diagnostic(code(arbiter::evm::database))] pub db: DatabasePool,
Database(#[from] diesel::result::Error), pub rng: StdRng,
pub engine: evm::Engine,
#[error("Database pool error: {0}")] }
#[diagnostic(code(arbiter::evm::database_pool))]
DatabasePool(#[from] db::PoolError), impl EvmActor {
pub fn new(vault: ActorRef<Vault>, db: DatabasePool) -> Self {
#[error("Grant creation error: {0}")] // is it safe to seed rng from system once?
#[diagnostic(code(arbiter::evm::creation))] // todo: audit
Creation(#[from] evm::CreationError), let rng = StdRng::from_rng(&mut rng());
} let engine = evm::Engine::new(db.clone(), vault.clone());
Self {
#[derive(Actor)] vault,
pub struct EvmActor { db,
pub keyholder: ActorRef<KeyHolder>, rng,
pub db: DatabasePool, engine,
pub rng: StdRng, }
pub engine: evm::Engine, }
} }
impl EvmActor { #[messages]
pub fn new(keyholder: ActorRef<KeyHolder>, db: DatabasePool) -> Self { impl EvmActor {
// is it safe to seed rng from system once? #[message]
// todo: audit pub async fn generate(&mut self) -> Result<(i32, Address), Error> {
let rng = StdRng::from_rng(&mut rng()); let (mut key_cell, address) = safe_signer::generate(&mut self.rng);
let engine = evm::Engine::new(db.clone());
Self { let plaintext = key_cell.read_inline(|reader| SafeCell::new(reader.to_vec()));
keyholder,
db, let aead_id: i32 = self
rng, .vault
engine, .ask(CreateNew { plaintext })
} .await
} .map_err(|_| Error::VaultSend)?;
}
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
#[messages] let wallet_id = insert_into(schema::evm_wallet::table)
impl EvmActor { .values(&models::NewEvmWallet {
#[message] address: address.as_slice().to_vec(),
pub async fn generate(&mut self) -> Result<Address, Error> { aead_encrypted_id: aead_id,
let (mut key_cell, address) = safe_signer::generate(&mut self.rng); })
.returning(schema::evm_wallet::id)
let plaintext = key_cell.read_inline(|reader| SafeCell::new(reader.to_vec())); .get_result(&mut conn)
.await
let aead_id: i32 = self .map_err(DatabaseError::from)?;
.keyholder
.ask(CreateNew { plaintext }) Ok((wallet_id, address))
.await }
.map_err(|_| Error::KeyholderSend)?;
#[message]
let mut conn = self.db.get().await?; pub async fn list_wallets(&self) -> Result<Vec<(i32, Address)>, Error> {
insert_into(schema::evm_wallet::table) let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
.values(&models::NewEvmWallet { let rows: Vec<models::EvmWallet> = schema::evm_wallet::table
address: address.as_slice().to_vec(), .select(models::EvmWallet::as_select())
aead_encrypted_id: aead_id, .load(&mut conn)
}) .await
.execute(&mut conn) .map_err(DatabaseError::from)?;
.await?;
Ok(rows
Ok(address) .into_iter()
} .map(|w| (w.id, Address::from_slice(&w.address)))
.collect())
#[message] }
pub async fn list_wallets(&self) -> Result<Vec<Address>, Error> { }
let mut conn = self.db.get().await?;
let rows: Vec<models::EvmWallet> = schema::evm_wallet::table #[messages]
.select(models::EvmWallet::as_select()) impl EvmActor {
.load(&mut conn) #[message]
.await?; pub async fn operator_create_grant(
&mut self,
Ok(rows basic: SharedGrantSettings,
.into_iter() grant: SpecificGrant,
.map(|w| Address::from_slice(&w.address)) ) -> Result<i32, Error> {
.collect()) match grant {
} SpecificGrant::EtherTransfer(settings) => self
} .engine
.create_grant::<EtherTransfer>(CombinedSettings {
#[messages] shared: basic,
impl EvmActor { specific: settings,
#[message] })
pub async fn useragent_create_grant( .await
&mut self, .map_err(Error::from),
client_id: i32, SpecificGrant::TokenTransfer(settings) => self
basic: SharedGrantSettings, .engine
grant: SpecificGrant, .create_grant::<TokenTransfer>(CombinedSettings {
) -> Result<i32, evm::CreationError> { shared: basic,
match grant { specific: settings,
SpecificGrant::EtherTransfer(settings) => { })
self.engine .await
.create_grant::<EtherTransfer>( .map_err(Error::from),
client_id, }
FullGrant { }
basic,
specific: settings, #[message]
}, pub async fn useragent_delete_grant(
) &mut self,
.await grant_id: i32,
} ) -> Result<(), Error> {
SpecificGrant::TokenTransfer(settings) => { self.engine
self.engine .revoke_grant(grant_id)
.create_grant::<TokenTransfer>( .await
client_id, .map_err(Error::from)
FullGrant { }
basic,
specific: settings, #[message]
}, pub async fn operator_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
) match self.engine.list_all_grants().await {
.await Ok(grants) => Ok(grants),
} Err(ListError::Database(db_err)) => Err(Error::Database(db_err)),
} Err(ListError::Integrity(integrity_err)) => Err(Error::Integrity(integrity_err)),
} }
}
#[message]
pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> { #[message]
let mut conn = self.db.get().await?; pub async fn shared_analyze_transaction(
diesel::update(schema::evm_basic_grant::table) &mut self,
.filter(schema::evm_basic_grant::id.eq(grant_id)) client_id: i32,
.set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now())) wallet_address: Address,
.execute(&mut conn) transaction: TxEip1559,
.await?; ) -> Result<SpecificMeaning, SignTransactionError> {
Ok(()) let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
} let wallet = schema::evm_wallet::table
.select(models::EvmWallet::as_select())
#[message] .filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> { .first(&mut conn)
match self.engine.list_all_grants().await { .await
Ok(grants) => Ok(grants), .optional()
Err(ListGrantsError::Database(db)) => Err(Error::Database(db)), .map_err(DatabaseError::from)?
Err(ListGrantsError::Pool(pool)) => Err(Error::DatabasePool(pool)), .ok_or(SignTransactionError::WalletNotFound)?;
} let wallet_access = schema::evm_wallet_access::table
} .select(models::EvmWalletAccess::as_select())
.filter(schema::evm_wallet_access::wallet_id.eq(wallet.id))
#[message] .filter(schema::evm_wallet_access::client_id.eq(client_id))
pub async fn shared_analyze_transaction( .first(&mut conn)
&mut self, .await
client_id: i32, .optional()
wallet_address: Address, .map_err(DatabaseError::from)?
transaction: TxEip1559, .ok_or(SignTransactionError::WalletNotFound)?;
) -> Result<SpecificMeaning, SignTransactionError> { drop(conn);
let mut conn = self.db.get().await?;
let wallet = schema::evm_wallet::table let meaning = self
.select(models::EvmWallet::as_select()) .engine
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice())) .evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
.first(&mut conn) .await?;
.await
.optional()? Ok(meaning)
.ok_or(SignTransactionError::WalletNotFound)?; }
drop(conn);
#[message]
let meaning = self pub async fn client_sign_transaction(
.engine &mut self,
.evaluate_transaction( client_id: i32,
wallet.id, wallet_address: Address,
client_id, mut transaction: TxEip1559,
transaction.clone(), ) -> Result<Signature, SignTransactionError> {
RunKind::Execution, let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
) let wallet = schema::evm_wallet::table
.await?; .select(models::EvmWallet::as_select())
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
Ok(meaning) .first(&mut conn)
} .await
.optional()
#[message] .map_err(DatabaseError::from)?
pub async fn client_sign_transaction( .ok_or(SignTransactionError::WalletNotFound)?;
&mut self, let wallet_access = schema::evm_wallet_access::table
client_id: i32, .select(models::EvmWalletAccess::as_select())
wallet_address: Address, .filter(schema::evm_wallet_access::wallet_id.eq(wallet.id))
mut transaction: TxEip1559, .filter(schema::evm_wallet_access::client_id.eq(client_id))
) -> Result<Signature, SignTransactionError> { .first(&mut conn)
let mut conn = self.db.get().await?; .await
let wallet = schema::evm_wallet::table .optional()
.select(models::EvmWallet::as_select()) .map_err(DatabaseError::from)?
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice())) .ok_or(SignTransactionError::WalletNotFound)?;
.first(&mut conn) drop(conn);
.await
.optional()? let raw_key: SafeCell<Vec<u8>> = self
.ok_or(SignTransactionError::WalletNotFound)?; .vault
drop(conn); .ask(Decrypt {
aead_id: wallet.aead_encrypted_id,
let raw_key: SafeCell<Vec<u8>> = self })
.keyholder .await
.ask(Decrypt { .map_err(|_| SignTransactionError::VaultSend)?;
aead_id: wallet.aead_encrypted_id,
}) let signer = safe_signer::SafeSigner::from_cell(raw_key)?;
.await
.map_err(|_| SignTransactionError::KeyholderSend)?; self.engine
.evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
let signer = safe_signer::SafeSigner::from_cell(raw_key)?; .await?;
self.engine Ok(signer.sign_transaction_sync(&mut transaction)?)
.evaluate_transaction( }
wallet.id, }
client_id,
transaction.clone(),
RunKind::Execution,
)
.await?;
use alloy::network::TxSignerSync as _;
Ok(signer.sign_transaction_sync(&mut transaction)?)
}
}

View File

@@ -0,0 +1,127 @@
use crate::{
actors::flow_coordinator::ApprovalError,
peers::{
client::ClientProfile,
operator::{OperatorSession, session::BeginNewClientApproval},
},
};
use kameo::{
Actor, messages,
prelude::{ActorId, ActorRef, ActorStopReason, Context, WeakActorRef},
reply::ReplySender,
};
use std::{ops::ControlFlow, time::Duration};
const APPROVAL_TIMEOUT: Duration = Duration::from_secs(30);
pub struct Args {
pub client: ClientProfile,
pub operators: Vec<ActorRef<OperatorSession>>,
pub reply: ReplySender<Result<bool, ApprovalError>>,
}
pub struct ClientApprovalController {
/// Number of operators that have not yet responded (approval or denial) or died.
pending: usize,
/// Number of approvals received so far.
approved: usize,
reply: Option<ReplySender<Result<bool, ApprovalError>>>,
}
impl ClientApprovalController {
fn send_reply(&mut self, result: Result<bool, ApprovalError>) {
if let Some(reply) = self.reply.take() {
reply.send(result);
}
}
}
impl Actor for ClientApprovalController {
type Args = Args;
type Error = ();
async fn on_start(
Args {
client,
operators,
reply,
}: Self::Args,
actor_ref: ActorRef<Self>,
) -> Result<Self, Self::Error> {
let this = Self {
pending: operators.len(),
approved: 0,
reply: Some(reply),
};
for operator in operators {
actor_ref.link(&operator).await;
let _ = operator
.tell(BeginNewClientApproval {
client: client.clone(),
controller: actor_ref.clone(),
})
.await;
}
let weak = actor_ref.downgrade();
tokio::spawn(async move {
tokio::time::sleep(APPROVAL_TIMEOUT).await;
if let Some(r) = weak.upgrade() {
let _ = r.tell(OnApprovalTimeout {}).await;
}
});
Ok(this)
}
async fn on_link_died(
&mut self,
_: WeakActorRef<Self>,
_: ActorId,
_: ActorStopReason,
) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
// A linked operator died before responding — counts as a non-approval.
self.pending = self.pending.saturating_sub(1);
if self.pending == 0 {
// At least one operator didn't approve: deny.
self.send_reply(Ok(false));
return Ok(ControlFlow::Break(ActorStopReason::Normal));
}
Ok(ControlFlow::Continue(()))
}
}
#[messages]
impl ClientApprovalController {
#[message(ctx)]
pub fn client_approval_answer(&mut self, approved: bool, ctx: &mut Context<Self, ()>) {
if !approved {
// Denial wins immediately regardless of other pending responses.
self.send_reply(Ok(false));
ctx.stop();
return;
}
self.approved += 1;
self.pending = self.pending.saturating_sub(1);
if self.pending == 0 {
// Every connected operator approved.
self.send_reply(Ok(true));
ctx.stop();
}
}
/// Fired after `APPROVAL_TIMEOUT` elapses. Any operator that hasn't responded
/// by then is treated as a denial to prevent zombie sessions from blocking the flow.
#[message(ctx)]
pub fn on_approval_timeout(&mut self, ctx: &mut Context<Self, ()>) {
if self.pending > 0 {
self.send_reply(Ok(false));
ctx.stop();
}
}
}

View File

@@ -0,0 +1,114 @@
use crate::{
actors::{
flow_coordinator::client_connect_approval::ClientApprovalController,
operator_registry::{GetConnected, OperatorRegistry},
},
peers::client::{ClientProfile, session::ClientSession},
};
use kameo::{
Actor,
actor::{ActorId, ActorRef, Spawn},
messages,
prelude::{ActorStopReason, Context, WeakActorRef},
reply::DelegatedReply,
};
use std::{collections::HashMap, ops::ControlFlow};
use tracing::info;
pub mod client_connect_approval;
pub struct FlowCoordinator {
pub clients: HashMap<ActorId, ActorRef<ClientSession>>,
operator_registry: ActorRef<OperatorRegistry>,
}
impl FlowCoordinator {
pub fn new(operator_registry: ActorRef<OperatorRegistry>) -> Self {
Self {
clients: HashMap::default(),
operator_registry,
}
}
}
impl Actor for FlowCoordinator {
type Args = Self;
type Error = ();
async fn on_start(args: Self::Args, _: ActorRef<Self>) -> Result<Self, Self::Error> {
Ok(args)
}
async fn on_link_died(
&mut self,
_: WeakActorRef<Self>,
id: ActorId,
_: ActorStopReason,
) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
if self.clients.remove(&id).is_some() {
info!(
?id,
actor = "FlowCoordinator",
event = "client.disconnected"
);
} else {
info!(
?id,
actor = "FlowCoordinator",
event = "unknown.actor.disconnected"
);
}
Ok(ControlFlow::Continue(()))
}
}
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq, Hash)]
pub enum ApprovalError {
#[error("No operators connected")]
NoOperatorsConnected,
}
#[messages]
impl FlowCoordinator {
#[message(ctx)]
pub async fn register_client(
&mut self,
actor: ActorRef<ClientSession>,
ctx: &mut Context<Self, ()>,
) {
info!(id = %actor.id(), actor = "FlowCoordinator", event = "client.connected");
ctx.actor_ref().link(&actor).await;
self.clients.insert(actor.id(), actor);
}
#[message(ctx)]
pub async fn request_client_approval(
&mut self,
client: ClientProfile,
ctx: &mut Context<Self, DelegatedReply<Result<bool, ApprovalError>>>,
) -> DelegatedReply<Result<bool, ApprovalError>> {
let (reply, Some(reply_sender)) = ctx.reply_sender() else {
unreachable!("Expected `request_client_approval` to have callback channel");
};
let Ok(refs) = self.operator_registry.ask(GetConnected).await else {
reply_sender.send(Err(ApprovalError::NoOperatorsConnected));
return reply;
};
if refs.is_empty() {
reply_sender.send(Err(ApprovalError::NoOperatorsConnected));
return reply;
}
ClientApprovalController::spawn(client_connect_approval::Args {
client,
operators: refs,
reply: reply_sender,
});
reply
}
}

View File

@@ -1,243 +0,0 @@
use std::ops::Deref as _;
use argon2::{Algorithm, Argon2, password_hash::Salt as ArgonSalt};
use chacha20poly1305::{
AeadInPlace, Key, KeyInit as _, XChaCha20Poly1305, XNonce,
aead::{AeadMut, Error, Payload},
};
use rand::{
Rng as _, SeedableRng,
rngs::{StdRng, SysRng},
};
use crate::safe_cell::{SafeCell, SafeCellHandle as _};
pub const ROOT_KEY_TAG: &[u8] = "arbiter/seal/v1".as_bytes();
pub const TAG: &[u8] = "arbiter/private-key/v1".as_bytes();
pub const NONCE_LENGTH: usize = 24;
#[derive(Default)]
pub struct Nonce([u8; NONCE_LENGTH]);
impl Nonce {
pub fn increment(&mut self) {
for i in (0..self.0.len()).rev() {
if self.0[i] == 0xFF {
self.0[i] = 0;
} else {
self.0[i] += 1;
break;
}
}
}
pub fn to_vec(&self) -> Vec<u8> {
self.0.to_vec()
}
}
impl<'a> TryFrom<&'a [u8]> for Nonce {
type Error = ();
fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
if value.len() != NONCE_LENGTH {
return Err(());
}
let mut nonce = [0u8; NONCE_LENGTH];
nonce.copy_from_slice(value);
Ok(Self(nonce))
}
}
pub struct KeyCell(pub SafeCell<Key>);
impl From<SafeCell<Key>> for KeyCell {
fn from(value: SafeCell<Key>) -> Self {
Self(value)
}
}
impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
type Error = ();
fn try_from(mut value: SafeCell<Vec<u8>>) -> Result<Self, Self::Error> {
let value = value.read();
if value.len() != size_of::<Key>() {
return Err(());
}
let cell = SafeCell::new_inline(|cell_write: &mut Key| {
cell_write.copy_from_slice(&value);
});
Ok(Self(cell))
}
}
impl KeyCell {
pub fn new_secure_random() -> Self {
let key = SafeCell::new_inline(|key_buffer: &mut Key| {
#[allow(
clippy::unwrap_used,
reason = "Rng failure is unrecoverable and should panic"
)]
let mut rng = StdRng::try_from_rng(&mut SysRng).unwrap();
rng.fill_bytes(key_buffer);
});
key.into()
}
pub fn encrypt_in_place(
&mut self,
nonce: &Nonce,
associated_data: &[u8],
mut buffer: impl AsMut<Vec<u8>>,
) -> Result<(), Error> {
let key_reader = self.0.read();
let key_ref = key_reader.deref();
let cipher = XChaCha20Poly1305::new(key_ref);
let nonce = XNonce::from_slice(nonce.0.as_ref());
let buffer = buffer.as_mut();
cipher.encrypt_in_place(nonce, associated_data, buffer)
}
pub fn decrypt_in_place(
&mut self,
nonce: &Nonce,
associated_data: &[u8],
buffer: &mut SafeCell<Vec<u8>>,
) -> Result<(), Error> {
let key_reader = self.0.read();
let key_ref = key_reader.deref();
let cipher = XChaCha20Poly1305::new(key_ref);
let nonce = XNonce::from_slice(nonce.0.as_ref());
let mut buffer = buffer.write();
let buffer: &mut Vec<u8> = buffer.as_mut();
cipher.decrypt_in_place(nonce, associated_data, buffer)
}
pub fn encrypt(
&mut self,
nonce: &Nonce,
associated_data: &[u8],
plaintext: impl AsRef<[u8]>,
) -> Result<Vec<u8>, Error> {
let key_reader = self.0.read();
let key_ref = key_reader.deref();
let mut cipher = XChaCha20Poly1305::new(key_ref);
let nonce = XNonce::from_slice(nonce.0.as_ref());
let ciphertext = cipher.encrypt(
nonce,
Payload {
msg: plaintext.as_ref(),
aad: associated_data,
},
)?;
Ok(ciphertext)
}
}
pub type Salt = [u8; ArgonSalt::RECOMMENDED_LENGTH];
pub fn generate_salt() -> Salt {
let mut salt = Salt::default();
#[allow(
clippy::unwrap_used,
reason = "Rng failure is unrecoverable and should panic"
)]
let mut rng = StdRng::try_from_rng(&mut SysRng).unwrap();
rng.fill_bytes(&mut salt);
salt
}
/// User password might be of different length, have not enough entropy, etc...
/// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation.
pub fn derive_seal_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
#[allow(clippy::unwrap_used)]
let params = argon2::Params::new(262_144, 3, 4, None).unwrap();
let hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut key = SafeCell::new(Key::default());
password.read_inline(|password_source| {
let mut key_buffer = key.write();
let key_buffer: &mut [u8] = key_buffer.as_mut();
#[allow(
clippy::unwrap_used,
reason = "Better fail completely than return a weak key"
)]
hasher
.hash_password_into(password_source.deref(), salt, key_buffer)
.unwrap();
});
key.into()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::safe_cell::SafeCell;
#[test]
pub fn derive_seal_key_deterministic() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let password2 = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key1 = derive_seal_key(password, &salt);
let mut key2 = derive_seal_key(password2, &salt);
let key1_reader = key1.0.read();
let key2_reader = key2.0.read();
assert_eq!(key1_reader.deref(), key2_reader.deref());
}
#[test]
pub fn successful_derive() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key = derive_seal_key(password, &salt);
let key_reader = key.0.read();
let key_ref = key_reader.deref();
assert_ne!(key_ref.as_slice(), &[0u8; 32][..]);
}
#[test]
pub fn encrypt_decrypt() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key = derive_seal_key(password, &salt);
let nonce = Nonce(*b"unique nonce 123 1231233"); // 24 bytes for XChaCha20Poly1305
let associated_data = b"associated data";
let mut buffer = b"secret data".to_vec();
key.encrypt_in_place(&nonce, associated_data, &mut buffer)
.unwrap();
assert_ne!(buffer, b"secret data");
let mut buffer = SafeCell::new(buffer);
key.decrypt_in_place(&nonce, associated_data, &mut buffer)
.unwrap();
let buffer = buffer.read();
assert_eq!(*buffer, b"secret data");
}
#[test]
// We should fuzz this
pub fn test_nonce_increment() {
let mut nonce = Nonce([0u8; NONCE_LENGTH]);
nonce.increment();
assert_eq!(
nonce.0,
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1
]
);
}
}

View File

@@ -1,47 +1,59 @@
use kameo::actor::{ActorRef, Spawn}; use crate::{
use miette::Diagnostic; actors::{
use thiserror::Error; bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator,
operator_registry::OperatorRegistry, vault::Vault,
use crate::{ },
actors::{bootstrap::Bootstrapper, evm::EvmActor, keyholder::KeyHolder, router::MessageRouter}, db,
db, };
};
use kameo::actor::{ActorRef, Spawn};
pub mod bootstrap; use kameo_actors::{DeliveryStrategy, message_bus::MessageBus};
pub mod client; use thiserror::Error;
mod evm;
pub mod keyholder; pub mod bootstrap;
pub mod router; pub mod evm;
pub mod user_agent; pub mod flow_coordinator;
pub mod operator_registry;
#[derive(Error, Debug, Diagnostic)] pub mod vault;
pub enum SpawnError {
#[error("Failed to spawn Bootstrapper actor")] #[derive(Error, Debug)]
#[diagnostic(code(SpawnError::Bootstrapper))] pub enum SpawnError {
Bootstrapper(#[from] bootstrap::Error), #[error("Failed to spawn Bootstrapper actor")]
Bootstrapper(#[from] bootstrap::Error),
#[error("Failed to spawn KeyHolder actor")]
#[diagnostic(code(SpawnError::KeyHolder))] #[error("Failed to spawn Vault actor")]
KeyHolder(#[from] keyholder::Error), Vault(#[from] vault::Error),
} }
/// Long-lived actors that are shared across all connections and handle global state and operations /// Long-lived actors that are shared across all connections and handle global state and operations
#[derive(Clone)] #[derive(Clone)]
pub struct GlobalActors { pub struct GlobalActors {
pub key_holder: ActorRef<KeyHolder>, pub vault: ActorRef<Vault>,
pub bootstrapper: ActorRef<Bootstrapper>, pub bootstrapper: ActorRef<Bootstrapper>,
pub router: ActorRef<MessageRouter>, pub flow_coordinator: ActorRef<FlowCoordinator>,
pub evm: ActorRef<EvmActor>, pub operator_registry: ActorRef<OperatorRegistry>,
} pub evm: ActorRef<EvmActor>,
pub events: ActorRef<MessageBus>,
impl GlobalActors { }
pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> {
let key_holder = KeyHolder::spawn(KeyHolder::new(db.clone()).await?); impl GlobalActors {
Ok(Self { pub fn spawn_message_bus() -> ActorRef<MessageBus> {
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?), MessageBus::spawn(MessageBus::new(DeliveryStrategy::Guaranteed))
evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db)), }
key_holder,
router: MessageRouter::spawn(MessageRouter::default()), pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> {
}) let message_bus = Self::spawn_message_bus();
} let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?);
} let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default());
Ok(Self {
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db)),
vault: key_holder,
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new(
operator_registry.clone(),
)),
operator_registry,
events: message_bus,
})
}
}

View File

@@ -0,0 +1,61 @@
use crate::peers::operator::OperatorSession;
use kameo::{
Actor,
actor::{ActorId, ActorRef},
error::Infallible,
messages,
prelude::{ActorStopReason, Context, WeakActorRef},
};
use std::{collections::HashMap, ops::ControlFlow};
use tracing::info;
#[derive(Default)]
pub struct OperatorRegistry {
connected: HashMap<ActorId, ActorRef<OperatorSession>>,
}
impl Actor for OperatorRegistry {
type Args = Self;
type Error = Infallible;
async fn on_start(args: Self::Args, _: ActorRef<Self>) -> Result<Self, Self::Error> {
Ok(args)
}
async fn on_link_died(
&mut self,
_: WeakActorRef<Self>,
id: ActorId,
_: ActorStopReason,
) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
if self.connected.remove(&id).is_some() {
info!(
?id,
actor = "OperatorRegistry",
event = "operator.disconnected"
);
}
Ok(ControlFlow::Continue(()))
}
}
#[messages]
impl OperatorRegistry {
#[message(ctx)]
pub async fn connect_operator(
&mut self,
actor: ActorRef<OperatorSession>,
ctx: &mut Context<Self, ()>,
) {
info!(id = %actor.id(), actor = "OperatorRegistry", event = "operator.connected");
ctx.actor_ref().link(&actor).await;
self.connected.insert(actor.id(), actor);
}
#[message]
pub fn get_connected(&self) -> Vec<ActorRef<OperatorSession>> {
self.connected.values().cloned().collect()
}
}

View File

@@ -1,173 +0,0 @@
use std::{collections::HashMap, ops::ControlFlow};
use ed25519_dalek::VerifyingKey;
use kameo::{
Actor,
actor::{ActorId, ActorRef},
messages,
prelude::{ActorStopReason, Context, WeakActorRef},
reply::DelegatedReply,
};
use tokio::{sync::watch, task::JoinSet};
use tracing::{info, warn};
use crate::actors::{
client::session::ClientSession,
user_agent::session::{RequestNewClientApproval, UserAgentSession},
};
#[derive(Default)]
pub struct MessageRouter {
pub user_agents: HashMap<ActorId, ActorRef<UserAgentSession>>,
pub clients: HashMap<ActorId, ActorRef<ClientSession>>,
}
impl Actor for MessageRouter {
type Args = Self;
type Error = ();
async fn on_start(args: Self::Args, _: ActorRef<Self>) -> Result<Self, Self::Error> {
Ok(args)
}
async fn on_link_died(
&mut self,
_: WeakActorRef<Self>,
id: ActorId,
_: ActorStopReason,
) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
if self.user_agents.remove(&id).is_some() {
info!(
?id,
actor = "MessageRouter",
event = "useragent.disconnected"
);
} else if self.clients.remove(&id).is_some() {
info!(?id, actor = "MessageRouter", event = "client.disconnected");
} else {
info!(
?id,
actor = "MessageRouter",
event = "unknown.actor.disconnected"
);
}
Ok(ControlFlow::Continue(()))
}
}
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq, Hash)]
pub enum ApprovalError {
#[error("No user agents connected")]
NoUserAgentsConnected,
}
async fn request_client_approval(
user_agents: &[WeakActorRef<UserAgentSession>],
client_pubkey: VerifyingKey,
) -> Result<bool, ApprovalError> {
if user_agents.is_empty() {
return Err(ApprovalError::NoUserAgentsConnected);
}
let mut pool = JoinSet::new();
let (cancel_tx, cancel_rx) = watch::channel(());
for weak_ref in user_agents {
match weak_ref.upgrade() {
Some(agent) => {
let cancel_rx = cancel_rx.clone();
pool.spawn(async move {
agent
.ask(RequestNewClientApproval {
client_pubkey,
cancel_flag: cancel_rx.clone(),
})
.await
});
}
None => {
warn!(
id = weak_ref.id().to_string(),
actor = "MessageRouter",
event = "useragent.disconnected_before_approval"
);
}
}
}
while let Some(result) = pool.join_next().await {
match result {
Ok(Ok(approved)) => {
// cancel other pending requests
let _ = cancel_tx.send(());
return Ok(approved);
}
Ok(Err(err)) => {
warn!(
?err,
actor = "MessageRouter",
event = "useragent.approval_error"
);
}
Err(err) => {
warn!(
?err,
actor = "MessageRouter",
event = "useragent.approval_task_failed"
);
}
}
}
Err(ApprovalError::NoUserAgentsConnected)
}
#[messages]
impl MessageRouter {
#[message(ctx)]
pub async fn register_user_agent(
&mut self,
actor: ActorRef<UserAgentSession>,
ctx: &mut Context<Self, ()>,
) {
info!(id = %actor.id(), actor = "MessageRouter", event = "useragent.connected");
ctx.actor_ref().link(&actor).await;
self.user_agents.insert(actor.id(), actor);
}
#[message(ctx)]
pub async fn register_client(
&mut self,
actor: ActorRef<ClientSession>,
ctx: &mut Context<Self, ()>,
) {
info!(id = %actor.id(), actor = "MessageRouter", event = "client.connected");
ctx.actor_ref().link(&actor).await;
self.clients.insert(actor.id(), actor);
}
#[message(ctx)]
pub async fn request_client_approval(
&mut self,
client_pubkey: VerifyingKey,
ctx: &mut Context<Self, DelegatedReply<Result<bool, ApprovalError>>>,
) -> DelegatedReply<Result<bool, ApprovalError>> {
let (reply, Some(reply_sender)) = ctx.reply_sender() else {
unreachable!("Expected `request_client_approval` to have callback channel");
};
let weak_refs = self
.user_agents
.values()
.map(|agent| agent.downgrade())
.collect::<Vec<_>>();
tokio::task::spawn(async move {
let result = request_client_approval(&weak_refs, client_pubkey).await;
reply_sender.send(result);
});
reply
}
}

View File

@@ -1,222 +0,0 @@
use arbiter_proto::transport::Bi;
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update};
use diesel_async::RunQueryDsl;
use tracing::error;
use super::Error;
use crate::{
actors::{
bootstrap::ConsumeToken,
user_agent::{AuthPublicKey, UserAgentConnection, auth::Outbound},
},
db::schema,
};
pub struct ChallengeRequest {
pub pubkey: AuthPublicKey,
}
pub struct BootstrapAuthRequest {
pub pubkey: AuthPublicKey,
pub token: String,
}
pub struct ChallengeContext {
pub challenge_nonce: i32,
pub key: AuthPublicKey,
}
pub struct ChallengeSolution {
pub solution: Vec<u8>,
}
smlang::statemachine!(
name: Auth,
custom_error: true,
transitions: {
*Init + AuthRequest(ChallengeRequest) / async prepare_challenge = SentChallenge(ChallengeContext),
Init + BootstrapAuthRequest(BootstrapAuthRequest) / async verify_bootstrap_token = AuthOk(AuthPublicKey),
SentChallenge(ChallengeContext) + ReceivedSolution(ChallengeSolution) / async verify_solution = AuthOk(AuthPublicKey),
}
);
async fn create_nonce(db: &crate::db::DatabasePool, pubkey_bytes: &[u8]) -> Result<i32, Error> {
let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
db_conn
.exclusive_transaction(|conn| {
Box::pin(async move {
let current_nonce = schema::useragent_client::table
.filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec()))
.select(schema::useragent_client::nonce)
.first::<i32>(conn)
.await?;
update(schema::useragent_client::table)
.filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec()))
.set(schema::useragent_client::nonce.eq(current_nonce + 1))
.execute(conn)
.await?;
Result::<_, diesel::result::Error>::Ok(current_nonce)
})
})
.await
.optional()
.map_err(|e| {
error!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?
.ok_or_else(|| {
error!(?pubkey_bytes, "Public key not found in database");
Error::UnregisteredPublicKey
})
}
async fn register_key(db: &crate::db::DatabasePool, pubkey: &AuthPublicKey) -> Result<(), Error> {
let pubkey_bytes = pubkey.to_stored_bytes();
let key_type = pubkey.key_type();
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
diesel::insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(pubkey_bytes),
schema::useragent_client::nonce.eq(1),
schema::useragent_client::key_type.eq(key_type),
))
.execute(&mut conn)
.await
.map_err(|e| {
error!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?;
Ok(())
}
pub struct AuthContext<'a, T> {
pub(super) conn: &'a mut UserAgentConnection,
pub(super) transport: T,
}
impl<'a, T> AuthContext<'a, T> {
pub fn new(conn: &'a mut UserAgentConnection, transport: T) -> Self {
Self { conn, transport }
}
}
impl<T> AuthStateMachineContext for AuthContext<'_, T>
where
T: Bi<super::Inbound, Result<super::Outbound, Error>> + Send,
{
type Error = Error;
async fn prepare_challenge(
&mut self,
ChallengeRequest { pubkey }: ChallengeRequest,
) -> Result<ChallengeContext, Self::Error> {
let stored_bytes = pubkey.to_stored_bytes();
let nonce = create_nonce(&self.conn.db, &stored_bytes).await?;
self.transport
.send(Ok(Outbound::AuthChallenge { nonce }))
.await
.map_err(|e| {
error!(?e, "Failed to send auth challenge");
Error::Transport
})?;
Ok(ChallengeContext {
challenge_nonce: nonce,
key: pubkey,
})
}
#[allow(missing_docs)]
#[allow(clippy::result_unit_err)]
async fn verify_bootstrap_token(
&mut self,
BootstrapAuthRequest { pubkey, token }: BootstrapAuthRequest,
) -> Result<AuthPublicKey, Self::Error> {
let token_ok: bool = self
.conn
.actors
.bootstrapper
.ask(ConsumeToken {
token: token.clone(),
})
.await
.map_err(|e| {
error!(?e, "Failed to consume bootstrap token");
Error::internal("Failed to consume bootstrap token")
})?;
if !token_ok {
error!("Invalid bootstrap token provided");
return Err(Error::InvalidBootstrapToken);
}
register_key(&self.conn.db, &pubkey).await?;
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
Ok(pubkey)
}
#[allow(missing_docs)]
#[allow(clippy::unused_unit)]
async fn verify_solution(
&mut self,
ChallengeContext {
challenge_nonce,
key,
}: &ChallengeContext,
ChallengeSolution { solution }: ChallengeSolution,
) -> Result<AuthPublicKey, Self::Error> {
let formatted = arbiter_proto::format_challenge(*challenge_nonce, &key.to_stored_bytes());
let valid = match key {
AuthPublicKey::Ed25519(vk) => {
let sig = solution.as_slice().try_into().map_err(|_| {
error!(?solution, "Invalid Ed25519 signature length");
Error::InvalidChallengeSolution
})?;
vk.verify_strict(&formatted, &sig).is_ok()
}
AuthPublicKey::EcdsaSecp256k1(vk) => {
use k256::ecdsa::signature::Verifier as _;
let sig = k256::ecdsa::Signature::try_from(solution.as_slice()).map_err(|_| {
error!(?solution, "Invalid ECDSA signature bytes");
Error::InvalidChallengeSolution
})?;
vk.verify(&formatted, &sig).is_ok()
}
AuthPublicKey::Rsa(pk) => {
use rsa::signature::Verifier as _;
let verifying_key = rsa::pss::VerifyingKey::<sha2::Sha256>::new(pk.clone());
let sig = rsa::pss::Signature::try_from(solution.as_slice()).map_err(|_| {
error!(?solution, "Invalid RSA signature bytes");
Error::InvalidChallengeSolution
})?;
verifying_key.verify(&formatted, &sig).is_ok()
}
};
if valid {
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
}
Ok(key.clone())
}
}

View File

@@ -1,94 +0,0 @@
use crate::{
actors::GlobalActors,
db::{self, models::KeyType},
};
/// Abstraction over Ed25519 / ECDSA-secp256k1 / RSA public keys used during the auth handshake.
#[derive(Clone, Debug)]
pub enum AuthPublicKey {
Ed25519(ed25519_dalek::VerifyingKey),
/// Compressed SEC1 public key; signature bytes are raw 64-byte (r||s).
EcdsaSecp256k1(k256::ecdsa::VerifyingKey),
/// RSA-2048+ public key (Windows Hello / KeyCredentialManager); signature bytes are PSS+SHA-256.
Rsa(rsa::RsaPublicKey),
}
impl AuthPublicKey {
/// Canonical bytes stored in DB and echoed back in the challenge.
/// Ed25519: raw 32 bytes. ECDSA: SEC1 compressed 33 bytes. RSA: DER-encoded SPKI.
pub fn to_stored_bytes(&self) -> Vec<u8> {
match self {
AuthPublicKey::Ed25519(k) => k.to_bytes().to_vec(),
// SEC1 compressed (33 bytes) is the natural compact format for secp256k1
AuthPublicKey::EcdsaSecp256k1(k) => k.to_encoded_point(true).as_bytes().to_vec(),
AuthPublicKey::Rsa(k) => {
use rsa::pkcs8::EncodePublicKey as _;
#[allow(clippy::expect_used)]
k.to_public_key_der()
.expect("rsa SPKI encoding is infallible")
.to_vec()
}
}
}
pub fn key_type(&self) -> KeyType {
match self {
AuthPublicKey::Ed25519(_) => KeyType::Ed25519,
AuthPublicKey::EcdsaSecp256k1(_) => KeyType::EcdsaSecp256k1,
AuthPublicKey::Rsa(_) => KeyType::Rsa,
}
}
}
impl TryFrom<(KeyType, Vec<u8>)> for AuthPublicKey {
type Error = &'static str;
fn try_from(value: (KeyType, Vec<u8>)) -> Result<Self, Self::Error> {
let (key_type, bytes) = value;
match key_type {
KeyType::Ed25519 => {
let bytes: [u8; 32] = bytes.try_into().map_err(|_| "invalid Ed25519 key length")?;
let key = ed25519_dalek::VerifyingKey::from_bytes(&bytes)
.map_err(|_e| "invalid Ed25519 key")?;
Ok(AuthPublicKey::Ed25519(key))
}
KeyType::EcdsaSecp256k1 => {
let point =
k256::EncodedPoint::from_bytes(&bytes).map_err(|_e| "invalid ECDSA key")?;
let key = k256::ecdsa::VerifyingKey::from_encoded_point(&point)
.map_err(|_e| "invalid ECDSA key")?;
Ok(AuthPublicKey::EcdsaSecp256k1(key))
}
KeyType::Rsa => {
use rsa::pkcs8::DecodePublicKey as _;
let key = rsa::RsaPublicKey::from_public_key_der(&bytes)
.map_err(|_e| "invalid RSA key")?;
Ok(AuthPublicKey::Rsa(key))
}
}
}
}
// Messages, sent by user agent to connection client without having a request
#[derive(Debug)]
pub enum OutOfBand {
ClientConnectionRequest { pubkey: ed25519_dalek::VerifyingKey },
ClientConnectionCancel,
}
pub struct UserAgentConnection {
pub(crate) db: db::DatabasePool,
pub(crate) actors: GlobalActors,
}
impl UserAgentConnection {
pub fn new(db: db::DatabasePool, actors: GlobalActors) -> Self {
Self { db, actors }
}
}
pub mod auth;
pub mod session;
pub use auth::authenticate;
pub use session::UserAgentSession;

View File

@@ -1,132 +0,0 @@
use std::borrow::Cow;
use arbiter_proto::transport::Sender;
use async_trait::async_trait;
use ed25519_dalek::VerifyingKey;
use kameo::{Actor, messages};
use thiserror::Error;
use tokio::sync::watch;
use tracing::error;
use crate::actors::{
router::RegisterUserAgent,
user_agent::{OutOfBand, UserAgentConnection},
};
mod state;
use state::{DummyContext, UserAgentEvents, UserAgentStateMachine};
#[derive(Debug, Error)]
pub enum Error {
#[error("State transition failed")]
State,
#[error("Internal error: {message}")]
Internal { message: Cow<'static, str> },
}
impl Error {
pub fn internal(message: impl Into<Cow<'static, str>>) -> Self {
Self::Internal {
message: message.into(),
}
}
}
pub struct UserAgentSession {
props: UserAgentConnection,
state: UserAgentStateMachine<DummyContext>,
#[allow(dead_code, reason = "The session keeps ownership of the outbound transport even before the state-machine flow starts using it directly")]
sender: Box<dyn Sender<OutOfBand>>,
}
mod connection;
pub(crate) use connection::{
BootstrapError, HandleBootstrapEncryptedKey, HandleEvmWalletCreate, HandleEvmWalletList,
HandleGrantCreate, HandleGrantDelete, HandleGrantList, HandleQueryVaultState,
};
pub use connection::{HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError};
impl UserAgentSession {
pub(crate) fn new(props: UserAgentConnection, sender: Box<dyn Sender<OutOfBand>>) -> Self {
Self {
props,
state: UserAgentStateMachine::new(DummyContext),
sender,
}
}
pub fn new_test(db: crate::db::DatabasePool, actors: crate::actors::GlobalActors) -> Self {
struct DummySender;
#[async_trait]
impl Sender<OutOfBand> for DummySender {
async fn send(
&mut self,
_item: OutOfBand,
) -> Result<(), arbiter_proto::transport::Error> {
Ok(())
}
}
Self::new(UserAgentConnection::new(db, actors), Box::new(DummySender))
}
fn transition(&mut self, event: UserAgentEvents) -> Result<(), Error> {
self.state.process_event(event).map_err(|e| {
error!(?e, "State transition failed");
Error::State
})?;
Ok(())
}
}
#[messages]
impl UserAgentSession {
#[message]
pub async fn request_new_client_approval(
&mut self,
client_pubkey: VerifyingKey,
mut cancel_flag: watch::Receiver<()>,
) -> Result<bool, ()> {
if self
.sender
.send(OutOfBand::ClientConnectionRequest {
pubkey: client_pubkey,
})
.await
.is_err()
{
return Err(());
}
let _ = cancel_flag.changed().await;
let _ = self.sender.send(OutOfBand::ClientConnectionCancel).await;
Ok(false)
}
}
impl Actor for UserAgentSession {
type Args = Self;
type Error = Error;
async fn on_start(
args: Self::Args,
this: kameo::prelude::ActorRef<Self>,
) -> Result<Self, Self::Error> {
args.props
.actors
.router
.ask(RegisterUserAgent {
actor: this.clone(),
})
.await
.map_err(|err| {
error!(?err, "Failed to register user agent connection with router");
Error::internal("Failed to register user agent connection with router")
})?;
Ok(args)
}
}

View File

@@ -1,354 +0,0 @@
use std::sync::Mutex;
use alloy::primitives::Address;
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use kameo::error::SendError;
use kameo::messages;
use tracing::{error, info};
use x25519_dalek::{EphemeralSecret, PublicKey};
use crate::actors::keyholder::KeyHolderState;
use crate::actors::user_agent::session::Error;
use crate::evm::policies::{Grant, SpecificGrant};
use crate::safe_cell::SafeCell;
use crate::{
actors::{
evm::{
Generate, ListWallets, UseragentCreateGrant, UseragentDeleteGrant, UseragentListGrants,
},
keyholder::{self, Bootstrap, TryUnseal},
user_agent::session::{
UserAgentSession,
state::{UnsealContext, UserAgentEvents, UserAgentStates},
},
},
safe_cell::SafeCellHandle as _,
};
impl UserAgentSession {
fn take_unseal_secret(&mut self) -> Result<(EphemeralSecret, PublicKey), Error> {
let UserAgentStates::WaitingForUnsealKey(unseal_context) = self.state.state() else {
error!("Received encrypted key in invalid state");
return Err(Error::internal("Invalid state for unseal encrypted key"));
};
let ephemeral_secret = {
#[allow(
clippy::unwrap_used,
reason = "Mutex poison is unrecoverable and should panic"
)]
let mut secret_lock = unseal_context.secret.lock().unwrap();
let secret = secret_lock.take();
match secret {
Some(secret) => secret,
None => {
drop(secret_lock);
error!("Ephemeral secret already taken");
return Err(Error::internal("Ephemeral secret already taken"));
}
}
};
Ok((ephemeral_secret, unseal_context.client_public_key))
}
fn decrypt_client_key_material(
ephemeral_secret: EphemeralSecret,
client_public_key: PublicKey,
nonce: &[u8],
ciphertext: &[u8],
associated_data: &[u8],
) -> Result<SafeCell<Vec<u8>>, ()> {
let nonce = XNonce::from_slice(nonce);
let shared_secret = ephemeral_secret.diffie_hellman(&client_public_key);
let cipher = XChaCha20Poly1305::new(shared_secret.as_bytes().into());
let mut key_buffer = SafeCell::new(ciphertext.to_vec());
let decryption_result = key_buffer.write_inline(|write_handle| {
cipher.decrypt_in_place(nonce, associated_data, write_handle)
});
match decryption_result {
Ok(_) => Ok(key_buffer),
Err(err) => {
error!(?err, "Failed to decrypt encrypted key material");
Err(())
}
}
}
}
pub struct UnsealStartResponse {
pub server_pubkey: PublicKey,
}
#[derive(Debug, Error)]
pub enum UnsealError {
#[error("Invalid key provided for unsealing")]
InvalidKey,
#[error("Internal error during unsealing process")]
General(#[from] super::Error),
}
#[derive(Debug, Error)]
pub enum BootstrapError {
#[error("Invalid key provided for bootstrapping")]
InvalidKey,
#[error("Vault is already bootstrapped")]
AlreadyBootstrapped,
#[error("Internal error during bootstrapping process")]
General(#[from] super::Error),
}
#[messages]
impl UserAgentSession {
#[message]
pub async fn handle_unseal_request(
&mut self,
client_pubkey: x25519_dalek::PublicKey,
) -> Result<UnsealStartResponse, Error> {
let secret = EphemeralSecret::random();
let public_key = PublicKey::from(&secret);
self.transition(UserAgentEvents::UnsealRequest(UnsealContext {
secret: Mutex::new(Some(secret)),
client_public_key: client_pubkey,
}))?;
Ok(UnsealStartResponse {
server_pubkey: public_key,
})
}
#[message]
pub async fn handle_unseal_encrypted_key(
&mut self,
nonce: Vec<u8>,
ciphertext: Vec<u8>,
associated_data: Vec<u8>,
) -> Result<(), UnsealError> {
let (ephemeral_secret, client_public_key) = match self.take_unseal_secret() {
Ok(values) => values,
Err(Error::State) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Err(UnsealError::InvalidKey);
}
Err(_err) => {
return Err(Error::internal("Failed to take unseal secret").into());
}
};
let seal_key_buffer = match Self::decrypt_client_key_material(
ephemeral_secret,
client_public_key,
&nonce,
&ciphertext,
&associated_data,
) {
Ok(buffer) => buffer,
Err(()) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Err(UnsealError::InvalidKey);
}
};
match self
.props
.actors
.key_holder
.ask(TryUnseal {
seal_key_raw: seal_key_buffer,
})
.await
{
Ok(_) => {
info!("Successfully unsealed key with client-provided key");
self.transition(UserAgentEvents::ReceivedValidKey)?;
Ok(())
}
Err(SendError::HandlerError(keyholder::Error::InvalidKey)) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(UnsealError::InvalidKey)
}
Err(SendError::HandlerError(err)) => {
error!(?err, "Keyholder failed to unseal key");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(UnsealError::InvalidKey)
}
Err(err) => {
error!(?err, "Failed to send unseal request to keyholder");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(Error::internal("Vault actor error").into())
}
}
}
#[message]
pub(crate) async fn handle_bootstrap_encrypted_key(
&mut self,
nonce: Vec<u8>,
ciphertext: Vec<u8>,
associated_data: Vec<u8>,
) -> Result<(), BootstrapError> {
let (ephemeral_secret, client_public_key) = match self.take_unseal_secret() {
Ok(values) => values,
Err(Error::State) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Err(BootstrapError::InvalidKey);
}
Err(err) => return Err(err.into()),
};
let seal_key_buffer = match Self::decrypt_client_key_material(
ephemeral_secret,
client_public_key,
&nonce,
&ciphertext,
&associated_data,
) {
Ok(buffer) => buffer,
Err(()) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Err(BootstrapError::InvalidKey);
}
};
match self
.props
.actors
.key_holder
.ask(Bootstrap {
seal_key_raw: seal_key_buffer,
})
.await
{
Ok(_) => {
info!("Successfully bootstrapped vault with client-provided key");
self.transition(UserAgentEvents::ReceivedValidKey)?;
Ok(())
}
Err(SendError::HandlerError(keyholder::Error::AlreadyBootstrapped)) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(BootstrapError::AlreadyBootstrapped)
}
Err(SendError::HandlerError(err)) => {
error!(?err, "Keyholder failed to bootstrap vault");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(BootstrapError::InvalidKey)
}
Err(err) => {
error!(?err, "Failed to send bootstrap request to keyholder");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(BootstrapError::General(Error::internal(
"Vault actor error",
)))
}
}
}
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_query_vault_state(&mut self) -> Result<KeyHolderState, Error> {
use crate::actors::keyholder::GetState;
let vault_state = match self.props.actors.key_holder.ask(GetState {}).await {
Ok(state) => state,
Err(err) => {
error!(?err, actor = "useragent", "keyholder.query.failed");
return Err(Error::internal("Vault is in broken state"));
}
};
Ok(vault_state)
}
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_evm_wallet_create(&mut self) -> Result<Address, Error> {
match self.props.actors.evm.ask(Generate {}).await {
Ok(address) => Ok(address),
Err(SendError::HandlerError(err)) => Err(Error::internal(format!(
"EVM wallet generation failed: {err}"
))),
Err(err) => {
error!(?err, "EVM actor unreachable during wallet create");
Err(Error::internal("EVM actor unreachable"))
}
}
}
#[message]
pub(crate) async fn handle_evm_wallet_list(&mut self) -> Result<Vec<Address>, Error> {
match self.props.actors.evm.ask(ListWallets {}).await {
Ok(wallets) => Ok(wallets),
Err(err) => {
error!(?err, "EVM wallet list failed");
Err(Error::internal("Failed to list EVM wallets"))
}
}
}
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_grant_list(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
match self.props.actors.evm.ask(UseragentListGrants {}).await {
Ok(grants) => Ok(grants),
Err(err) => {
error!(?err, "EVM grant list failed");
Err(Error::internal("Failed to list EVM grants"))
}
}
}
#[message]
pub(crate) async fn handle_grant_create(
&mut self,
client_id: i32,
basic: crate::evm::policies::SharedGrantSettings,
grant: crate::evm::policies::SpecificGrant,
) -> Result<i32, Error> {
match self
.props
.actors
.evm
.ask(UseragentCreateGrant {
client_id,
basic,
grant,
})
.await
{
Ok(grant_id) => Ok(grant_id),
Err(err) => {
error!(?err, "EVM grant create failed");
Err(Error::internal("Failed to create EVM grant"))
}
}
}
#[message]
pub(crate) async fn handle_grant_delete(&mut self, grant_id: i32) -> Result<(), Error> {
match self
.props
.actors
.evm
.ask(UseragentDeleteGrant { grant_id })
.await
{
Ok(()) => Ok(()),
Err(err) => {
error!(?err, "EVM grant delete failed");
Err(Error::internal("Failed to delete EVM grant"))
}
}
}
}

View File

@@ -1,27 +0,0 @@
use std::sync::Mutex;
use x25519_dalek::{EphemeralSecret, PublicKey};
pub struct UnsealContext {
pub client_public_key: PublicKey,
pub secret: Mutex<Option<EphemeralSecret>>,
}
smlang::statemachine!(
name: UserAgent,
custom_error: false,
transitions: {
*Idle + UnsealRequest(UnsealContext) / generate_temp_keypair = WaitingForUnsealKey(UnsealContext),
WaitingForUnsealKey(UnsealContext) + ReceivedValidKey = Unsealed,
WaitingForUnsealKey(UnsealContext) + ReceivedInvalidKey = Idle,
}
);
pub struct DummyContext;
impl UserAgentStateMachineContext for DummyContext {
#[allow(missing_docs)]
#[allow(clippy::unused_unit)]
fn generate_temp_keypair(&mut self, event_data: UnsealContext) -> Result<UnsealContext, ()> {
Ok(event_data)
}
}

View File

@@ -1,412 +1,462 @@
use chrono::Utc; use crate::{
use diesel::{ crypto::{
ExpressionMethods as _, OptionalExtension, QueryDsl, SelectableHelper, KeyCell, derive_key,
dsl::{insert_into, update}, encryption::v1::{self, Nonce},
}; integrity::v1::HmacSha256,
use diesel_async::{AsyncConnection, RunQueryDsl}; },
use kameo::{Actor, Reply, messages}; db::{
use strum::{EnumDiscriminants, IntoDiscriminant}; self,
use tracing::{error, info}; models::{self, RootKeyHistory},
schema::{self},
use crate::safe_cell::SafeCell; },
use crate::{ };
db::{ use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
self,
models::{self, RootKeyHistory}, use chrono::Utc;
schema::{self}, use diesel::{
}, ExpressionMethods as _, OptionalExtension, QueryDsl, SelectableHelper,
safe_cell::SafeCellHandle as _, dsl::{insert_into, update},
}; };
use encryption::v1::{self, KeyCell, Nonce}; use diesel_async::{AsyncConnection, RunQueryDsl};
use hmac::{KeyInit as _, Mac as _};
pub mod encryption; use kameo::{Actor, Reply, actor::ActorRef, messages};
use kameo_actors::message_bus::{MessageBus, Publish};
#[derive(Default, EnumDiscriminants)] use strum::{EnumDiscriminants, IntoDiscriminant};
#[strum_discriminants(derive(Reply), vis(pub), name(KeyHolderState))] use tracing::{error, info};
enum State {
#[default] pub mod events {
Unbootstrapped,
Sealed { #[derive(Clone, Copy)]
root_key_history_id: i32, pub struct Bootstrapped;
},
Unsealed { #[derive(Clone, Copy)]
root_key_history_id: i32, pub struct Unsealed;
root_key: KeyCell,
}, #[derive(Clone, Copy)]
} pub struct VaultResealed;
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum Error { #[derive(Debug, thiserror::Error)]
#[error("Keyholder is already bootstrapped")] pub enum Error {
#[diagnostic(code(arbiter::keyholder::already_bootstrapped))] #[error("Vault is already bootstrapped")]
AlreadyBootstrapped, AlreadyBootstrapped,
#[error("Keyholder is not bootstrapped")] #[error("Vault is not bootstrapped")]
#[diagnostic(code(arbiter::keyholder::not_bootstrapped))] NotBootstrapped,
NotBootstrapped, #[error("Vault is sealed")]
#[error("Invalid key provided")] Sealed,
#[diagnostic(code(arbiter::keyholder::invalid_key))] #[error("Invalid key provided")]
InvalidKey, InvalidKey,
#[error("Requested aead entry not found")] #[error("Requested aead entry not found")]
#[diagnostic(code(arbiter::keyholder::aead_not_found))] NotFound,
NotFound,
#[error("Encryption error: {0}")]
#[error("Encryption error: {0}")] Encryption(#[from] chacha20poly1305::aead::Error),
#[diagnostic(code(arbiter::keyholder::encryption_error))]
Encryption(#[from] chacha20poly1305::aead::Error), #[error("Database error: {0}")]
DatabaseConnection(#[from] db::PoolError),
#[error("Database error: {0}")]
#[diagnostic(code(arbiter::keyholder::database_error))] #[error("Database transaction error: {0}")]
DatabaseConnection(#[from] db::PoolError), DatabaseTransaction(#[from] diesel::result::Error),
#[error("Database transaction error: {0}")] #[error("Broken database")]
#[diagnostic(code(arbiter::keyholder::database_transaction_error))] BrokenDatabase,
DatabaseTransaction(#[from] diesel::result::Error), }
#[error("Broken database")] struct Unsealed {
#[diagnostic(code(arbiter::keyholder::broken_database))] root_key_history_id: i32,
BrokenDatabase, root_key: KeyCell,
} }
/// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed). #[derive(Default, EnumDiscriminants)]
/// Provides API for encrypting and decrypting data using the vault root key. #[strum_discriminants(derive(Reply), vis(pub), name(VaultState))]
/// Abstraction over database to make sure nonces are never reused and encryption keys are never exposed in plaintext outside of this actor. enum State {
#[derive(Actor)] #[default]
pub struct KeyHolder { Unbootstrapped,
db: db::DatabasePool, Sealed {
state: State, root_key_history_id: i32,
} },
Unsealed(Unsealed),
#[messages] }
impl KeyHolder {
pub async fn new(db: db::DatabasePool) -> Result<Self, Error> { /// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed).
let state = { ///
let mut conn = db.get().await?; /// Provides API for encrypting and decrypting data using the vault root key.
/// Abstraction over database to make sure nonces are never reused and encryption keys are never exposed in plaintext outside of this actor.
let (root_key_history,) = schema::arbiter_settings::table #[derive(Actor)]
.left_join(schema::root_key_history::table) pub struct Vault {
.select((Option::<RootKeyHistory>::as_select(),)) db: db::DatabasePool,
.get_result::<(Option<RootKeyHistory>,)>(&mut conn) state: State,
.await?; events: ActorRef<MessageBus>,
}
match root_key_history {
Some(root_key_history) => State::Sealed { #[messages]
root_key_history_id: root_key_history.id, impl Vault {
}, pub async fn new(db: db::DatabasePool, events: ActorRef<MessageBus>) -> Result<Self, Error> {
None => State::Unbootstrapped, let state = {
} let mut conn = db.get().await?;
};
let (root_key_history,) = schema::arbiter_settings::table
Ok(Self { db, state }) .left_join(schema::root_key_history::table)
} .select((Option::<RootKeyHistory>::as_select(),))
.get_result::<(Option<RootKeyHistory>,)>(&mut conn)
// Exclusive transaction to avoid race condtions if multiple keyholders write .await?;
// additional layer of protection against nonce-reuse
async fn get_new_nonce(pool: &db::DatabasePool, root_key_id: i32) -> Result<Nonce, Error> { match root_key_history {
let mut conn = pool.get().await?; Some(root_key_history) => State::Sealed {
root_key_history_id: root_key_history.id,
let nonce = conn },
.exclusive_transaction(|conn| { None => State::Unbootstrapped,
Box::pin(async move { }
let current_nonce: Vec<u8> = schema::root_key_history::table };
.filter(schema::root_key_history::id.eq(root_key_id))
.select(schema::root_key_history::data_encryption_nonce) Ok(Self { db, state, events })
.first(conn) }
.await?;
// Exclusive transaction to avoid race condtions if multiple vaults write
let mut nonce = // additional layer of protection against nonce-reuse
v1::Nonce::try_from(current_nonce.as_slice()).map_err(|_| { async fn get_new_nonce(pool: &db::DatabasePool, root_key_id: i32) -> Result<Nonce, Error> {
error!( let mut conn = pool.get().await?;
"Broken database: invalid nonce for root key history id={}",
root_key_id let nonce = conn
); .exclusive_transaction(async |conn| {
Error::BrokenDatabase let current_nonce: Vec<u8> = schema::root_key_history::table
})?; .filter(schema::root_key_history::id.eq(root_key_id))
nonce.increment(); .select(schema::root_key_history::data_encryption_nonce)
.first(&mut *conn)
update(schema::root_key_history::table) .await?;
.filter(schema::root_key_history::id.eq(root_key_id))
.set(schema::root_key_history::data_encryption_nonce.eq(nonce.to_vec())) let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|()| {
.execute(conn) error!(
.await?; "Broken database: invalid nonce for root key history id={}",
root_key_id
Result::<_, Error>::Ok(nonce) );
}) Error::BrokenDatabase
}) })?;
.await?; nonce.increment();
Ok(nonce) update(schema::root_key_history::table)
} .filter(schema::root_key_history::id.eq(root_key_id))
.set(schema::root_key_history::data_encryption_nonce.eq(nonce.to_vec()))
#[message] .execute(&mut *conn)
pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> { .await?;
if !matches!(self.state, State::Unbootstrapped) {
return Err(Error::AlreadyBootstrapped); Result::<_, Error>::Ok(nonce)
} })
let salt = v1::generate_salt(); .await?;
let mut seal_key = v1::derive_seal_key(seal_key_raw, &salt);
let mut root_key = KeyCell::new_secure_random(); Ok(nonce)
}
// Zero nonces are fine because they are one-time
let root_key_nonce = v1::Nonce::default(); const fn expect_unsealed(state: &mut State) -> Result<&mut Unsealed, Error> {
let data_encryption_nonce = v1::Nonce::default(); match state {
State::Unsealed(unsealed) => Ok(unsealed),
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| { State::Unbootstrapped => Err(Error::NotBootstrapped),
let root_key_reader = reader.as_slice(); State::Sealed { .. } => Err(Error::Sealed),
seal_key }
.encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, root_key_reader) }
.map_err(|err| {
error!(?err, "Fatal bootstrap error"); #[message]
Error::Encryption(err) pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
}) if !matches!(self.state, State::Unbootstrapped) {
})?; return Err(Error::AlreadyBootstrapped);
}
let mut conn = self.db.get().await?; let salt = v1::generate_salt();
let mut seal_key = derive_key(seal_key_raw, &salt);
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec(); let mut root_key = KeyCell::new_secure_random();
let root_key_history_id = conn
.transaction(|conn| { // Zero nonces are fine because they are one-time
Box::pin(async move { let root_key_nonce = Nonce::default();
let root_key_history_id: i32 = insert_into(schema::root_key_history::table) let data_encryption_nonce = Nonce::default();
.values(&models::NewRootKeyHistory {
ciphertext: root_key_ciphertext, let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| {
tag: v1::ROOT_KEY_TAG.to_vec(), let root_key_reader = reader.as_slice();
root_key_encryption_nonce: root_key_nonce.to_vec(), seal_key
data_encryption_nonce: data_encryption_nonce_bytes, .encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, root_key_reader)
schema_version: 1, .map_err(|err| {
salt: salt.to_vec(), error!(?err, "Fatal bootstrap error");
}) Error::Encryption(err)
.returning(schema::root_key_history::id) })
.get_result(conn) })?;
.await?;
let mut conn = self.db.get().await?;
update(schema::arbiter_settings::table)
.set(schema::arbiter_settings::root_key_id.eq(root_key_history_id)) let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
.execute(conn) let root_key_history_id = conn
.await?; .transaction(async |conn| {
let root_key_history_id: i32 = insert_into(schema::root_key_history::table)
Result::<_, diesel::result::Error>::Ok(root_key_history_id) .values(&models::NewRootKeyHistory {
}) ciphertext: root_key_ciphertext.clone(),
}) tag: v1::ROOT_KEY_TAG.to_vec(),
.await?; root_key_encryption_nonce: root_key_nonce.to_vec(),
data_encryption_nonce: data_encryption_nonce_bytes.clone(),
self.state = State::Unsealed { schema_version: 1,
root_key, salt: salt.to_vec(),
root_key_history_id, })
}; .returning(schema::root_key_history::id)
.get_result(&mut *conn)
info!("Keyholder bootstrapped successfully"); .await?;
Ok(()) update(schema::arbiter_settings::table)
} .set(schema::arbiter_settings::root_key_id.eq(root_key_history_id))
.execute(&mut *conn)
#[message] .await?;
pub async fn try_unseal(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
let State::Sealed { Result::<_, diesel::result::Error>::Ok(root_key_history_id)
root_key_history_id, })
} = &self.state .await?;
else {
return Err(Error::NotBootstrapped); self.state = State::Unsealed(Unsealed {
}; root_key,
root_key_history_id,
// We don't want to hold connection while doing expensive KDF work });
let current_key = {
let mut conn = self.db.get().await?; info!("Vault bootstrapped successfully");
schema::root_key_history::table let _ = self.events.tell(Publish(events::Bootstrapped)).await;
.filter(schema::root_key_history::id.eq(*root_key_history_id))
.select(schema::root_key_history::data_encryption_nonce) Ok(())
.select(RootKeyHistory::as_select()) }
.first(&mut conn)
.await? #[message]
}; pub async fn try_unseal(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
let State::Sealed {
let salt = &current_key.salt; root_key_history_id,
let salt = v1::Salt::try_from(salt.as_slice()).map_err(|_| { } = &self.state
error!("Broken database: invalid salt for root key"); else {
Error::BrokenDatabase return Err(Error::NotBootstrapped);
})?; };
let mut seal_key = v1::derive_seal_key(seal_key_raw, &salt);
// We don't want to hold connection while doing expensive KDF work
let mut root_key = SafeCell::new(current_key.ciphertext.clone()); let current_key = {
let mut conn = self.db.get().await?;
let nonce = v1::Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err( schema::root_key_history::table
|_| { .filter(schema::root_key_history::id.eq(*root_key_history_id))
error!("Broken database: invalid nonce for root key"); .select(RootKeyHistory::as_select())
Error::BrokenDatabase .first(&mut conn)
}, .await?
)?; };
seal_key let salt = &current_key.salt;
.decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key) let salt = v1::Salt::try_from(salt.as_slice()).map_err(|_| {
.map_err(|err| { error!("Broken database: invalid salt for root key");
error!(?err, "Failed to unseal root key: invalid seal key"); Error::BrokenDatabase
Error::InvalidKey })?;
})?; let mut seal_key = derive_key(seal_key_raw, &salt);
self.state = State::Unsealed { let mut root_key = SafeCell::new(current_key.ciphertext.clone());
root_key_history_id: current_key.id,
root_key: v1::KeyCell::try_from(root_key).map_err(|err| { let nonce =
error!(?err, "Broken database: invalid encryption key size"); Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(|()| {
Error::BrokenDatabase error!("Broken database: invalid nonce for root key");
})?, Error::BrokenDatabase
}; })?;
info!("Keyholder unsealed successfully"); seal_key
.decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key)
Ok(()) .map_err(|err| {
} error!(?err, "Failed to unseal root key: invalid seal key");
Error::InvalidKey
// Decrypts the `aead_encrypted` entry with the given ID and returns the plaintext })?;
#[message]
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> { self.state = State::Unsealed(Unsealed {
let State::Unsealed { root_key, .. } = &mut self.state else { root_key_history_id: current_key.id,
return Err(Error::NotBootstrapped); root_key: KeyCell::try_from(root_key).map_err(|err| {
}; error!(?err, "Broken database: invalid encryption key size");
Error::BrokenDatabase
let row: models::AeadEncrypted = { })?,
let mut conn = self.db.get().await?; });
schema::aead_encrypted::table
.select(models::AeadEncrypted::as_select()) info!("Vault unsealed successfully");
.filter(schema::aead_encrypted::id.eq(aead_id)) let _ = self.events.tell(Publish(events::Unsealed)).await;
.first(&mut conn)
.await Ok(())
.optional()? }
.ok_or(Error::NotFound)?
}; #[message]
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
let nonce = v1::Nonce::try_from(row.current_nonce.as_slice()).map_err(|_| { let Unsealed { root_key, .. } = Self::expect_unsealed(&mut self.state)?;
error!(
"Broken database: invalid nonce for aead_encrypted id={}", let row: models::AeadEncrypted = {
aead_id let mut conn = self.db.get().await?;
); schema::aead_encrypted::table
Error::BrokenDatabase .select(models::AeadEncrypted::as_select())
})?; .filter(schema::aead_encrypted::id.eq(aead_id))
let mut output = SafeCell::new(row.ciphertext); .first(&mut conn)
root_key.decrypt_in_place(&nonce, v1::TAG, &mut output)?; .await
Ok(output) .optional()?
} .ok_or(Error::NotFound)?
};
// Creates new `aead_encrypted` entry in the database and returns it's ID
#[message] let nonce = Nonce::try_from(row.current_nonce.as_slice()).map_err(|()| {
pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>) -> Result<i32, Error> { error!(
let State::Unsealed { "Broken database: invalid nonce for aead_encrypted id={}",
root_key, aead_id
root_key_history_id, );
} = &mut self.state Error::BrokenDatabase
else { })?;
return Err(Error::NotBootstrapped); let mut output = SafeCell::new(row.ciphertext);
}; root_key.decrypt_in_place(&nonce, v1::TAG, &mut output)?;
Ok(output)
// Order matters here - `get_new_nonce` acquires connection, so we need to call it before next acquire }
// Borrow checker note: &mut borrow a few lines above is disjoint from this field
let nonce = Self::get_new_nonce(&self.db, *root_key_history_id).await?; // Creates new `aead_encrypted` entry in the database and returns it's ID
#[message]
let mut ciphertext_buffer = plaintext.write(); pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>) -> Result<i32, Error> {
let ciphertext_buffer: &mut Vec<u8> = ciphertext_buffer.as_mut(); let Unsealed {
root_key.encrypt_in_place(&nonce, v1::TAG, &mut *ciphertext_buffer)?; root_key,
root_key_history_id,
let ciphertext = std::mem::take(ciphertext_buffer); } = Self::expect_unsealed(&mut self.state)?;
let mut conn = self.db.get().await?; // Order matters here - `get_new_nonce` acquires connection, so we need to call it before next acquire
let aead_id: i32 = insert_into(schema::aead_encrypted::table) // Borrow checker note: &mut borrow a few lines above is disjoint from this field
.values(&models::NewAeadEncrypted { let nonce = Self::get_new_nonce(&self.db, *root_key_history_id).await?;
ciphertext,
tag: v1::TAG.to_vec(), let mut ciphertext_buffer = plaintext.write();
current_nonce: nonce.to_vec(), let ciphertext_buffer: &mut Vec<u8> = ciphertext_buffer.as_mut();
schema_version: 1, root_key.encrypt_in_place(&nonce, v1::TAG, &mut *ciphertext_buffer)?;
associated_root_key_id: *root_key_history_id,
created_at: Utc::now().into(), let ciphertext = std::mem::take(ciphertext_buffer);
})
.returning(schema::aead_encrypted::id) let mut conn = self.db.get().await?;
.get_result(&mut conn) let aead_id: i32 = insert_into(schema::aead_encrypted::table)
.await?; .values(&models::NewAeadEncrypted {
ciphertext,
Ok(aead_id) tag: v1::TAG.to_vec(),
} current_nonce: nonce.to_vec(),
schema_version: 1,
#[message] associated_root_key_id: *root_key_history_id,
pub fn get_state(&self) -> KeyHolderState { created_at: Utc::now().into(),
self.state.discriminant() })
} .returning(schema::aead_encrypted::id)
.get_result(&mut conn)
#[message] .await?;
pub fn seal(&mut self) -> Result<(), Error> {
let State::Unsealed { Ok(aead_id)
root_key_history_id, }
..
} = &self.state #[message]
else { pub fn get_state(&self) -> VaultState {
return Err(Error::NotBootstrapped); self.state.discriminant()
}; }
self.state = State::Sealed {
root_key_history_id: *root_key_history_id, #[message]
}; pub fn sign_integrity(&mut self, mac_input: Vec<u8>) -> Result<(i32, Vec<u8>), Error> {
Ok(()) let Unsealed {
} root_key,
} root_key_history_id,
} = Self::expect_unsealed(&mut self.state)?;
#[cfg(test)]
mod tests { let mut hmac = root_key.0.read_inline(|k| {
use diesel::SelectableHelper; HmacSha256::new_from_slice(k)
.unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
use diesel_async::RunQueryDsl; });
hmac.update(&root_key_history_id.to_be_bytes());
use crate::{ hmac.update(&mac_input);
db::{self},
safe_cell::SafeCell, let mac = hmac.finalize().into_bytes().to_vec();
}; Ok((*root_key_history_id, mac))
}
use super::*;
#[message]
async fn bootstrapped_actor(db: &db::DatabasePool) -> KeyHolder { pub fn verify_integrity(
let mut actor = KeyHolder::new(db.clone()).await.unwrap(); &mut self,
let seal_key = SafeCell::new(b"test-seal-key".to_vec()); mac_input: Vec<u8>,
actor.bootstrap(seal_key).await.unwrap(); expected_mac: Vec<u8>,
actor key_version: i32,
} ) -> Result<bool, Error> {
let Unsealed {
#[tokio::test] root_key,
#[test_log::test] root_key_history_id,
async fn nonce_monotonic_even_when_nonce_allocation_interleaves() { } = Self::expect_unsealed(&mut self.state)?;
let db = db::create_test_pool().await;
let mut actor = bootstrapped_actor(&db).await; if *root_key_history_id != key_version {
let root_key_history_id = match actor.state { return Ok(false);
State::Unsealed { }
root_key_history_id,
.. let mut hmac = root_key.0.read_inline(|k| {
} => root_key_history_id, HmacSha256::new_from_slice(k)
_ => panic!("expected unsealed state"), .unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
}; });
hmac.update(&key_version.to_be_bytes());
let n1 = KeyHolder::get_new_nonce(&db, root_key_history_id) hmac.update(&mac_input);
.await
.unwrap(); Ok(hmac.verify_slice(&expected_mac).is_ok())
let n2 = KeyHolder::get_new_nonce(&db, root_key_history_id) }
.await
.unwrap(); #[message]
assert!(n2.to_vec() > n1.to_vec(), "nonce must increase"); pub async fn seal(&mut self) -> Result<(), Error> {
let Unsealed {
let mut conn = db.get().await.unwrap(); root_key_history_id,
let root_row: models::RootKeyHistory = schema::root_key_history::table ..
.select(models::RootKeyHistory::as_select()) } = Self::expect_unsealed(&mut self.state)?;
.first(&mut conn)
.await self.state = State::Sealed {
.unwrap(); root_key_history_id: *root_key_history_id,
assert_eq!(root_row.data_encryption_nonce, n2.to_vec()); };
let _ = self.events.tell(Publish(events::VaultResealed)).await;
let id = actor Ok(())
.create_new(SafeCell::new(b"post-interleave".to_vec())) }
.await }
.unwrap();
let row: models::AeadEncrypted = schema::aead_encrypted::table #[cfg(test)]
.filter(schema::aead_encrypted::id.eq(id)) mod tests {
.select(models::AeadEncrypted::as_select()) use crate::actors::GlobalActors;
.first(&mut conn) use arbiter_crypto::safecell::SafeCellHandle as _;
.await
.unwrap(); use super::*;
assert!(
row.current_nonce > n2.to_vec(), async fn bootstrapped_actor(db: &db::DatabasePool) -> Vault {
"next write must advance nonce" let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
); .await
} .unwrap();
} let seal_key = SafeCell::new(b"test-seal-key".to_vec());
actor.bootstrap(seal_key).await.unwrap();
actor
}
#[tokio::test]
#[test_log::test]
async fn nonce_monotonic_even_when_nonce_allocation_interleaves() {
let db = db::create_test_pool().await;
let mut actor = bootstrapped_actor(&db).await;
let State::Unsealed(Unsealed {
root_key_history_id,
..
}) = actor.state
else {
panic!("expected unsealed state")
};
let n1 = Vault::get_new_nonce(&db, root_key_history_id)
.await
.unwrap();
let n2 = Vault::get_new_nonce(&db, root_key_history_id)
.await
.unwrap();
assert!(n2.to_vec() > n1.to_vec(), "nonce must increase");
let mut conn = db.get().await.unwrap();
let root_row: RootKeyHistory = schema::root_key_history::table
.select(RootKeyHistory::as_select())
.first(&mut conn)
.await
.unwrap();
assert_eq!(root_row.data_encryption_nonce, n2.to_vec());
let id = actor
.create_new(SafeCell::new(b"post-interleave".to_vec()))
.await
.unwrap();
let row: models::AeadEncrypted = schema::aead_encrypted::table
.filter(schema::aead_encrypted::id.eq(id))
.select(models::AeadEncrypted::as_select())
.first(&mut conn)
.await
.unwrap();
assert!(
row.current_nonce > n2.to_vec(),
"next write must advance nonce"
);
}
}

View File

@@ -1,65 +1,57 @@
use std::sync::Arc; use crate::{
actors::GlobalActors,
use miette::Diagnostic; context::tls::TlsManager,
use thiserror::Error; db::{self},
};
use crate::{
actors::GlobalActors, use std::sync::Arc;
context::tls::TlsManager, use thiserror::Error;
db::{self},
}; pub mod tls;
pub mod tls; #[derive(Error, Debug)]
pub enum InitError {
#[derive(Error, Debug, Diagnostic)] #[error("Database setup failed: {0}")]
pub enum InitError { DatabaseSetup(#[from] db::DatabaseSetupError),
#[error("Database setup failed: {0}")]
#[diagnostic(code(arbiter_server::init::database_setup))] #[error("Connection acquire failed: {0}")]
DatabaseSetup(#[from] db::DatabaseSetupError), DatabasePool(#[from] db::PoolError),
#[error("Connection acquire failed: {0}")] #[error("Database query error: {0}")]
#[diagnostic(code(arbiter_server::init::database_pool))] DatabaseQuery(#[from] diesel::result::Error),
DatabasePool(#[from] db::PoolError),
#[error("TLS initialization failed: {0}")]
#[error("Database query error: {0}")] Tls(#[from] tls::InitError),
#[diagnostic(code(arbiter_server::init::database_query))]
DatabaseQuery(#[from] diesel::result::Error), #[error("Actor spawn failed: {0}")]
ActorSpawn(#[from] crate::actors::SpawnError),
#[error("TLS initialization failed: {0}")]
#[diagnostic(code(arbiter_server::init::tls_init))] #[error("I/O Error: {0}")]
Tls(#[from] tls::InitError), Io(#[from] std::io::Error),
}
#[error("Actor spawn failed: {0}")]
#[diagnostic(code(arbiter_server::init::actor_spawn))] pub struct __ServerContextInner {
ActorSpawn(#[from] crate::actors::SpawnError), pub db: db::DatabasePool,
pub tls: TlsManager,
#[error("I/O Error: {0}")] pub actors: GlobalActors,
#[diagnostic(code(arbiter_server::init::io))] }
Io(#[from] std::io::Error), #[derive(Clone)]
} pub struct ServerContext(Arc<__ServerContextInner>);
pub struct _ServerContextInner { impl std::ops::Deref for ServerContext {
pub db: db::DatabasePool, type Target = __ServerContextInner;
pub tls: TlsManager,
pub actors: GlobalActors, fn deref(&self) -> &Self::Target {
} &self.0
#[derive(Clone)] }
pub struct ServerContext(Arc<_ServerContextInner>); }
impl std::ops::Deref for ServerContext { impl ServerContext {
type Target = _ServerContextInner; pub async fn new(db: db::DatabasePool) -> Result<Self, InitError> {
Ok(Self(Arc::new(__ServerContextInner {
fn deref(&self) -> &Self::Target { actors: GlobalActors::spawn(db.clone()).await?,
&self.0 tls: TlsManager::new(db.clone()).await?,
} db,
} })))
}
impl ServerContext { }
pub async fn new(db: db::DatabasePool) -> Result<Self, InitError> {
Ok(Self(Arc::new(_ServerContextInner {
actors: GlobalActors::spawn(db.clone()).await?,
tls: TlsManager::new(db.clone()).await?,
db,
})))
}
}

View File

@@ -1,253 +1,257 @@
use std::string::FromUtf8Error; use crate::db::{
self,
use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper as _}; models::{NewTlsHistory, TlsHistory},
use diesel_async::{AsyncConnection, RunQueryDsl}; schema::{
use miette::Diagnostic; arbiter_settings,
use pem::Pem; tls_history::{self},
use rcgen::{ },
BasicConstraints, Certificate, CertificateParams, CertifiedIssuer, DistinguishedName, DnType, };
IsCa, Issuer, KeyPair, KeyUsagePurpose,
}; use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper as _};
use rustls::pki_types::pem::PemObject; use diesel_async::{AsyncConnection, RunQueryDsl};
use thiserror::Error; use pem::Pem;
use tonic::transport::CertificateDer; use rcgen::{
BasicConstraints, Certificate, CertificateParams, CertifiedIssuer, DistinguishedName, DnType,
use crate::db::{ IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType,
self, };
models::{NewTlsHistory, TlsHistory}, use rustls::pki_types::pem::PemObject;
schema::{ use std::{net::Ipv4Addr, string::FromUtf8Error};
arbiter_settings, use thiserror::Error;
tls_history::{self}, use tonic::transport::CertificateDer;
},
}; const ENCODE_CONFIG: pem::EncodeConfig = {
let line_ending = if cfg!(target_family = "windows") {
const ENCODE_CONFIG: pem::EncodeConfig = { pem::LineEnding::CRLF
let line_ending = match cfg!(target_family = "windows") { } else {
true => pem::LineEnding::CRLF, pem::LineEnding::LF
false => pem::LineEnding::LF, };
}; pem::EncodeConfig::new().set_line_ending(line_ending)
pem::EncodeConfig::new().set_line_ending(line_ending) };
};
#[derive(Error, Debug)]
#[derive(Error, Debug, Diagnostic)] pub enum InitError {
pub enum InitError { #[error("Key generation error during TLS initialization: {0}")]
#[error("Key generation error during TLS initialization: {0}")] KeyGeneration(#[from] rcgen::Error),
#[diagnostic(code(arbiter_server::tls_init::key_generation))]
KeyGeneration(#[from] rcgen::Error), #[error("Key invalid format: {0}")]
KeyInvalidFormat(#[from] FromUtf8Error),
#[error("Key invalid format: {0}")]
#[diagnostic(code(arbiter_server::tls_init::key_invalid_format))] #[error("Key deserialization error: {0}")]
KeyInvalidFormat(#[from] FromUtf8Error), KeyDeserializationError(rcgen::Error),
#[error("Key deserialization error: {0}")] #[error("Database error during TLS initialization: {0}")]
#[diagnostic(code(arbiter_server::tls_init::key_deserialization))] DatabaseError(#[from] diesel::result::Error),
KeyDeserializationError(rcgen::Error),
#[error("Pem deserialization error during TLS initialization: {0}")]
#[error("Database error during TLS initialization: {0}")] PemDeserializationError(#[from] rustls::pki_types::pem::Error),
#[diagnostic(code(arbiter_server::tls_init::database_error))]
DatabaseError(#[from] diesel::result::Error), #[error("Database pool acquire error during TLS initialization: {0}")]
DatabasePoolAcquire(#[from] db::PoolError),
#[error("Pem deserialization error during TLS initialization: {0}")] }
#[diagnostic(code(arbiter_server::tls_init::pem_deserialization))]
PemDeserializationError(#[from] rustls::pki_types::pem::Error), pub type PemCert = String;
#[error("Database pool acquire error during TLS initialization: {0}")] pub fn encode_cert_to_pem(cert: &CertificateDer<'_>) -> PemCert {
#[diagnostic(code(arbiter_server::tls_init::database_pool_acquire))] pem::encode_config(&Pem::new("CERTIFICATE", cert.to_vec()), ENCODE_CONFIG)
DatabasePoolAcquire(#[from] db::PoolError), }
}
#[expect(
pub type PemCert = String; unused,
reason = "may be needed for future cert rotation implementation"
pub fn encode_cert_to_pem(cert: &CertificateDer) -> PemCert { )]
pem::encode_config(&Pem::new("CERTIFICATE", cert.to_vec()), ENCODE_CONFIG) struct SerializedTls {
} cert_pem: PemCert,
cert_key_pem: String,
#[allow(unused)] }
struct SerializedTls {
cert_pem: PemCert, struct TlsCa {
cert_key_pem: String, issuer: Issuer<'static, KeyPair>,
} cert: CertificateDer<'static>,
}
struct TlsCa {
issuer: Issuer<'static, KeyPair>, impl TlsCa {
cert: CertificateDer<'static>, fn generate() -> Result<Self, InitError> {
} let keypair = KeyPair::generate()?;
let mut params = CertificateParams::new(["Arbiter Instance CA".into()])?;
impl TlsCa { params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
fn generate() -> Result<Self, InitError> { params.key_usages = vec![
let keypair = KeyPair::generate()?; KeyUsagePurpose::KeyCertSign,
let mut params = CertificateParams::new(["Arbiter Instance CA".into()])?; KeyUsagePurpose::CrlSign,
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); KeyUsagePurpose::DigitalSignature,
params.key_usages = vec![ ];
KeyUsagePurpose::KeyCertSign,
KeyUsagePurpose::CrlSign, let mut dn = DistinguishedName::new();
KeyUsagePurpose::DigitalSignature, dn.push(DnType::CommonName, "Arbiter Instance CA");
]; params.distinguished_name = dn;
let certified_issuer = CertifiedIssuer::self_signed(params, keypair)?;
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, "Arbiter Instance CA"); let cert_key_pem = certified_issuer.key().serialize_pem();
params.distinguished_name = dn;
let certified_issuer = CertifiedIssuer::self_signed(params, keypair)?; #[expect(
clippy::unwrap_used,
let cert_key_pem = certified_issuer.key().serialize_pem(); reason = "Broken cert couldn't bootstrap server anyway"
)]
#[allow( let issuer = Issuer::from_ca_cert_pem(
clippy::unwrap_used, &certified_issuer.pem(),
reason = "Broken cert couldn't bootstrap server anyway" KeyPair::from_pem(cert_key_pem.as_ref()).unwrap(),
)] )
let issuer = Issuer::from_ca_cert_pem( .unwrap();
&certified_issuer.pem(),
KeyPair::from_pem(cert_key_pem.as_ref()).unwrap(), Ok(Self {
) issuer,
.unwrap(); cert: certified_issuer.der().clone(),
})
Ok(Self { }
issuer, fn generate_leaf(&self) -> Result<TlsCert, InitError> {
cert: certified_issuer.der().clone(), let cert_key = KeyPair::generate()?;
}) let mut params = CertificateParams::new(["Arbiter Instance Leaf".into()])?;
} params.is_ca = IsCa::NoCa;
fn generate_leaf(&self) -> Result<TlsCert, InitError> { params.key_usages = vec![
let cert_key = KeyPair::generate()?; KeyUsagePurpose::DigitalSignature,
let mut params = CertificateParams::new(["Arbiter Instance Leaf".into()])?; KeyUsagePurpose::KeyEncipherment,
params.is_ca = IsCa::NoCa; ];
params.key_usages = vec![ params
KeyUsagePurpose::DigitalSignature, .subject_alt_names
KeyUsagePurpose::KeyEncipherment, .push(SanType::IpAddress(Ipv4Addr::LOCALHOST.into()));
];
let mut dn = DistinguishedName::new();
let mut dn = DistinguishedName::new(); dn.push(DnType::CommonName, "Arbiter Instance Leaf");
dn.push(DnType::CommonName, "Arbiter Instance Leaf"); params.distinguished_name = dn;
params.distinguished_name = dn;
let new_cert = params.signed_by(&cert_key, &self.issuer)?;
let new_cert = params.signed_by(&cert_key, &self.issuer)?;
Ok(TlsCert {
Ok(TlsCert { cert: new_cert,
cert: new_cert, cert_key,
cert_key, })
}) }
}
#[expect(
#[allow(unused)] unused,
fn serialize(&self) -> Result<SerializedTls, InitError> { clippy::unnecessary_wraps,
let cert_key_pem = self.issuer.key().serialize_pem(); reason = "may be needed for future cert rotation implementation"
Ok(SerializedTls { )]
cert_pem: encode_cert_to_pem(&self.cert), fn serialize(&self) -> Result<SerializedTls, InitError> {
cert_key_pem, let cert_key_pem = self.issuer.key().serialize_pem();
}) Ok(SerializedTls {
} cert_pem: encode_cert_to_pem(&self.cert),
cert_key_pem,
#[allow(unused)] })
fn try_deserialize(cert_pem: &str, cert_key_pem: &str) -> Result<Self, InitError> { }
let keypair =
KeyPair::from_pem(cert_key_pem).map_err(InitError::KeyDeserializationError)?; #[expect(
let issuer = Issuer::from_ca_cert_pem(cert_pem, keypair)?; unused,
Ok(Self { reason = "may be needed for future cert rotation implementation"
issuer, )]
cert: CertificateDer::from_pem_slice(cert_pem.as_bytes())?, fn try_deserialize(cert_pem: &str, cert_key_pem: &str) -> Result<Self, InitError> {
}) let keypair =
} KeyPair::from_pem(cert_key_pem).map_err(InitError::KeyDeserializationError)?;
} let issuer = Issuer::from_ca_cert_pem(cert_pem, keypair)?;
Ok(Self {
struct TlsCert { issuer,
cert: Certificate, cert: CertificateDer::from_pem_slice(cert_pem.as_bytes())?,
cert_key: KeyPair, })
} }
}
// TODO: Implement cert rotation
pub struct TlsManager { struct TlsCert {
cert: CertificateDer<'static>, cert: Certificate,
keypair: KeyPair, cert_key: KeyPair,
ca_cert: CertificateDer<'static>, }
_db: db::DatabasePool,
} // TODO: Implement cert rotation
pub struct TlsManager {
impl TlsManager { cert: CertificateDer<'static>,
pub async fn generate_new(db: &db::DatabasePool) -> Result<Self, InitError> { keypair: KeyPair,
let ca = TlsCa::generate()?; ca_cert: CertificateDer<'static>,
let new_cert = ca.generate_leaf()?; _db: db::DatabasePool,
}
{
let mut conn = db.get().await?; impl TlsManager {
conn.transaction(|conn| { pub async fn generate_new(db: &db::DatabasePool) -> Result<Self, InitError> {
Box::pin(async { let ca = TlsCa::generate()?;
let new_tls_history = NewTlsHistory { let new_cert = ca.generate_leaf()?;
cert: new_cert.cert.pem(),
cert_key: new_cert.cert_key.serialize_pem(), {
ca_cert: encode_cert_to_pem(&ca.cert), let mut conn = db.get().await?;
ca_key: ca.issuer.key().serialize_pem(), conn.transaction(async |conn| {
}; let new_tls_history = NewTlsHistory {
cert: new_cert.cert.pem(),
let inserted_tls_history: i32 = diesel::insert_into(tls_history::table) cert_key: new_cert.cert_key.serialize_pem(),
.values(&new_tls_history) ca_cert: encode_cert_to_pem(&ca.cert),
.returning(tls_history::id) ca_key: ca.issuer.key().serialize_pem(),
.get_result(conn) };
.await?;
let inserted_tls_history: i32 = diesel::insert_into(tls_history::table)
diesel::update(arbiter_settings::table) .values(&new_tls_history)
.set(arbiter_settings::tls_id.eq(inserted_tls_history)) .returning(tls_history::id)
.execute(conn) .get_result(&mut *conn)
.await?; .await?;
Result::<_, diesel::result::Error>::Ok(()) diesel::update(arbiter_settings::table)
}) .set(arbiter_settings::tls_id.eq(inserted_tls_history))
}) .execute(&mut *conn)
.await?; .await?;
}
Result::<_, diesel::result::Error>::Ok(())
Ok(Self { })
cert: new_cert.cert.der().clone(), .await?;
keypair: new_cert.cert_key, }
ca_cert: ca.cert,
_db: db.clone(), Ok(Self {
}) cert: new_cert.cert.der().clone(),
} keypair: new_cert.cert_key,
ca_cert: ca.cert,
pub async fn new(db: db::DatabasePool) -> Result<Self, InitError> { _db: db.clone(),
let cert_data: Option<TlsHistory> = { })
let mut conn = db.get().await?; }
arbiter_settings::table
.left_join(tls_history::table) pub async fn new(db: db::DatabasePool) -> Result<Self, InitError> {
.select(Option::<TlsHistory>::as_select()) let cert_data: Option<TlsHistory> = {
.first(&mut conn) let mut conn = db.get().await?;
.await? arbiter_settings::table
}; .left_join(tls_history::table)
.select(Option::<TlsHistory>::as_select())
match cert_data { .first(&mut conn)
Some(data) => { .await?
let try_load = || -> Result<_, Box<dyn std::error::Error>> { };
let keypair = KeyPair::from_pem(&data.cert_key)?;
let cert = CertificateDer::from_pem_slice(data.cert.as_bytes())?; match cert_data {
let ca_cert = CertificateDer::from_pem_slice(data.ca_cert.as_bytes())?; Some(data) => {
Ok(Self { let try_load = || -> Result<_, Box<dyn std::error::Error>> {
cert, let keypair = KeyPair::from_pem(&data.cert_key)?;
keypair, let cert = CertificateDer::from_pem_slice(data.cert.as_bytes())?;
ca_cert, let ca_cert = CertificateDer::from_pem_slice(data.ca_cert.as_bytes())?;
_db: db.clone(), Ok(Self {
}) cert,
}; keypair,
match try_load() { ca_cert,
Ok(manager) => Ok(manager), _db: db.clone(),
Err(e) => { })
eprintln!("Failed to load existing TLS certs: {e}. Generating new ones."); };
Self::generate_new(&db).await match try_load() {
} Ok(manager) => Ok(manager),
} Err(e) => {
} eprintln!("Failed to load existing TLS certs: {e}. Generating new ones.");
None => Self::generate_new(&db).await, Self::generate_new(&db).await
} }
} }
}
pub fn cert(&self) -> &CertificateDer<'static> { None => Self::generate_new(&db).await,
&self.cert }
} }
pub fn ca_cert(&self) -> &CertificateDer<'static> {
&self.ca_cert pub const fn cert(&self) -> &CertificateDer<'static> {
} &self.cert
}
pub fn cert_pem(&self) -> PemCert { pub const fn ca_cert(&self) -> &CertificateDer<'static> {
encode_cert_to_pem(&self.cert) &self.ca_cert
} }
pub fn key_pem(&self) -> String {
self.keypair.serialize_pem() pub fn cert_pem(&self) -> PemCert {
} encode_cert_to_pem(&self.cert)
} }
pub fn key_pem(&self) -> String {
self.keypair.serialize_pem()
}
}

Some files were not shown because too many files have changed in this diff Show More