Skip to content

Commit 81f7f19

Browse files
committed
fix(api-keys): support imported key wallets
Reuse the existing KeyType-aware secret resolution for API-key signing so imported private-key wallets no longer get parsed as mnemonics. Add Rust and Node regressions for API-key signing on imported wallets, and update API-key docs/comments to describe encrypted secret copies instead of mnemonic-only copies.
1 parent 3e5a8fd commit 81f7f19

8 files changed

Lines changed: 187 additions & 61 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/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: 94 additions & 23 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, KeyType, 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,15 @@ 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(
136+
&key_file,
137+
&wallet.id,
138+
wallet.key_type.clone(),
139+
token,
140+
chain.chain_type,
141+
index,
142+
)?;
138143

139144
// 7. Sign (extract signable portion first — e.g. strips Solana sig-slot headers)
140145
let signer = signer_for_chain(chain.chain_type);
@@ -195,7 +200,14 @@ pub fn sign_message_with_api_key(
195200
}));
196201
}
197202

198-
let key = decrypt_key_from_api_key(&key_file, &wallet.id, token, chain.chain_type, index)?;
203+
let key = decrypt_key_from_api_key(
204+
&key_file,
205+
&wallet.id,
206+
wallet.key_type.clone(),
207+
token,
208+
chain.chain_type,
209+
index,
210+
)?;
199211
let signer = signer_for_chain(chain.chain_type);
200212
let output = signer.sign_message(key.expose(), msg_bytes)?;
201213

@@ -255,7 +267,14 @@ pub fn enforce_policy_and_decrypt_key(
255267
}));
256268
}
257269

258-
let key = decrypt_key_from_api_key(&key_file, &wallet.id, token, chain.chain_type, index)?;
270+
let key = decrypt_key_from_api_key(
271+
&key_file,
272+
&wallet.id,
273+
wallet.key_type.clone(),
274+
token,
275+
chain.chain_type,
276+
index,
277+
)?;
259278

260279
Ok((key, key_file))
261280
}
@@ -297,6 +316,7 @@ fn load_policies_for_key(
297316
fn decrypt_key_from_api_key(
298317
key_file: &ApiKeyFile,
299318
wallet_id: &str,
319+
key_type: KeyType,
300320
token: &str,
301321
chain_type: ows_core::ChainType,
302322
index: Option<u32>,
@@ -309,17 +329,7 @@ fn decrypt_key_from_api_key(
309329

310330
let envelope: CryptoEnvelope = serde_json::from_value(envelope_value.clone())?;
311331
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-
)?)
332+
crate::ops::secret_to_signing_key(&secret, key_type, chain_type, index)
323333
}
324334

325335
#[cfg(test)]
@@ -557,6 +567,67 @@ mod tests {
557567
assert!(!sign_result.signature.is_empty());
558568
}
559569

570+
#[test]
571+
fn imported_private_key_wallet_signs_with_api_key() {
572+
let dir = tempfile::tempdir().unwrap();
573+
let vault = dir.path().to_path_buf();
574+
575+
let wallet = crate::import_wallet_private_key(
576+
"imported-wallet",
577+
"ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
578+
Some("evm"),
579+
Some(""),
580+
Some(&vault),
581+
None,
582+
None,
583+
)
584+
.unwrap();
585+
let policy_id = setup_test_policy(&vault);
586+
587+
let (token, _) = create_api_key(
588+
"imported-wallet-agent",
589+
std::slice::from_ref(&wallet.id),
590+
std::slice::from_ref(&policy_id),
591+
"",
592+
None,
593+
Some(&vault),
594+
)
595+
.unwrap();
596+
597+
let chain = ows_core::parse_chain("base").unwrap();
598+
let tx_bytes = vec![0u8; 32];
599+
600+
let tx_result = sign_with_api_key(
601+
&token,
602+
"imported-wallet",
603+
&chain,
604+
&tx_bytes,
605+
None,
606+
Some(&vault),
607+
);
608+
assert!(
609+
tx_result.is_ok(),
610+
"sign_with_api_key failed: {:?}",
611+
tx_result.err()
612+
);
613+
assert!(!tx_result.unwrap().signature.is_empty());
614+
615+
let msg_result = sign_message_with_api_key(
616+
&token,
617+
"imported-wallet",
618+
&chain,
619+
b"hello",
620+
None,
621+
Some(&vault),
622+
);
623+
assert!(
624+
msg_result.is_ok(),
625+
"sign_message_with_api_key failed: {:?}",
626+
msg_result.err()
627+
);
628+
assert!(!msg_result.unwrap().signature.is_empty());
629+
}
630+
560631
#[test]
561632
fn sign_with_api_key_wrong_chain_denied() {
562633
let dir = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)