Keystore
Decrypt and encrypt Web3 Secret Storage v3 JSON keystores - the encrypted key files produced by geth, ethers, foundry, and MyEtherWallet.
Almost everyone migrating from geth, ethers, foundry/cast, or MyEtherWallet has
their keys on disk as an encrypted JSON keystore (the Web3 Secret Storage v3
format), not as a raw private key. eth.keystore reads and writes that format so
you can load an existing key with its password -- and write one back out -- without
ever handling the raw key by hand.
Decrypting a geth keystore
decrypt parses a v3 document, runs the key-derivation function over the
password, verifies the MAC, and returns the recovered 32-byte private key.
const eth = @import("eth");
// `json` is the bytes of e.g. ~/.ethereum/keystore/UTC--...--<address>
const private_key = try eth.keystore.decrypt(allocator, json, "my password");
// private_key is [32]u8 -- hand it straight to a Signer or Wallet.
var signer = eth.signer.Signer.init(private_key);
defer signer.deinit(); // zeroes the key when you are doneThe MAC is keccak256(derived_key[16..32] ++ ciphertext), exactly as defined by
the spec, and it is checked in constant time (std.crypto.timing_safe.eql).
A wrong password (or a tampered file) yields error.InvalidPassword rather than a
garbage key:
const result = eth.keystore.decrypt(allocator, json, "wrong");
// => error.InvalidPasswordBoth KDFs defined by the standard are supported transparently -- you do not pick one on decrypt, it is read from the file:
- scrypt (the geth/ethers default)
- pbkdf2 (HMAC-SHA256)
The cipher is always aes-128-ctr, the only cipher the v3 spec defines.
Encrypting a key
encrypt produces a v3 JSON string you can write to disk and later open with
geth, ethers, or cast. The caller owns the returned slice.
const json = try eth.keystore.encrypt(allocator, private_key, "my password", eth.runtime.blockingIo(), .{});
defer allocator.free(json);
// json is a complete {"version":3,"id":...,"crypto":{...}} document.By default this uses scrypt with N = 2^18 (262144), r = 8, p = 1 and a
freshly generated random salt and IV -- the same cost parameters geth and ethers
write. Those defaults are deliberately expensive; override them through
EncryptOptions when you need speed (for example in tests) or the pbkdf2 KDF:
// Faster scrypt for a test fixture (N = 2^12 = 4096):
const fast = try eth.keystore.encrypt(allocator, key, "pw", eth.runtime.blockingIo(), .{ .scrypt_log_n = 12 });
defer allocator.free(fast);
// pbkdf2-HMAC-SHA256 instead of scrypt:
const pb = try eth.keystore.encrypt(allocator, key, "pw", eth.runtime.blockingIo(), .{
.kdf = .pbkdf2,
.pbkdf2_c = 262144,
});
defer allocator.free(pb);encrypt then decrypt with the same password round-trips back to the original
key.
Interop is verified against the canonical vectors
The module's tests decrypt the two official Web3 Secret Storage Definition test
vectors -- the scrypt one and the pbkdf2 one -- with password testpassword. Both
recover the same well-known private key
7a28b5ba57c53603b0b07b56bba752f7784bf506fa95edc395f5cf6c7514fe9d. That is the
proof of interop: a file written by geth/ethers/foundry decrypts here to exactly
the bytes those tools expect.
Memory hygiene
Key material is sensitive, so the module wipes it. The derived key, the plaintext
key copy used during encryption, and the ciphertext working buffer are all cleared
with the project's secureZero (volatile writes that the compiler may not elide)
before decrypt/encrypt return. The recovered key you receive is yours to guard:
pass it into a Signer, whose deinit() zeroes it, and avoid copying it into
long-lived buffers.