diff --git a/README.md b/README.md index 99e89ca..1830062 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # Tx3 SDK for Java The Java SDK is the Java 21 client library for Tx3 protocols. It contains public contract values, -the signer interface, the typed error hierarchy, TII protocol loading and introspection, and an -asynchronous low-level TRP client. +the typed error hierarchy, TII protocol loading and introspection, an asynchronous low-level TRP +client, and extensible signer contracts with a raw-key Java Ed25519 signer and a Cardano mnemonic +signer derived at `m/1852'/1815'/0'/0/0`. ## Requirements @@ -95,6 +96,25 @@ resolve time reports `ResolutionException`. Generated clients seed the same buil `Tx3ClientBuilder.fromParts(...)`, bind statically known parties with `withPartyUnchecked`, and construct canonical values with `argTagged`; that path retains no TII or parameter schema. +## Sign transactions + +`Ed25519Signer` accepts only a 32-byte private-key seed and an address controlled by that key. +Mnemonic derivation belongs to `CardanoSigner`; both implementations sign the 32-byte +`txHashHex` from `SignRequest` and return a `VKEY` witness. + +```java +import land.tx3.sdk.Address; +import land.tx3.sdk.CardanoSigner; +import land.tx3.sdk.SignRequest; + +var signer = new CardanoSigner(mnemonic, new Address("addr_test1...")); +var witness = signer.sign(new SignRequest(txHashHex, txCborHex)); +``` + +Key inputs and derived key material are kept in defensive copies and are never written to logs or +error messages. Invalid keys and malformed hashes use the SDK's typed `ValidationException`; +derivation, address-binding, and cryptographic failures use `SigningException`. + ## Development These are the canonical foundation checks: diff --git a/src/main/java/land/tx3/sdk/CardanoSigner.java b/src/main/java/land/tx3/sdk/CardanoSigner.java new file mode 100644 index 0000000..57bcb31 --- /dev/null +++ b/src/main/java/land/tx3/sdk/CardanoSigner.java @@ -0,0 +1,84 @@ +package land.tx3.sdk; + +import com.bloxbean.cardano.client.crypto.CryptoException; +import com.bloxbean.cardano.client.crypto.bip32.HdKeyPair; +import com.bloxbean.cardano.client.crypto.cip1852.CIP1852; +import com.bloxbean.cardano.client.crypto.cip1852.DerivationPath; +import com.bloxbean.cardano.client.crypto.config.CryptoConfiguration; +import java.util.Arrays; + +/** A Cardano BIP32-Ed25519 signer derived at {@code m/1852'/1815'/0'/0/0}. */ +public final class CardanoSigner implements Signer { + private final Address address; + private final byte[] privateKey; + private final byte[] publicKey; + private final byte[] chainCode; + + /** + * Creates a Cardano signer from an Icarus/CIP-1852 mnemonic and its payment address. + * + *

Derived key material is defensively copied and is never included in errors or logs. + * + * @param mnemonic BIP-39 mnemonic phrase + * @param address Cardano address controlled by the derived payment key + * @throws ValidationException if the mnemonic is null or blank + * @throws SigningException if derivation fails or the address is not controlled by the key + */ + public CardanoSigner(String mnemonic, Address address) { + if (address == null) { + throw new ValidationException("address", "address must not be null"); + } + if (mnemonic == null || mnemonic.isBlank()) { + throw new ValidationException("mnemonic", "mnemonic must not be null or blank"); + } + this.address = address; + + final HdKeyPair keyPair; + try { + keyPair = + new CIP1852() + .getKeyPairFromMnemonic( + mnemonic, DerivationPath.createExternalAddressDerivationPath()); + } catch (RuntimeException exception) { + throw new SigningException(address, "CIP-1852 key derivation failed", exception); + } + + this.privateKey = keyPair.getPrivateKey().getKeyData().clone(); + this.publicKey = keyPair.getPublicKey().getKeyData().clone(); + this.chainCode = keyPair.getPublicKey().getChainCode().clone(); + SignerSupport.verifyAddressBinding(address, this.publicKey); + } + + @Override + public Address address() { + return address; + } + + /** + * Signs the request's 32-byte transaction hash with the derived BIP32-Ed25519 key. + * + * @throws ValidationException if the request is null + * @throws SigningException if signing fails + */ + @Override + public Witness sign(SignRequest request) { + if (request == null) { + throw new ValidationException("request", "sign request must not be null"); + } + byte[] hash = SignerSupport.decodeHex(request.txHashHex(), "txHashHex", 32); + try { + byte[] signature = + CryptoConfiguration.INSTANCE.getSigningProvider().signExtended(hash, privateKey); + return new Witness( + SignerSupport.encodeHex(publicKey), SignerSupport.encodeHex(signature), WitnessType.VKEY); + } catch (CryptoException exception) { + throw new SigningException(address, "Cardano signing failed", exception); + } finally { + Arrays.fill(hash, (byte) 0); + } + } + + byte[] chainCode() { + return chainCode.clone(); + } +} diff --git a/src/main/java/land/tx3/sdk/Ed25519Signer.java b/src/main/java/land/tx3/sdk/Ed25519Signer.java new file mode 100644 index 0000000..0b47eb6 --- /dev/null +++ b/src/main/java/land/tx3/sdk/Ed25519Signer.java @@ -0,0 +1,93 @@ +package land.tx3.sdk; + +import com.bloxbean.cardano.client.crypto.KeyGenUtil; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Arrays; + +/** A raw-key Ed25519 signer backed by the Java 21 Ed25519 provider. */ +public final class Ed25519Signer implements Signer { + private static final byte[] PKCS8_SEED_PREFIX = + new byte[] { + 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, + 0x20 + }; + + private final Address address; + private final byte[] privateKey; + private final byte[] publicKey; + + /** + * Creates a signer from a 32-byte Ed25519 private-key seed and its Cardano address. + * + *

The key is defensively copied and is never included in errors or logs. Mnemonic input is + * deliberately unsupported; use {@link CardanoSigner} for CIP-1852 mnemonic derivation. + * + * @param privateKey 32-byte Ed25519 private-key seed + * @param address Cardano address controlled by the key + * @throws ValidationException if the key is null or not 32 bytes + * @throws SigningException if the address is malformed or is not controlled by the key + */ + public Ed25519Signer(byte[] privateKey, Address address) { + if (address == null) { + throw new ValidationException("address", "address must not be null"); + } + if (privateKey == null || privateKey.length != 32) { + throw new ValidationException("privateKey", "private key must be exactly 32 bytes"); + } + this.privateKey = privateKey.clone(); + this.address = address; + try { + this.publicKey = KeyGenUtil.getPublicKeyFromPrivateKey(this.privateKey).clone(); + } catch (RuntimeException exception) { + throw new SigningException(address, "Ed25519 public-key derivation failed", exception); + } + SignerSupport.verifyAddressBinding(address, this.publicKey); + } + + @Override + public Address address() { + return address; + } + + /** + * Signs the request's 32-byte transaction hash with Ed25519. + * + * @throws ValidationException if the request is null + * @throws SigningException if the Java Ed25519 provider cannot sign the hash + */ + @Override + public Witness sign(SignRequest request) { + if (request == null) { + throw new ValidationException("request", "sign request must not be null"); + } + byte[] hash = SignerSupport.decodeHex(request.txHashHex(), "txHashHex", 32); + try { + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(toJavaPrivateKey()); + signer.update(hash); + return new Witness( + SignerSupport.encodeHex(publicKey), + SignerSupport.encodeHex(signer.sign()), + WitnessType.VKEY); + } catch (GeneralSecurityException exception) { + throw new SigningException(address, "Ed25519 signing failed", exception); + } finally { + Arrays.fill(hash, (byte) 0); + } + } + + private PrivateKey toJavaPrivateKey() throws GeneralSecurityException { + byte[] encoded = new byte[PKCS8_SEED_PREFIX.length + privateKey.length]; + System.arraycopy(PKCS8_SEED_PREFIX, 0, encoded, 0, PKCS8_SEED_PREFIX.length); + System.arraycopy(privateKey, 0, encoded, PKCS8_SEED_PREFIX.length, privateKey.length); + try { + return KeyFactory.getInstance("Ed25519").generatePrivate(new PKCS8EncodedKeySpec(encoded)); + } finally { + Arrays.fill(encoded, (byte) 0); + } + } +} diff --git a/src/main/java/land/tx3/sdk/SignRequest.java b/src/main/java/land/tx3/sdk/SignRequest.java index 0156c19..d6b5107 100644 --- a/src/main/java/land/tx3/sdk/SignRequest.java +++ b/src/main/java/land/tx3/sdk/SignRequest.java @@ -7,14 +7,11 @@ public record SignRequest(String txHashHex, String txCborHex) { * * @param txHashHex hexadecimal bound transaction hash * @param txCborHex hexadecimal full transaction CBOR - * @throws ValidationException if either envelope is null + * @throws ValidationException if the hash is not exactly 32 bytes of hexadecimal or the CBOR is + * empty or malformed hexadecimal */ public SignRequest { - if (txHashHex == null) { - throw new ValidationException("txHashHex", "transaction hash must not be null"); - } - if (txCborHex == null) { - throw new ValidationException("txCborHex", "transaction CBOR must not be null"); - } + SignerSupport.decodeHex(txHashHex, "txHashHex", 32); + SignerSupport.decodeHex(txCborHex, "txCborHex", -1); } } diff --git a/src/main/java/land/tx3/sdk/SignerSupport.java b/src/main/java/land/tx3/sdk/SignerSupport.java new file mode 100644 index 0000000..c07f10e --- /dev/null +++ b/src/main/java/land/tx3/sdk/SignerSupport.java @@ -0,0 +1,55 @@ +package land.tx3.sdk; + +import com.bloxbean.cardano.client.address.AddressProvider; +import java.util.HexFormat; + +final class SignerSupport { + private static final HexFormat HEX = HexFormat.of(); + + private SignerSupport() {} + + static byte[] decodeHex(String value, String field, int expectedBytes) { + if (value == null || value.isEmpty()) { + throw new ValidationException(field, field + " must not be null or empty"); + } + + final byte[] decoded; + try { + decoded = HEX.parseHex(value); + } catch (IllegalArgumentException exception) { + throw new ValidationException(field, field + " must contain only complete hexadecimal bytes"); + } + + if (expectedBytes >= 0 && decoded.length != expectedBytes) { + throw new ValidationException(field, field + " must be exactly " + expectedBytes + " bytes"); + } + return decoded; + } + + static String encodeHex(byte[] value) { + return HEX.formatHex(value); + } + + static void verifyAddressBinding(Address address, byte[] publicKey) { + final com.bloxbean.cardano.client.address.Address parsed; + try { + parsed = new com.bloxbean.cardano.client.address.Address(address.value()); + } catch (RuntimeException exception) { + throw new SigningException( + address, "signer address is not a valid Cardano address", exception); + } + + try { + if (!parsed.isPubKeyHashInPaymentPart()) { + throw new SigningException(address, "signer address must contain a payment key credential"); + } + if (!AddressProvider.verifyAddress(parsed, publicKey)) { + throw new SigningException(address, "signer key does not control the supplied address"); + } + } catch (SigningException exception) { + throw exception; + } catch (RuntimeException exception) { + throw new SigningException(address, "signer address cannot be bound to the key", exception); + } + } +} diff --git a/src/test/java/consumer/ExternalConsumerTest.java b/src/test/java/consumer/ExternalConsumerTest.java index 7f1de43..d63af8a 100644 --- a/src/test/java/consumer/ExternalConsumerTest.java +++ b/src/test/java/consumer/ExternalConsumerTest.java @@ -9,6 +9,10 @@ import land.tx3.sdk.ArgValue; import land.tx3.sdk.ClientOptions; import land.tx3.sdk.ParamType; +import land.tx3.sdk.SignRequest; +import land.tx3.sdk.Signer; +import land.tx3.sdk.Witness; +import land.tx3.sdk.WitnessType; import org.junit.jupiter.api.Test; /** Compile-time smoke test from outside the library package. */ @@ -24,4 +28,25 @@ void importsPublicEntryPoint() { ArgValue.integer(BigInteger.valueOf(42)), ArgEncoder.encode(new ParamType.Integer(), BigInteger.valueOf(42))); } + + @Test + void implementsSignerOutsideTheLibraryPackage() { + Signer signer = + new Signer() { + @Override + public Address address() { + return new Address("external-consumer-address"); + } + + @Override + public Witness sign(SignRequest request) { + return new Witness("00", "11", WitnessType.VKEY); + } + }; + + var request = + new SignRequest("0000000000000000000000000000000000000000000000000000000000000000", "80"); + assertEquals("external-consumer-address", signer.address().value()); + assertEquals(WitnessType.VKEY, signer.sign(request).type()); + } } diff --git a/src/test/java/land/tx3/sdk/ContractSerializationTest.java b/src/test/java/land/tx3/sdk/ContractSerializationTest.java index e2e4c4a..bb2e51c 100644 --- a/src/test/java/land/tx3/sdk/ContractSerializationTest.java +++ b/src/test/java/land/tx3/sdk/ContractSerializationTest.java @@ -18,8 +18,10 @@ void serializesSignerContractsDeterministically() throws Exception { assertEquals( "{\"value\":\"addr_test1\"}", mapper.writeValueAsString(new Address("addr_test1"))); assertEquals( - "{\"txHashHex\":\"aabb\",\"txCborHex\":\"ccdd\"}", - mapper.writeValueAsString(new SignRequest("aabb", "ccdd"))); + "{\"txHashHex\":\"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f\",\"txCborHex\":\"ccdd\"}", + mapper.writeValueAsString( + new SignRequest( + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "ccdd"))); assertEquals( "{\"publicKeyHex\":\"0011\",\"signatureHex\":\"2233\",\"type\":\"vkey\"}", mapper.writeValueAsString(new Witness("0011", "2233", WitnessType.VKEY))); diff --git a/src/test/java/land/tx3/sdk/SignerTest.java b/src/test/java/land/tx3/sdk/SignerTest.java new file mode 100644 index 0000000..6f0529c --- /dev/null +++ b/src/test/java/land/tx3/sdk/SignerTest.java @@ -0,0 +1,169 @@ +package land.tx3.sdk; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.bloxbean.cardano.client.address.AddressProvider; +import com.bloxbean.cardano.client.address.Credential; +import com.bloxbean.cardano.client.common.model.Network; +import com.bloxbean.cardano.client.crypto.Blake2bUtil; +import com.bloxbean.cardano.client.crypto.config.CryptoConfiguration; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.HexFormat; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class SignerTest { + private static final HexFormat HEX = HexFormat.of(); + private static JsonNode vectors; + + @BeforeAll + static void loadVectors() throws IOException { + try (var input = SignerTest.class.getResourceAsStream("/fixtures/signer-vectors.json")) { + if (input == null) { + throw new IOException("missing signer vector fixture"); + } + vectors = new ObjectMapper().readTree(input); + } + } + + @Test + void rawSignerMatchesIndependentPublicKeyAndSignatureVector() { + JsonNode vector = vectors.get("rawEd25519"); + byte[] seed = HEX.parseHex(vector.get("privateKeySeedHex").textValue()); + String publicKeyHex = vector.at("/expected/publicKeyHex").textValue(); + var signer = new Ed25519Signer(seed, enterpriseAddress(publicKeyHex)); + + seed[0] ^= (byte) 0xff; + Witness witness = signer.sign(new SignRequest(vector.get("messageHex").textValue(), "80")); + + assertEquals(publicKeyHex, witness.publicKeyHex()); + assertEquals(vector.at("/expected/signatureHex").textValue(), witness.signatureHex()); + assertEquals(WitnessType.VKEY, witness.type()); + } + + @Test + void cardanoSignerMatchesIndependentCip1852VectorAndSigns() { + JsonNode vector = vectors.get("cardanoCip1852"); + var address = new Address(vector.at("/expected/address").textValue()); + var signer = new CardanoSigner(vector.get("mnemonic").textValue(), address); + var request = + new SignRequest( + vectors.at("/rawEd25519/messageHex").textValue(), + "84a400818258200000000000000000000000000000000000000000000000000000000000000000"); + + Witness witness = signer.sign(request); + + assertEquals(address, signer.address()); + assertEquals(vector.at("/expected/publicKeyHex").textValue(), witness.publicKeyHex()); + assertEquals( + vector.at("/expected/chainCodeHex").textValue(), HEX.formatHex(signer.chainCode())); + assertEquals(WitnessType.VKEY, witness.type()); + assertTrue( + CryptoConfiguration.INSTANCE + .getSigningProvider() + .verify( + HEX.parseHex(witness.signatureHex()), + HEX.parseHex(request.txHashHex()), + HEX.parseHex(witness.publicKeyHex()))); + } + + @Test + void customSignerCanImplementThePublicContract() { + Signer signer = + new Signer() { + private final Address address = new Address("custom-address"); + + @Override + public Address address() { + return address; + } + + @Override + public Witness sign(SignRequest request) { + return new Witness("00", "11", WitnessType.VKEY); + } + }; + + assertEquals("custom-address", signer.address().value()); + assertEquals( + WitnessType.VKEY, + signer + .sign( + new SignRequest( + "0000000000000000000000000000000000000000000000000000000000000000", "80")) + .type()); + } + + @Test + void rejectsInvalidKeysHashesAndAddressBindingsWithTypedErrors() { + JsonNode raw = vectors.get("rawEd25519"); + byte[] seed = HEX.parseHex(raw.get("privateKeySeedHex").textValue()); + Address address = enterpriseAddress(raw.at("/expected/publicKeyHex").textValue()); + + assertEquals( + "privateKey", + assertThrows(ValidationException.class, () -> new Ed25519Signer(new byte[31], address)) + .field()); + assertEquals( + "txHashHex", + assertThrows(ValidationException.class, () -> new SignRequest("00", "80")).field()); + assertEquals( + "txCborHex", + assertThrows( + ValidationException.class, + () -> + new SignRequest( + "0000000000000000000000000000000000000000000000000000000000000000", + "not-hex")) + .field()); + assertThrows(SigningException.class, () -> new Ed25519Signer(seed, new Address("bad"))); + assertThrows( + SigningException.class, + () -> new CardanoSigner(vectors.at("/cardanoCip1852/mnemonic").textValue(), address)); + assertEquals( + "request", + assertThrows(ValidationException.class, () -> new Ed25519Signer(seed, address).sign(null)) + .field()); + assertEquals( + "mnemonic", + assertThrows(ValidationException.class, () -> new CardanoSigner(" ", address)).field()); + } + + @Test + void rawSignerExposesNoMnemonicConstructor() { + assertFalse( + java.util.Arrays.stream(Ed25519Signer.class.getDeclaredConstructors()) + .anyMatch( + constructor -> + java.util.Arrays.asList(constructor.getParameterTypes()) + .contains(String.class))); + } + + @Test + void cardanoChainCodeIsDefensivelyCopied() { + JsonNode vector = vectors.get("cardanoCip1852"); + var signer = + new CardanoSigner( + vector.get("mnemonic").textValue(), + new Address(vector.at("/expected/address").textValue())); + + byte[] first = signer.chainCode(); + byte[] expected = first.clone(); + first[0] ^= (byte) 0xff; + + assertArrayEquals(expected, signer.chainCode()); + } + + private static Address enterpriseAddress(String publicKeyHex) { + byte[] keyHash = Blake2bUtil.blake2bHash224(HEX.parseHex(publicKeyHex)); + String address = + AddressProvider.getEntAddress(Credential.fromKey(keyHash), new Network(0, 1)).toBech32(); + return new Address(address); + } +} diff --git a/src/test/resources/fixtures/signer-vectors.json b/src/test/resources/fixtures/signer-vectors.json new file mode 100644 index 0000000..49ec699 --- /dev/null +++ b/src/test/resources/fixtures/signer-vectors.json @@ -0,0 +1,32 @@ +{ + "description": "Canonical public vectors for SDK signer implementations. The CIP-1852 case was generated independently with the checksum-verified cardano-addresses 4.0.8 release; the raw Ed25519 case uses the RFC 8032 test key and a fixed 32-byte transaction-hash-shaped message. These are test-only public keys and mnemonic material, never funded identities or production credentials.", + "cardanoCip1852": { + "oracle": { + "tool": "cardano-address", + "version": "4.0.8", + "sourceCommit": "75588b052ead9fcd4925506c95d29b686e28f036", + "releaseAsset": "cardano-address-4.0.8-macos.tar.gz", + "releaseSha256": "f52c10d8a23060750160c31dce06fa97156bd7daf082a0461018c51405eacb60" + }, + "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "derivationPath": "m/1852'/1815'/0'/0/0", + "network": "preprod", + "expected": { + "publicKeyHex": "7ea09a34aebb13c9841c71397b1cabfec5ddf950405293dee496cac2f437480a", + "chainCodeHex": "88848e8af62a27a57e982215741c9eac17e6e45cbfd6ea65a0e0dcc03bb777b2", + "address": "addr_test1vq8ac7qqy0vtulyl7wntmsxc6wex80gvcyjy33qffrhm7ss9hjl0y" + } + }, + "rawEd25519": { + "oracle": { + "spec": "RFC 8032, section 7.1 test key 1", + "messageSignature": "Generated independently with Node.js crypto Ed25519 from the RFC key over the fixed message below; any standards-conformant Ed25519 verifier may validate it." + }, + "privateKeySeedHex": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + "messageHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "expected": { + "publicKeyHex": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", + "signatureHex": "00c1db988bb12fd7351a6054ae3fac90fab7e4fc56b1651c7181f5f55f896f663933d3a90605d9058e9d0ac45950ee2d3c9c9b14857415587179fe0ccac35f09" + } + } +}