Skip to content

feat(plugins): migrate keystore CLI from FullNode to Toolkit#6637

Open
barbatos2011 wants to merge 22 commits intotronprotocol:developfrom
barbatos2011:001-keystore-toolkit-migration
Open

feat(plugins): migrate keystore CLI from FullNode to Toolkit#6637
barbatos2011 wants to merge 22 commits intotronprotocol:developfrom
barbatos2011:001-keystore-toolkit-migration

Conversation

@barbatos2011
Copy link
Copy Markdown

@barbatos2011 barbatos2011 commented Apr 2, 2026

What

Migrate the --keystore-factory interactive REPL from FullNode.jar to Toolkit.jar as picocli subcommands, extract the keystore core library from framework to crypto module, and add new capabilities (list, update, SM2 support, JSON output, password-file support).

Why

The current --keystore-factory has several limitations:

  1. Coupled to FullNode.jar — operators must have the full node binary and configuration to manage keystore files, even though keystore operations have zero dependency on the node runtime
  2. Interactive REPL only — cannot be scripted, piped, or used in CI/CD pipelines; no support for --password-file or structured output
  3. Security gapsScanner-based password input echoes to terminal; no --key-file option forces private keys to be typed interactively or passed as command-line arguments (visible in ps, shell history)
  4. Module coupling — the keystore library (Wallet.java, WalletUtils.java) lives in framework and calls Args.getInstance(), preventing reuse by plugins (Toolkit.jar) without pulling in the entire framework dependency chain

Changes

Commit 1: refactor(crypto): extract keystore library from framework module

Extract the keystore package from framework to crypto module to break the framework dependency:

File Change
Wallet.java Moved to crypto/, removed Args import, added boolean ecKey parameter to decrypt(), changed MAC comparison to constant-time MessageDigest.isEqual()
WalletUtils.java Moved to crypto/, removed Args import, added boolean ecKey parameter to generateNewWalletFile(), generateFullNewWalletFile(), generateLightNewWalletFile(), loadCredentials()
Credentials.java, WalletFile.java Moved to crypto/ unchanged
WitnessInitializer.java Updated to pass Args.getInstance().isECKeyCryptoEngine() to loadCredentials()
KeystoreFactory.java Updated to pass CommonParameter.getInstance().isECKeyCryptoEngine() to keystore calls
plugins/build.gradle Added implementation project(":crypto") with exclusions (libp2p, prometheus, aspectj, httpcomponents)
CredentialsTest.java Merged two test files (fixed keystroe typo directory) into crypto module
WalletFileTest.java Moved to crypto module

Commit 2: feat(plugins): add keystore new and import commands to Toolkit

Add picocli subcommands for the two core keystore operations:

java -jar Toolkit.jar keystore new [--password-file <file>] [--keystore-dir <dir>] [--json] [--sm2]
java -jar Toolkit.jar keystore import --key-file <file> [--password-file <file>] [--keystore-dir <dir>] [--json] [--sm2]

Security design:

  • Interactive password input uses Console.readPassword() (no echo), with null check for EOF (Ctrl+D)
  • Password char[] compared directly via Arrays.equals() (avoids creating unnecessary String), zeroed in finally blocks
  • Password/key file byte[] zeroed after reading via Arrays.fill(bytes, (byte) 0)
  • Private key import reads from --key-file or interactive prompt, never from command-line arguments
  • Accepts 0x-prefixed keys (common Ethereum format)
  • Keystore files set to 600 permissions via Files.setPosixFilePermissions
  • JSON output via ObjectMapper.writeValueAsString(), not string concatenation
  • --sm2 flag for SM2 algorithm support

Shared utilities:

  • KeystoreCliUtilsreadPassword(), ensureDirectory() (uses Files.createDirectories), setOwnerOnly(), printJson()
  • ensureDirectory() uses Files.createDirectories() to avoid TOCTOU race conditions
  • Toolkit.java updated to register Keystore.class in subcommands

Test infrastructure:

  • Ethereum standard test vectors (inline JSON from Web3 Secret Storage spec, pbkdf2 + scrypt) with known password/private key — verifies exact private key recovery
  • Dynamic format compatibility tests — generate keystore, serialize to file, deserialize, decrypt, verify Web3 Secret Storage structure + TRON address format
  • Property tests: 100 random encrypt/decrypt roundtrips + 2 standard scrypt roundtrips (with timeout) + 50 wrong-password rejection tests
  • CLI tests: password-file, invalid password, empty password, special-char password, custom directory, no-TTY error, JSON output, key-file, invalid key (short, non-hex), SM2, whitespace-padded key (with roundtrip verification), duplicate address import, dir-is-file

Commit 3: feat(plugins): add keystore list and update commands, deprecate --keystore-factory

java -jar Toolkit.jar keystore list [--keystore-dir <dir>] [--json]
java -jar Toolkit.jar keystore update <address> [--password-file <file>] [--keystore-dir <dir>] [--json] [--sm2]
  • list: scans directory for keystore JSON files, displays address + filename, skips non-keystore files. JSON serialization failure returns exit code 1.
  • update: decrypts with old password, re-encrypts with new password. Atomic file write (temp file + Files.move(ATOMIC_MOVE)) prevents corruption on interrupt. Wrong old password fails without modifying the file. Supports Windows line endings in password file.
  • @Deprecated annotation on KeystoreFactory class + deprecation warning in start() + migration notice in help() method
  • Backward compatibility test: WitnessInitializerKeystoreTest verifies new tool's keystore can be loaded by WitnessInitializer.initFromKeystore()

Scope

  • Does NOT change any consensus or transaction processing logic
  • Does NOT modify protobuf definitions or public APIs
  • Does NOT break existing localwitnesskeystore config — WitnessInitializer continues to load keystore files at node startup
  • The --keystore-factory flag remains functional with a deprecation warning

Test

  • Full project test suite passes (2300+ tests, 0 failures)
  • 14 crypto module tests (roundtrip property tests, Ethereum standard vectors, format compatibility, credentials, wallet file)
  • 26 plugins module tests (all 4 subcommands: success paths, error paths, JSON output, no-TTY, SM2, special chars, whitespace, duplicate, CRLF)
  • 2 new framework tests (deprecation warning, WitnessInitializer backward compat). Existing WitnessInitializerTest and SupplementTest updated for new signatures.
  • Regression: WitnessInitializerTest, ArgsTest, SupplementTest all pass
  • Manual verification: built Toolkit.jar, tested all 4 commands end-to-end
  • Checkstyle passes (checkstyleMain + checkstyleTest)
  • Security scanning: Semgrep (OWASP + secrets, 0 findings), FindSecBugs (0 new findings in new code)

Cross-implementation compatibility

Source Format Result
Ethereum spec vector (inline) pbkdf2 c=262144 Decrypt OK, private key exact match
Ethereum spec vector (inline) scrypt n=262144 Decrypt OK, private key exact match
java-tron (dynamic) scrypt n=262144 Roundtrip OK, TRON address verified
java-tron (dynamic) scrypt n=4096 Roundtrip OK

Migration from --keystore-factory

Feature Old --keystore-factory New Toolkit.jar keystore
Create keystore GenKeystore keystore new
Import private key ImportPrivateKey keystore import
List keystores Not supported keystore list
Change password Not supported keystore update
Non-interactive mode Not supported --password-file, --key-file
JSON output Not supported --json
SM2 algorithm Not supported --sm2
Custom directory Not supported --keystore-dir
Password no-echo No (echoed to terminal) Yes (Console.readPassword)
Script/CI friendly No (interactive REPL) Yes (single command, exit codes)

The old --keystore-factory remains functional with a deprecation warning during the transition period.

Usage

java -jar Toolkit.jar keystore <command> [options]

Commands

Command Description
new Generate a new keystore file with a random keypair
import Import an existing private key into a keystore file
list List all keystore files and addresses in a directory
update Change the password of an existing keystore file

Common Options

Option Description
--keystore-dir <path> Keystore directory (default: ./Wallet)
--json Output in JSON format for scripting
--password-file <path> Read password from file (non-interactive)
--sm2 Use SM2 algorithm instead of ECDSA
--key-file <path> Read private key from file (import only)

Examples

# Create a new keystore
java -jar Toolkit.jar keystore new --password-file pw.txt

# Import a private key
java -jar Toolkit.jar keystore import --key-file key.txt --password-file pw.txt

# List all keystores
java -jar Toolkit.jar keystore list --keystore-dir ./Wallet

# List in JSON format
java -jar Toolkit.jar keystore list --json

# Change password (password file: old password on line 1, new on line 2)
java -jar Toolkit.jar keystore update <address> --password-file passwords.txt

# Use SM2 algorithm
java -jar Toolkit.jar keystore new --sm2 --password-file pw.txt

@barbatos2011 barbatos2011 force-pushed the 001-keystore-toolkit-migration branch 3 times, most recently from f30d460 to cc02b8c Compare April 2, 2026 16:49
if (!keystoreDir.exists() || !keystoreDir.isDirectory()) {
return null;
}
File[] files = keystoreDir.listFiles((dir, name) -> name.endsWith(".json"));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

When duplicate-address keystores exist, keystore update <address> does not have a uniquely defined target. findKeystoreByAddress() returns the first matching file from keystoreDir.listFiles(), but that enumeration order is unspecified. In practice this makes the command nondeterministic: it may update the wrong file or fail depending on which matching file is encountered first.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed. findKeystoreByAddress() now collects all matches instead of returning the first one. When multiple keystores share the same address, it prints all matching filenames and returns an error:

Multiple keystores found for address TXxx...:
  UTC--2026-04-02T10-00-00--TXxx.json
  UTC--2026-04-02T11-00-00--TXxx.json
Please remove duplicates and retry.

This makes the behavior deterministic — the command refuses to proceed rather than silently picking one at random.

Comment thread plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java Outdated
+ " for the selected algorithm.");
return 1;
}
String fileName = WalletUtils.generateWalletFile(password, keyPair, keystoreDir, true);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Medium] No duplicate-address warning on import

When importing a private key whose address already exists in keystoreDir, the command silently creates a second keystore file. Combined with the nondeterministic findKeystoreByAddress in KeystoreUpdate, this creates a foot-gun: import the same key twice, then keystore update picks one at random.

Suggestion: scan the directory for existing keystores with the same address before writing, and print a warning:
WARNING: keystore for address already exists:
Still allow the import (for legitimate use cases), but make the user aware.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed. KeystoreImport now scans the target directory before writing and prints a warning if a keystore for the same address already exists:

WARNING: keystore for address TXxx... already exists: UTC--2026-04-02T10-00-00--TXxx.json

The import still proceeds (legitimate use case: re-importing with a different password), but the user is made aware. Combined with the findKeystoreByAddress fix in KeystoreUpdate, the foot-gun is mitigated: import warns upfront, and update refuses to operate on ambiguous duplicates.

}
try {
oldPassword = new String(oldPwd);
newPassword = new String(newPwd);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Low] Password String objects remain on the heap after use

The char[] arrays from Console.readPassword() are correctly zeroed in finally blocks (lines 115-117), but the passwords are then copied into immutable String objects (oldPassword = new String(oldPwd) at line 107, newPassword = new
String(newPwd) at line 108). These String copies cannot be zeroed and remain in memory until GC collects them.

The same pattern exists in KeystoreCliUtils.readPassword() (line 74: String password = new String(pwd1)).

This is a known Java limitation. Worth documenting with a comment at minimum, or refactoring the downstream APIs (Wallet.decrypt, Wallet.createStandard) to accept char[] in a follow-up.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Acknowledged — this is a known Java limitation. The downstream APIs (Wallet.decrypt, Wallet.createStandard, WalletUtils.loadCredentials) all accept String parameters, so converting char[] to String is unavoidable without refactoring the entire keystore API chain.

Current mitigations:

  • char[] from Console.readPassword() is zeroed in finally blocks
  • byte[] from Files.readAllBytes() is zeroed after conversion
  • Password comparison uses Arrays.equals(char[], char[]) to avoid creating a second String

Full char[]-throughout refactoring is tracked as a follow-up.

Comment thread plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java
Comment thread plugins/src/main/java/common/org/tron/plugins/KeystoreList.java Outdated
@barbatos2011 barbatos2011 force-pushed the 001-keystore-toolkit-migration branch from cc02b8c to 1492a49 Compare April 3, 2026 08:53
@3for
Copy link
Copy Markdown
Collaborator

3for commented Apr 4, 2026

  1. Even though multiple keystore files for the same private key may be a historical behavior, could we tighten this further so that repeatedly importing the same private key into the same keystore directory always maps to a single keystore file? That would also simplify the update flow. I noticed that current Ethereum geth behaves this way.

    More detail on geth for reference:

    • geth account import does not create multiple keystore files when the same private key is imported more than once into the same keystore directory. After the first successful import, later imports of the same key fail with ErrAccountAlreadyExists instead of writing another file.
    • The CLI account import eventually calls KeyStore.ImportECDSA, see cmd/geth/accountcmd.go#L342 and cmd/geth/accountcmd.go#L361.
    • ImportECDSA checks ks.cache.hasAddress(key.Address) before writing; if the address already exists, it returns ErrAccountAlreadyExists, see accounts/keystore/keystore.go#L451-L462.
    • There is also an existing test showing that importing the same key a second time fails, even with a different password, see accounts/keystore/keystore_test.go#L347-L362.

    For account update:

    • geth account update does not create an extra keystore file for the same account. It decrypts the existing key with the old password, re-encrypts it with the new password, and writes it back to the original file path.
    • The CLI account update only takes an <address> and resolves the account by address, see cmd/geth/accountcmd.go#L130-L154 and cmd/geth/accountcmd.go#L280-L311.
    • KeyStore.Update first finds and decrypts the account via getDecryptedKey, then calls StoreKey(a.URL.Path, key, newPassphrase) to write back to the original path, see accounts/keystore/keystore.go#L475-L482.
    • Under the hood, StoreKey writes to a temp file and then renames it over the target, so the behavior is replacement, not “write a second copy”, see accounts/keystore/passphrase.go#L105-L129.

    One more detail:

    • If the keystore directory already contains multiple same-address files because of manual copying or other external operations, update goes down the ambiguous-match path and returns multiple keys match address (...) instead of updating one arbitrarily, see accounts/keystore/account_cache.go#L165-L198.
  2. Right now, on the first run of java -jar Toolkit.jar keystore new --password-file pw.txt, the tool prints Error: pw.txt. Then if I manually create pw.txt and put adadd in it, the next run prints Invalid password: must be at least 6 characters. That means I have to go back and edit pw.txt again. Could we borrow the Ethereum-style --password flow here and let the user enter it interactively in the terminal instead? That feels like a better UX for first-time use.

  3. With the current java -jar Toolkit.jar keystore import --key-file key.txt --password-file pw.txt flow, key.txt and pw.txt store the raw private key and raw password in plaintext. By contrast, Ethereum geth wallet import does not read a raw private key file; it reads an Ethereum presale wallet file instead. That seems like a safer model overall.

@barbatos2011
Copy link
Copy Markdown
Author

Thanks for the detailed review with geth source references @3for! Here's my response to each point:

1. Duplicate import — now blocked by default

Fixed in d7f7c6b. keystore import now rejects importing when a keystore for the same address already exists, consistent with geth's ErrAccountAlreadyExists behavior:

Keystore for address TXxx... already exists: UTC--2026-04-02T10-00-00--TXxx.json. Use --force to import anyway.

A --force flag is available for legitimate use cases (e.g., re-importing with a different password).

2. Error messages improved

Fixed in d7f7c6b. When --password-file or --key-file points to a nonexistent file, the error message is now clear and suggests the alternative:

Password file not found: /path/to/pw.txt. Omit --password-file for interactive input.

Note: our tool already supports interactive password input when --password-file is omitted — the flow is the same as geth's (prompt "Enter password:" + "Confirm password:"). The issue was only the unclear error message when a specified file doesn't exist.

3. Plaintext key file — same as geth account import

This is actually consistent with geth's behavior. The comparison in the review was with geth wallet import, which imports Ethereum presale wallet files (a specific historical format) — a different use case entirely.

geth's equivalent command for raw private key import is geth account import <keyfile>, which reads a plaintext private key file — exactly the same as our keystore import --key-file:

  • geth account import source — reads raw hex private key from file
  • geth wallet import — imports encrypted presale wallet (different purpose)

Our --key-file targets the use case of migrating plaintext private keys from fullnode.conf's localwitness field into encrypted keystore files — this actually improves security by moving from plaintext config to encrypted storage.

Comment on lines +168 to +169
if (address.equals(wf.getAddress())) {
return file.getName();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The definition of “what counts as a keystore” is inconsistent with KeystoreList.java here. This check should also require wf.getCrypto() != null && wf.getVersion() == 3.

Right now, KeystoreList, KeystoreImport, and KeystoreUpdate each deserialize WalletFile and apply their own filtering logic separately. Suggest extracting a shared helper like static boolean isKeystoreFile(WalletFile wf) and reusing the same predicate across list / import / update so the behavior stays consistent.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good point. Extracted KeystoreCliUtils.isValidKeystoreFile(WalletFile) that checks address != null && crypto != null && version == 3, now shared across list, import, and update.

Comment on lines +203 to +209
System.err.println("Multiple keystores found for address "
+ targetAddress + ":");
for (File m : matches) {
System.err.println(" " + m.getName());
}
System.err.println("Please remove duplicates and retry.");
return null;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

When this branch is taken, it prints Multiple keystores found for address... and returns null. But the caller above interprets that null as “not found” and prints No keystore found for address.... Those two messages seem inconsistent.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed. Moved the error messages into findKeystoreByAddress itself — each case (not found, multiple matches, no directory) prints exactly one specific message. The caller no longer adds a redundant "No keystore found".

@halibobo1205 halibobo1205 added this to the GreatVoyage-v4.8.2 milestone Apr 8, 2026
List<String> privateKeys = new ArrayList<>();
try {
Credentials credentials = WalletUtils.loadCredentials(pwd, new File(fileName));
Credentials credentials = WalletUtils.loadCredentials(pwd, new File(fileName),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

#6588 has already decided to remove the unused SM2 algorithm and related configuration. Why is this PR still adding compatibility for it (e.g., the boolean ecKey parameter and --sm2 option)?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@halibobo1205 It has currently been decided to retain SM2. See: #6627 (comment)

@halibobo1205
Copy link
Copy Markdown
Collaborator

The new Toolkit.jar keystore commands are completely incompatible with the old --keystore-factory CLI — different command names, different options, and a different workflow. This would impose a significant migration cost on existing users.

Before investing further effort here, I think we should first assess how many users are actually using the keystore feature. Given that wallet-cli already provides equivalent functionality, the existing keystore user base is likely very small. If that's the case, we should update the README in plugins/ to clearly document the migration path and the new command's usage.

KeystoreUpdate.class
},
commandListHeading = "%nCommands:%n%nThe most commonly used keystore commands are:%n"
)
Copy link
Copy Markdown
Collaborator

@halibobo1205 halibobo1205 Apr 14, 2026

Choose a reason for hiding this comment

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

[SHOULD]: Suggest moving keystore-related classes into the org.tron.plugins.keystore package. Similarly, existing db-related commands could be moved into org.tron.plugins.db. Previously, with only db commands in the plugins module, having everything in the top-level package was fine. But now that keystore is being added, organizing by feature package would be cleaner.
[MUST]: update README.md for new commands.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

README: Already added a Keystore section to plugins/README.md with migration mapping and usage examples for all subcommands.

Package restructuring: Agree this would be cleaner. The current flat layout in org.tron.plugins is the existing convention — all db command classes (Db, DbConvert, DbCopy, etc.) are already at the top-level package. The keystore classes follow the same pattern. Since a proper restructuring would involve moving both db and keystore classes together for consistency, I'd suggest handling it as a separate refactor PR to keep this one focused.

@Command(name = "keystore",
mixinStandardHelpOptions = true,
version = "keystore command 1.0",
description = "Manage keystore files for witness account keys.",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[SHOULD]: The description says "witness account keys", but the implementation is generic and works for any account, not just witnesses. Suggest changing to "Manage keystore files for account keys."

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch. Updated to "Manage keystore files for account keys." — the implementation is indeed generic and not witness-specific.

} catch (IOException e) {
System.err.println("Warning: could not set file permissions on " + file.getName());
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[NIT]: Suggest printing a warning to the user when file permissions cannot be set — both for the Windows UnsupportedOperationException case (currently silently skipped) and the IOException case (currently only prints the file name without guidance). For example:

catch (UnsupportedOperationException | IOException e) {
    System.err.println("Warning: could not set file permissions on " + file.getName()
        + ". Please manually restrict access to this file.");
}

This way, users are aware that the keystore file is not protected and can take action themselves.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Updated. Both UnsupportedOperationException and IOException now print a warning with guidance to manually restrict access.

Barbatos added 9 commits April 21, 2026 15:57
Cover utility methods (stripLineEndings, jsonMap, isValidKeystoreFile,
checkFileExists, readPassword, ensureDirectory, printJson,
printSecurityTips, atomicMove, generateKeystoreFile, setOwnerOnly) with
direct unit tests, including edge cases like BOM, various line endings,
empty strings, and both ECDSA/SM2 key paths.
- Add WalletFilePojoTest covering all getter/setter, equals, hashCode
  paths for WalletFile and its inner classes (Crypto, CipherParams,
  Aes128CtrKdfParams, ScryptKdfParams), plus JSON deserialization with
  both scrypt and pbkdf2 KDF types
- Expand KeystoreFactoryDeprecationTest to cover help, invalid command,
  quit, empty line, and genKeystore/importprivatekey dispatch paths
- Tests live in framework module so their coverage data is included in
  crypto module's jacoco report (crypto:jacocoTestReport reads framework
  exec files)
Prevents address-spoofing attacks where a crafted keystore declares one
address in JSON but encrypts a different private key. After decryption,
verify that the declared address matches the address derived from the
decrypted key. Null/empty addresses are still accepted for compatibility
with Ethereum-style keystores that omit the field.
- Remove newWalletFile.setAddress(walletFile.getAddress()) — createStandard
  already sets the correctly-derived address, and propagating the JSON
  address could carry a spoofed value forward
- Use the derived address from newWalletFile for output messages instead
  of walletFile.getAddress(), keeping the output correct as a
  defense-in-depth measure even if upstream validation is weakened
- Add tests for tampered-address rejection and derived-address output
Clarify that 'keystore list' displays the declared address from each
keystore's JSON without decrypting it. A tampered keystore may claim an
address that does not correspond to its encrypted key; verification only
happens at decryption time (e.g. 'keystore update').
Move the temp-file + atomic-rename + POSIX 0600 permissions pattern from
plugins into WalletUtils.generateWalletFile and a new public
WalletUtils.writeWalletFile method. Use Files.createTempFile with
PosixFilePermissions.asFileAttribute to atomically create the temp file
with owner-only permissions, eliminating the brief world-readable window
present in the previous File.createTempFile + setOwnerOnly sequence.

All callers benefit automatically, including framework/KeystoreFactory
(which previously wrote 0644 files) and any third-party project that
consumes the crypto artifact. Removes ~68 lines of duplicated plumbing
from KeystoreCliUtils.

- Windows/non-POSIX fallback is best-effort (DOS attributes) and
  documented as such in JavaDoc
- Error path suppresses secondary IOException via addSuppressed so the
  original cause is preserved
- OWNER_ONLY wrapped with Collections.unmodifiableSet for defense
  in depth
- WalletUtilsWriteTest covers permissions, replacement, temp cleanup,
  parent-directory creation, and a failure-path test that forces move
  to fail and asserts no temp file remains
- KeystoreUpdateTest adversarially pre-loosens perms to 0644 and
  verifies update narrows back to 0600
…closure

By default java.nio.file.Files.readAllBytes follows symbolic links,
allowing an attacker who can plant files in a user-supplied path to
redirect reads to arbitrary files (e.g. /etc/shadow, SSH private keys).
The first ~1KB of the linked file would be misused as a password or
private key.

Introduces KeystoreCliUtils.readRegularFile that:
- stats the path with LinkOption.NOFOLLOW_LINKS (lstat semantics)
- rejects symlinks, directories, FIFOs and other non-regular files
- opens the byte channel with LinkOption.NOFOLLOW_LINKS too, closing
  the TOCTOU window between stat and open
- enforces a single size check via the lstat-returned attributes
  instead of a separate File.length() call

All three call sites are migrated:
- KeystoreCliUtils.readPassword (used by new/import)
- KeystoreImport.readPrivateKey (key file)
- KeystoreUpdate.call (password file for old+new passwords)

Tests:
- unit tests for readRegularFile covering success, missing file,
  too-large, symlink refused, directory refused, and empty file
- end-to-end tests in KeystoreImportTest that provide a symlinked
  --key-file and --password-file and assert refusal
@barbatos2011 barbatos2011 force-pushed the 001-keystore-toolkit-migration branch from ba01b3b to 40ec5ce Compare April 21, 2026 09:13
Barbatos added 3 commits April 21, 2026 17:31
WalletUtils.inputPassword() previously applied trim() + split("\\s+")[0]
on the non-TTY (stdin) branch, silently truncating passphrases like
"correct horse battery staple" to "correct" when piped via stdin. This
produced keystores encrypted with only the first word, which would then
fail to decrypt under new CLIs that correctly preserve the full password.

- Replace trim() + split("\\s+")[0] with stripPasswordLine() that only
  strips the UTF-8 BOM and trailing line terminators, preserving internal
  whitespace
- Expose stripPasswordLine as the canonical public helper and consolidate
  KeystoreCliUtils.stripLineEndings into it (plugins now delegates)
- Fix a pre-existing Scanner lifecycle bug: inputPassword() used to
  allocate a fresh Scanner(System.in) on every call, so the second call
  in inputPassword2Twice() lost bytes the first Scanner had buffered.
  The Scanner is now lazily-cached and shared across calls, with a
  resetSharedStdinScanner() hook for tests
- KeystoreFactory.importPrivateKey keeps trim()+split for private-key
  input (hex strings have no legitimate whitespace)

New tests cover: internal whitespace preservation, tabs, CRLF, BOM,
leading/trailing spaces, the inputPassword2Twice piped double-read path,
and direct unit tests of stripPasswordLine (null, empty, only-BOM,
only-terminators, BOM-then-terminator).
…failure

When `keystore update` decryption fails and the provided old password
contains whitespace, print a hint pointing users at the
`--keystore-factory` non-TTY truncation bug: keystores created with
`echo PASS | java -jar FullNode.jar --keystore-factory` were encrypted
using only the first whitespace-separated word of the password.

The hint is gated on whitespace in the supplied password so that the
overwhelmingly common case (user simply mistyped) does not get noise
about an unrelated legacy bug. Wording explicitly tells the user to
re-run with just the first word as the current password and offers the
full phrase as the new password.

Two tests cover both branches of the gate (hint fires when password
contains whitespace, suppressed otherwise).

Deliberately not adding automatic fallback to split("\\s+")[0] during
decryption — that would lower effective password entropy by giving an
attacker a free first-token brute-force channel. Recovery stays explicit
and user-driven.
…lure

When WitnessInitializer fails to load the SR keystore due to a
CipherException and the provided password contains whitespace, log a
tip pointing the operator at the same legacy `--keystore-factory`
non-TTY truncation bug handled in the Toolkit plugins CLI.

Gives SR operators a breadcrumb when their node refuses to start after
upgrading past the inputPassword() fix: their keystore may have been
encrypted using only the first word of their password, and they can
recover by using that first word temporarily, then resetting with
Toolkit.jar keystore update.

The tip only fires when the password has whitespace (truncation cannot
explain a failure otherwise), keeping the common wrong-password case
quiet.
List<Map<String, String>> entries = new ArrayList<>();
for (File file : files) {
try {
WalletFile walletFile = MAPPER.readValue(file, WalletFile.class);
Copy link
Copy Markdown
Collaborator

@lxcmyf lxcmyf Apr 22, 2026

Choose a reason for hiding this comment

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

[SHOULD]Keystore directory scans still follow symlinks

The PR hardened --password-file and --key-file reads with NOFOLLOW_LINKS, but the keystore-directory scans in list, import, and update still call MAPPER.readValue(file, ...) directly on every *.json entry. In a hostile or group-writable keystore directory, a symlink or special file can still make the process read arbitrary files or block on non-regular files. The same regular-file / nofollow gate used by readRegularFile() needs to be applied before deserializing candidate keystores.

return null;
}
try {
String password = WalletUtils.stripPasswordLine(
Copy link
Copy Markdown
Collaborator

@lxcmyf lxcmyf Apr 22, 2026

Choose a reason for hiding this comment

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

[SHOULD]Multi-line password files become literal passwords in new/import

readPassword() reads the entire file and only strips trailing line endings. For keystore new and keystore import, a two-line file therefore becomes a password like old\nnew, which still passes validation and creates a keystore successfully, but neither visible line alone will unlock it later. This is easy to trigger by accidentally reusing an update password file. The non-update path should reject multi-line files or explicitly consume only the first line.

/**
* Check if a WalletFile represents a valid V3 keystore.
*/
static boolean isValidKeystoreFile(WalletFile wf) {
Copy link
Copy Markdown
Collaborator

@lxcmyf lxcmyf Apr 22, 2026

Choose a reason for hiding this comment

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

[SHOULD]Keystore detection accepts unusable JSON stubs

isValidKeystoreFile() currently treats any JSON with address, non-null crypto, and version == 3 as a keystore. A stub like {"address":"T...","version":3,"crypto":{}} now passes discovery even though Wallet.validate() would later reject it for missing or unsupported cipher/KDF data. That means keystore list can show junk entries, keystore import can refuse a valid import as a duplicate, and keystore update can report duplicate matches or fail against the bogus file. Discovery should reuse Wallet.validate() or at least require supported cipher/KDF fields so only decryptable keystores are matched.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

[Feature] Move keystore-factory as toolkit subcommand

8 participants