Skip to content

feat: Add @aptos-labs/aptos-keystore — Encrypted Private Key Storage Package#848

Open
gregnazario wants to merge 5 commits intomainfrom
cursor/aptos-key-encryption-standard-3583
Open

feat: Add @aptos-labs/aptos-keystore — Encrypted Private Key Storage Package#848
gregnazario wants to merge 5 commits intomainfrom
cursor/aptos-key-encryption-standard-3583

Conversation

@gregnazario
Copy link
Collaborator

@gregnazario gregnazario commented Mar 20, 2026

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-sdk lean — zero impact on existing SDK users.

Install:

npm install @aptos-labs/aptos-keystore
# For Argon2id (recommended):
npm install hash-wasm

Key features:

  • Supports all Aptos private key types: Ed25519, Secp256k1, Secp256r1
  • AES-256-GCM authenticated encryption (confidentiality + integrity, no separate MAC)
  • Argon2id KDF by default when hash-wasm is installed, falls back to scrypt
  • PBKDF2-HMAC-SHA256 also available
  • Password or key-file encryption (string or Uint8Array)
  • Portable JSON format for cross-SDK interop (TypeScript, Rust, Python, Go)

Package structure:

  • Peer-depends on @aptos-labs/ts-sdk ^6.2.0
  • Optional peer dep on hash-wasm ^4.12.0 (for Argon2id)
  • Direct dep on @noble/hashes (scrypt, pbkdf2, sha256)
  • Own build (tsup), tests (vitest), lint (biome)

Usage:

import { Ed25519PrivateKey } from "@aptos-labs/ts-sdk";
import { encryptKeystore, decryptKeystore } from "@aptos-labs/aptos-keystore";

const privateKey = Ed25519PrivateKey.generate();
const keystore = await encryptKeystore({ privateKey, password: "my-password" });
const recovered = await decryptKeystore({ keystore, password: "my-password" });

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:

  • Default KDF selection (argon2id when hash-wasm present)
  • All three KDFs × all three key types
  • AES-256-GCM auth: wrong password, tampered ciphertext, tampered tag
  • Key-file (Uint8Array) password support
  • JSON round-trip serialization
  • Signature verification after decrypt
cd keystore && pnpm install && pnpm test

Related Links

Checklist

  • Have you ran pnpm fmt?
  • Have you updated the CHANGELOG.md?
Open in Web Open in Cursor 

@cursor cursor bot force-pushed the cursor/aptos-key-encryption-standard-3583 branch from d698df8 to a6eb427 Compare March 20, 2026 02:12
@gregnazario gregnazario force-pushed the cursor/aptos-key-encryption-standard-3583 branch from caec9ee to 0907691 Compare March 20, 2026 02:59
Comment on lines +320 to +322
argon2Iterations = 3,
argon2Parallelism = 4,
argon2MemorySize = 65536,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix to 128mb, 1 parallelism, 2 iterations

@gregnazario gregnazario requested a review from Copilot March 20, 2026 21:18
@gregnazario gregnazario marked this pull request as ready for review March 20, 2026 21:18
@gregnazario gregnazario requested a review from a team as a code owner March 20, 2026 21:18
cursoragent and others added 4 commits March 20, 2026 21:19
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>
@cursor cursor bot force-pushed the cursor/aptos-key-encryption-standard-3583 branch from 107a104 to fe2a84e Compare March 20, 2026 21:19
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 / decryptKeystore APIs 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-wasm peer 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.

Comment on lines +187 to +188
} catch {
return null;
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
} 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;

Copilot uses AI. Check for mistakes.
Comment on lines +205 to +207
async function deriveKey(password: Uint8Array, kdf: KeystoreKdf, kdfparams: KeystoreKdfParams): Promise<Uint8Array> {
const salt = hexToBytes(kdfparams.salt);

Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +232 to +236
const params = kdfparams as Pbkdf2KdfParams;
return noblePbkdf2(sha256, password, salt, {
c: params.c,
dkLen: params.dklen,
});
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
throw new Error("Invalid password: decryption failed (GCM authentication)");
}

return createPrivateKey(plaintext, keystore.key_type);
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;

Copilot uses AI. Check for mistakes.
Comment on lines +26 to +38
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();
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
… 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>
@cursor cursor bot changed the title feat: Add Aptos Keystore — Encrypted Private Key Storage Standard feat: Add @aptos-labs/aptos-keystore — Encrypted Private Key Storage Package Mar 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants