Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 36 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1812,9 +1812,10 @@ PQ key sizes:
| `ML_DSA_44` | 32 B | 1312 B | 2560 B — encoded `rho‖K‖tr‖s1‖s2‖t0` (pk re-derivable from it) | fixed 2420 B |

The address is derived with the same formula as ECDSA / SM2:
`0x41 ‖ Keccak-256(public_key)[12..32]`, so a PQ address is indistinguishable from a
regular T-address until it signs a transaction (PQ signatures land in
`Transaction.pq_auth_sig` instead of `Transaction.signature`).
`prefix ‖ Keccak-256(public_key)[12..32]`, where `prefix` is the network address
prefix (`DecodeUtil.addressPreFixByte`, `0x41` on mainnet/Nile). So a PQ address is
indistinguishable from a regular T-address until it signs a transaction (PQ
signatures land in `Transaction.pq_auth_sig` instead of `Transaction.signature`).

### Generate a PQ keypair (no keystore)

Expand All @@ -1834,9 +1835,17 @@ publicKey (1312 bytes): <2624 hex chars>
privateKey (2560 bytes): <5120 hex chars>
privateKey persisted form ...: <5120 hex chars>

WARNING: store either the seed or the persisted private key securely.
WARNING: store the persisted private key securely — it is the portable backup.
The seed can also be used as a backup to re-derive this key.
```

> **Backup caveat for `FN_DSA_512` (Falcon).** Falcon key generation uses
> floating-point sampling, so re-deriving the keypair from the 48-byte seed is
> only guaranteed to reproduce the *same* key on the same CPU architecture + JVM.
> For Falcon, **back up the persisted private key** (the portable form); treat the
> seed as same-architecture-only. ML-DSA-44 keygen is deterministic, so its seed
> is a fully portable backup. `GeneratePQKey` prints this caveat for Falcon.

### Register a PQ wallet (creates a keystore)

>RegisterWalletPQ [scheme]
Expand All @@ -1859,12 +1868,17 @@ RegisterWalletPQ successful, keystore file: ./Wallet/TXxxxxxxxxxxxxxxxxxxxxxxxxx

>ImportWalletPQ [scheme] <seed_or_persisted_private_key_hex_or_file>
> Import an existing PQ keypair. The argument is **required** and may be either the
hex string itself or a path to a file containing it. The format is auto-detected by
length against the chosen scheme:
hex string itself or a path to a file containing it. The two are disambiguated
without guessing: if the argument is itself a valid hex string of exactly the
seed or persisted-key length for the scheme, it is used as the key material
directly (even if a same-named file happens to exist); otherwise it is treated as
a file path. Within each source the form is then auto-detected by length against
the chosen scheme:
>
> - **FN_DSA_512**: 48-byte / 96-hex-char seed (derives the full keypair
> deterministically), or 2176-byte / 4352-hex-char extended private key
> (`f‖g‖F‖h`, used verbatim).
> deterministically **on the same CPU architecture and JVM**; cross-platform
> replay is not guaranteed — see the backup caveat above), or 2176-byte /
> 4352-hex-char extended private key (`f‖g‖F‖h`, used verbatim).
> - **ML_DSA_44**: 32-byte / 64-hex-char seed, or 2560-byte / 5120-hex-char
> encoded private key (`rho‖K‖tr‖s1‖s2‖t0`).
>
Expand Down Expand Up @@ -1923,15 +1937,25 @@ MASTER_PASSWORD='testpassword123A' \
java -jar build/libs/wallet-cli.jar --output json register-wallet-pq \
--name pqalice --scheme ML_DSA_44

# Import an existing keypair from its persisted-private-key (or seed) hex
# Import an existing keypair. Prefer --key-file: it reads the hex from a file
# (or from stdin with '-') and keeps the secret out of shell history and the
# process list. '--extended-private-key-hex' still works but is discouraged
# because the secret is visible in shell history and in `ps`. Provide exactly
# one of the two — passing both is rejected.
MASTER_PASSWORD='testpassword123A' \
java -jar build/libs/wallet-cli.jar --output json import-wallet-pq \
--name pqalice \
--extended-private-key-hex <persisted-private-key or seed hex>
--key-file /tmp/my-falcon-key.hex
# Read from stdin instead of a file:
MASTER_PASSWORD='testpassword123A' \
java -jar build/libs/wallet-cli.jar --output json import-wallet-pq \
printf '%s' "$PQ_KEY_HEX" | java -jar build/libs/wallet-cli.jar --output json import-wallet-pq \
--name pqalice --scheme ML_DSA_44 \
--extended-private-key-hex <ML-DSA-44 seed or encoded private key hex>
--key-file -
# Discouraged (secret exposed on the command line):
MASTER_PASSWORD='testpassword123A' \
java -jar build/libs/wallet-cli.jar --output json import-wallet-pq \
--name pqalice \
--extended-private-key-hex <persisted-private-key or seed hex>
```

`generate-pq-key` returns a JSON envelope containing `scheme`, `address`,
Expand Down
12 changes: 6 additions & 6 deletions src/main/java/org/tron/common/crypto/pqc/MLDSA44.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
import java.security.MessageDigest;
import java.security.SecureRandom;
import org.bouncycastle.crypto.AsymmetricCipherKeyPair;
import org.bouncycastle.pqc.crypto.mldsa.MLDSAKeyGenerationParameters;
import org.bouncycastle.pqc.crypto.mldsa.MLDSAKeyPairGenerator;
import org.bouncycastle.pqc.crypto.mldsa.MLDSAParameters;
import org.bouncycastle.pqc.crypto.mldsa.MLDSAPrivateKeyParameters;
import org.bouncycastle.pqc.crypto.mldsa.MLDSAPublicKeyParameters;
import org.bouncycastle.pqc.crypto.mldsa.MLDSASigner;
import org.bouncycastle.crypto.generators.MLDSAKeyPairGenerator;
import org.bouncycastle.crypto.params.MLDSAKeyGenerationParameters;
import org.bouncycastle.crypto.params.MLDSAParameters;
import org.bouncycastle.crypto.params.MLDSAPrivateKeyParameters;
import org.bouncycastle.crypto.params.MLDSAPublicKeyParameters;
import org.bouncycastle.crypto.params.ParametersWithRandom;
import org.bouncycastle.crypto.prng.FixedSecureRandom;
import org.bouncycastle.crypto.signers.MLDSASigner;
import org.tron.protos.Protocol.PQScheme;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import java.util.EnumMap;
import java.util.Map;
import org.tron.common.crypto.Hash;
import org.tron.common.utils.DecodeUtil;
import org.tron.protos.Protocol.PQScheme;

/**
Expand Down Expand Up @@ -253,8 +254,12 @@ public static byte[] deriveHash(PQScheme scheme, byte[] publicKey) {
*/
public static byte[] computeAddress(PQScheme scheme, byte[] publicKey) {
byte[] h = deriveHash(scheme, publicKey);
if (h.length < 20) {
throw new IllegalStateException(
"deriveHash returned " + h.length + " bytes, need at least 20 for address derivation");
}
byte[] addr = new byte[21];
addr[0] = 0x41;
addr[0] = DecodeUtil.addressPreFixByte;
System.arraycopy(h, h.length - 20, addr, 1, 20);
return addr;
}
Expand Down
36 changes: 34 additions & 2 deletions src/main/java/org/tron/common/utils/TransactionUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,22 @@
public class TransactionUtils {
private static final ThreadLocal<Integer> PERMISSION_ID_OVERRIDE = new ThreadLocal<>();

// Protobuf field number of Transaction.pq_auth_sig (Tron.proto). The Trident
// SDK's Chain.Transaction does not define this field, so on those objects PQ
// auth signatures are preserved only as unknown field 6. Shared so the
// bandwidth estimate in WalletApi reads the same field number.
// TODO(trident-pq): once the Trident SDK models pq_auth_sig on
// Chain.Transaction, drop this constant and use the generated accessors.
public static final int PQ_AUTH_SIG_FIELD_NUMBER = 6;

/** True if the (possibly Trident-typed) transaction carries any PQ auth signature. */
// TODO(trident-pq): replace the unknown-field probe with
// transaction.getPqAuthSigCount() > 0 when Trident's Chain.Transaction
// exposes pq_auth_sig natively.
private static boolean hasPqAuthSig(Chain.Transaction transaction) {
return transaction.getUnknownFields().hasField(PQ_AUTH_SIG_FIELD_NUMBER);
}

/**
* Obtain a data bytes after removing the id and SHA-256(data)
*
Expand Down Expand Up @@ -306,6 +322,9 @@ public static Transaction sign(Transaction transaction, SignInterface myKey) {
return transaction;
}

// TODO(trident-pq): this Chain<->Protocol byte round-trip only exists because
// Trident's Chain.Transaction cannot carry pq_auth_sig. Drop the conversion
// and add the PQAuthSig directly once Trident models the field.
public static Chain.Transaction signPQ(
Chain.Transaction transaction, PQSignature signer, PQScheme scheme)
throws InvalidProtocolBufferException {
Expand Down Expand Up @@ -352,8 +371,13 @@ public static Chain.Transaction setTimestamp(Chain.Transaction transaction) {
}

public static Chain.Transaction setExpirationTime(Chain.Transaction transaction, boolean multi) {
if (transaction.getSignatureCount() == 0) {
long expirationTime = System.currentTimeMillis() + (multi ? 24L * 3600 * 1000 : 6 * 60 * 60 * 1000);
// Both ECDSA signatures and PQ auth signatures cover raw_data (the
// expiration lives there), so refuse to rewrite expiration once EITHER kind
// of signature is attached — otherwise an already-attached pq_auth_sig is
// silently invalidated (txid changes -> SIGERROR on broadcast).
if (transaction.getSignatureCount() == 0 && !hasPqAuthSig(transaction)) {
long expirationTime =
System.currentTimeMillis() + (multi ? 24L * 3600 * 1000 : 6 * 60 * 60 * 1000);
Chain.Transaction.Builder builder = transaction.toBuilder();
Chain.Transaction.raw.Builder rowBuilder =
transaction.getRawData().toBuilder();
Expand All @@ -364,6 +388,10 @@ public static Chain.Transaction setExpirationTime(Chain.Transaction transaction,
return transaction;
}

// TODO(trident-pq): the Chain<->Protocol byte round-trip is only needed to
// preserve pq_auth_sig (and read getPqAuthSigCount() in the Protocol overload)
// because Trident's Chain.Transaction lacks the field. Simplify once Trident
// models pq_auth_sig.
public static Chain.Transaction setPermissionId(Chain.Transaction transaction, String tipString)
throws CancelException, InvalidProtocolBufferException {
return Chain.Transaction.parseFrom(
Expand All @@ -372,7 +400,11 @@ public static Chain.Transaction setPermissionId(Chain.Transaction transaction, S

public static Transaction setPermissionId(Transaction transaction, String tipString)
throws CancelException {
// Changing permissionId mutates raw_data, which would invalidate any
// already-attached signature — including PQ auth signatures, which do not
// bump getSignatureCount(). Bail out if either kind is present.
if (transaction.getSignatureCount() != 0
|| transaction.getPqAuthSigCount() != 0
|| transaction.getRawData().getContract(0).getPermissionId() != 0) {
return transaction;
}
Expand Down
61 changes: 61 additions & 0 deletions src/main/java/org/tron/keystore/Wallet.java
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,16 @@ public static boolean validPassword (byte[] password, WalletFile walletFile)

validate(walletFile);

// PQ keystores may be seed-only (no main ciphertext/mac), so the ECDSA-style
// ciphertext/mac check below would wrongly report "Invalid password". Route
// them through a MAC-only validation path that does not decrypt key material
// or construct a signer, so no private-key bytes leak into heap. The
// non-null + non-empty scheme test matches how the rest of the codebase
// distinguishes PQ wallets from legacy ECDSA ones (scheme null/empty == ECDSA).
if (walletFile.getScheme() != null && !walletFile.getScheme().isEmpty()) {
return validatePasswordPQ(password, walletFile);
}

WalletFile.Crypto crypto = walletFile.getCrypto();

byte[] mac = ByteArray.fromHexString(crypto.getMac());
Expand Down Expand Up @@ -548,6 +558,57 @@ void clearSecrets() {
}
}

// Password-validation-only path for PQ keystores. Verifies MACs (and
// structural invariants) without decrypting any key material or constructing
// a signer, so no private-key bytes leak into heap. Used by validPassword to
// avoid the signer-retention issue of the full verifyAndDecryptPQ path.
private static boolean validatePasswordPQ(byte[] password, WalletFile walletFile)
throws CipherException {
if (walletFile.getScheme() == null) {
throw new CipherException("Wallet has no PQ scheme tag");
}
final PQScheme scheme;
try {
scheme = PQScheme.valueOf(walletFile.getScheme());
} catch (IllegalArgumentException e) {
throw new CipherException("Unsupported PQ scheme: " + walletFile.getScheme(), e);
}
if (!PQSchemeRegistry.contains(scheme)) {
throw new CipherException("Unsupported PQ scheme: " + scheme);
}

WalletFile.Crypto crypto = walletFile.getCrypto();

boolean extPresent = isExtSegmentPresent(crypto);
boolean seedPresent = isSeedSegmentPresent(crypto);
requireSegmentShape(crypto, extPresent, seedPresent);
if (!extPresent && !seedPresent) {
throw new CipherException(
"PQ wallet has neither ciphertext nor seedciphertext");
}

byte[] derivedKey = deriveScryptKey(password, crypto);
try {
if (extPresent) {
byte[] extCipherText = ByteArray.fromHexString(crypto.getCiphertext());
byte[] storedMac = ByteArray.fromHexString(crypto.getMac());
if (!Arrays.equals(generateMac(derivedKey, extCipherText), storedMac)) {
throw new CipherException("Invalid password provided");
}
}
if (seedPresent) {
byte[] seedCipherText = ByteArray.fromHexString(crypto.getSeedciphertext());
byte[] storedMac = ByteArray.fromHexString(crypto.getSeedmac());
if (!Arrays.equals(generateMac(derivedKey, seedCipherText), storedMac)) {
throw new CipherException("Invalid password provided");
}
}
return true;
} finally {
StringUtils.clear(derivedKey);
}
}

// Shared decrypt-and-verify core for PQ keystores, used by both decryptPQ and
// reEncryptPQ so the security checks cannot drift between them. Resolves and
// validates the scheme, verifies every persisted segment's MAC under
Expand Down
Loading