diff --git a/README.md b/README.md
index 777f733..269fa02 100644
--- a/README.md
+++ b/README.md
@@ -66,6 +66,7 @@ Jagged supports streaming encryption and decryption using standard recipient typ
- [X25519](https://github.com/C2SP/C2SP/blob/main/age.md#the-x25519-recipient-type) recipients and identities
- [scrypt](https://github.com/C2SP/C2SP/blob/main/age.md#the-scrypt-recipient-type) recipients and identities
- [ssh-rsa](https://github.com/FiloSottile/age/blob/main/README.md#ssh-keys) recipients and identities
+- [ssh-ed25519](https://github.com/FiloSottile/age/blob/main/README.md#ssh-keys) recipients and identities
# Specifications
@@ -120,6 +121,21 @@ a File Key.
The scrypt type encrypts a File Key with ChaCha20-Poly1305.
+The ssh-ed25519 and ssh-rsa types support reading private key pairs formatted using OpenSSH Private Key Version 1.
+
+- [OpenSSH PROTOCOL.key](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key)
+
+The ssh-ed25519 type uses Curve25519 for Elliptic Curve Diffie-Hellman shared secret key exchanges based on computing
+equivalent values from keys described in the Edwards-curve Digital Signature Algorithm edwards25519.
+
+- [RFC 8032](https://www.rfc-editor.org/rfc/rfc8032) Edwards-Curve Digital Signature Algorithm
+
+The ssh-ed25519 type reads SSH public keys encoded according to the SSH protocol.
+
+- [RFC 8709](https://www.rfc-editor.org/rfc/rfc8709) Ed25519 and Ed448 Public Key Algorithms for the Secure Shell (SSH) Protocol
+
+The ssh-ed25519 type encrypts a File Key with ChaCha20-Poly1305.
+
The ssh-rsa type encrypts a File Key with RSA-OAEP.
- [RFC 8017](https://www.rfc-editor.org/rfc/rfc8017) PKCS #1: RSA Cryptography Specifications Version 2.2
@@ -239,11 +255,25 @@ The `jagged-ssh` module supports encryption and decryption using public and priv
implementation is compatible with the [agessh](https://pkg.go.dev/filippo.io/age/agessh) package, which defines
recipient stanzas with an algorithm and an encoded fingerprint of the public key.
+The `SshEd25519RecipientStanzaReaderFactory` creates instances of `RecipientStanzaReader` using an
+[OpenSSH Version 1 Private Key](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key).
+
+The `SshEd25519RecipientStanzaWriterFactory` creates instances of `RecipientStanzaWriter` using an SSH Ed25519 public
+key encoded according to [RFC 8709 Section 4](https://www.rfc-editor.org/rfc/rfc8709#name-public-key-format).
+
The `SshRsaRecipientStanzaReaderFactory` creates instances of `RecipientStanzaReader` using an RSA private key or an
[OpenSSH Version 1 Private Key](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key).
The `SshRsaRecipientStanzaWriterFactory` creates instances of `RecipientStanzaWriter` using an RSA public key.
+The SSH Ed25519 implementation uses Elliptic Curve Diffie-Hellman with Curve25519 as defined in
+[RFC 7748 Section 6.1](https://www.rfc-editor.org/rfc/rfc7748.html#section-6.1). As integrated in the age reference
+implementation, the SSH Ed25519 implementation converts the public key coordinate from the twisted Edwards curve to the
+corresponding coordinate on the Montgomery curve according to the birational maps described in
+[RFC 7748 Section 4.1](https://www.rfc-editor.org/rfc/rfc7748#section-4.1). The implementation converts the Ed25519
+private key seed to the corresponding X25519 private key using the first 32 bytes of an `SHA-512` hash of the seed.
+The SSH Ed25519 implementation uses ChaCha20-Poly1305 for encrypting and decrypting File Keys.
+
The SSH RSA implementation uses Optimal Asymmetric Encryption Padding as defined in
[RFC 8017 Section 7.1](https://www.rfc-editor.org/rfc/rfc8017#section-7.1). Following the age implementation, RSA OAEP
cipher operations use `SHA-256` as the hash algorithm with the mask generation function.
diff --git a/jagged-framework/src/main/java/com/exceptionfactory/jagged/framework/crypto/CryptographicAlgorithmKey.java b/jagged-framework/src/main/java/com/exceptionfactory/jagged/framework/crypto/CryptographicAlgorithmKey.java
index 8856aba..1621b04 100644
--- a/jagged-framework/src/main/java/com/exceptionfactory/jagged/framework/crypto/CryptographicAlgorithmKey.java
+++ b/jagged-framework/src/main/java/com/exceptionfactory/jagged/framework/crypto/CryptographicAlgorithmKey.java
@@ -40,11 +40,11 @@ class CryptographicAlgorithmKey implements SecretKey {
* Cryptographic Algorithm Key constructor with required symmetric key
*
* @param key Symmetric Key
- * @param cryptographicKeyType Cryptographic Key Type
+ * @param cryptographicKeyDescription Cryptographic Key Description
* @param cryptographicAlgorithm Cryptographic Algorithm
*/
- CryptographicAlgorithmKey(final byte[] key, final CryptographicKeyType cryptographicKeyType, final CryptographicAlgorithm cryptographicAlgorithm) {
- this(getValidatedKey(key, cryptographicKeyType), cryptographicAlgorithm);
+ CryptographicAlgorithmKey(final byte[] key, final CryptographicKeyDescription cryptographicKeyDescription, final CryptographicAlgorithm cryptographicAlgorithm) {
+ this(getValidatedKey(key, cryptographicKeyDescription), cryptographicAlgorithm);
}
private CryptographicAlgorithmKey(final byte[] validatedKey, final CryptographicAlgorithm cryptographicAlgorithm) {
@@ -101,10 +101,10 @@ public boolean isDestroyed() {
return destroyed.get();
}
- private static byte[] getValidatedKey(final byte[] key, final CryptographicKeyType cryptographicKeyType) {
+ private static byte[] getValidatedKey(final byte[] key, final CryptographicKeyDescription cryptographicKeyDescription) {
Objects.requireNonNull(key, "Symmetric Key required");
- Objects.requireNonNull(cryptographicKeyType, "Cryptographic Key Type required");
- final int cryptographicKeyLength = cryptographicKeyType.getKeyLength();
+ Objects.requireNonNull(cryptographicKeyDescription, "Cryptographic Key Description required");
+ final int cryptographicKeyLength = cryptographicKeyDescription.getKeyLength();
if (cryptographicKeyLength == key.length) {
return key;
} else {
diff --git a/jagged-framework/src/main/java/com/exceptionfactory/jagged/framework/crypto/CryptographicKeyDescription.java b/jagged-framework/src/main/java/com/exceptionfactory/jagged/framework/crypto/CryptographicKeyDescription.java
new file mode 100644
index 0000000..0ccc300
--- /dev/null
+++ b/jagged-framework/src/main/java/com/exceptionfactory/jagged/framework/crypto/CryptographicKeyDescription.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.framework.crypto;
+
+/**
+ * Abstraction for describing Cryptographic Key properties
+ */
+public interface CryptographicKeyDescription {
+ /**
+ * Get key length in bytes
+ *
+ * @return Key length in bytes
+ */
+ int getKeyLength();
+}
diff --git a/jagged-framework/src/main/java/com/exceptionfactory/jagged/framework/crypto/CryptographicKeyType.java b/jagged-framework/src/main/java/com/exceptionfactory/jagged/framework/crypto/CryptographicKeyType.java
index 67d06c7..752d578 100644
--- a/jagged-framework/src/main/java/com/exceptionfactory/jagged/framework/crypto/CryptographicKeyType.java
+++ b/jagged-framework/src/main/java/com/exceptionfactory/jagged/framework/crypto/CryptographicKeyType.java
@@ -18,7 +18,7 @@
/**
* Cryptographic Key Type references for construction and validation
*/
-enum CryptographicKeyType {
+enum CryptographicKeyType implements CryptographicKeyDescription {
/** Extracted intermediate key for subsequent expansion */
EXTRACTED_KEY(32),
@@ -51,7 +51,8 @@ enum CryptographicKeyType {
*
* @return Key length in bytes
*/
- int getKeyLength() {
+ @Override
+ public int getKeyLength() {
return keyLength;
}
}
diff --git a/jagged-framework/src/main/java/com/exceptionfactory/jagged/framework/crypto/MacKey.java b/jagged-framework/src/main/java/com/exceptionfactory/jagged/framework/crypto/MacKey.java
index 24fc6b2..d0e72af 100644
--- a/jagged-framework/src/main/java/com/exceptionfactory/jagged/framework/crypto/MacKey.java
+++ b/jagged-framework/src/main/java/com/exceptionfactory/jagged/framework/crypto/MacKey.java
@@ -23,9 +23,9 @@ public class MacKey extends CryptographicAlgorithmKey {
* Message Authentication Code Key constructor with required symmetric key
*
* @param key Symmetric Key with byte length based on Cryptographic Key Type
- * @param cryptographicKeyType Cryptographic Key Type
+ * @param cryptographicKeyDescription Cryptographic Key Description
*/
- MacKey(final byte[] key, final CryptographicKeyType cryptographicKeyType) {
- super(key, cryptographicKeyType, CryptographicAlgorithm.HMACSHA256);
+ public MacKey(final byte[] key, final CryptographicKeyDescription cryptographicKeyDescription) {
+ super(key, cryptographicKeyDescription, CryptographicAlgorithm.HMACSHA256);
}
}
diff --git a/jagged-ssh/pom.xml b/jagged-ssh/pom.xml
index 5aa29a1..6e39d7d 100644
--- a/jagged-ssh/pom.xml
+++ b/jagged-ssh/pom.xml
@@ -20,6 +20,10 @@
com.exceptionfactory.jagged
jagged-api
+
+ com.exceptionfactory.jagged
+ jagged-framework
+
org.junit.jupiter
junit-jupiter-api
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/Ed25519KeyConverter.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/Ed25519KeyConverter.java
new file mode 100644
index 0000000..08015f6
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/Ed25519KeyConverter.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import com.exceptionfactory.jagged.framework.crypto.SharedSecretKey;
+
+import java.security.GeneralSecurityException;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+
+/**
+ * Abstraction for converting Ed25519 keys to X25519 keys
+ */
+interface Ed25519KeyConverter {
+ /**
+ * Get X25519 Private Key from Ed25519 Private Key using first 32 bytes of SHA-512 digested key
+ *
+ * @param ed25519PrivateKey Ed25519 private key
+ * @return X25519 Private Key
+ * @throws GeneralSecurityException Thrown on failure to convert private key
+ */
+ PrivateKey getPrivateKey(Ed25519PrivateKey ed25519PrivateKey) throws GeneralSecurityException;
+
+ /**
+ * Get X25519 Private Key from SSH Ed25519 derived key
+ *
+ * @param derivedKey SSH Ed25519 derived key
+ * @return X25519 Private Key
+ * @throws GeneralSecurityException Thrown on failure to convert private key
+ */
+ PrivateKey getPrivateKey(SshEd25519DerivedKey derivedKey) throws GeneralSecurityException;
+
+ /**
+ * Get X25519 Public Key from Ed25519 Public Key computed using birational mapping described in RFC 7748 Section 4.1
+ *
+ * @param ed25519PublicKey Ed25519 public key
+ * @return X25519 Public Key
+ * @throws GeneralSecurityException Thrown on failure to convert public key
+ */
+ PublicKey getPublicKey(Ed25519PublicKey ed25519PublicKey) throws GeneralSecurityException;
+
+ /**
+ * Get X25519 Public Key from computed Shared Secret Key
+ *
+ * @param sharedSecretKey Computed shared secret key
+ * @return X25519 Public Key
+ * @throws GeneralSecurityException Thrown on key processing failures
+ */
+ PublicKey getPublicKey(SharedSecretKey sharedSecretKey) throws GeneralSecurityException;
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/Ed25519KeyIndicator.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/Ed25519KeyIndicator.java
new file mode 100644
index 0000000..49b78bd
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/Ed25519KeyIndicator.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+/**
+ * Ed25519 Key indicator fields
+ */
+enum Ed25519KeyIndicator {
+ /** Algorithm */
+ KEY_ALGORITHM("Ed25519"),
+
+ /** Format */
+ KEY_FORMAT("RAW");
+
+ private final String indicator;
+
+ Ed25519KeyIndicator(final String indicator) {
+ this.indicator = indicator;
+ }
+
+ String getIndicator() {
+ return indicator;
+ }
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/Ed25519PrivateKey.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/Ed25519PrivateKey.java
new file mode 100644
index 0000000..c666784
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/Ed25519PrivateKey.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import java.security.PrivateKey;
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Ed25519 Private Key containing raw key bytes
+ */
+class Ed25519PrivateKey implements PrivateKey {
+ private static final byte ZERO = 0;
+
+ private final AtomicBoolean destroyed = new AtomicBoolean();
+
+ private final byte[] encoded;
+
+ /**
+ * Ed25519 Private Key constructor with raw bytes containing private key seed
+ *
+ * @param encoded private key seed of 32 bytes
+ */
+ Ed25519PrivateKey(final byte[] encoded) {
+ this.encoded = Objects.requireNonNull(encoded, "Encoded Key required");
+ }
+
+ /**
+ * Get algorithm describes the type of key
+ *
+ * @return Algorithm is Ed25519
+ */
+ @Override
+ public String getAlgorithm() {
+ return Ed25519KeyIndicator.KEY_ALGORITHM.getIndicator();
+ }
+
+ /**
+ * Get format describes the encoded content bytes
+ *
+ * @return Encoded key format is RAW
+ */
+ @Override
+ public String getFormat() {
+ return Ed25519KeyIndicator.KEY_FORMAT.getIndicator();
+ }
+
+ /**
+ * Get encoded key bytes consisting of private key seed
+ *
+ * @return encoded private key array of 32 bytes
+ */
+ @Override
+ public byte[] getEncoded() {
+ return encoded.clone();
+ }
+
+ /**
+ * Get string representation of key algorithm
+ *
+ * @return Key algorithm
+ */
+ @Override
+ public String toString() {
+ return getAlgorithm();
+ }
+
+ /**
+ * Destroy Key so that it cannot be used for subsequent operations
+ */
+ @Override
+ public void destroy() {
+ Arrays.fill(encoded, ZERO);
+ destroyed.set(true);
+ }
+
+ /**
+ * Return destroyed status
+ *
+ * @return Key destroyed status
+ */
+ @Override
+ public boolean isDestroyed() {
+ return destroyed.get();
+ }
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/Ed25519PublicKey.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/Ed25519PublicKey.java
new file mode 100644
index 0000000..fcaa0e0
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/Ed25519PublicKey.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import java.security.PublicKey;
+import java.util.Objects;
+
+/**
+ * Ed25519 Public Key containing raw key bytes
+ */
+class Ed25519PublicKey implements PublicKey {
+ private final byte[] encoded;
+
+ /**
+ * Ed25519 Public Key constructor with raw key bytes
+ *
+ * @param encoded raw byte array of 32 bytes
+ */
+ Ed25519PublicKey(final byte[] encoded) {
+ this.encoded = Objects.requireNonNull(encoded, "Encoded Key required");
+ }
+
+ /**
+ * Get algorithm describes the type of key
+ *
+ * @return Algorithm is Ed25519
+ */
+ @Override
+ public String getAlgorithm() {
+ return Ed25519KeyIndicator.KEY_ALGORITHM.getIndicator();
+ }
+
+ /**
+ * Get format describes the encoded content bytes
+ *
+ * @return Encoded key format is RAW
+ */
+ @Override
+ public String getFormat() {
+ return Ed25519KeyIndicator.KEY_FORMAT.getIndicator();
+ }
+
+ /**
+ * Get encoded key bytes consisting of original key
+ *
+ * @return encoded public key bytes
+ */
+ @Override
+ public byte[] getEncoded() {
+ return encoded.clone();
+ }
+
+ /**
+ * Get string representation of key algorithm
+ *
+ * @return Key algorithm
+ */
+ @Override
+ public String toString() {
+ return getAlgorithm();
+ }
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/EllipticCurveKeyType.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/EllipticCurveKeyType.java
new file mode 100644
index 0000000..5f4aa30
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/EllipticCurveKeyType.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+/**
+ * Elliptic Curve Key Type enumerates standard properties for Ed25519 and X25519 keys
+ */
+enum EllipticCurveKeyType {
+ /** Ed25519 coordinate key of 32 bytes for twisted Edwards curve digital signature operations */
+ ED25519("Ed25519", 32),
+
+ /** Curve25519 coordinate key of 32 bytes for X25519 key agreement operations */
+ X25519("X25519", 32);
+
+ private final String algorithm;
+
+ private final int keyLength;
+
+ EllipticCurveKeyType(final String algorithm, final int keyLength) {
+ this.algorithm = algorithm;
+ this.keyLength = keyLength;
+ }
+
+ /**
+ * Get algorithm name for Java Cryptography Architecture operations
+ *
+ * @return Algorithm name
+ */
+ String getAlgorithm() {
+ return algorithm;
+ }
+
+ /**
+ * Get key length in bytes
+ *
+ * @return Key length in bytes
+ */
+ int getKeyLength() {
+ return keyLength;
+ }
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/EmptyInputKey.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/EmptyInputKey.java
new file mode 100644
index 0000000..5ccf46b
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/EmptyInputKey.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import com.exceptionfactory.jagged.framework.crypto.MacKey;
+
+/**
+ * Empty Input Key extension of Message Authentication Code Key for HKDF from salt key
+ */
+final class EmptyInputKey extends MacKey {
+ private static final byte[] EMPTY = new byte[]{};
+
+ /**
+ * Empty Input Key constructor
+ *
+ */
+ EmptyInputKey() {
+ super(EMPTY, SshEd25519KeyType.EMPTY);
+ }
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/OpenSshKeyPairReader.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/OpenSshKeyPairReader.java
index fde6c5b..48277f9 100644
--- a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/OpenSshKeyPairReader.java
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/OpenSshKeyPairReader.java
@@ -37,6 +37,8 @@ class OpenSshKeyPairReader extends OpenSshKeyByteBufferReader {
private static final SshRsaOpenSshKeyPairReader SSH_RSA_OPEN_SSH_KEY_PAIR_READER = new SshRsaOpenSshKeyPairReader();
+ private static final SshEd25519OpenSshKeyPairReader SSH_ED25519_OPEN_SSH_KEY_PAIR_READER = new SshEd25519OpenSshKeyPairReader();
+
/**
* Read Public and Private Key Pair from buffer containing OpenSSH Key Version 1
*
@@ -119,6 +121,8 @@ private KeyPair readKeyPair(final SshKeyType sshKeyType, final ByteBuffer privat
if (sshKeyType == privateSshKeyType) {
if (SshKeyType.RSA == privateSshKeyType) {
keyPair = SSH_RSA_OPEN_SSH_KEY_PAIR_READER.read(privateKeyBuffer);
+ } else if (SshKeyType.ED25519 == privateSshKeyType) {
+ keyPair = SSH_ED25519_OPEN_SSH_KEY_PAIR_READER.read(privateKeyBuffer);
} else {
throw new InvalidKeyException(String.format("OpenSSH Private Key Type [%s] not supported", sshKeyType.getKeyType()));
}
@@ -163,6 +167,7 @@ private void readPrivateKeyPadding(final ByteBuffer buffer) throws BadPaddingExc
if (padExpected != pad) {
throw new BadPaddingException(String.format("Private Key Padding Character [%d] does not match expected [%d]", pad, padExpected));
}
+ padExpected++;
}
}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/PublicKeyReader.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/PublicKeyReader.java
new file mode 100644
index 0000000..fa8a5e5
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/PublicKeyReader.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import java.nio.ByteBuffer;
+import java.security.GeneralSecurityException;
+import java.security.PublicKey;
+
+/**
+ * Reader abstraction for loading Public Keys
+ *
+ * @param Public Key Type
+ */
+interface PublicKeyReader {
+ /**
+ * Read Public Key
+ *
+ * @param inputBuffer Input Buffer to be read
+ * @return Public Key
+ * @throws GeneralSecurityException Thrown on failures to parse input buffer
+ */
+ T read(ByteBuffer inputBuffer) throws GeneralSecurityException;
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SharedSecretKeyProducer.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SharedSecretKeyProducer.java
new file mode 100644
index 0000000..65c56f2
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SharedSecretKeyProducer.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import com.exceptionfactory.jagged.framework.crypto.SharedSecretKey;
+
+import java.security.GeneralSecurityException;
+import java.security.PublicKey;
+
+/**
+ * Abstraction around javax.crypto.KeyAgreement for Shared Secret Key production
+ */
+interface SharedSecretKeyProducer {
+ /**
+ * Get Shared Secret Key using provided Public Key
+ *
+ * @param publicKey Public Key
+ * @return Shared Secret Key
+ * @throws GeneralSecurityException Thrown on failures to produce Shared Secret Key
+ */
+ SharedSecretKey getSharedSecretKey(PublicKey publicKey) throws GeneralSecurityException;
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SharedWrapKeyProducer.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SharedWrapKeyProducer.java
new file mode 100644
index 0000000..b17d720
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SharedWrapKeyProducer.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import com.exceptionfactory.jagged.framework.crypto.CipherKey;
+import com.exceptionfactory.jagged.framework.crypto.SharedSecretKey;
+
+import java.security.GeneralSecurityException;
+import java.security.PublicKey;
+
+/**
+ * Abstraction for producing Wrap Key from Shared Secret Key using HMAC-based Extract-and-Expand Key Derivation Function described in RFC 5869
+ */
+interface SharedWrapKeyProducer {
+ /**
+ * Get Wrap Key using shared secret key and ephemeral public key
+ *
+ * @param sharedSecretKey Shared Secret Key
+ * @param ephemeralPublicKey Ephemeral Public Key from Recipient Stanza Arguments
+ * @return Recipient Stanza Cipher Key for decrypting File Key
+ * @throws GeneralSecurityException Thrown on key derivation failures
+ */
+ CipherKey getWrapKey(SharedSecretKey sharedSecretKey, PublicKey ephemeralPublicKey) throws GeneralSecurityException;
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519DerivedKey.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519DerivedKey.java
new file mode 100644
index 0000000..dd76212
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519DerivedKey.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import com.exceptionfactory.jagged.framework.crypto.MacKey;
+
+/**
+ * SSH Ed25519 Derived Key containing results of HKDF-SHA-256 from marshalled SSH public key
+ */
+final class SshEd25519DerivedKey extends MacKey {
+ /**
+ * SSH Ed25519 Derived Public Key constructor with required key
+ *
+ * @param key Derived Key consisting of 32 bytes
+ */
+ SshEd25519DerivedKey(final byte[] key) {
+ super(key, SshEd25519KeyType.DERIVED);
+ }
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519KeyType.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519KeyType.java
new file mode 100644
index 0000000..f0aad34
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519KeyType.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import com.exceptionfactory.jagged.framework.crypto.CryptographicKeyDescription;
+
+/**
+ * SSH Ed25519 Key Type references for construction and validation
+ */
+enum SshEd25519KeyType implements CryptographicKeyDescription {
+ /** Derived Secret Key */
+ DERIVED(32),
+
+ /** Empty Input Key for HKDF-SHA-256 */
+ EMPTY(0),
+
+ /** Marshalled Public Key */
+ MARSHALLED(51);
+
+ private final int keyLength;
+
+ SshEd25519KeyType(final int keyLength) {
+ this.keyLength = keyLength;
+ }
+
+ /**
+ * Get key length in bytes
+ *
+ * @return Key length in bytes
+ */
+ @Override
+ public int getKeyLength() {
+ return keyLength;
+ }
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519MarshalledKey.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519MarshalledKey.java
new file mode 100644
index 0000000..96d9843
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519MarshalledKey.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import com.exceptionfactory.jagged.framework.crypto.MacKey;
+
+/**
+ * SSH Ed25519 Marshalled Public Key containing marshalled SSH public key bytes
+ */
+final class SshEd25519MarshalledKey extends MacKey {
+ /**
+ * SSH Ed25519 Marshalled Public Key constructor with required key
+ *
+ * @param key Marshalled Key consisting of 51 bytes
+ */
+ SshEd25519MarshalledKey(final byte[] key) {
+ super(key, SshEd25519KeyType.MARSHALLED);
+ }
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519OpenSshKeyPairReader.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519OpenSshKeyPairReader.java
new file mode 100644
index 0000000..b49e836
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519OpenSshKeyPairReader.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import java.nio.ByteBuffer;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+
+/**
+ * SSH Ed25519 implementation reads the Ed25519 Private Key portion of an OpenSSH Version 1 Key
+ */
+class SshEd25519OpenSshKeyPairReader extends OpenSshKeyByteBufferReader {
+ /**
+ * Read Ed25519 Key Pair from bytes
+ *
+ * @param buffer Input Buffer to be read
+ * @return Ed25519 Public and Private Key Pair
+ * @throws GeneralSecurityException Thrown on failures to parse input buffer
+ */
+ @Override
+ public KeyPair read(final ByteBuffer buffer) throws GeneralSecurityException {
+ final byte[] publicKeyBlock = readBlock(buffer);
+ final Ed25519PublicKey publicKey = new Ed25519PublicKey(publicKeyBlock);
+
+ final byte[] privatePublicKeyBlock = readBlock(buffer);
+ final byte[] privateKeySeed = new byte[EllipticCurveKeyType.ED25519.getKeyLength()];
+ System.arraycopy(privatePublicKeyBlock, 0, privateKeySeed, 0, privateKeySeed.length);
+
+ final Ed25519PrivateKey privateKey = new Ed25519PrivateKey(privateKeySeed);
+ return new KeyPair(publicKey, privateKey);
+ }
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519PublicKeyMarshaller.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519PublicKeyMarshaller.java
new file mode 100644
index 0000000..c44c76e
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519PublicKeyMarshaller.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.PublicKey;
+import java.util.Objects;
+
+/**
+ * SSH Ed25519 implementation of Public Key Marshaller writes the Ed25519 public key along with the key algorithm
+ */
+class SshEd25519PublicKeyMarshaller implements PublicKeyMarshaller {
+ private static final byte[] SSH_ED25519_ALGORITHM = SshEd25519RecipientIndicator.STANZA_TYPE.getIndicator().getBytes(StandardCharsets.UTF_8);
+
+ private static final int BUFFER_SIZE = 128;
+
+ /**
+ * Get Public Key marshalled according to SSH wire format requirements
+ *
+ * @param publicKey Ed25519 Public Key to be marshalled
+ * @return Byte array containing marshalled public key with ssh-ed25519 algorithm and public key
+ */
+ @Override
+ public byte[] getMarshalledKey(final PublicKey publicKey) {
+ Objects.requireNonNull(publicKey, "Public Key required");
+
+ final ByteBuffer buffer = ByteBuffer.allocate(BUFFER_SIZE);
+ writeBytes(buffer, SSH_ED25519_ALGORITHM);
+ writeBytes(buffer, publicKey.getEncoded());
+
+ final byte[] marshalledKey = new byte[buffer.position()];
+ buffer.flip();
+ buffer.get(marshalledKey);
+ return marshalledKey;
+ }
+
+ private void writeBytes(final ByteBuffer buffer, final byte[] bytes) {
+ buffer.putInt(bytes.length);
+ buffer.put(bytes);
+ }
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519PublicKeyReader.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519PublicKeyReader.java
new file mode 100644
index 0000000..684caa4
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519PublicKeyReader.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.InvalidKeyException;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Objects;
+
+/**
+ * SSH Ed25519 Public Key Reader implementation based on ssh-ed25519 format described in RFC 8709 Section 4
+ */
+class SshEd25519PublicKeyReader extends SshPublicKeyReader {
+ private static final int ENCODED_LENGTH = 68;
+
+ private static final String ALGORITHM_FORMAT = SshEd25519RecipientIndicator.STANZA_TYPE.getIndicator();
+
+ private static final byte[] ALGORITHM = ALGORITHM_FORMAT.getBytes(StandardCharsets.UTF_8);
+
+ private static final byte SPACE_SEPARATOR = 32;
+
+ private static final Base64.Decoder DECODER = Base64.getDecoder();
+
+ /**
+ * Read Public Key
+ *
+ * @param inputBuffer Input Buffer to be read
+ * @return Ed25519 Public Key
+ * @throws GeneralSecurityException Thrown on failures to parse input buffer
+ */
+ @Override
+ public Ed25519PublicKey read(final ByteBuffer inputBuffer) throws GeneralSecurityException {
+ Objects.requireNonNull(inputBuffer, "Input Buffer required");
+
+ final byte[] algorithm = new byte[ALGORITHM.length];
+ inputBuffer.get(algorithm);
+
+ final Ed25519PublicKey publicKey;
+
+ if (Arrays.equals(ALGORITHM, algorithm)) {
+ final byte separator = inputBuffer.get();
+ if (SPACE_SEPARATOR == separator) {
+ publicKey = readEncodedPublicKey(inputBuffer);
+ } else {
+ throw new InvalidKeyException("Algorithm format space separator not found");
+ }
+ } else {
+ throw new InvalidKeyException(String.format("Public key algorithm format [%s] not found", ALGORITHM_FORMAT));
+ }
+
+ return publicKey;
+ }
+
+ private Ed25519PublicKey readEncodedPublicKey(final ByteBuffer inputBuffer) throws InvalidKeyException {
+ final Ed25519PublicKey publicKey;
+
+ if (inputBuffer.remaining() >= ENCODED_LENGTH) {
+ final byte[] encoded = new byte[ENCODED_LENGTH];
+ inputBuffer.get(encoded);
+
+ final byte[] decoded = DECODER.decode(encoded);
+ final ByteBuffer decodedBuffer = ByteBuffer.wrap(decoded);
+
+ final byte[] algorithm = readBlock(decodedBuffer);
+ if (Arrays.equals(ALGORITHM, algorithm)) {
+ publicKey = readPublicKey(decodedBuffer);
+ } else {
+ throw new InvalidKeyException(String.format("Encoded key algorithm [%s] not found", ALGORITHM_FORMAT));
+ }
+ } else {
+ final int remaining = inputBuffer.remaining();
+ final String message = String.format("Encoded public key length [%d] less than required [%d]", remaining, ENCODED_LENGTH);
+ throw new InvalidKeyException(message);
+ }
+
+ return publicKey;
+ }
+
+ private Ed25519PublicKey readPublicKey(final ByteBuffer decodedBuffer) throws InvalidKeyException {
+ final byte[] block = readBlock(decodedBuffer);
+ if (EllipticCurveKeyType.ED25519.getKeyLength() == block.length) {
+ return new Ed25519PublicKey(block);
+ } else {
+ final String message = String.format("Public key length [%d] not expected [%d]", block.length, EllipticCurveKeyType.ED25519.getKeyLength());
+ throw new InvalidKeyException(message);
+ }
+ }
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientIndicator.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientIndicator.java
new file mode 100644
index 0000000..843302f
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientIndicator.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+/**
+ * SSH Ed25519 Recipient Indicators for reading and writing Recipient Stanzas
+ */
+enum SshEd25519RecipientIndicator {
+ /** SSH Ed25519 Recipient Stanza Type */
+ STANZA_TYPE("ssh-ed25519"),
+
+ /** Key Information used for HKDF-SHA-256 */
+ KEY_INFORMATION("age-encryption.org/v1/ssh-ed25519");
+
+ private final String indicator;
+
+ SshEd25519RecipientIndicator(final String indicator) {
+ this.indicator = indicator;
+ }
+
+ public String getIndicator() {
+ return indicator;
+ }
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientStanza.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientStanza.java
new file mode 100644
index 0000000..5da1d6f
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientStanza.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import com.exceptionfactory.jagged.RecipientStanza;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * SSH Ed25519 Recipient Stanza with arguments containing encoded public key fingerprint and ephemeral share
+ */
+class SshEd25519RecipientStanza implements RecipientStanza {
+ private final List arguments;
+
+ private final byte[] body;
+
+ SshEd25519RecipientStanza(final String keyFingerprint, final String ephemeralShare, final byte[] body) {
+ Objects.requireNonNull(keyFingerprint, "Key Fingerprint required");
+ Objects.requireNonNull(ephemeralShare, "Ephemeral Share required");
+ this.arguments = Collections.unmodifiableList(Arrays.asList(keyFingerprint, ephemeralShare));
+ this.body = Objects.requireNonNull(body, "Stanza Body required");
+ }
+
+ /**
+ * Get Recipient Stanza Type returns ssh-ed25519
+ *
+ * @return SSH Ed25519 Type
+ */
+ @Override
+ public String getType() {
+ return SshEd25519RecipientIndicator.STANZA_TYPE.getIndicator();
+ }
+
+ /**
+ * Get Recipient Stanza Arguments containing the key fingerprint and ephemeral share encoded
+ *
+ * @return Recipient Stanza Arguments
+ */
+ @Override
+ public List getArguments() {
+ return arguments;
+ }
+
+ /**
+ * Get Recipient Stanza Body containing encrypted File Key
+ *
+ * @return Encrypted File Key body
+ */
+ @Override
+ public byte[] getBody() {
+ return body.clone();
+ }
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientStanzaReader.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientStanzaReader.java
new file mode 100644
index 0000000..26bec84
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientStanzaReader.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import com.exceptionfactory.jagged.FileKey;
+import com.exceptionfactory.jagged.RecipientStanza;
+import com.exceptionfactory.jagged.RecipientStanzaReader;
+import com.exceptionfactory.jagged.UnsupportedRecipientStanzaException;
+import com.exceptionfactory.jagged.framework.codec.CanonicalBase64;
+import com.exceptionfactory.jagged.framework.crypto.CipherKey;
+import com.exceptionfactory.jagged.framework.crypto.EncryptedFileKey;
+import com.exceptionfactory.jagged.framework.crypto.FileKeyDecryptor;
+import com.exceptionfactory.jagged.framework.crypto.SharedSecretKey;
+
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+
+import static com.exceptionfactory.jagged.ssh.SshEd25519RecipientIndicator.STANZA_TYPE;
+
+/**
+ * SSH Ed25519 implementation of Recipient Stanza Reader compatible with age-ssh
+ */
+final class SshEd25519RecipientStanzaReader implements RecipientStanzaReader {
+ private static final int EPHEMERAL_SHARE_ENCODED_LENGTH = 43;
+
+ private static final int ENCRYPTED_FILE_KEY_LENGTH = 32;
+
+ private static final CanonicalBase64.Decoder BASE64_DECODER = CanonicalBase64.getDecoder();
+
+ private static final SshEd25519PublicKeyMarshaller PUBLIC_KEY_MARSHALLER = new SshEd25519PublicKeyMarshaller();
+
+ private static final PublicKeyFingerprintProducer PUBLIC_KEY_FINGERPRINT_PRODUCER = new StandardPublicKeyFingerprintProducer();
+
+ private final Ed25519KeyConverter keyConverter;
+
+ private final SharedSecretKeyProducer sharedSecretKeyProducer;
+
+ private final SharedSecretKeyProducer derivedSharedSecretKeyProducer;
+
+ private final SharedWrapKeyProducer sharedWrapKeyProducer;
+
+ private final FileKeyDecryptor fileKeyDecryptor;
+
+ private final String publicKeyFingerprint;
+
+ /**
+ * SSH Ed25519 Recipient Stanza Reader with Ed25519 Key Pair for decryption of File Key
+ *
+ * @param publicKey Ed25519 Public Key
+ * @param privateKey Ed25519 Private Key
+ * @param keyPairGeneratorFactory X25519 Key Pair Generator Factory
+ * @param keyAgreementFactory X25519 Key Agreement Factory
+ * @param fileKeyDecryptor File Key Decryptor
+ * @throws GeneralSecurityException Thrown on failures to derive Public Key
+ */
+ SshEd25519RecipientStanzaReader(
+ final Ed25519PublicKey publicKey,
+ final Ed25519PrivateKey privateKey,
+ final X25519KeyPairGeneratorFactory keyPairGeneratorFactory,
+ final X25519KeyAgreementFactory keyAgreementFactory,
+ final FileKeyDecryptor fileKeyDecryptor
+ ) throws GeneralSecurityException {
+ Objects.requireNonNull(publicKey, "Public Key required");
+ Objects.requireNonNull(privateKey, "Private Key required");
+ Objects.requireNonNull(keyPairGeneratorFactory, "Key Pair Generator Factory required");
+ Objects.requireNonNull(keyAgreementFactory, "Key Agreement Factory required");
+ this.fileKeyDecryptor = Objects.requireNonNull(fileKeyDecryptor, "File Key Decryptor required");
+
+ final byte[] marshalledKey = PUBLIC_KEY_MARSHALLER.getMarshalledKey(publicKey);
+ publicKeyFingerprint = PUBLIC_KEY_FINGERPRINT_PRODUCER.getFingerprint(marshalledKey);
+
+ keyConverter = new StandardEd25519KeyConverter(keyPairGeneratorFactory);
+ final PrivateKey privateKeyConverted = keyConverter.getPrivateKey(privateKey);
+ sharedSecretKeyProducer = new X25519SharedSecretKeyProducer(privateKeyConverted, keyAgreementFactory);
+ sharedWrapKeyProducer = getWrapKeyProducer();
+
+ final SshEd25519SharedWrapKeyProducer sshEd25519SharedWrapKeyProducer = new SshEd25519SharedWrapKeyProducer();
+ final SshEd25519DerivedKey sshEd25519DerivedKey = sshEd25519SharedWrapKeyProducer.getDerivedKey(publicKey);
+ final PrivateKey derivedPrivateKey = keyConverter.getPrivateKey(sshEd25519DerivedKey);
+ derivedSharedSecretKeyProducer = new X25519SharedSecretKeyProducer(derivedPrivateKey, keyAgreementFactory);
+ }
+
+ /**
+ * Get File Key from matching ssh-ed25519 Recipient Stanza
+ *
+ * @param recipientStanzas One or more Recipient Stanzas parsed from the age file header
+ * @return File Key decrypted from matching ssh-ed25519 Recipient Stanza arguments
+ * @throws GeneralSecurityException Thrown on failure to read or decrypt File Key
+ */
+ @Override
+ public FileKey getFileKey(final Iterable recipientStanzas) throws GeneralSecurityException {
+ Objects.requireNonNull(recipientStanzas, "Recipient Stanzas required");
+
+ final List exceptions = new ArrayList<>();
+ for (final RecipientStanza recipientStanza : recipientStanzas) {
+ final String recipientStanzaType = recipientStanza.getType();
+ if (STANZA_TYPE.getIndicator().equals(recipientStanzaType)) {
+ try {
+ return getFileKey(recipientStanza);
+ } catch (final Exception e) {
+ exceptions.add(e);
+ }
+ }
+ }
+
+ if (exceptions.isEmpty()) {
+ throw new UnsupportedRecipientStanzaException(String.format("%s Recipient Stanzas not found", STANZA_TYPE.getIndicator()));
+ } else {
+ final String message = String.format("%s Recipient Stanza not matched", STANZA_TYPE.getIndicator());
+ final UnsupportedRecipientStanzaException exception = new UnsupportedRecipientStanzaException(message);
+ exceptions.forEach(exception::addSuppressed);
+ throw exception;
+ }
+ }
+
+ private FileKey getFileKey(final RecipientStanza recipientStanza) throws GeneralSecurityException {
+ final Iterator recipientStanzaArguments = recipientStanza.getArguments().iterator();
+ final String recipientKeyFingerprint = getRecipientKeyFingerprint(recipientStanzaArguments);
+
+ if (publicKeyFingerprint.equals(recipientKeyFingerprint)) {
+ final SharedSecretKey ephemeralSharedSecretKey = getEphemeralSharedSecretKey(recipientStanzaArguments);
+ final PublicKey ephemeralSharedPublicKey = keyConverter.getPublicKey(ephemeralSharedSecretKey);
+ final SharedSecretKey sharedSecretKey = sharedSecretKeyProducer.getSharedSecretKey(ephemeralSharedPublicKey);
+ final PublicKey sharedPublicKey = keyConverter.getPublicKey(sharedSecretKey);
+
+ final SharedSecretKey derivedSharedSecretKey = derivedSharedSecretKeyProducer.getSharedSecretKey(sharedPublicKey);
+ final CipherKey wrapKey = sharedWrapKeyProducer.getWrapKey(derivedSharedSecretKey, ephemeralSharedPublicKey);
+
+ final byte[] encryptedFileKeyEncoded = recipientStanza.getBody();
+ final int encryptedFileKeyLength = encryptedFileKeyEncoded.length;
+ if (encryptedFileKeyLength == ENCRYPTED_FILE_KEY_LENGTH) {
+ final EncryptedFileKey encryptedFileKey = new EncryptedFileKey(encryptedFileKeyEncoded);
+ return fileKeyDecryptor.getFileKey(encryptedFileKey, wrapKey);
+ } else {
+ final String message = String.format("Recipient Stanza Body length [%d] not required length [%d]", encryptedFileKeyLength, ENCRYPTED_FILE_KEY_LENGTH);
+ throw new UnsupportedRecipientStanzaException(message);
+ }
+ } else {
+ final String message = String.format("%s Recipient Stanza Key Fingerprint [%s] not matched", STANZA_TYPE.getIndicator(), recipientKeyFingerprint);
+ throw new UnsupportedRecipientStanzaException(message);
+ }
+ }
+
+ private SharedSecretKey getEphemeralSharedSecretKey(final Iterator recipientStanzaArguments) throws UnsupportedRecipientStanzaException {
+ if (recipientStanzaArguments.hasNext()) {
+ final String ephemeralShareEncoded = recipientStanzaArguments.next();
+
+ if (recipientStanzaArguments.hasNext()) {
+ final String message = String.format("%s Recipient Stanza extra argument not expected", STANZA_TYPE.getIndicator());
+ throw new UnsupportedRecipientStanzaException(message);
+ }
+
+ final int encodedLength = ephemeralShareEncoded.length();
+ if (EPHEMERAL_SHARE_ENCODED_LENGTH == encodedLength) {
+ final byte[] ephemeralShareEncodedBytes = ephemeralShareEncoded.getBytes(StandardCharsets.US_ASCII);
+ final byte[] ephemeralShare = BASE64_DECODER.decode(ephemeralShareEncodedBytes);
+ return new SharedSecretKey(ephemeralShare);
+ } else {
+ final String message = String.format("%s ephemeral share length [%d] not expected", STANZA_TYPE.getIndicator(), encodedLength);
+ throw new UnsupportedRecipientStanzaException(message);
+ }
+ } else {
+ final String message = String.format("%s ephemeral share argument not found", STANZA_TYPE.getIndicator());
+ throw new UnsupportedRecipientStanzaException(message);
+ }
+ }
+
+ private String getRecipientKeyFingerprint(final Iterator arguments) throws UnsupportedRecipientStanzaException {
+ if (arguments.hasNext()) {
+ return arguments.next();
+ } else {
+ throw new UnsupportedRecipientStanzaException("Key Fingerprint argument not found");
+ }
+ }
+
+ private SharedWrapKeyProducer getWrapKeyProducer() throws GeneralSecurityException {
+ final X25519BasePointPublicKey basePointPublicKey = new X25519BasePointPublicKey();
+ final SharedSecretKey basePointPublicKeyEncoded = new SharedSecretKey(basePointPublicKey.getEncoded());
+ final PublicKey basePointPublicKeyConverted = keyConverter.getPublicKey(basePointPublicKeyEncoded);
+ final SharedSecretKey basePointSharedSecretKey = sharedSecretKeyProducer.getSharedSecretKey(basePointPublicKeyConverted);
+ final PublicKey recipientPublicKey = keyConverter.getPublicKey(basePointSharedSecretKey);
+ return new X25519SharedWrapKeyProducer(recipientPublicKey);
+ }
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientStanzaReaderFactory.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientStanzaReaderFactory.java
new file mode 100644
index 0000000..febfb68
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientStanzaReaderFactory.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import com.exceptionfactory.jagged.RecipientStanzaReader;
+import com.exceptionfactory.jagged.framework.crypto.FileKeyDecryptor;
+import com.exceptionfactory.jagged.framework.crypto.FileKeyDecryptorFactory;
+
+import java.nio.ByteBuffer;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+import java.security.PrivateKey;
+import java.security.Provider;
+import java.security.PublicKey;
+
+/**
+ * Factory abstraction for returning initialized ssh-ed25519 Recipient Stanza Readers from an Ed25519 Private Key
+ */
+public final class SshEd25519RecipientStanzaReaderFactory {
+ private SshEd25519RecipientStanzaReaderFactory() {
+
+ }
+
+ /**
+ * Create new ssh-ed25519 Recipient Stanza Reader using an unencrypted OpenSSH Version 1 Ed25519 Private Key
+ *
+ * @param encoded Byte array containing an unencrypted OpenSSH Version 1 Ed25519 Private Key
+ * @return ssh-ed25519 Recipient Stanza Reader
+ * @throws GeneralSecurityException Thrown on failures to read private or process public key
+ */
+ public static RecipientStanzaReader newRecipientStanzaReader(final byte[] encoded) throws GeneralSecurityException {
+ final X25519KeyPairGeneratorFactory keyPairGeneratorFactory = new X25519KeyPairGeneratorFactory();
+ final X25519KeyAgreementFactory keyAgreementFactory = new X25519KeyAgreementFactory();
+ final FileKeyDecryptorFactory fileKeyDecryptorFactory = new FileKeyDecryptorFactory();
+
+ return newRecipientStanzaReader(encoded, keyPairGeneratorFactory, keyAgreementFactory, fileKeyDecryptorFactory);
+ }
+
+ /**
+ * Create new ssh-ed25519 Recipient Stanza Reader using an unencrypted OpenSSH Version 1 Ed25519 Private Key
+ *
+ * @param encoded Byte array containing an unencrypted OpenSSH Version 1 Ed25519 Private Key
+ * @param provider Security Provider for algorithm implementation resolution
+ * @return ssh-ed25519 Recipient Stanza Reader
+ * @throws GeneralSecurityException Thrown on failures to read private or process public key
+ */
+ public static RecipientStanzaReader newRecipientStanzaReader(final byte[] encoded, final Provider provider) throws GeneralSecurityException {
+ final X25519KeyPairGeneratorFactory keyPairGeneratorFactory = new X25519KeyPairGeneratorFactory(provider);
+ final X25519KeyAgreementFactory keyAgreementFactory = new X25519KeyAgreementFactory(provider);
+ final FileKeyDecryptorFactory fileKeyDecryptorFactory = new FileKeyDecryptorFactory(provider);
+
+ return newRecipientStanzaReader(encoded, keyPairGeneratorFactory, keyAgreementFactory, fileKeyDecryptorFactory);
+ }
+
+ private static RecipientStanzaReader newRecipientStanzaReader(
+ final byte[] encoded,
+ final X25519KeyPairGeneratorFactory keyPairGeneratorFactory,
+ final X25519KeyAgreementFactory keyAgreementFactory,
+ final FileKeyDecryptorFactory fileKeyDecryptorFactory
+ ) throws GeneralSecurityException {
+ final OpenSshKeyPairReader openSshKeyPairReader = new OpenSshKeyPairReader();
+ final ByteBuffer encodedBuffer = ByteBuffer.wrap(encoded);
+ final KeyPair keyPair = openSshKeyPairReader.read(encodedBuffer);
+
+ final PublicKey publicKey = keyPair.getPublic();
+ final Ed25519PublicKey ed25519PublicKey = (Ed25519PublicKey) publicKey;
+ final PrivateKey privateKey = keyPair.getPrivate();
+ final Ed25519PrivateKey ed25519PrivateKey = (Ed25519PrivateKey) privateKey;
+
+ final FileKeyDecryptor fileKeyDecryptor = fileKeyDecryptorFactory.newFileKeyDecryptor();
+ return new SshEd25519RecipientStanzaReader(ed25519PublicKey, ed25519PrivateKey, keyPairGeneratorFactory, keyAgreementFactory, fileKeyDecryptor);
+ }
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientStanzaWriter.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientStanzaWriter.java
new file mode 100644
index 0000000..a3ee67e
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientStanzaWriter.java
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import com.exceptionfactory.jagged.FileKey;
+import com.exceptionfactory.jagged.RecipientStanza;
+import com.exceptionfactory.jagged.RecipientStanzaWriter;
+import com.exceptionfactory.jagged.framework.codec.CanonicalBase64;
+import com.exceptionfactory.jagged.framework.crypto.CipherKey;
+import com.exceptionfactory.jagged.framework.crypto.EncryptedFileKey;
+import com.exceptionfactory.jagged.framework.crypto.FileKeyEncryptor;
+import com.exceptionfactory.jagged.framework.crypto.SharedSecretKey;
+
+import java.security.GeneralSecurityException;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.SecureRandom;
+import java.util.Collections;
+import java.util.Objects;
+
+/**
+ * SSH Ed25519 implementation of Recipient Stanza Writer compatible with age-ssh
+ */
+final class SshEd25519RecipientStanzaWriter implements RecipientStanzaWriter {
+ private static final SecureRandom SECURE_RANDOM = new SecureRandom();
+
+ private static final CanonicalBase64.Encoder ENCODER = CanonicalBase64.getEncoder();
+
+ private final SshEd25519PublicKeyMarshaller publicKeyMarshaller = new SshEd25519PublicKeyMarshaller();
+
+ private final PublicKeyFingerprintProducer publicKeyFingerprintProducer = new StandardPublicKeyFingerprintProducer();
+
+ private final Ed25519PublicKey publicKey;
+
+ private final PublicKey publicKeyConverted;
+
+ private final Ed25519KeyConverter keyConverter;
+
+ private final X25519KeyAgreementFactory keyAgreementFactory;
+
+ private final FileKeyEncryptor fileKeyEncryptor;
+
+ private final SharedSecretKeyProducer derivedSharedSecretKeyProducer;
+
+ /**
+ * SSH Ed25519 Recipient Stanza Writer with Ed25519 Public Key
+ *
+ * @param publicKey Ed25519 Public Key for recipient of encrypted File Key
+ * @param keyPairGeneratorFactory X25519 Key Pair Generator Factory for key processing
+ * @param keyAgreementFactory X25519 Key Agreement Factory for key derivation
+ * @param fileKeyEncryptor File Key Encryptor
+ * @throws GeneralSecurityException Thrown on failures to convert between key formats
+ */
+ SshEd25519RecipientStanzaWriter(
+ final Ed25519PublicKey publicKey,
+ final X25519KeyPairGeneratorFactory keyPairGeneratorFactory,
+ final X25519KeyAgreementFactory keyAgreementFactory,
+ final FileKeyEncryptor fileKeyEncryptor
+ ) throws GeneralSecurityException {
+ this.publicKey = Objects.requireNonNull(publicKey, "Public Key required");
+ this.keyAgreementFactory = Objects.requireNonNull(keyAgreementFactory, "Key Agreement Factory required");
+ this.fileKeyEncryptor = Objects.requireNonNull(fileKeyEncryptor, "File Key Encryptor required");
+ this.keyConverter = new StandardEd25519KeyConverter(keyPairGeneratorFactory);
+ this.publicKeyConverted = keyConverter.getPublicKey(publicKey);
+
+ final SshEd25519SharedWrapKeyProducer sshEd25519SharedWrapKeyProducer = new SshEd25519SharedWrapKeyProducer();
+ final SshEd25519DerivedKey sshEd25519DerivedKey = sshEd25519SharedWrapKeyProducer.getDerivedKey(publicKey);
+ final PrivateKey derivedPrivateKey = keyConverter.getPrivateKey(sshEd25519DerivedKey);
+ derivedSharedSecretKeyProducer = new X25519SharedSecretKeyProducer(derivedPrivateKey, keyAgreementFactory);
+ }
+
+ /**
+ * Get Recipient Stanzas containing one ssh-ed25519 Recipient Stanza with the encrypted File Key
+ *
+ * @param fileKey File Key to be encrypted
+ * @return Singleton List of ssh-ed25519 Recipient Stanza with encrypted File Key
+ * @throws GeneralSecurityException Thrown key derivation or encryption failures
+ */
+ @Override
+ public Iterable getRecipientStanzas(final FileKey fileKey) throws GeneralSecurityException {
+ Objects.requireNonNull(fileKey, "File Key required");
+
+ final SharedSecretKeyProducer ephemeralSharedSecretKeyProducer = getEphemeralSharedSecretKeyProducer();
+ final PublicKey basePointPublicKey = getBasePointPublicKey();
+ final SharedSecretKey ephemeralSharedSecretKey = ephemeralSharedSecretKeyProducer.getSharedSecretKey(basePointPublicKey);
+
+ final CipherKey wrapKey = getWrapKey(ephemeralSharedSecretKeyProducer, ephemeralSharedSecretKey);
+ final EncryptedFileKey encryptedFileKey = fileKeyEncryptor.getEncryptedFileKey(fileKey, wrapKey);
+ final byte[] encryptedFileKeyEncoded = encryptedFileKey.getEncoded();
+
+ final byte[] marshalledKey = publicKeyMarshaller.getMarshalledKey(publicKey);
+ final String keyFingerprint = publicKeyFingerprintProducer.getFingerprint(marshalledKey);
+ final String ephemeralShare = ENCODER.encodeToString(ephemeralSharedSecretKey.getEncoded());
+ final RecipientStanza recipientStanza = new SshEd25519RecipientStanza(keyFingerprint, ephemeralShare, encryptedFileKeyEncoded);
+ return Collections.singletonList(recipientStanza);
+ }
+
+ private CipherKey getWrapKey(final SharedSecretKeyProducer ephemeralSharedSecretKeyProducer, final SharedSecretKey ephemeralSharedSecretKey) throws GeneralSecurityException {
+ final PublicKey ephemeralSharedPublicKey = keyConverter.getPublicKey(ephemeralSharedSecretKey);
+ final SharedSecretKey sharedSecretKey = ephemeralSharedSecretKeyProducer.getSharedSecretKey(publicKeyConverted);
+ final PublicKey sharedPublicKey = keyConverter.getPublicKey(sharedSecretKey);
+
+ final SharedSecretKey derivedSharedSecretKey = derivedSharedSecretKeyProducer.getSharedSecretKey(sharedPublicKey);
+ final SharedWrapKeyProducer sharedWrapKeyProducer = new X25519SharedWrapKeyProducer(publicKeyConverted);
+ return sharedWrapKeyProducer.getWrapKey(derivedSharedSecretKey, ephemeralSharedPublicKey);
+ }
+
+ private PublicKey getBasePointPublicKey() throws GeneralSecurityException {
+ final X25519BasePointPublicKey basePointPublicKey = new X25519BasePointPublicKey();
+ final SharedSecretKey basePointPublicKeyEncoded = new SharedSecretKey(basePointPublicKey.getEncoded());
+ return keyConverter.getPublicKey(basePointPublicKeyEncoded);
+ }
+
+ private SharedSecretKeyProducer getEphemeralSharedSecretKeyProducer() throws GeneralSecurityException {
+ final byte[] ephemeralKeyEncoded = getEphemeralKeyEncoded();
+ final SshEd25519DerivedKey ephemeralDerivedKey = new SshEd25519DerivedKey(ephemeralKeyEncoded);
+ final PrivateKey ephemeralPrivateKey = keyConverter.getPrivateKey(ephemeralDerivedKey);
+ return new X25519SharedSecretKeyProducer(ephemeralPrivateKey, keyAgreementFactory);
+ }
+
+ private byte[] getEphemeralKeyEncoded() {
+ final byte[] ephemeralPrivateKeyEncoded = new byte[EllipticCurveKeyType.X25519.getKeyLength()];
+ SECURE_RANDOM.nextBytes(ephemeralPrivateKeyEncoded);
+ return ephemeralPrivateKeyEncoded;
+ }
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientStanzaWriterFactory.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientStanzaWriterFactory.java
new file mode 100644
index 0000000..2df00d5
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientStanzaWriterFactory.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import com.exceptionfactory.jagged.RecipientStanzaWriter;
+import com.exceptionfactory.jagged.framework.crypto.FileKeyEncryptor;
+import com.exceptionfactory.jagged.framework.crypto.FileKeyEncryptorFactory;
+
+import java.nio.ByteBuffer;
+import java.security.GeneralSecurityException;
+import java.security.Provider;
+
+/**
+ * Factory abstraction for returning initialized ssh-ed25519 Recipient Stanza Writers from an Ed25519 Public Key
+ */
+public final class SshEd25519RecipientStanzaWriterFactory {
+ private SshEd25519RecipientStanzaWriterFactory() {
+
+ }
+
+ /**
+ * Create new ssh-ed25519 Recipient Stanza Writer using an SSH Ed25519 public key encoded according to RFC 8709 Section 4
+ *
+ * @param encoded Byte array containing an SSH Ed25519 public key
+ * @return ssh-ed25519 Recipient Stanza Writer
+ * @throws GeneralSecurityException Thrown on failures to read or process public key
+ */
+ public static RecipientStanzaWriter newRecipientStanzaWriter(final byte[] encoded) throws GeneralSecurityException {
+ final X25519KeyPairGeneratorFactory keyPairGeneratorFactory = new X25519KeyPairGeneratorFactory();
+ final X25519KeyAgreementFactory keyAgreementFactory = new X25519KeyAgreementFactory();
+ final FileKeyEncryptorFactory fileKeyEncryptorFactory = new FileKeyEncryptorFactory();
+
+ return newRecipientStanzaWriter(encoded, keyPairGeneratorFactory, keyAgreementFactory, fileKeyEncryptorFactory);
+ }
+
+ /**
+ * Create new ssh-ed25519 Recipient Stanza Writer using an SSH Ed25519 public key encoded according to RFC 8709 Section 4
+ *
+ * @param encoded Byte array containing an SSH Ed25519 public key
+ * @param provider Security Provider for algorithm implementation resolution
+ * @return ssh-ed25519 Recipient Stanza Writer
+ * @throws GeneralSecurityException Thrown on failures to read or process public key
+ */
+ public static RecipientStanzaWriter newRecipientStanzaWriter(final byte[] encoded, final Provider provider) throws GeneralSecurityException {
+ final X25519KeyPairGeneratorFactory keyPairGeneratorFactory = new X25519KeyPairGeneratorFactory(provider);
+ final X25519KeyAgreementFactory keyAgreementFactory = new X25519KeyAgreementFactory(provider);
+ final FileKeyEncryptorFactory fileKeyEncryptorFactory = new FileKeyEncryptorFactory(provider);
+
+ return newRecipientStanzaWriter(encoded, keyPairGeneratorFactory, keyAgreementFactory, fileKeyEncryptorFactory);
+ }
+
+ private static RecipientStanzaWriter newRecipientStanzaWriter(
+ final byte[] encoded,
+ final X25519KeyPairGeneratorFactory keyPairGeneratorFactory,
+ final X25519KeyAgreementFactory keyAgreementFactory,
+ final FileKeyEncryptorFactory fileKeyEncryptorFactory
+ ) throws GeneralSecurityException {
+ final SshEd25519PublicKeyReader publicKeyReader = new SshEd25519PublicKeyReader();
+ final ByteBuffer inputBuffer = ByteBuffer.wrap(encoded);
+ final Ed25519PublicKey publicKey = publicKeyReader.read(inputBuffer);
+ final FileKeyEncryptor fileKeyEncryptor = fileKeyEncryptorFactory.newFileKeyEncryptor();
+ return new SshEd25519RecipientStanzaWriter(publicKey, keyPairGeneratorFactory, keyAgreementFactory, fileKeyEncryptor);
+ }
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519SharedWrapKeyProducer.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519SharedWrapKeyProducer.java
new file mode 100644
index 0000000..be22df2
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshEd25519SharedWrapKeyProducer.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import com.exceptionfactory.jagged.framework.crypto.HashedDerivedKeyProducer;
+
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.PublicKey;
+import java.util.Objects;
+
+/**
+ * SSH Ed25519 implementation with HKDF-SHA-256 for deriving decryption key using marshalled Ed25519 public key
+ */
+class SshEd25519SharedWrapKeyProducer extends HashedDerivedKeyProducer {
+ private static final byte[] KEY_INFORMATION = SshEd25519RecipientIndicator.KEY_INFORMATION.getIndicator().getBytes(StandardCharsets.UTF_8);
+
+ private static final SshEd25519PublicKeyMarshaller PUBLIC_KEY_MARSHALLER = new SshEd25519PublicKeyMarshaller();
+
+ /**
+ * Get Derived Key using marshalled SSH Ed25519 public key with HKDF-SHA-256 and empty input key
+ *
+ * @param publicKey SSH Ed25519 Public Key
+ * @return Wrap Cipher Key for decrypting wrapped File Key
+ * @throws GeneralSecurityException Thrown on failure to derive wrap key
+ */
+ SshEd25519DerivedKey getDerivedKey(final PublicKey publicKey) throws GeneralSecurityException {
+ Objects.requireNonNull(publicKey, "Public Key required");
+
+ final byte[] marshalledKey = PUBLIC_KEY_MARSHALLER.getMarshalledKey(publicKey);
+ final SshEd25519MarshalledKey sshEd25519MarshalledKey = new SshEd25519MarshalledKey(marshalledKey);
+
+ final EmptyInputKey inputKey = new EmptyInputKey();
+ final byte[] derivedKey = getDerivedKey(inputKey, sshEd25519MarshalledKey, KEY_INFORMATION);
+ return new SshEd25519DerivedKey(derivedKey);
+ }
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshKeyType.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshKeyType.java
index 001f0ad..f047146 100644
--- a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshKeyType.java
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshKeyType.java
@@ -19,6 +19,8 @@
* SSH Key Types
*/
enum SshKeyType {
+ DSS("ssh-dss"),
+
ED25519("ssh-ed25519"),
RSA("ssh-rsa");
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshPublicKeyReader.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshPublicKeyReader.java
new file mode 100644
index 0000000..8a4b629
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/SshPublicKeyReader.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import java.nio.ByteBuffer;
+import java.security.InvalidKeyException;
+import java.security.PublicKey;
+
+/**
+ * SSH Public Key Reader with shared methods
+ *
+ * @param Public Key Type
+ */
+abstract class SshPublicKeyReader implements PublicKeyReader {
+ /**
+ * Read length-delimited array of bytes
+ *
+ * @param buffer Byte buffer to be read
+ * @return Byte array read
+ * @throws InvalidKeyException Thrown on invalid number of bytes indicated to be read
+ */
+ protected byte[] readBlock(final ByteBuffer buffer) throws InvalidKeyException {
+ final int length = buffer.getInt();
+ if (length > buffer.remaining()) {
+ throw new InvalidKeyException(String.format("Public Key block length [%d] not valid", length));
+ }
+
+ final byte[] block = new byte[length];
+ buffer.get(block);
+ return block;
+ }
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/StandardEd25519KeyConverter.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/StandardEd25519KeyConverter.java
new file mode 100644
index 0000000..b28c398
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/StandardEd25519KeyConverter.java
@@ -0,0 +1,199 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import com.exceptionfactory.jagged.framework.crypto.SharedSecretKey;
+
+import java.math.BigInteger;
+import java.security.GeneralSecurityException;
+import java.security.KeyFactory;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.Provider;
+import java.security.PublicKey;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.Arrays;
+import java.util.Objects;
+
+/**
+ * Standard implementation of Ed25519 key converter using Java Cryptography Architecture interfaces and BigInteger processing
+ */
+final class StandardEd25519KeyConverter implements Ed25519KeyConverter {
+ /** Curve25519 coordinate length in bytes */
+ private static final int COORDINATE_LENGTH = 32;
+
+ /** PKCS8 Private Key specification encoded length in bytes containing 32 byte key plus DER encoded version and algorithm */
+ private static final int PRIVATE_KEY_SPECIFICATION_ENCODED_LENGTH = 48;
+
+ /** PKCS8 Private Key DER encoded length in bytes containing 32 byte key plus version and algorithm identifier */
+ private static final int PRIVATE_KEY_DER_ENCODED_LENGTH = 46;
+
+ private static final int PRIVATE_KEY_DER_ENCODED_LENGTH_INDEX = 1;
+
+ private static final int PRIVATE_KEY_DER_HEADER_LENGTH = 16;
+
+ private static final String DIGEST_ALGORITHM = "SHA-512";
+
+ private static final int CURVE_25519_EXPONENT = 255;
+
+ private static final BigInteger CURVE_25519_PRIME = BigInteger.valueOf(2).pow(CURVE_25519_EXPONENT).subtract(BigInteger.valueOf(19));
+
+ private static final byte SIGNIFICANT_BIT_MASK = 0b01111111;
+
+ private final int publicKeyEncodedLength;
+
+ private final byte[] publicKeyHeader;
+
+ private final byte[] privateKeyHeader;
+
+ private final KeyFactory keyFactory;
+
+ StandardEd25519KeyConverter(final X25519KeyPairGeneratorFactory keyPairGeneratorFactory) throws NoSuchAlgorithmException {
+ final KeyPairGenerator keyPairGenerator = keyPairGeneratorFactory.getKeyPairGenerator();
+ final Provider provider = keyPairGenerator.getProvider();
+ keyFactory = KeyFactory.getInstance(EllipticCurveKeyType.X25519.getAlgorithm(), provider);
+ final KeyPair keyPair = keyPairGenerator.generateKeyPair();
+
+ final PrivateKey privateKey = keyPair.getPrivate();
+ privateKeyHeader = getPrivateKeyHeader(privateKey);
+
+ final PublicKey publicKey = keyPair.getPublic();
+ final byte[] publicKeyEncoded = publicKey.getEncoded();
+ publicKeyEncodedLength = publicKeyEncoded.length;
+ final int publicKeyHeaderLength = publicKeyEncodedLength - COORDINATE_LENGTH;
+ publicKeyHeader = Arrays.copyOfRange(publicKeyEncoded, 0, publicKeyHeaderLength);
+ }
+
+ /**
+ * Get X25519 Private Key from first 32 bytes of SHA-512 hash of Ed25519 Private Key
+ *
+ * @param ed25519PrivateKey Ed25519 private key
+ * @return X25519 Private Key
+ * @throws GeneralSecurityException Thrown on failure to generate private key
+ */
+ @Override
+ public PrivateKey getPrivateKey(final Ed25519PrivateKey ed25519PrivateKey) throws GeneralSecurityException {
+ Objects.requireNonNull(ed25519PrivateKey, "Ed25519 Private Key required");
+
+ final MessageDigest messageDigest = MessageDigest.getInstance(DIGEST_ALGORITHM);
+ final byte[] encoded = ed25519PrivateKey.getEncoded();
+ final byte[] digested = messageDigest.digest(encoded);
+ final byte[] converted = new byte[COORDINATE_LENGTH];
+ System.arraycopy(digested, 0, converted, 0, COORDINATE_LENGTH);
+
+ final PKCS8EncodedKeySpec privateKeySpec = getPrivateKeySpec(converted);
+ return keyFactory.generatePrivate(privateKeySpec);
+ }
+
+ /**
+ * Get X25519 Private Key from SSH Ed25519 derived key
+ *
+ * @param derivedKey SSH Ed25519 derived key
+ * @return X25519 Private Key
+ * @throws GeneralSecurityException Thrown on failure to convert private key
+ */
+ @Override
+ public PrivateKey getPrivateKey(final SshEd25519DerivedKey derivedKey) throws GeneralSecurityException {
+ final byte[] encoded = Objects.requireNonNull(derivedKey, "Derived Key required").getEncoded();
+ final PKCS8EncodedKeySpec privateKeySpec = getPrivateKeySpec(encoded);
+ return keyFactory.generatePrivate(privateKeySpec);
+ }
+
+ /**
+ * Get X25519 Public Key from Ed25519 Public Key public computed using equivalence mapping described in RFC 7748 Section 4.1
+ *
+ * @param ed25519PublicKey Ed25519 public key
+ * @return X25519 Public Key
+ * @throws GeneralSecurityException Thrown on failure to convert public key
+ */
+ @Override
+ public PublicKey getPublicKey(final Ed25519PublicKey ed25519PublicKey) throws GeneralSecurityException {
+ final byte[] encoded = Objects.requireNonNull(ed25519PublicKey, "Ed25519 Public Key required").getEncoded();
+ final byte[] montgomeryCoordinate = getMontgomeryCoordinate(encoded);
+
+ final X509EncodedKeySpec publicKeySpec = getPublicKeySpec(montgomeryCoordinate);
+ return keyFactory.generatePublic(publicKeySpec);
+ }
+
+ /**
+ * Get X25519 Public Key from shared secret encoded binary key
+ *
+ * @param sharedSecretKey Computed shared secret key
+ * @return X25519 Public Key
+ * @throws GeneralSecurityException Thrown on key processing failures
+ */
+ @Override
+ public PublicKey getPublicKey(final SharedSecretKey sharedSecretKey) throws GeneralSecurityException {
+ Objects.requireNonNull(sharedSecretKey, "Key required");
+ final byte[] encoded = sharedSecretKey.getEncoded();
+ final X509EncodedKeySpec publicKeySpec = getPublicKeySpec(encoded);
+ return keyFactory.generatePublic(publicKeySpec);
+ }
+
+ private PKCS8EncodedKeySpec getPrivateKeySpec(final byte[] privateKeyEncoded) {
+ final byte[] keySpec = new byte[PRIVATE_KEY_SPECIFICATION_ENCODED_LENGTH];
+ System.arraycopy(privateKeyHeader, 0, keySpec, 0, privateKeyHeader.length);
+ System.arraycopy(privateKeyEncoded, 0, keySpec, privateKeyHeader.length, privateKeyEncoded.length);
+ return new PKCS8EncodedKeySpec(keySpec);
+ }
+
+ private X509EncodedKeySpec getPublicKeySpec(final byte[] publicKeyEncoded) {
+ final byte[] keySpec = new byte[publicKeyEncodedLength];
+ System.arraycopy(publicKeyHeader, 0, keySpec, 0, publicKeyHeader.length);
+ System.arraycopy(publicKeyEncoded, 0, keySpec, publicKeyHeader.length, publicKeyEncoded.length);
+ return new X509EncodedKeySpec(keySpec);
+ }
+
+ private static byte[] getPrivateKeyHeader(final PrivateKey privateKey) {
+ final byte[] privateKeyEncoded = privateKey.getEncoded();
+ final byte[] privateKeyHeader = Arrays.copyOfRange(privateKeyEncoded, 0, PRIVATE_KEY_DER_HEADER_LENGTH);
+ // Set DER encoded length to override potential longer values from other providers
+ privateKeyHeader[PRIVATE_KEY_DER_ENCODED_LENGTH_INDEX] = PRIVATE_KEY_DER_ENCODED_LENGTH;
+ return privateKeyHeader;
+ }
+
+ private byte[] getMontgomeryCoordinate(final byte[] edwardsCoordinateLittleEndian) {
+ final byte[] reversed = getReversed(edwardsCoordinateLittleEndian);
+ reversed[0] &= SIGNIFICANT_BIT_MASK;
+
+ final BigInteger secondEdwardsCoordinate = new BigInteger(reversed);
+ final BigInteger denominator = BigInteger.ONE.subtract(secondEdwardsCoordinate);
+ final BigInteger inverted = denominator.modInverse(CURVE_25519_PRIME);
+
+ final BigInteger numerator = BigInteger.ONE.add(secondEdwardsCoordinate);
+ final BigInteger firstEdwardsCoordinate = numerator.multiply(inverted);
+ final BigInteger montgomeryCoordinate = firstEdwardsCoordinate.mod(CURVE_25519_PRIME);
+
+ final byte[] montgomeryCoordinateEncoded = montgomeryCoordinate.toByteArray();
+ return getReversed(montgomeryCoordinateEncoded);
+ }
+
+ private byte[] getReversed(final byte[] encoded) {
+ final byte[] reversed = new byte[encoded.length];
+
+ int i = encoded.length - 1;
+ for (final byte encodedItem : encoded) {
+ reversed[i] = encodedItem;
+ i--;
+ }
+
+ return reversed;
+ }
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/X25519BasePointPublicKey.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/X25519BasePointPublicKey.java
new file mode 100644
index 0000000..c4a9a74
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/X25519BasePointPublicKey.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import java.security.PublicKey;
+
+/**
+ * Curve25519 Base Point Public Key as described in RFC 7748 Section 4.1
+ */
+class X25519BasePointPublicKey implements PublicKey {
+ private static final byte BASE_POINT = 9;
+
+ private static final String FORMAT = "RAW";
+
+ private static final byte[] BASE_POINT_PUBLIC_KEY = new byte[EllipticCurveKeyType.X25519.getKeyLength()];
+
+ static {
+ BASE_POINT_PUBLIC_KEY[0] = BASE_POINT;
+ }
+
+ /**
+ * Get algorithm returns X25519 for Key Agreement operations
+ *
+ * @return X25519 algorithm
+ */
+ @Override
+ public String getAlgorithm() {
+ return EllipticCurveKeyType.X25519.getAlgorithm();
+ }
+
+ /**
+ * Get format returns RAW
+ *
+ * @return RAW format
+ */
+ @Override
+ public String getFormat() {
+ return FORMAT;
+ }
+
+ /**
+ * Get encoded Base Point Public Key as 32 bytes with leading 9 following RFC 7748 Section 4.1
+ *
+ * @return Base Point Public Key as 32 bytes
+ */
+ @Override
+ public byte[] getEncoded() {
+ return BASE_POINT_PUBLIC_KEY.clone();
+ }
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/X25519KeyAgreementFactory.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/X25519KeyAgreementFactory.java
new file mode 100644
index 0000000..fac2c5c
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/X25519KeyAgreementFactory.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import javax.crypto.KeyAgreement;
+import java.security.GeneralSecurityException;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.Provider;
+import java.util.Objects;
+
+/**
+ * Factory abstraction for initialized instances of javax.crypto.KeyAgreement with X25519
+ */
+final class X25519KeyAgreementFactory {
+ private final Provider provider;
+
+ /**
+ * Key Agreement Factory default constructor uses the system default Security Provider configuration
+ */
+ X25519KeyAgreementFactory() {
+ provider = null;
+ }
+
+ /**
+ * Key Agreement Factory constructor with support for custom Security Provider
+ *
+ * @param provider Security Provider supporting X25519
+ */
+ X25519KeyAgreementFactory(final Provider provider) {
+ this.provider = Objects.requireNonNull(provider, "Provider required");
+ }
+
+ /**
+ * Get Key Agreement initialized using the provided Private Key
+ *
+ * @param privateKey Private Key
+ * @return X25519 Key Agreement
+ * @throws GeneralSecurityException Thrown on initialization failures
+ */
+ KeyAgreement getInitializedKeyAgreement(final PrivateKey privateKey) throws GeneralSecurityException {
+ final KeyAgreement keyAgreement = getKeyAgreement();
+ keyAgreement.init(privateKey);
+ return keyAgreement;
+ }
+
+ private KeyAgreement getKeyAgreement() throws NoSuchAlgorithmException {
+ final KeyAgreement keyAgreement;
+
+ if (provider == null) {
+ keyAgreement = KeyAgreement.getInstance(EllipticCurveKeyType.X25519.getAlgorithm());
+ } else {
+ keyAgreement = KeyAgreement.getInstance(EllipticCurveKeyType.X25519.getAlgorithm(), provider);
+ }
+
+ return keyAgreement;
+ }
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/X25519KeyPairGeneratorFactory.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/X25519KeyPairGeneratorFactory.java
new file mode 100644
index 0000000..900078c
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/X25519KeyPairGeneratorFactory.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.Provider;
+import java.util.Objects;
+
+/**
+ * Factory abstraction for instances of javax.security.KeyPairGenerator with X25519
+ */
+final class X25519KeyPairGeneratorFactory {
+ private final Provider provider;
+
+ /**
+ * Key Pair Generator Factory default constructor uses the system default Security Provider configuration
+ */
+ X25519KeyPairGeneratorFactory() {
+ provider = null;
+ }
+
+ /**
+ * Key Pair Generator Factory constructor with support for custom Security Provider
+ *
+ * @param provider Security Provider supporting X25519
+ */
+ X25519KeyPairGeneratorFactory(final Provider provider) {
+ this.provider = Objects.requireNonNull(provider, "Provider required");
+ }
+
+ KeyPairGenerator getKeyPairGenerator() throws NoSuchAlgorithmException {
+ final KeyPairGenerator keyPairGenerator;
+
+ if (provider == null) {
+ keyPairGenerator = KeyPairGenerator.getInstance(EllipticCurveKeyType.X25519.getAlgorithm());
+ } else {
+ keyPairGenerator = KeyPairGenerator.getInstance(EllipticCurveKeyType.X25519.getAlgorithm(), provider);
+ }
+
+ return keyPairGenerator;
+ }
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/X25519SharedSecretKeyProducer.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/X25519SharedSecretKeyProducer.java
new file mode 100644
index 0000000..4d7e208
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/X25519SharedSecretKeyProducer.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import com.exceptionfactory.jagged.framework.crypto.SharedSecretKey;
+
+import javax.crypto.KeyAgreement;
+import java.security.GeneralSecurityException;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.util.Objects;
+
+/**
+ * Shared Secret Key Producer using X25519 Key Agreement described in RFC 7748
+ */
+final class X25519SharedSecretKeyProducer implements SharedSecretKeyProducer {
+ private static final boolean LAST_PHASE = true;
+
+ private final PrivateKey privateKey;
+
+ private final X25519KeyAgreementFactory keyAgreementFactory;
+
+ /**
+ * X25519 Shared Secret Key Producer with Private Key for initialization
+ *
+ * @param privateKey X25519 Private Key
+ * @param keyAgreementFactory Key Agreement Factory
+ */
+ X25519SharedSecretKeyProducer(final PrivateKey privateKey, final X25519KeyAgreementFactory keyAgreementFactory) {
+ this.privateKey = Objects.requireNonNull(privateKey, "Private Key required");
+ this.keyAgreementFactory = Objects.requireNonNull(keyAgreementFactory, "Key Agreement Factory required");
+ }
+
+ /**
+ * Get Shared Secret Key from X25519 Public Key
+ *
+ * @param publicKey X25519 Public Key
+ * @return Shared Secret Key
+ * @throws GeneralSecurityException Thrown on failures to generate shared secret key
+ */
+ @Override
+ public SharedSecretKey getSharedSecretKey(final PublicKey publicKey) throws GeneralSecurityException {
+ Objects.requireNonNull(publicKey, "Public Key required");
+ final KeyAgreement keyAgreement = keyAgreementFactory.getInitializedKeyAgreement(privateKey);
+ keyAgreement.doPhase(publicKey, LAST_PHASE);
+ final byte[] secretKey = keyAgreement.generateSecret();
+ return new SharedSecretKey(secretKey);
+ }
+}
diff --git a/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/X25519SharedWrapKeyProducer.java b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/X25519SharedWrapKeyProducer.java
new file mode 100644
index 0000000..5d4566c
--- /dev/null
+++ b/jagged-ssh/src/main/java/com/exceptionfactory/jagged/ssh/X25519SharedWrapKeyProducer.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import com.exceptionfactory.jagged.framework.crypto.CipherKey;
+import com.exceptionfactory.jagged.framework.crypto.HashedDerivedKeyProducer;
+import com.exceptionfactory.jagged.framework.crypto.SharedSaltKey;
+import com.exceptionfactory.jagged.framework.crypto.SharedSecretKey;
+
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.PublicKey;
+import java.util.Objects;
+
+/**
+ * Standard implementation with HKDF-SHA-256 for File Key decryption using X25519 key derived from SSH Ed25519 key
+ */
+class X25519SharedWrapKeyProducer extends HashedDerivedKeyProducer implements SharedWrapKeyProducer {
+ private static final byte[] KEY_INFORMATION = SshEd25519RecipientIndicator.KEY_INFORMATION.getIndicator().getBytes(StandardCharsets.UTF_8);
+
+ /** Public Coordinate Length after ASN.1 with DER header */
+ private static final int PUBLIC_COORDINATE_LENGTH = EllipticCurveKeyType.X25519.getKeyLength();
+
+ private static final int SHARED_SALT_KEY_LENGTH = 64;
+
+ private final byte[] recipientPublicCoordinate;
+
+ X25519SharedWrapKeyProducer(final PublicKey recipientPublicKey) {
+ this.recipientPublicCoordinate = getPublicCoordinate(Objects.requireNonNull(recipientPublicKey, "Recipient Public Key required"));
+ }
+
+ /**
+ * Get Wrap Cipher Key using Shared Secret Key with HKDF-SHA-256 derivation
+ *
+ * @param sharedSecretKey Shared Secret Key
+ * @param ephemeralPublicKey Ephemeral Public Key from Recipient Stanza Arguments
+ * @return Wrap Cipher Key for decrypting wrapped File Key
+ * @throws GeneralSecurityException Thrown on failure to derive wrap key
+ */
+ @Override
+ public CipherKey getWrapKey(final SharedSecretKey sharedSecretKey, final PublicKey ephemeralPublicKey) throws GeneralSecurityException {
+ Objects.requireNonNull(sharedSecretKey, "Shared Secret Key required");
+ Objects.requireNonNull(ephemeralPublicKey, "Ephemeral Public Key required");
+ final SharedSaltKey sharedSaltKey = getSharedSaltKey(ephemeralPublicKey);
+ final byte[] wrapKey = getDerivedKey(sharedSecretKey, sharedSaltKey, KEY_INFORMATION);
+ return new CipherKey(wrapKey);
+ }
+
+ private SharedSaltKey getSharedSaltKey(final PublicKey ephemeralPublicKey) {
+ final byte[] saltKey = new byte[SHARED_SALT_KEY_LENGTH];
+ final byte[] ephemeralPublicCoordinate = getPublicCoordinate(ephemeralPublicKey);
+ System.arraycopy(ephemeralPublicCoordinate, 0, saltKey, 0, PUBLIC_COORDINATE_LENGTH);
+ System.arraycopy(recipientPublicCoordinate, 0, saltKey, PUBLIC_COORDINATE_LENGTH, PUBLIC_COORDINATE_LENGTH);
+ return new SharedSaltKey(saltKey);
+ }
+
+ private static byte[] getPublicCoordinate(final PublicKey publicKey) {
+ final byte[] encoded = publicKey.getEncoded();
+ final int encodedLength = encoded.length;
+ final int headerLength = encodedLength - PUBLIC_COORDINATE_LENGTH;
+ return getPublicCoordinate(encoded, headerLength);
+ }
+
+ private static byte[] getPublicCoordinate(final byte[] encoded, final int startPosition) {
+ final byte[] publicCoordinate = new byte[PUBLIC_COORDINATE_LENGTH];
+ System.arraycopy(encoded, startPosition, publicCoordinate, 0, PUBLIC_COORDINATE_LENGTH);
+ return publicCoordinate;
+ }
+}
diff --git a/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/Ed25519KeyPairProvider.java b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/Ed25519KeyPairProvider.java
new file mode 100644
index 0000000..23a5299
--- /dev/null
+++ b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/Ed25519KeyPairProvider.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import com.exceptionfactory.jagged.framework.codec.CanonicalBase64;
+
+import java.nio.charset.StandardCharsets;
+
+final class Ed25519KeyPairProvider {
+
+ private static final String PUBLIC_KEY_ENCODED = "HURauP50ZGJDuUkn6tjy/PGQWmTR2FFyDYXP071GP3U";
+
+ private static final String PRIVATE_KEY_ENCODED = "nWNC6CoPYFBs1dSBWSYPFjQ4+APPoH/3DQoB2kCairA";
+
+ private static final CanonicalBase64.Decoder DECODER = CanonicalBase64.getDecoder();
+
+ private static Ed25519PublicKey publicKey;
+
+ private static Ed25519PrivateKey privateKey;
+
+ private Ed25519KeyPairProvider() {
+
+ }
+
+ static synchronized Ed25519PublicKey getPublicKey() {
+ if (publicKey == null) {
+ setKeyPair();
+ }
+ return publicKey;
+ }
+
+ static synchronized Ed25519PrivateKey getPrivateKey() {
+ if (privateKey == null) {
+ setKeyPair();
+ }
+ return privateKey;
+ }
+
+ private static void setKeyPair() {
+ final byte[] publicKeyEncoded = DECODER.decode(PUBLIC_KEY_ENCODED.getBytes(StandardCharsets.UTF_8));
+ publicKey = new Ed25519PublicKey(publicKeyEncoded);
+
+ final byte[] privateKeyEncoded = DECODER.decode(PRIVATE_KEY_ENCODED.getBytes(StandardCharsets.UTF_8));
+ privateKey = new Ed25519PrivateKey(privateKeyEncoded);
+ }
+}
diff --git a/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/OpenSshKeyPairReaderTest.java b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/OpenSshKeyPairReaderTest.java
index daa484f..b27ccf5 100644
--- a/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/OpenSshKeyPairReaderTest.java
+++ b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/OpenSshKeyPairReaderTest.java
@@ -48,7 +48,7 @@ class OpenSshKeyPairReaderTest {
private static final byte VALID_PADDING = 1;
- private static final byte INVALID_PADDING = 2;
+ private static final byte INVALID_PADDING = 3;
private static final String INVALID_KEY_TYPE = "ssh-invalid";
@@ -376,7 +376,7 @@ void testReadPrivateKeyTypeUnsupported() throws IOException {
final ByteBuffer formattedBuffer = ByteBuffer.allocate(BUFFER_SIZE);
putCipherKdfKeyCount(formattedBuffer, VALID_KEY_COUNT);
- final SshKeyType sshKeyType = SshKeyType.ED25519;
+ final SshKeyType sshKeyType = SshKeyType.DSS;
final ByteBuffer publicKeyBuffer = ByteBuffer.allocate(BUFFER_SIZE);
putBlock(publicKeyBuffer, sshKeyType.getKeyType().getBytes(StandardCharsets.UTF_8));
@@ -401,22 +401,32 @@ void testReadBadPadding() throws IOException, GeneralSecurityException {
final ByteBuffer rsaPrivateKeyBuffer = getRsaPrivateKeyBuffer();
rsaPrivateKeyBuffer.put(INVALID_PADDING);
- final ByteBuffer rsaKeyPairBuffer = getRsaKeyPairBuffer(rsaPrivateKeyBuffer);
+ final ByteBuffer rsaKeyPairBuffer = getKeyPairBuffer(SshKeyType.RSA, rsaPrivateKeyBuffer);
assertThrows(BadPaddingException.class, () -> reader.read(rsaKeyPairBuffer));
}
@Test
- void testRead() throws IOException, GeneralSecurityException {
+ void testReadRsa() throws IOException, GeneralSecurityException {
final ByteBuffer rsaPrivateKeyBuffer = getRsaPrivateKeyBuffer();
- final ByteBuffer rsaKeyPairBuffer = getRsaKeyPairBuffer(rsaPrivateKeyBuffer);
+ final ByteBuffer rsaKeyPairBuffer = getKeyPairBuffer(SshKeyType.RSA, rsaPrivateKeyBuffer);
final KeyPair keyPair = reader.read(rsaKeyPairBuffer);
- assertKeyPairFound(keyPair);
+ assertKeyPairFound(keyPair, RSA_ALGORITHM);
}
- static ByteBuffer getRsaKeyPairBuffer(final ByteBuffer privateKeyBuffer) throws IOException {
+ @Test
+ void testReadEd25519() throws IOException, GeneralSecurityException {
+ final ByteBuffer ed25519PrivateKeyBuffer = getEd25519PrivateKeyBuffer();
+ final ByteBuffer ed25519KeyPairBuffer = getKeyPairBuffer(SshKeyType.ED25519, ed25519PrivateKeyBuffer);
+
+ final KeyPair keyPair = reader.read(ed25519KeyPairBuffer);
+
+ assertKeyPairFound(keyPair, Ed25519KeyIndicator.KEY_ALGORITHM.getIndicator());
+ }
+
+ static ByteBuffer getKeyPairBuffer(final SshKeyType sshKeyType, final ByteBuffer privateKeyBuffer) throws IOException {
final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
outputStream.write(OpenSshKeyIndicator.HEADER.getIndicator());
outputStream.write(KeySeparator.LINE_FEED.getCode());
@@ -425,8 +435,6 @@ static ByteBuffer getRsaKeyPairBuffer(final ByteBuffer privateKeyBuffer) throws
final ByteBuffer formattedBuffer = ByteBuffer.allocate(BUFFER_SIZE);
putCipherKdfKeyCount(formattedBuffer, VALID_KEY_COUNT);
- final SshKeyType sshKeyType = SshKeyType.RSA;
-
final ByteBuffer publicKeyBuffer = ByteBuffer.allocate(BUFFER_SIZE);
putBlock(publicKeyBuffer, sshKeyType.getKeyType().getBytes(StandardCharsets.UTF_8));
putBuffer(formattedBuffer, publicKeyBuffer);
@@ -452,7 +460,21 @@ static ByteBuffer getRsaPrivateKeyBuffer() throws NoSuchAlgorithmException {
return privateKeyBuffer;
}
- private static void assertKeyPairFound(final KeyPair keyPair) {
+ static ByteBuffer getEd25519PrivateKeyBuffer() {
+ final ByteBuffer privateKeyBuffer = ByteBuffer.allocate(BUFFER_SIZE);
+ privateKeyBuffer.putInt(CHECK_NUMBER);
+ privateKeyBuffer.putInt(CHECK_NUMBER);
+ putBlock(privateKeyBuffer, SshKeyType.ED25519.getKeyType().getBytes(StandardCharsets.UTF_8));
+
+ final ByteBuffer ed25519PrivateKeyBuffer = SshEd25519OpenSshKeyPairReaderTest.getPrivateKeyBuffer();
+ privateKeyBuffer.put(ed25519PrivateKeyBuffer);
+ putBlock(privateKeyBuffer, EMPTY_BLOCK);
+ privateKeyBuffer.put(VALID_PADDING);
+
+ return privateKeyBuffer;
+ }
+
+ private static void assertKeyPairFound(final KeyPair keyPair, final String algorithm) {
assertNotNull(keyPair);
final PrivateKey privateKey = keyPair.getPrivate();
@@ -461,8 +483,8 @@ private static void assertKeyPairFound(final KeyPair keyPair) {
final PublicKey publicKey = keyPair.getPublic();
assertNotNull(publicKey);
- assertEquals(RSA_ALGORITHM, privateKey.getAlgorithm());
- assertEquals(RSA_ALGORITHM, publicKey.getAlgorithm());
+ assertEquals(algorithm, privateKey.getAlgorithm());
+ assertEquals(algorithm, publicKey.getAlgorithm());
}
private static byte[] getEncoded(final ByteBuffer formattedBuffer) {
diff --git a/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/SshEd25519OpenSshKeyPairReaderTest.java b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/SshEd25519OpenSshKeyPairReaderTest.java
new file mode 100644
index 0000000..d606fe1
--- /dev/null
+++ b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/SshEd25519OpenSshKeyPairReaderTest.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.ByteBuffer;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+class SshEd25519OpenSshKeyPairReaderTest {
+ private static final int BUFFER_SIZE = 128;
+
+ private final SshEd25519OpenSshKeyPairReader reader = new SshEd25519OpenSshKeyPairReader();
+
+ @Test
+ void testRead() throws GeneralSecurityException {
+ final ByteBuffer inputBuffer = getPrivateKeyBuffer();
+ final KeyPair keyPair = reader.read(inputBuffer);
+
+ assertNotNull(keyPair);
+
+ final PrivateKey privateKey = keyPair.getPrivate();
+ assertNotNull(privateKey);
+ assertArrayEquals(Ed25519KeyPairProvider.getPrivateKey().getEncoded(), privateKey.getEncoded());
+
+ final PublicKey publicKey = keyPair.getPublic();
+ assertNotNull(publicKey);
+ assertArrayEquals(Ed25519KeyPairProvider.getPublicKey().getEncoded(), publicKey.getEncoded());
+
+ assertEquals(Ed25519KeyIndicator.KEY_ALGORITHM.getIndicator(), privateKey.getAlgorithm());
+ assertEquals(Ed25519KeyIndicator.KEY_ALGORITHM.getIndicator(), publicKey.getAlgorithm());
+ }
+
+ static ByteBuffer getPrivateKeyBuffer() {
+ final byte[] publicKeyBlock = Ed25519KeyPairProvider.getPublicKey().getEncoded();
+ final byte[] privateKeyBlock = Ed25519KeyPairProvider.getPrivateKey().getEncoded();
+
+ final ByteBuffer inputBuffer = ByteBuffer.allocate(BUFFER_SIZE);
+
+ inputBuffer.putInt(publicKeyBlock.length);
+ inputBuffer.put(publicKeyBlock);
+
+ final int privatePublicKeyBlockLength = privateKeyBlock.length + publicKeyBlock.length;
+ inputBuffer.putInt(privatePublicKeyBlockLength);
+ inputBuffer.put(privateKeyBlock);
+ inputBuffer.put(publicKeyBlock);
+
+ inputBuffer.flip();
+ return inputBuffer;
+ }
+}
diff --git a/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/SshEd25519PublicKeyMarshallerTest.java b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/SshEd25519PublicKeyMarshallerTest.java
new file mode 100644
index 0000000..449383e
--- /dev/null
+++ b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/SshEd25519PublicKeyMarshallerTest.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+class SshEd25519PublicKeyMarshallerTest {
+ private static final byte[] SSH_ED25519_ALGORITHM_SERIALIZED = {0, 0, 0, 11, 115, 115, 104, 45, 101, 100, 50, 53, 53, 49, 57};
+
+ private SshEd25519PublicKeyMarshaller marshaller;
+
+ @BeforeEach
+ void setMarshaller() {
+ marshaller = new SshEd25519PublicKeyMarshaller();
+ }
+
+ @Test
+ void testGetMarshalledKey() {
+ final Ed25519PublicKey publicKey = Ed25519KeyPairProvider.getPublicKey();
+ final byte[] marshalledKey = marshaller.getMarshalledKey(publicKey);
+
+ assertNotNull(marshalledKey);
+ assertEquals(SshEd25519KeyType.MARSHALLED.getKeyLength(), marshalledKey.length);
+
+ final byte[] algorithm = Arrays.copyOfRange(marshalledKey, 0, SSH_ED25519_ALGORITHM_SERIALIZED.length);
+ assertArrayEquals(SSH_ED25519_ALGORITHM_SERIALIZED, algorithm);
+ }
+}
diff --git a/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/SshEd25519PublicKeyReaderTest.java b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/SshEd25519PublicKeyReaderTest.java
new file mode 100644
index 0000000..84b9ecb
--- /dev/null
+++ b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/SshEd25519PublicKeyReaderTest.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidKeyException;
+import java.util.Base64;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SshEd25519PublicKeyReaderTest {
+ private static final int BUFFER_SIZE = 128;
+
+ private static final int REQUIRED_LENGTH = 68;
+
+ private static final byte[] ALGORITHM = SshEd25519RecipientIndicator.STANZA_TYPE.getIndicator().getBytes(StandardCharsets.UTF_8);
+
+ private static final byte SPACE_SEPARATOR = 32;
+
+ private static final int SHORT_BLOCK = 16;
+
+ private static final Base64.Encoder ENCODER = Base64.getEncoder().withoutPadding();
+
+ private final SshEd25519PublicKeyReader reader = new SshEd25519PublicKeyReader();
+
+ @Test
+ void testRead() throws Exception {
+ final ByteBuffer inputBuffer = getPublicKeyBuffer();
+
+ final Ed25519PublicKey publicKey = reader.read(inputBuffer);
+
+ assertNotNull(publicKey);
+ }
+
+ @Test
+ void testReadAlgorithmNotFound() {
+ final ByteBuffer inputBuffer = ByteBuffer.allocate(BUFFER_SIZE);
+
+ final InvalidKeyException exception = assertThrows(InvalidKeyException.class, () -> reader.read(inputBuffer));
+ assertTrue(exception.getMessage().contains(SshEd25519RecipientIndicator.STANZA_TYPE.getIndicator()));
+ }
+
+ @Test
+ void testReadSpaceNotFound() {
+ final ByteBuffer inputBuffer = ByteBuffer.allocate(BUFFER_SIZE);
+ inputBuffer.put(ALGORITHM);
+ inputBuffer.put(ALGORITHM);
+ inputBuffer.flip();
+
+ assertThrows(InvalidKeyException.class, () -> reader.read(inputBuffer));
+ }
+
+ @Test
+ void testReadLengthLessThanRequired() {
+ final ByteBuffer inputBuffer = ByteBuffer.allocate(BUFFER_SIZE);
+ inputBuffer.put(ALGORITHM);
+ inputBuffer.put(SPACE_SEPARATOR);
+ inputBuffer.flip();
+
+ final InvalidKeyException exception = assertThrows(InvalidKeyException.class, () -> reader.read(inputBuffer));
+ assertTrue(exception.getMessage().contains(Integer.toString(REQUIRED_LENGTH)));
+ }
+
+ @Test
+ void testReadEncodedAlgorithmNotFound() {
+ final byte[] empty = new byte[REQUIRED_LENGTH];
+ final byte[] encoded = ENCODER.encode(empty);
+
+ final ByteBuffer inputBuffer = getInputBuffer(encoded);
+
+ final InvalidKeyException exception = assertThrows(InvalidKeyException.class, () -> reader.read(inputBuffer));
+ assertTrue(exception.getMessage().contains(SshEd25519RecipientIndicator.STANZA_TYPE.getIndicator()));
+ }
+
+ @Test
+ void testReadEncodedKeyBlockLengthNotValid() {
+ final ByteBuffer marshalled = ByteBuffer.allocate(REQUIRED_LENGTH);
+ marshalled.putInt(ALGORITHM.length);
+ marshalled.put(ALGORITHM);
+ marshalled.putInt(REQUIRED_LENGTH);
+
+ final byte[] encoded = ENCODER.encode(marshalled.array());
+ final ByteBuffer inputBuffer = getInputBuffer(encoded);
+
+ final InvalidKeyException exception = assertThrows(InvalidKeyException.class, () -> reader.read(inputBuffer));
+ assertTrue(exception.getMessage().contains(Integer.toString(REQUIRED_LENGTH)));
+ }
+
+ @Test
+ void testReadEncodedKeyBlockLengthNotExpected() {
+ final ByteBuffer marshalled = ByteBuffer.allocate(REQUIRED_LENGTH);
+ marshalled.putInt(ALGORITHM.length);
+ marshalled.put(ALGORITHM);
+ marshalled.putInt(SHORT_BLOCK);
+
+ final byte[] encoded = ENCODER.encode(marshalled.array());
+ final ByteBuffer inputBuffer = getInputBuffer(encoded);
+
+ final InvalidKeyException exception = assertThrows(InvalidKeyException.class, () -> reader.read(inputBuffer));
+ assertTrue(exception.getMessage().contains(Integer.toString(SHORT_BLOCK)));
+ }
+
+ static ByteBuffer getPublicKeyBuffer() {
+ final SshEd25519PublicKeyMarshaller publicKeyMarshaller = new SshEd25519PublicKeyMarshaller();
+
+ final byte[] marshalledKey = publicKeyMarshaller.getMarshalledKey(Ed25519KeyPairProvider.getPublicKey());
+ final byte[] encodedKey = ENCODER.encode(marshalledKey);
+
+ return getInputBuffer(encodedKey);
+ }
+
+ private static ByteBuffer getInputBuffer(final byte[] encodedKey) {
+ final ByteBuffer inputBuffer = ByteBuffer.allocate(BUFFER_SIZE);
+
+ inputBuffer.put(ALGORITHM);
+ inputBuffer.put(SPACE_SEPARATOR);
+ inputBuffer.put(encodedKey);
+
+ inputBuffer.flip();
+ return inputBuffer;
+ }
+}
diff --git a/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientStanzaReaderFactoryTest.java b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientStanzaReaderFactoryTest.java
new file mode 100644
index 0000000..daa0458
--- /dev/null
+++ b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientStanzaReaderFactoryTest.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import com.exceptionfactory.jagged.RecipientStanzaReader;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.security.GeneralSecurityException;
+import java.security.Provider;
+import java.security.Security;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+class SshEd25519RecipientStanzaReaderFactoryTest {
+ private static final String ALGORITHM_FILTER = String.format("KeyAgreement.%s", EllipticCurveKeyType.X25519.getAlgorithm());
+
+ @Test
+ void testNewRecipientStanzaReaderOpenSshKey() throws GeneralSecurityException, IOException {
+ final byte[] encoded = getOpenSshKeyEncoded();
+
+ final RecipientStanzaReader reader = SshEd25519RecipientStanzaReaderFactory.newRecipientStanzaReader(encoded);
+
+ assertNotNull(reader);
+ }
+
+ @Test
+ void testNewRecipientStanzaReaderOpenSshKeyWithProvider() throws GeneralSecurityException, IOException {
+ final byte[] encoded = getOpenSshKeyEncoded();
+ final Provider provider = getProvider();
+
+ final RecipientStanzaReader reader = SshEd25519RecipientStanzaReaderFactory.newRecipientStanzaReader(encoded, provider);
+
+ assertNotNull(reader);
+ }
+
+ private byte[] getOpenSshKeyEncoded() throws IOException {
+ final ByteBuffer privateKeyBuffer = OpenSshKeyPairReaderTest.getEd25519PrivateKeyBuffer();
+ final ByteBuffer inputBuffer = OpenSshKeyPairReaderTest.getKeyPairBuffer(SshKeyType.ED25519, privateKeyBuffer);
+ final byte[] encoded = new byte[inputBuffer.remaining()];
+ inputBuffer.get(encoded);
+ return encoded;
+ }
+
+ private Provider getProvider() {
+ final Provider[] providers = Security.getProviders(ALGORITHM_FILTER);
+ return providers[0];
+ }
+}
diff --git a/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientStanzaReaderTest.java b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientStanzaReaderTest.java
new file mode 100644
index 0000000..436db3e
--- /dev/null
+++ b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientStanzaReaderTest.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import com.exceptionfactory.jagged.FileKey;
+import com.exceptionfactory.jagged.RecipientStanza;
+import com.exceptionfactory.jagged.UnsupportedRecipientStanzaException;
+import com.exceptionfactory.jagged.framework.codec.CanonicalBase64;
+import com.exceptionfactory.jagged.framework.crypto.FileKeyDecryptor;
+import com.exceptionfactory.jagged.framework.crypto.FileKeyDecryptorFactory;
+import com.exceptionfactory.jagged.framework.crypto.FileKeyEncryptor;
+import com.exceptionfactory.jagged.framework.crypto.FileKeyEncryptorFactory;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.security.GeneralSecurityException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class SshEd25519RecipientStanzaReaderTest {
+ private static final String REJECTED = String.class.getSimpleName();
+
+ private static final byte[] EMPTY_BODY = new byte[0];
+
+ private static FileKey fileKey;
+
+ private static Ed25519PrivateKey privateKey;
+
+ private static Ed25519PublicKey publicKey;
+
+ private static String publicKeyFingerprint;
+
+ @Mock
+ private RecipientStanza recipientStanza;
+
+ private SshEd25519RecipientStanzaWriter writer;
+
+ private SshEd25519RecipientStanzaReader reader;
+
+ @BeforeAll
+ static void setFileKey() throws GeneralSecurityException {
+ fileKey = new FileKey();
+
+ publicKey = Ed25519KeyPairProvider.getPublicKey();
+ privateKey = Ed25519KeyPairProvider.getPrivateKey();
+
+ final SshEd25519PublicKeyMarshaller publicKeyMarshaller = new SshEd25519PublicKeyMarshaller();
+ final byte[] marshalledKey = publicKeyMarshaller.getMarshalledKey(publicKey);
+ final PublicKeyFingerprintProducer publicKeyFingerprintProducer = new StandardPublicKeyFingerprintProducer();
+ publicKeyFingerprint = publicKeyFingerprintProducer.getFingerprint(marshalledKey);
+ }
+
+ @BeforeEach
+ void setReader() throws GeneralSecurityException {
+ final X25519KeyPairGeneratorFactory keyPairGeneratorFactory = new X25519KeyPairGeneratorFactory();
+ final X25519KeyAgreementFactory keyAgreementFactory = new X25519KeyAgreementFactory();
+
+ final FileKeyEncryptorFactory fileKeyEncryptorFactory = new FileKeyEncryptorFactory();
+ final FileKeyEncryptor fileKeyEncryptor = fileKeyEncryptorFactory.newFileKeyEncryptor();
+ writer = new SshEd25519RecipientStanzaWriter(publicKey, keyPairGeneratorFactory, keyAgreementFactory, fileKeyEncryptor);
+
+ final FileKeyDecryptorFactory fileKeyDecryptorFactory = new FileKeyDecryptorFactory();
+ final FileKeyDecryptor fileKeyDecryptor = fileKeyDecryptorFactory.newFileKeyDecryptor();
+ reader = new SshEd25519RecipientStanzaReader(publicKey, privateKey, keyPairGeneratorFactory, keyAgreementFactory, fileKeyDecryptor);
+ }
+
+ @Test
+ void testGetFileKeyEmptyRecipientStanzas() {
+ assertThrows(UnsupportedRecipientStanzaException.class, () -> reader.getFileKey(Collections.emptyList()));
+ }
+
+ @Test
+ void testGetFileKeyRecipientStanzaTypeRejected() {
+ final List stanzas = Collections.singletonList(recipientStanza);
+ when(recipientStanza.getType()).thenReturn(REJECTED);
+
+ assertThrows(UnsupportedRecipientStanzaException.class, () -> reader.getFileKey(stanzas));
+ }
+
+ @Test
+ void testGetFileKeyRecipientStanzaKeyFingerprintNotFound() {
+ final List stanzas = Collections.singletonList(recipientStanza);
+ when(recipientStanza.getType()).thenReturn(SshEd25519RecipientIndicator.STANZA_TYPE.getIndicator());
+ when(recipientStanza.getArguments()).thenReturn(Collections.emptyList());
+
+ assertThrows(UnsupportedRecipientStanzaException.class, () -> reader.getFileKey(stanzas));
+ }
+
+ @Test
+ void testGetFileKeyRecipientStanzaKeyFingerprintNotMatched() {
+ final List stanzas = Collections.singletonList(recipientStanza);
+ when(recipientStanza.getType()).thenReturn(SshEd25519RecipientIndicator.STANZA_TYPE.getIndicator());
+ when(recipientStanza.getArguments()).thenReturn(Collections.singletonList(REJECTED));
+
+ assertThrows(UnsupportedRecipientStanzaException.class, () -> reader.getFileKey(stanzas));
+ }
+
+ @Test
+ void testGetFileKeyEphemeralShareNotFound() {
+ final List stanzas = Collections.singletonList(recipientStanza);
+ when(recipientStanza.getType()).thenReturn(SshEd25519RecipientIndicator.STANZA_TYPE.getIndicator());
+ when(recipientStanza.getArguments()).thenReturn(Collections.singletonList(publicKeyFingerprint));
+
+ assertThrows(UnsupportedRecipientStanzaException.class, () -> reader.getFileKey(stanzas));
+ }
+
+ @Test
+ void testGetFileKeyExtraArgumentFound() {
+ final List stanzas = Collections.singletonList(recipientStanza);
+ when(recipientStanza.getType()).thenReturn(SshEd25519RecipientIndicator.STANZA_TYPE.getIndicator());
+ when(recipientStanza.getArguments()).thenReturn(Arrays.asList(publicKeyFingerprint, REJECTED, REJECTED));
+
+ assertThrows(UnsupportedRecipientStanzaException.class, () -> reader.getFileKey(stanzas));
+ }
+
+ @Test
+ void testGetFileKeyEphemeralShareLengthNotMatched() {
+ final List stanzas = Collections.singletonList(recipientStanza);
+ when(recipientStanza.getType()).thenReturn(SshEd25519RecipientIndicator.STANZA_TYPE.getIndicator());
+ when(recipientStanza.getArguments()).thenReturn(Arrays.asList(publicKeyFingerprint, REJECTED));
+
+ assertThrows(UnsupportedRecipientStanzaException.class, () -> reader.getFileKey(stanzas));
+ }
+
+ @Test
+ void testGetFileKeyBodyEmpty() {
+ final List stanzas = Collections.singletonList(recipientStanza);
+ when(recipientStanza.getType()).thenReturn(SshEd25519RecipientIndicator.STANZA_TYPE.getIndicator());
+
+ final String ephemeralShare = CanonicalBase64.getEncoder().encodeToString(publicKey.getEncoded());
+
+ when(recipientStanza.getArguments()).thenReturn(Arrays.asList(publicKeyFingerprint, ephemeralShare));
+ when(recipientStanza.getBody()).thenReturn(EMPTY_BODY);
+
+ assertThrows(UnsupportedRecipientStanzaException.class, () -> reader.getFileKey(stanzas));
+ }
+
+ @Test
+ void testGetFileKey() throws GeneralSecurityException {
+ final Iterable recipientStanzas = writer.getRecipientStanzas(fileKey);
+
+ assertNotNull(recipientStanzas);
+
+ final FileKey fileKeyRead = reader.getFileKey(recipientStanzas);
+
+ assertNotNull(fileKeyRead);
+ assertArrayEquals(fileKey.getEncoded(), fileKeyRead.getEncoded());
+ }
+}
diff --git a/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientStanzaWriterFactoryTest.java b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientStanzaWriterFactoryTest.java
new file mode 100644
index 0000000..320a96e
--- /dev/null
+++ b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/SshEd25519RecipientStanzaWriterFactoryTest.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import com.exceptionfactory.jagged.RecipientStanzaWriter;
+import org.junit.jupiter.api.Test;
+
+import java.nio.ByteBuffer;
+import java.security.GeneralSecurityException;
+import java.security.Provider;
+import java.security.Security;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+class SshEd25519RecipientStanzaWriterFactoryTest {
+ private static final String ALGORITHM_FILTER = String.format("KeyAgreement.%s", EllipticCurveKeyType.X25519.getAlgorithm());
+
+ @Test
+ void testNewRecipientStanzaReaderOpenSshKey() throws GeneralSecurityException {
+ final byte[] encoded = getSshKeyEncoded();
+
+ final RecipientStanzaWriter writer = SshEd25519RecipientStanzaWriterFactory.newRecipientStanzaWriter(encoded);
+
+ assertNotNull(writer);
+ }
+
+ @Test
+ void testNewRecipientStanzaReaderOpenSshKeyWithProvider() throws GeneralSecurityException {
+ final byte[] encoded = getSshKeyEncoded();
+ final Provider provider = getProvider();
+
+ final RecipientStanzaWriter writer = SshEd25519RecipientStanzaWriterFactory.newRecipientStanzaWriter(encoded, provider);
+
+ assertNotNull(writer);
+ }
+
+ private byte[] getSshKeyEncoded() {
+ final ByteBuffer publicKeyBuffer = SshEd25519PublicKeyReaderTest.getPublicKeyBuffer();
+ final byte[] encoded = new byte[publicKeyBuffer.remaining()];
+ publicKeyBuffer.get(encoded);
+ return encoded;
+ }
+
+ private Provider getProvider() {
+ final Provider[] providers = Security.getProviders(ALGORITHM_FILTER);
+ return providers[0];
+ }
+}
diff --git a/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/SshRsaRecipientStanzaReaderFactoryTest.java b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/SshRsaRecipientStanzaReaderFactoryTest.java
index 3f494a8..00c2978 100644
--- a/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/SshRsaRecipientStanzaReaderFactoryTest.java
+++ b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/SshRsaRecipientStanzaReaderFactoryTest.java
@@ -45,7 +45,7 @@ void testNewRecipientStanzaReader() throws GeneralSecurityException {
@Test
void testNewRecipientStanzaReaderOpenSshKey() throws GeneralSecurityException, IOException {
final ByteBuffer rsaPrivateKeyBuffer = OpenSshKeyPairReaderTest.getRsaPrivateKeyBuffer();
- final ByteBuffer inputBuffer = OpenSshKeyPairReaderTest.getRsaKeyPairBuffer(rsaPrivateKeyBuffer);
+ final ByteBuffer inputBuffer = OpenSshKeyPairReaderTest.getKeyPairBuffer(SshKeyType.RSA, rsaPrivateKeyBuffer);
final byte[] encoded = new byte[inputBuffer.remaining()];
inputBuffer.get(encoded);
diff --git a/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/StandardEd25519KeyConverterTest.java b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/StandardEd25519KeyConverterTest.java
new file mode 100644
index 0000000..27e2ce5
--- /dev/null
+++ b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/StandardEd25519KeyConverterTest.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import com.exceptionfactory.jagged.framework.codec.CanonicalBase64;
+import com.exceptionfactory.jagged.framework.crypto.SharedSecretKey;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.security.GeneralSecurityException;
+import java.security.Key;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class StandardEd25519KeyConverterTest {
+ private static final String PUBLIC_KEY_CONVERTED = "r6lYp5xuYAh/v5QnHbcaCqPHRl//xBGwdOC84UOYcR0";
+
+ private static final String PRIVATE_KEY_CONVERTED = "OFnlRqUFQJ9xJt1LyDqzl+hACateNzj38P6OxYY/piE";
+
+ private static final String EXPECTED_FORMAT = "RAW";
+
+ private static final CanonicalBase64.Encoder ENCODER = CanonicalBase64.getEncoder();
+
+ private static byte[] publicKeyEncoded;
+
+ private static byte[] privateKeyEncoded;
+
+ private StandardEd25519KeyConverter converter;
+
+ @BeforeAll
+ static void setKeyPair() {
+ publicKeyEncoded = Ed25519KeyPairProvider.getPublicKey().getEncoded();
+ privateKeyEncoded = Ed25519KeyPairProvider.getPrivateKey().getEncoded();
+ }
+
+ @BeforeEach
+ void setConverter() throws GeneralSecurityException {
+ final X25519KeyPairGeneratorFactory keyPairGeneratorFactory = new X25519KeyPairGeneratorFactory();
+ converter = new StandardEd25519KeyConverter(keyPairGeneratorFactory);
+ }
+
+ @Test
+ void testGetPublicKey() throws GeneralSecurityException {
+ final SharedSecretKey sharedSecretKey = new SharedSecretKey(publicKeyEncoded);
+ final PublicKey publicKey = converter.getPublicKey(sharedSecretKey);
+
+ assertNotNull(publicKey);
+ final byte[] decoded = getDecoded(publicKey);
+ assertArrayEquals(publicKeyEncoded, decoded);
+ }
+
+ @Test
+ void testGetPublicKeyConverted() throws GeneralSecurityException {
+ final Ed25519PublicKey ed25519PublicKey = new Ed25519PublicKey(publicKeyEncoded);
+ final PublicKey publicKey = converter.getPublicKey(ed25519PublicKey);
+
+ assertNotNull(publicKey);
+ final byte[] decoded = getDecoded(publicKey);
+ final String encoded = ENCODER.encodeToString(decoded);
+
+ assertEquals(PUBLIC_KEY_CONVERTED, encoded);
+ assertEquals(EllipticCurveKeyType.ED25519.getAlgorithm(), ed25519PublicKey.getAlgorithm());
+ assertEquals(EllipticCurveKeyType.ED25519.getAlgorithm(), ed25519PublicKey.toString());
+ assertEquals(EXPECTED_FORMAT, ed25519PublicKey.getFormat());
+ }
+
+ @Test
+ void testGetPrivateKeyDerived() throws GeneralSecurityException {
+ final SshEd25519DerivedKey derivedKey = new SshEd25519DerivedKey(privateKeyEncoded);
+ final PrivateKey privateKey = converter.getPrivateKey(derivedKey);
+
+ assertNotNull(privateKey);
+ final byte[] decoded = getDecoded(privateKey);
+ assertArrayEquals(privateKeyEncoded, decoded);
+ }
+
+ @Test
+ void testGetPrivateKeyConverted() throws GeneralSecurityException {
+ final Ed25519PrivateKey ed25519PrivateKey = new Ed25519PrivateKey(privateKeyEncoded);
+ final PrivateKey privateKey = converter.getPrivateKey(ed25519PrivateKey);
+
+ assertNotNull(privateKey);
+ final byte[] decoded = getDecoded(privateKey);
+ final String encoded = ENCODER.encodeToString(decoded);
+
+ assertEquals(PRIVATE_KEY_CONVERTED, encoded);
+ assertEquals(EllipticCurveKeyType.ED25519.getAlgorithm(), ed25519PrivateKey.getAlgorithm());
+ assertEquals(EllipticCurveKeyType.ED25519.getAlgorithm(), ed25519PrivateKey.toString());
+ assertEquals(EXPECTED_FORMAT, ed25519PrivateKey.getFormat());
+
+ ed25519PrivateKey.destroy();
+ assertTrue(ed25519PrivateKey.isDestroyed());
+ }
+
+ private byte[] getDecoded(final Key key) {
+ final byte[] encoded = key.getEncoded();
+ final int encodedLength = encoded.length;
+ final int startPosition = encodedLength - EllipticCurveKeyType.X25519.getKeyLength();
+ return Arrays.copyOfRange(encoded, startPosition, encodedLength);
+ }
+}
diff --git a/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/X25519BasePointPublicKeyTest.java b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/X25519BasePointPublicKeyTest.java
new file mode 100644
index 0000000..e1d144b
--- /dev/null
+++ b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/X25519BasePointPublicKeyTest.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class X25519BasePointPublicKeyTest {
+ private static final int BASE_POINT = 9;
+
+ private static final String FORMAT = "RAW";
+
+ @Test
+ void testGetAlgorithm() {
+ final X25519BasePointPublicKey basePointPublicKey = new X25519BasePointPublicKey();
+
+ assertEquals(EllipticCurveKeyType.X25519.getAlgorithm(), basePointPublicKey.getAlgorithm());
+ }
+
+ @Test
+ void testGetFormat() {
+ final X25519BasePointPublicKey basePointPublicKey = new X25519BasePointPublicKey();
+
+ assertEquals(FORMAT, basePointPublicKey.getFormat());
+ }
+
+ @Test
+ void testGetEncoded() {
+ final X25519BasePointPublicKey basePointPublicKey = new X25519BasePointPublicKey();
+
+ final byte[] encoded = basePointPublicKey.getEncoded();
+ assertEquals(EllipticCurveKeyType.X25519.getKeyLength(), encoded.length);
+ assertEquals(BASE_POINT, encoded[0]);
+ }
+}
diff --git a/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/X25519KeyAgreementFactoryTest.java b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/X25519KeyAgreementFactoryTest.java
new file mode 100644
index 0000000..0117140
--- /dev/null
+++ b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/X25519KeyAgreementFactoryTest.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import javax.crypto.KeyAgreement;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.Provider;
+import java.security.Security;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+class X25519KeyAgreementFactoryTest {
+ private static final String ALGORITHM_FILTER = String.format("KeyAgreement.%s", EllipticCurveKeyType.X25519.getAlgorithm());
+
+ private static PrivateKey privateKey;
+
+ @BeforeAll
+ static void setPrivateKey() throws NoSuchAlgorithmException {
+ final X25519KeyPairGeneratorFactory keyPairGeneratorFactory = new X25519KeyPairGeneratorFactory();
+ final KeyPairGenerator keyPairGenerator = keyPairGeneratorFactory.getKeyPairGenerator();
+ final KeyPair keyPair = keyPairGenerator.generateKeyPair();
+ privateKey = keyPair.getPrivate();
+ }
+
+ @Test
+ void testGetInitializedKeyAgreement() throws GeneralSecurityException {
+ final X25519KeyAgreementFactory keyAgreementFactory = new X25519KeyAgreementFactory();
+ final KeyAgreement keyAgreement = keyAgreementFactory.getInitializedKeyAgreement(privateKey);
+
+ assertNotNull(keyAgreement);
+ }
+
+ @Test
+ void testGetInitializedKeyAgreementWithProvider() throws GeneralSecurityException {
+ final Provider provider = getProvider();
+ final X25519KeyAgreementFactory keyAgreementFactory = new X25519KeyAgreementFactory(provider);
+ final KeyAgreement keyAgreement = keyAgreementFactory.getInitializedKeyAgreement(privateKey);
+
+ assertNotNull(keyAgreement);
+ }
+
+ private Provider getProvider() {
+ final Provider[] providers = Security.getProviders(ALGORITHM_FILTER);
+ return providers[0];
+ }
+}
diff --git a/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/X25519KeyPairGeneratorFactoryTest.java b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/X25519KeyPairGeneratorFactoryTest.java
new file mode 100644
index 0000000..c88e787
--- /dev/null
+++ b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/X25519KeyPairGeneratorFactoryTest.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import org.junit.jupiter.api.Test;
+
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.Provider;
+import java.security.Security;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+class X25519KeyPairGeneratorFactoryTest {
+ private static final String ALGORITHM_FILTER = String.format("KeyPairGenerator.%s", EllipticCurveKeyType.X25519.getAlgorithm());
+
+ @Test
+ void testGetKeyPairGenerator() throws NoSuchAlgorithmException {
+ final X25519KeyPairGeneratorFactory keyPairGeneratorFactory = new X25519KeyPairGeneratorFactory();
+ final KeyPairGenerator keyPairGenerator = keyPairGeneratorFactory.getKeyPairGenerator();
+
+ assertNotNull(keyPairGenerator);
+ }
+
+ @Test
+ void testGetKeyPairGeneratorWithProvider() throws NoSuchAlgorithmException {
+ final Provider provider = getProvider();
+ final X25519KeyPairGeneratorFactory keyPairGeneratorFactory = new X25519KeyPairGeneratorFactory(provider);
+ final KeyPairGenerator keyPairGenerator = keyPairGeneratorFactory.getKeyPairGenerator();
+
+ assertNotNull(keyPairGenerator);
+ }
+
+ private Provider getProvider() {
+ final Provider[] providers = Security.getProviders(ALGORITHM_FILTER);
+ return providers[0];
+ }
+}
diff --git a/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/X25519SharedSecretKeyProducerTest.java b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/X25519SharedSecretKeyProducerTest.java
new file mode 100644
index 0000000..e35eed0
--- /dev/null
+++ b/jagged-ssh/src/test/java/com/exceptionfactory/jagged/ssh/X25519SharedSecretKeyProducerTest.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2023 Jagged Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exceptionfactory.jagged.ssh;
+
+import com.exceptionfactory.jagged.framework.crypto.SharedSecretKey;
+import org.junit.jupiter.api.Test;
+
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+
+class X25519SharedSecretKeyProducerTest {
+
+ @Test
+ void testGetSharedSecretKey() throws GeneralSecurityException {
+ final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(EllipticCurveKeyType.X25519.getAlgorithm());
+
+ final KeyPair senderKeyPair = keyPairGenerator.generateKeyPair();
+ final KeyPair recipientKeyPair = keyPairGenerator.generateKeyPair();
+
+ final X25519KeyAgreementFactory keyAgreementFactory = new X25519KeyAgreementFactory();
+
+ final X25519SharedSecretKeyProducer senderProducer = new X25519SharedSecretKeyProducer(senderKeyPair.getPrivate(), keyAgreementFactory);
+ final SharedSecretKey senderSharedSecretKey = senderProducer.getSharedSecretKey(recipientKeyPair.getPublic());
+
+ final X25519SharedSecretKeyProducer recipientProducer = new X25519SharedSecretKeyProducer(recipientKeyPair.getPrivate(), keyAgreementFactory);
+ final SharedSecretKey recipientSharedSecretKey = recipientProducer.getSharedSecretKey(senderKeyPair.getPublic());
+
+ assertArrayEquals(senderSharedSecretKey.getEncoded(), recipientSharedSecretKey.getEncoded());
+ }
+}