feat: Add @aptos-labs/aptos-keystore — Encrypted Private Key Storage Package#848
feat: Add @aptos-labs/aptos-keystore — Encrypted Private Key Storage Package#848gregnazario wants to merge 5 commits intomainfrom
Conversation
d698df8 to
a6eb427
Compare
caec9ee to
0907691
Compare
| argon2Iterations = 3, | ||
| argon2Parallelism = 4, | ||
| argon2MemorySize = 65536, |
There was a problem hiding this comment.
Fix to 128mb, 1 parallelism, 2 iterations
Implement an encrypted credential management system based on Ethereum's Web3 Secret Storage Definition (keystore v3), adapted for all Aptos key types. The format is designed to be portable across Aptos SDKs. Key features: - Support for Ed25519, Secp256k1, and Secp256r1 private keys - Password-based or key-file-based encryption - scrypt (default) and PBKDF2-HMAC-SHA256 key derivation functions - AES-128-CTR symmetric cipher via Web Crypto API - SHA-256 MAC for password verification - Portable JSON format for cross-SDK interoperability New exports: - encryptKeystore(args): Encrypt a private key to keystore JSON - decryptKeystore(args): Decrypt keystore JSON to recover private key - AptosKeyStore: TypeScript interface for the keystore format - KeystorePrivateKey, KeystoreEncryptOptions: Supporting types Includes 23 unit tests covering all key types, both KDFs, round-trip encryption/decryption, error handling, and cryptographic verification. Co-authored-by: Greg Nazario <greg@gnazar.io>
- Switch cipher from AES-128-CTR to AES-256-GCM authenticated encryption
- Eliminates separate MAC field; GCM auth tag handles both integrity
and password verification
- Uses full 32-byte derived key (256-bit) instead of 16-byte (128-bit)
- 12-byte GCM nonce + 16-byte auth tag stored in cipherparams
- Add Argon2id as the default KDF (via hash-wasm, WASM-based)
- Defaults: iterations=3, parallelism=4, memorySize=65536 KiB (64 MiB)
- Winner of the Password Hashing Competition, OWASP recommended
- Superior GPU/ASIC resistance compared to scrypt
- Retain scrypt and PBKDF2 as alternative KDFs for compatibility
- Update all 26 tests for new cipher and KDF parameters
Co-authored-by: Greg Nazario <greg@gnazar.io>
Move hash-wasm from dependencies to an optional peerDependency to
avoid bloating the bundle (~2.1 MB on disk, ~29 KB minified for
argon2 alone) for users who don't need Argon2id.
Changes:
- hash-wasm is now an optional peer dependency, not a direct dependency
- Default KDF changed from argon2id to scrypt (zero extra deps needed)
- Argon2id loaded via dynamic import() only when explicitly requested
- Clear error message if argon2id is requested without hash-wasm installed
- Users who want argon2id: npm install hash-wasm, then { kdf: 'argon2id' }
Bundle impact: zero for users who don't use argon2id.
Co-authored-by: Greg Nazario <greg@gnazar.io>
…lable The intended behavior is to always use Argon2id for password-based encryption. The KDF selection now works as: 1. If no KDF specified: try to load hash-wasm dynamically - If available: use argon2id (strongest option) - If not installed: fall back to scrypt (no extra deps needed) 2. If KDF explicitly specified: use that KDF - argon2id: requires hash-wasm, throws clear error if missing - scrypt/pbkdf2: always available, no extra deps This keeps hash-wasm as an optional peer dependency (zero bundle impact when not installed) while defaulting to the strongest KDF available. Co-authored-by: Greg Nazario <greg@gnazar.io>
107a104 to
fe2a84e
Compare
There was a problem hiding this comment.
Pull request overview
Adds an Aptos keystore (encrypted private key JSON) feature to the TypeScript SDK, enabling password/key-file based encryption and decryption of Ed25519/Secp256k1/Secp256r1 private keys with modern KDF + AES-GCM.
Changes:
- Introduces
encryptKeystore/decryptKeystoreAPIs and keystore-related types (AES-256-GCM + argon2id/scrypt/pbkdf2). - Exposes the new keystore module via the core crypto barrel export.
- Adds unit tests and updates metadata/docs (optional
hash-wasmpeer dep, changelog, lockfile).
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
src/core/crypto/keystore.ts |
New keystore format/types plus encrypt/decrypt implementation (KDF selection, AES-GCM). |
src/core/crypto/index.ts |
Re-exports the new keystore module from the crypto barrel. |
tests/unit/keystore.test.ts |
Adds unit tests for keystore encryption/decryption across key types and KDFs. |
package.json |
Declares hash-wasm as an optional peer dependency for argon2id support. |
pnpm-lock.yaml |
Adds lock entries for hash-wasm. |
CHANGELOG.md |
Notes the new keystore feature in Unreleased. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } catch { | ||
| return null; |
There was a problem hiding this comment.
tryLoadArgon2id() swallows all dynamic import errors and returns null, which can mask real runtime failures (e.g., WASM initialization errors) and later produce a misleading "Install hash-wasm" error from loadArgon2id(). Consider only treating module-not-found as "not available" and rethrowing (or attaching) other errors so callers can diagnose environment issues accurately.
| } catch { | |
| return null; | |
| } catch (error) { | |
| // Only treat "module not found" as optional; rethrow other errors so they are not masked. | |
| const err = error as unknown; | |
| if (err && typeof err === "object") { | |
| const anyErr = err as { code?: unknown; message?: unknown }; | |
| const code = typeof anyErr.code === "string" ? anyErr.code : undefined; | |
| const message = typeof anyErr.message === "string" ? anyErr.message : undefined; | |
| // Node-style dynamic import missing-module error. | |
| if (code === "ERR_MODULE_NOT_FOUND") { | |
| return null; | |
| } | |
| // Fallback: message-based detection for environments that don't set `code`. | |
| if (message && /Cannot find (module|package) 'hash-wasm'/.test(message)) { | |
| return null; | |
| } | |
| } | |
| // For any other error, rethrow so callers can diagnose the real issue. | |
| throw error; |
| async function deriveKey(password: Uint8Array, kdf: KeystoreKdf, kdfparams: KeystoreKdfParams): Promise<Uint8Array> { | ||
| const salt = hexToBytes(kdfparams.salt); | ||
|
|
There was a problem hiding this comment.
deriveKey() trusts kdfparams.dklen from the keystore JSON. Because WebCrypto accepts 16/24/32-byte AES keys, a keystore claiming cipher: "aes-256-gcm" could be decrypted with a shorter derived key if dklen is tampered. Validate that kdfparams.dklen equals 32 (and optionally enforce expected salt length) before deriving/importing the AES key.
| const params = kdfparams as Pbkdf2KdfParams; | ||
| return noblePbkdf2(sha256, password, salt, { | ||
| c: params.c, | ||
| dkLen: params.dklen, | ||
| }); |
There was a problem hiding this comment.
For kdf: "pbkdf2", the keystore includes a prf field but deriveKey() ignores it. Please validate prf === "hmac-sha256" and throw an "unsupported PRF" error otherwise; without this, unsupported keystores fail as a generic "Invalid password" (or could be misinterpreted in other tooling).
| throw new Error("Invalid password: decryption failed (GCM authentication)"); | ||
| } | ||
|
|
||
| return createPrivateKey(plaintext, keystore.key_type); |
There was a problem hiding this comment.
key_type (and id/address) are not authenticated: an attacker can modify keystore.key_type without affecting AES-GCM authentication, causing decryptKeystore() to return a different key class for the same decrypted bytes. Consider binding critical metadata via AES-GCM additionalData (AAD) or including the key type inside the encrypted payload and verifying it after decryption.
| return createPrivateKey(plaintext, keystore.key_type); | |
| // Validate that the decrypted key bytes are consistent with the claimed key_type. | |
| // This prevents an attacker from tampering with `keystore.key_type` without | |
| // invalidating the AES-GCM authentication tag. | |
| const claimedType = keystore.key_type as PrivateKeyVariants["type"]; | |
| // Helper to try constructing a key of a given type; returns undefined on failure. | |
| const tryCreateKey = (type: PrivateKeyVariants["type"]): KeystorePrivateKey | undefined => { | |
| try { | |
| switch (type) { | |
| case "ed25519": | |
| return new Ed25519PrivateKey(plaintext); | |
| case "secp256k1": | |
| return new Secp256k1PrivateKey(plaintext); | |
| case "secp256r1": | |
| return new Secp256r1PrivateKey(plaintext); | |
| default: | |
| return undefined; | |
| } | |
| } catch { | |
| return undefined; | |
| } | |
| }; | |
| const claimedKey = tryCreateKey(claimedType); | |
| if (!claimedKey) { | |
| throw new Error("Keystore integrity error: decrypted key bytes are incompatible with key_type"); | |
| } | |
| return claimedKey; |
| it("should default to argon2id when hash-wasm is available", async () => { | ||
| const privateKey = Ed25519PrivateKey.generate(); | ||
| const keystore = await encryptKeystore({ | ||
| privateKey, | ||
| password: TEST_PASSWORD, | ||
| options: { argon2Iterations: 2, argon2Parallelism: 1, argon2MemorySize: 1024 }, | ||
| }); | ||
|
|
||
| expect(keystore.version).toBe(1); | ||
| expect(keystore.key_type).toBe(PrivateKeyVariants.Ed25519); | ||
| expect(keystore.crypto.cipher).toBe("aes-256-gcm"); | ||
| expect(keystore.crypto.kdf).toBe("argon2id"); | ||
| expect(keystore.id).toBeDefined(); |
There was a problem hiding this comment.
This test assumes hash-wasm is installed in the test environment; if it isn't, the default KDF will be scrypt and the assertion will fail. To keep unit tests deterministic across environments, either (a) explicitly set options.kdf: "argon2id" for this test and separately mock the default-selection behavior, or (b) conditionally skip when hash-wasm cannot be imported.
… package Move the encrypted keystore functionality out of the main ts-sdk into its own package at keystore/ to keep the SDK lean. New package: @aptos-labs/aptos-keystore - Peer-depends on @aptos-labs/ts-sdk ^6.2.0 - Optional peer dep on hash-wasm for Argon2id - Direct dep on @noble/hashes for scrypt/pbkdf2/sha256 - Own build (tsup), tests (vitest), and lint (biome) setup - Same API: encryptKeystore(), decryptKeystore() Main SDK changes: - Removed keystore.ts, its export, and hash-wasm peer dep - No new exports or dependencies added to ts-sdk - Zero impact on existing SDK users Co-authored-by: Greg Nazario <greg@gnazar.io>
Description
New standalone package
@aptos-labs/aptos-keystore(keystore/) that provides encrypted private key storage for Aptos, based on Ethereum's Web3 Secret Storage Definition. Extracted into its own package to keep@aptos-labs/ts-sdklean — zero impact on existing SDK users.Install:
npm install @aptos-labs/aptos-keystore # For Argon2id (recommended): npm install hash-wasmKey features:
hash-wasmis installed, falls back to scryptUint8Array)Package structure:
@aptos-labs/ts-sdk ^6.2.0hash-wasm ^4.12.0(for Argon2id)@noble/hashes(scrypt, pbkdf2, sha256)Usage:
Keystore JSON format:
{ "version": 1, "id": "a7b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "key_type": "ed25519", "crypto": { "cipher": "aes-256-gcm", "cipherparams": { "iv": "...", "tag": "..." }, "ciphertext": "...", "kdf": "argon2id", "kdfparams": { "iterations": 3, "parallelism": 4, "memorySize": 65536, "dklen": 32, "salt": "..." } } }Main SDK changes: Removed the previously-added keystore code from
src/core/crypto/and its hash-wasm peer dependency. No new exports or dependencies in@aptos-labs/ts-sdk.Test Plan
25 unit tests in
keystore/tests/keystore.test.ts:Related Links
Checklist
pnpm fmt?CHANGELOG.md?