fix!: protect evm_wallet integrity and bind key ciphertext to wallet address
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

Three independent failures allowed an offline attacker with DB write access
to sign transactions using a different wallet's private key:
1. evm_wallet had no HMAC envelope — aead_encrypted_id could be swapped silently.
2. AEAD used a static tag as AAD — any valid ciphertext decrypted as any wallet key.
3. No post-decryption check that the derived address matched the requested wallet.

Fix: sign_entity covers (address, aead_encrypted_id) in a single transaction;
CreateNew/Decrypt take caller-provided AAD (wallet address bytes); after decryption
signer.address() is verified against the requested wallet address.
This commit is contained in:
CleverWild
2026-06-22 17:53:34 +02:00
parent b7ab5c79b7
commit 00ddf99d77
5 changed files with 127 additions and 35 deletions

View File

@@ -14,6 +14,8 @@ use kameo::actor::{ActorRef, Spawn as _};
use std::collections::{HashMap, HashSet};
use tokio::task::JoinSet;
const TEST_AAD: &[u8] = b"test-aad";
async fn write_concurrently(
actor: ActorRef<Vault>,
prefix: &'static str,
@@ -27,6 +29,7 @@ async fn write_concurrently(
let id = actor
.ask(CreateNew {
plaintext: SafeCell::new(plaintext.clone()),
aad: TEST_AAD.to_vec(),
})
.await
.unwrap();
@@ -120,7 +123,7 @@ async fn insert_failure_does_not_create_partial_row() {
drop(conn);
let err = actor
.create_new(SafeCell::new(b"should fail".to_vec()))
.create_new(SafeCell::new(b"should fail".to_vec()), TEST_AAD.to_vec())
.await
.unwrap_err();
assert!(matches!(err, Error::DatabaseTransaction(_)));
@@ -171,7 +174,7 @@ async fn decrypt_roundtrip_after_high_concurrency() {
.unwrap();
for (id, plaintext) in expected {
let mut decrypted = decryptor.decrypt(id).await.unwrap();
let mut decrypted = decryptor.decrypt(id, TEST_AAD.to_vec()).await.unwrap();
assert_eq!(*decrypted.read(), plaintext);
}
}

View File

@@ -12,6 +12,8 @@ use arbiter_server::{
use diesel::{QueryDsl, SelectableHelper};
use diesel_async::RunQueryDsl;
const TEST_AAD: &[u8] = b"test-aad";
#[tokio::test]
#[test_log::test]
async fn bootstrap() {
@@ -57,7 +59,7 @@ async fn create_new_before_bootstrap_fails() {
.unwrap();
let err = actor
.create_new(SafeCell::new(b"data".to_vec()))
.create_new(SafeCell::new(b"data".to_vec()), TEST_AAD.to_vec())
.await
.unwrap_err();
assert!(matches!(err, Error::NotBootstrapped));
@@ -71,7 +73,7 @@ async fn decrypt_before_bootstrap_fails() {
.await
.unwrap();
let err = actor.decrypt(1).await.unwrap_err();
let err = actor.decrypt(1, TEST_AAD.to_vec()).await.unwrap_err();
assert!(matches!(err, Error::NotBootstrapped));
}
@@ -85,7 +87,7 @@ async fn new_restores_sealed_state() {
let mut actor2 = Vault::new(db, GlobalActors::spawn_message_bus())
.await
.unwrap();
let err = actor2.decrypt(1).await.unwrap_err();
let err = actor2.decrypt(1, TEST_AAD.to_vec()).await.unwrap_err();
assert!(matches!(err, Error::Sealed));
}
@@ -97,7 +99,7 @@ async fn unseal_correct_password() {
let plaintext = b"survive a restart";
let aead_id = actor
.create_new(SafeCell::new(plaintext.to_vec()))
.create_new(SafeCell::new(plaintext.to_vec()), TEST_AAD.to_vec())
.await
.unwrap();
drop(actor);
@@ -108,7 +110,7 @@ async fn unseal_correct_password() {
let seal_key = SafeCell::new(b"test-seal-key".to_vec());
actor.try_unseal(seal_key).await.unwrap();
let mut decrypted = actor.decrypt(aead_id).await.unwrap();
let mut decrypted = actor.decrypt(aead_id, TEST_AAD.to_vec()).await.unwrap();
assert_eq!(*decrypted.read(), plaintext);
}
@@ -120,7 +122,7 @@ async fn unseal_wrong_then_correct_password() {
let plaintext = b"important data";
let aead_id = actor
.create_new(SafeCell::new(plaintext.to_vec()))
.create_new(SafeCell::new(plaintext.to_vec()), TEST_AAD.to_vec())
.await
.unwrap();
drop(actor);
@@ -136,6 +138,6 @@ async fn unseal_wrong_then_correct_password() {
let good_key = SafeCell::new(b"test-seal-key".to_vec());
actor.try_unseal(good_key).await.unwrap();
let mut decrypted = actor.decrypt(aead_id).await.unwrap();
let mut decrypted = actor.decrypt(aead_id, TEST_AAD.to_vec()).await.unwrap();
assert_eq!(*decrypted.read(), plaintext);
}

View File

@@ -10,6 +10,8 @@ use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper, dsl::update};
use diesel_async::RunQueryDsl;
use std::collections::HashSet;
const TEST_AAD: &[u8] = b"test-aad";
#[tokio::test]
#[test_log::test]
async fn create_decrypt_roundtrip() {
@@ -18,11 +20,11 @@ async fn create_decrypt_roundtrip() {
let plaintext = b"hello arbiter";
let aead_id = actor
.create_new(SafeCell::new(plaintext.to_vec()))
.create_new(SafeCell::new(plaintext.to_vec()), TEST_AAD.to_vec())
.await
.unwrap();
let mut decrypted = actor.decrypt(aead_id).await.unwrap();
let mut decrypted = actor.decrypt(aead_id, TEST_AAD.to_vec()).await.unwrap();
assert_eq!(*decrypted.read(), plaintext);
}
@@ -32,7 +34,7 @@ async fn decrypt_nonexistent_returns_not_found() {
let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_vault(&db).await;
let err = actor.decrypt(9999).await.unwrap_err();
let err = actor.decrypt(9999, TEST_AAD.to_vec()).await.unwrap_err();
assert!(matches!(err, Error::NotFound));
}
@@ -44,11 +46,11 @@ async fn ciphertext_differs_across_entries() {
let plaintext = b"same content";
let id1 = actor
.create_new(SafeCell::new(plaintext.to_vec()))
.create_new(SafeCell::new(plaintext.to_vec()), TEST_AAD.to_vec())
.await
.unwrap();
let id2 = actor
.create_new(SafeCell::new(plaintext.to_vec()))
.create_new(SafeCell::new(plaintext.to_vec()), TEST_AAD.to_vec())
.await
.unwrap();
@@ -68,8 +70,8 @@ async fn ciphertext_differs_across_entries() {
assert_ne!(row1.ciphertext, row2.ciphertext);
let mut d1 = actor.decrypt(id1).await.unwrap();
let mut d2 = actor.decrypt(id2).await.unwrap();
let mut d1 = actor.decrypt(id1, TEST_AAD.to_vec()).await.unwrap();
let mut d2 = actor.decrypt(id2, TEST_AAD.to_vec()).await.unwrap();
assert_eq!(*d1.read(), plaintext);
assert_eq!(*d2.read(), plaintext);
}
@@ -83,7 +85,7 @@ async fn nonce_never_reused() {
let n = 5;
for i in 0..n {
actor
.create_new(SafeCell::new(format!("secret {i}").into_bytes()))
.create_new(SafeCell::new(format!("secret {i}").into_bytes()), TEST_AAD.to_vec())
.await
.unwrap();
}
@@ -137,7 +139,7 @@ async fn broken_db_nonce_format_fails_closed() {
drop(conn);
let err = actor
.create_new(SafeCell::new(b"must fail".to_vec()))
.create_new(SafeCell::new(b"must fail".to_vec()), TEST_AAD.to_vec())
.await
.unwrap_err();
assert!(matches!(err, Error::BrokenDatabase));
@@ -145,7 +147,7 @@ async fn broken_db_nonce_format_fails_closed() {
let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_vault(&db).await;
let id = actor
.create_new(SafeCell::new(b"decrypt target".to_vec()))
.create_new(SafeCell::new(b"decrypt target".to_vec()), TEST_AAD.to_vec())
.await
.unwrap();
let mut conn = db.get().await.unwrap();
@@ -156,6 +158,6 @@ async fn broken_db_nonce_format_fails_closed() {
.unwrap();
drop(conn);
let err = actor.decrypt(id).await.unwrap_err();
let err = actor.decrypt(id, TEST_AAD.to_vec()).await.unwrap_err();
assert!(matches!(err, Error::BrokenDatabase));
}