Skip to content

Commit c319ac2

Browse files
authored
Merge pull request open-wallet-standard#150 from ggonzalez94/export-key-bug
fix API-key signing for imported wallets
2 parents c2f0379 + 3677ca0 commit c319ac2

9 files changed

Lines changed: 170 additions & 65 deletions

File tree

bindings/node/__test__/index.spec.mjs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,53 @@ describe('@open-wallet-standard/core', () => {
301301
deleteWallet(wallet.id, vaultDir);
302302
});
303303

304+
it('signs with an API key for a wallet imported from a private key', () => {
305+
createPolicy(JSON.stringify({
306+
id: 'test-imported-wallet',
307+
name: 'Imported Wallet',
308+
version: 1,
309+
created_at: '2026-03-31T00:00:00Z',
310+
rules: [
311+
{ type: 'allowed_chains', chain_ids: ['eip155:8453'] },
312+
],
313+
action: 'deny',
314+
}), vaultDir);
315+
316+
const wallet = importWalletPrivateKey(
317+
'policy-imported-wallet',
318+
'ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
319+
'',
320+
vaultDir,
321+
'evm',
322+
);
323+
const key = createApiKey(
324+
'imported-wallet-agent',
325+
[wallet.id],
326+
['test-imported-wallet'],
327+
'',
328+
null,
329+
vaultDir,
330+
);
331+
332+
const msgSig = signMessage(
333+
wallet.id,
334+
'base',
335+
'hello',
336+
key.token,
337+
undefined,
338+
undefined,
339+
vaultDir,
340+
);
341+
assert.ok(msgSig.signature.length > 0);
342+
343+
const txSig = signTransaction(wallet.id, 'base', 'deadbeef', key.token, null, vaultDir);
344+
assert.ok(txSig.signature.length > 0);
345+
346+
revokeApiKey(key.id, vaultDir);
347+
deletePolicy('test-imported-wallet', vaultDir);
348+
deleteWallet(wallet.id, vaultDir);
349+
});
350+
304351
it('executable policy gates signing', () => {
305352
const wallet = createWallet('exe-test', undefined, 12, vaultDir);
306353

docs/01-storage-format.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,11 +129,11 @@ Each API key is stored as a JSON file in `~/.ows/keys/`. The key file contains m
129129
| `wallet_ids` | array | yes | Wallet IDs this key is authorized to access |
130130
| `policy_ids` | array | yes | Policy IDs evaluated on every request made with this key |
131131
| `expires_at` | string | no | ISO 8601 expiry timestamp. `null` means no expiry. |
132-
| `wallet_secrets` | object | yes | Map of wallet ID → CryptoEnvelope. Each entry is the wallet's mnemonic re-encrypted under HKDF(token). |
132+
| `wallet_secrets` | object | yes | Map of wallet ID → CryptoEnvelope. Each entry is the wallet's decrypted secret re-encrypted under HKDF(token), whether that secret is a mnemonic phrase or private-key JSON. |
133133

134134
The `keys/` directory and its contents use the same strict permissions as `wallets/` (`700` for the directory, `600` for files) because `wallet_secrets` contains encrypted key material and `token_hash` must be protected against local reads.
135135

136-
Revoking an API key means deleting the key file. The encrypted mnemonic copies are destroyed. The original wallet file and other API keys are unaffected.
136+
Revoking an API key means deleting the key file. The encrypted secret copies are destroyed. The original wallet file and other API keys are unaffected.
137137

138138
### Crypto Object
139139

docs/03-policy-engine.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ If the owner wants policy-constrained access for themselves, they create an API
2929

3030
### Token-as-capability
3131

32-
When the owner creates an API key, OWS decrypts the wallet mnemonic using the owner's passphrase and **re-encrypts it under a key derived from the API token**. The encrypted copy is stored in the API key file. The agent presents the token with each signing request; the token serves as both authentication and decryption capability.
32+
When the owner creates an API key, OWS decrypts the wallet secret using the owner's passphrase and **re-encrypts it under a key derived from the API token**. The encrypted copy is stored in the API key file. The agent presents the token with each signing request; the token serves as both authentication and decryption capability.
3333

3434
### Key derivation (HKDF-SHA256)
3535

@@ -62,14 +62,14 @@ ows key create --name "claude-agent" --wallet agent-treasury --policy spending-l
6262
```
6363

6464
1. Owner enters wallet passphrase
65-
2. OWS decrypts the wallet mnemonic using scrypt(passphrase)
65+
2. OWS decrypts the wallet secret using scrypt(passphrase)
6666
3. Generates random token: `T = "ows_key_" + hex(random 256 bits)`
6767
4. Generates random salt S
6868
5. Derives key: `K = HKDF-SHA256(S, T, "ows-api-key-v1", 32)`
69-
6. Encrypts mnemonic with K via AES-256-GCM
70-
7. Stores key file with `token_hash: SHA256(T)`, policy IDs, and encrypted mnemonic copy
69+
6. Encrypts the wallet secret with K via AES-256-GCM
70+
7. Stores key file with `token_hash: SHA256(T)`, policy IDs, and encrypted secret copy
7171
8. Displays T once — owner provisions it to the agent
72-
9. Zeroizes mnemonic from memory
72+
9. Zeroizes the decrypted secret from memory
7373

7474
### Agent signing flow
7575

@@ -84,16 +84,16 @@ Agent calls: sign_transaction(wallet, chain, tx, "ows_key_a1b2c3...")
8484
6. Build `PolicyContext` (chain ID, wallet ID, API key ID, transaction context, spending context, timestamp)
8585
7. Evaluate all policies (AND semantics, short-circuit on first deny)
8686
8. If denied → return POLICY_DENIED error (key material never touched)
87-
9. HKDF-SHA256(salt, token) → AES key → decrypt mnemonic from key.wallet_secrets
88-
10. HD-derive chain-specific key
87+
9. HKDF-SHA256(salt, token) → AES key → decrypt secret from key.wallet_secrets
88+
10. Resolve the chain-specific signing key from that secret (HD derivation for mnemonic wallets, direct curve-key selection for private-key wallets)
8989
11. Sign transaction
90-
12. Zeroize mnemonic and derived key
90+
12. Zeroize decrypted secret and derived key
9191
13. Return signature
9292
```
9393

9494
### Revocation
9595

96-
Delete the API key file. The encrypted mnemonic copy is gone. `SHA256(T)` matches nothing. The token is useless. The original wallet and other API keys are unaffected.
96+
Delete the API key file. The encrypted secret copy is gone. `SHA256(T)` matches nothing. The token is useless. The original wallet and other API keys are unaffected.
9797

9898
### Security properties
9999

docs/05-key-isolation.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ Agent → sign_transaction(wallet, chain, tx, "ows_key_...")
8787
8888
└─► ows-lib (same process)
8989
├── token lookup + policy evaluation
90-
├── HKDF decrypt mnemonic (mlock'd, zeroized on drop)
90+
├── HKDF decrypt wallet secret (mlock'd, zeroized on drop)
9191
├── sign
9292
└── return signature
9393
```
@@ -103,14 +103,14 @@ Agent → sign_transaction(wallet, chain, tx, "ows_key_...")
103103
├── token lookup + policy evaluation
104104
└── fork/exec ows-enclave
105105
├── receive (token, wallet_id, tx) over stdin
106-
├── HKDF decrypt mnemonic
106+
├── HKDF decrypt wallet secret
107107
├── sign
108108
├── zeroize
109109
├── write signature to stdout
110110
└── exit
111111
```
112112

113-
The decrypt→sign→wipe path moves to a child process. The parent (agent's process) never has the mnemonic in its address space. The child is stateless — spawned per request, no daemon, no unlock step. If it crashes, the next request spawns a new one.
113+
The decrypt→sign→wipe path moves to a child process. The parent (agent's process) never has the decrypted secret in its address space. The child is stateless — spawned per request, no daemon, no unlock step. If it crashes, the next request spawns a new one.
114114

115115
## References
116116

docs/07-supported-chains.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ Master Seed (512 bits via PBKDF2)
119119
└── m/44'/461'/0'/0/0 → Filecoin Account 0
120120
```
121121

122-
A single mnemonic derives accounts across all supported chains. The wallet file stores the encrypted mnemonic; the signer derives the appropriate private key using each chain's coin type and derivation path.
122+
For mnemonic-based wallets, a single mnemonic derives accounts across all supported chains. Those wallet files store the encrypted mnemonic, and the signer derives the appropriate private key using each chain's coin type and derivation path. Wallets imported from raw private keys instead store encrypted curve-key material directly.
123123

124124
## Adding a New Chain
125125

docs/sdk-cli.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ Lists all keys with ID, name, wallets, policies, and creation time. Tokens are n
196196
ows key revoke --id <key-id> --confirm
197197
```
198198

199-
Deletes the key file. The encrypted mnemonic copy is gone — the token becomes useless.
199+
Deletes the key file. The encrypted secret copy is gone — the token becomes useless.
200200

201201
## End-to-End Example: Agent Access
202202

ows/crates/ows-core/src/api_key.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ pub struct ApiKeyFile {
1616
/// Optional expiry timestamp.
1717
#[serde(skip_serializing_if = "Option::is_none")]
1818
pub expires_at: Option<String>,
19-
/// Per-wallet encrypted mnemonic copies, keyed by wallet ID.
19+
/// Per-wallet encrypted secret copies, keyed by wallet ID.
2020
/// Each value is a CryptoEnvelope encrypted with HKDF(token).
2121
pub wallet_secrets: HashMap<String, serde_json::Value>,
2222
}

ows/crates/ows-lib/src/key_ops.rs

Lines changed: 76 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
use std::collections::HashMap;
22
use std::path::Path;
33

4-
use ows_core::{ApiKeyFile, OwsError};
5-
use ows_signer::{
6-
decrypt, encrypt_with_hkdf, signer_for_chain, CryptoEnvelope, HdDeriver, Mnemonic, SecretBytes,
7-
};
4+
use ows_core::{ApiKeyFile, EncryptedWallet, OwsError};
5+
use ows_signer::{decrypt, encrypt_with_hkdf, signer_for_chain, CryptoEnvelope, SecretBytes};
86

97
use crate::error::OwsLibError;
108
use crate::key_store;
@@ -15,9 +13,9 @@ use crate::vault;
1513
/// Create an API key for agent access to one or more wallets.
1614
///
1715
/// 1. Authenticates with the owner's passphrase
18-
/// 2. Decrypts the mnemonic for each wallet
16+
/// 2. Decrypts the wallet secret for each wallet
1917
/// 3. Generates a random token (`ows_key_...`)
20-
/// 4. Re-encrypts each mnemonic under HKDF(token)
18+
/// 4. Re-encrypts each secret under HKDF(token)
2119
/// 5. Stores the key file with token hash, policy IDs, and encrypted copies
2220
/// 6. Returns the raw token (shown once to the user)
2321
pub fn create_api_key(
@@ -77,8 +75,8 @@ pub fn create_api_key(
7775
/// 1. Look up key file by SHA256(token)
7876
/// 2. Check expiry and wallet scope
7977
/// 3. Load and evaluate policies
80-
/// 4. HKDF(token) → decrypt mnemonic
81-
/// 5. HD derive → sign
78+
/// 4. HKDF(token) → decrypt wallet secret
79+
/// 5. Resolve signing key → sign
8280
pub fn sign_with_api_key(
8381
token: &str,
8482
wallet_name_or_id: &str,
@@ -133,8 +131,8 @@ pub fn sign_with_api_key(
133131
}));
134132
}
135133

136-
// 6. Decrypt mnemonic from key file using HKDF(token)
137-
let key = decrypt_key_from_api_key(&key_file, &wallet.id, token, chain.chain_type, index)?;
134+
// 6. Decrypt wallet secret from key file using HKDF(token)
135+
let key = decrypt_key_from_api_key(&key_file, &wallet, token, chain.chain_type, index)?;
138136

139137
// 7. Sign (extract signable portion first — e.g. strips Solana sig-slot headers)
140138
let signer = signer_for_chain(chain.chain_type);
@@ -195,7 +193,7 @@ pub fn sign_message_with_api_key(
195193
}));
196194
}
197195

198-
let key = decrypt_key_from_api_key(&key_file, &wallet.id, token, chain.chain_type, index)?;
196+
let key = decrypt_key_from_api_key(&key_file, &wallet, token, chain.chain_type, index)?;
199197
let signer = signer_for_chain(chain.chain_type);
200198
let output = signer.sign_message(key.expose(), msg_bytes)?;
201199

@@ -255,7 +253,7 @@ pub fn enforce_policy_and_decrypt_key(
255253
}));
256254
}
257255

258-
let key = decrypt_key_from_api_key(&key_file, &wallet.id, token, chain.chain_type, index)?;
256+
let key = decrypt_key_from_api_key(&key_file, &wallet, token, chain.chain_type, index)?;
259257

260258
Ok((key, key_file))
261259
}
@@ -296,30 +294,21 @@ fn load_policies_for_key(
296294

297295
fn decrypt_key_from_api_key(
298296
key_file: &ApiKeyFile,
299-
wallet_id: &str,
297+
wallet: &EncryptedWallet,
300298
token: &str,
301299
chain_type: ows_core::ChainType,
302300
index: Option<u32>,
303301
) -> Result<SecretBytes, OwsLibError> {
304-
let envelope_value = key_file.wallet_secrets.get(wallet_id).ok_or_else(|| {
302+
let envelope_value = key_file.wallet_secrets.get(&wallet.id).ok_or_else(|| {
305303
OwsLibError::InvalidInput(format!(
306-
"API key has no encrypted secret for wallet {wallet_id}"
304+
"API key has no encrypted secret for wallet {}",
305+
wallet.id
307306
))
308307
})?;
309308

310309
let envelope: CryptoEnvelope = serde_json::from_value(envelope_value.clone())?;
311310
let secret = decrypt(&envelope, token)?;
312-
313-
// The secret is a mnemonic phrase — derive the signing key
314-
let phrase = std::str::from_utf8(secret.expose())
315-
.map_err(|_| OwsLibError::InvalidInput("wallet contains invalid UTF-8 mnemonic".into()))?;
316-
let mnemonic = Mnemonic::from_phrase(phrase)?;
317-
let signer = signer_for_chain(chain_type);
318-
let path = signer.default_derivation_path(index.unwrap_or(0));
319-
let curve = signer.curve();
320-
Ok(HdDeriver::derive_from_mnemonic_cached(
321-
&mnemonic, "", &path, curve,
322-
)?)
311+
crate::ops::secret_to_signing_key(&secret, &wallet.key_type, chain_type, index)
323312
}
324313

325314
#[cfg(test)]
@@ -557,6 +546,67 @@ mod tests {
557546
assert!(!sign_result.signature.is_empty());
558547
}
559548

549+
#[test]
550+
fn imported_private_key_wallet_signs_with_api_key() {
551+
let dir = tempfile::tempdir().unwrap();
552+
let vault = dir.path().to_path_buf();
553+
554+
let wallet = crate::import_wallet_private_key(
555+
"imported-wallet",
556+
"ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
557+
Some("evm"),
558+
Some(""),
559+
Some(&vault),
560+
None,
561+
None,
562+
)
563+
.unwrap();
564+
let policy_id = setup_test_policy(&vault);
565+
566+
let (token, _) = create_api_key(
567+
"imported-wallet-agent",
568+
std::slice::from_ref(&wallet.id),
569+
std::slice::from_ref(&policy_id),
570+
"",
571+
None,
572+
Some(&vault),
573+
)
574+
.unwrap();
575+
576+
let chain = ows_core::parse_chain("base").unwrap();
577+
let tx_bytes = vec![0u8; 32];
578+
579+
let tx_result = sign_with_api_key(
580+
&token,
581+
"imported-wallet",
582+
&chain,
583+
&tx_bytes,
584+
None,
585+
Some(&vault),
586+
);
587+
assert!(
588+
tx_result.is_ok(),
589+
"sign_with_api_key failed: {:?}",
590+
tx_result.err()
591+
);
592+
assert!(!tx_result.unwrap().signature.is_empty());
593+
594+
let msg_result = sign_message_with_api_key(
595+
&token,
596+
"imported-wallet",
597+
&chain,
598+
b"hello",
599+
None,
600+
Some(&vault),
601+
);
602+
assert!(
603+
msg_result.is_ok(),
604+
"sign_message_with_api_key failed: {:?}",
605+
msg_result.err()
606+
);
607+
assert!(!msg_result.unwrap().signature.is_empty());
608+
}
609+
560610
#[test]
561611
fn sign_with_api_key_wrong_chain_denied() {
562612
let dir = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)