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()); + } +}