org.apache.sshd.putty.PuttyKeyPairResourceParser Maven / Gradle / Ivy
The newest version!
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 org.apache.sshd.putty;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.apache.sshd.common.NamedResource;
import org.apache.sshd.common.config.keys.IdentityResourceLoader;
import org.apache.sshd.common.config.keys.loader.KeyPairResourceParser;
import org.apache.sshd.common.digest.BuiltinDigests;
import org.apache.sshd.common.util.GenericUtils;
import org.apache.sshd.common.util.MapEntryUtils;
import org.apache.sshd.common.util.ValidateUtils;
import org.apache.sshd.common.util.buffer.BufferUtils;
import org.apache.sshd.common.util.security.SecurityUtils;
import org.bouncycastle.crypto.generators.Argon2BytesGenerator;
import org.bouncycastle.crypto.params.Argon2Parameters;
//CHECKSTYLE:OFF
/**
* Loads a {@link KeyPair} from PuTTY's ".ppk" file.
* Note(s):
*
* -
* The file appears to be a text file but it doesn't have a fixed encoding like UTF-8.
* We use UTF-8 as the default encoding - since the important part is all ASCII,
* this shouldn't really hurt the interpretation of the key.
*
*
* -
* Based on code from Kohsuke's Trilead Putty Extension
*
*
* -
* Encrypted keys requires AES-256-CBC support, which is available only if the
*
* Java Cryptography Extension (JCE) Unlimited Strength Jurisdiction Policy Files are installed
*
*
*
* Sample PuTTY file format
*
* PuTTY-User-Key-File-2: ssh-rsa
* Encryption: none
* Comment: rsa-key-20080514
* Public-Lines: 4
* AAAAB3NzaC1yc2EAAAABJQAAAIEAiPVUpONjGeVrwgRPOqy3Ym6kF/f8bltnmjA2
* BMdAtaOpiD8A2ooqtLS5zWYuc0xkW0ogoKvORN+RF4JI+uNUlkxWxnzJM9JLpnvA
* HrMoVFaQ0cgDMIHtE1Ob1cGAhlNInPCRnGNJpBNcJ/OJye3yt7WqHP4SPCCLb6nL
* nmBUrLM=
* Private-Lines: 8
* AAAAgGtYgJzpktzyFjBIkSAmgeVdozVhgKmF6WsDMUID9HKwtU8cn83h6h7ug8qA
* hUWcvVxO201/vViTjWVz9ALph3uMnpJiuQaaNYIGztGJBRsBwmQW9738pUXcsUXZ
* 79KJP01oHn6Wkrgk26DIOsz04QOBI6C8RumBO4+F1WdfueM9AAAAQQDmA4hcK8Bx
* nVtEpcF310mKD3nsbJqARdw5NV9kCxPnEsmy7Sy1L4Ob/nTIrynbc3MA9HQVJkUz
* 7V0va5Pjm/T7AAAAQQCYbnG0UEekwk0LG1Hkxh1OrKMxCw2KWMN8ac3L0LVBg/Tk
* 8EnB2oT45GGeJaw7KzdoOMFZz0iXLsVLNUjNn2mpAAAAQQCN6SEfWqiNzyc/w5n/
* lFVDHExfVUJp0wXv+kzZzylnw4fs00lC3k4PZDSsb+jYCMesnfJjhDgkUA0XPyo8
* Emdk
* Private-MAC: 50c45751d18d74c00fca395deb7b7695e3ed6f77
*
* @param Generic public key type
* @param Generic private key type
* @author Apache MINA SSHD Project
*/
//CHECKSTYLE:ON
public interface PuttyKeyPairResourceParser
extends IdentityResourceLoader, KeyPairResourceParser {
String KEY_FILE_HEADER_PREFIX = "PuTTY-User-Key-File-";
String PUBLIC_LINES_HEADER = "Public-Lines";
String PRIVATE_LINES_HEADER = "Private-Lines";
String PPK_FILE_SUFFIX = ".ppk";
List KNOWN_HEADERS = Collections.unmodifiableList(
Arrays.asList(
KEY_FILE_HEADER_PREFIX,
PUBLIC_LINES_HEADER,
PRIVATE_LINES_HEADER));
/**
* Value (case insensitive) used to denote that private key is not encrypted
*/
String NO_PRIVATE_KEY_ENCRYPTION_VALUE = "none";
/** PUTTY key v3 MAC key length */
int FORMAT_3_MAC_KEY_LENGTH = 32;
@Override
default boolean canExtractKeyPairs(NamedResource resourceKey, List lines)
throws IOException, GeneralSecurityException {
if (GenericUtils.isEmpty(lines)) {
return false;
}
for (String l : lines) {
l = GenericUtils.trimToEmpty(l);
for (String hdr : KNOWN_HEADERS) {
if (l.startsWith(hdr)) {
return true;
}
}
}
return false;
}
static byte[] decodePrivateKeyBytes(
int formatVersion, byte[] prvBytes, String algName, int numBits, String algMode, String password,
Map headers)
throws GeneralSecurityException {
Objects.requireNonNull(prvBytes, "No encrypted key bytes");
ValidateUtils.checkNotNullAndNotEmpty(algName, "No encryption algorithm", GenericUtils.EMPTY_OBJECT_ARRAY);
ValidateUtils.checkTrue(numBits > 0, "Invalid encryption key size: %d", numBits);
ValidateUtils.checkNotNullAndNotEmpty(algMode, "No encryption mode", GenericUtils.EMPTY_OBJECT_ARRAY);
ValidateUtils.checkNotNullAndNotEmpty(password, "No encryption password", GenericUtils.EMPTY_OBJECT_ARRAY);
if (!"AES".equalsIgnoreCase(algName)) {
throw new NoSuchAlgorithmException("decodePrivateKeyBytes(" + algName + "-" + numBits + "-" + algMode + ") N/A");
}
if ((numBits != 128) && (numBits != 192) && (numBits != 256)) {
throw new InvalidKeySpecException("Requested key size (" + numBits + ") is not supported");
}
byte[] initVector = new byte[16];
byte[] keyValue = new byte[numBits / Byte.SIZE];
decodeEncryptionKey(formatVersion, password, initVector, keyValue, headers);
try {
return decodePrivateKeyBytes(prvBytes, algName, algMode, numBits, initVector, keyValue);
} finally {
Arrays.fill(initVector, (byte) 0); // eliminate sensitive data a.s.a.p.
Arrays.fill(keyValue, (byte) 0); // eliminate sensitive data a.s.a.p.
}
}
static byte[] decodePrivateKeyBytes(
byte[] encBytes, String cipherName, String cipherMode, int numBits, byte[] initVector, byte[] keyValue)
throws GeneralSecurityException {
String xform = cipherName + "/" + cipherMode + "/NoPadding";
int maxAllowedBits = Cipher.getMaxAllowedKeyLength(xform);
// see http://www.javamex.com/tutorials/cryptography/unrestricted_policy_files.shtml
if (numBits > maxAllowedBits) {
throw new InvalidKeySpecException("decodePrivateKeyBytes(" + xform + ")"
+ " required key length (" + numBits + ") exceeds max. available: "
+ maxAllowedBits);
}
SecretKeySpec skeySpec = new SecretKeySpec(keyValue, cipherName);
IvParameterSpec ivspec = new IvParameterSpec(initVector);
Cipher cipher = SecurityUtils.getCipher(xform);
cipher.init(Cipher.DECRYPT_MODE, skeySpec, ivspec);
return cipher.doFinal(encBytes);
}
/**
* Converts a pass-phrase into a key, by following the conventions that PuTTY uses. Used to decrypt the private key
* when it's encrypted.
*
* @param formatVersion The file format version
* @param passphrase The Password to be used as seed for the key - ignored if {@code null}/empty
* @param iv Initialization vector to be populated if necessary
* @param key Key to be populated
* @param headers Any extra headers found in the PPK file that might be used for KDF
* @throws GeneralSecurityException If cannot derive the key bytes from the password
*/
static void decodeEncryptionKey(
int formatVersion, String passphrase, byte[] iv, byte[] key, Map headers)
throws GeneralSecurityException {
String keyDerivationType = getStringHeaderValue(headers, "Key-Derivation");
if (GenericUtils.isBlank(keyDerivationType)) {
deriveFormat2EncryptionKey(passphrase, iv, key);
} else if ("Argon2id".equalsIgnoreCase(keyDerivationType)
|| "Argon2i".equalsIgnoreCase(keyDerivationType)
|| "Argon2d".equalsIgnoreCase(keyDerivationType)) {
deriveFormat3EncryptionKey(passphrase, keyDerivationType, iv, key, headers);
} else {
throw new NoSuchAlgorithmException("Unsupported KDF method: " + keyDerivationType);
}
}
static void deriveFormat3EncryptionKey(
String passphrase, String keyDerivationType, byte[] iv, byte[] key, Map headers)
throws GeneralSecurityException {
ValidateUtils.checkNotNullAndNotEmpty(headers, "Mising file headers for KDF purposes");
Objects.requireNonNull(passphrase, "No passphrase provded");
int parallelism = getIntegerHeaderValue(headers, "Argon2-Parallelism");
int iterations = getIntegerHeaderValue(headers, "Argon2-Passes");
int memory = getIntegerHeaderValue(headers, "Argon2-Memory");
byte[] salt = ValidateUtils.checkNotNullAndNotEmpty(
getHexArrayHeaderValue(headers, "Argon2-Salt"), "No Argon2 salt value provided");
byte[] hashValue = new byte[key.length + iv.length + FORMAT_3_MAC_KEY_LENGTH];
byte[] passBytes = passphrase.getBytes(StandardCharsets.UTF_8);
try {
Argon2Parameters.Builder builder;
if ("Argon2id".equalsIgnoreCase(keyDerivationType)) {
builder = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id);
} else if ("Argon2i".equalsIgnoreCase(keyDerivationType)) {
builder = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_i);
} else if ("Argon2d".equalsIgnoreCase(keyDerivationType)) {
builder = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_i);
} else {
throw new NoSuchAlgorithmException("Unsupported key derivation type: " + keyDerivationType);
}
Argon2Parameters params = builder
.withSalt(salt)
.withParallelism(parallelism)
.withMemoryAsKB(memory)
.withIterations(iterations)
.build();
Argon2BytesGenerator generator = new Argon2BytesGenerator();
generator.init(params);
generator.generateBytes(passBytes, hashValue);
} finally {
Arrays.fill(passBytes, (byte) 0); // eliminate sensitive data a.s.a.p.
}
try {
System.arraycopy(hashValue, 0, key, 0, key.length);
System.arraycopy(hashValue, key.length, iv, 0, iv.length);
} finally {
Arrays.fill(hashValue, (byte) 0); // eliminate sensitive data a.s.a.p.
}
}
static String getStringHeaderValue(Map headers, String key) {
return MapEntryUtils.isEmpty(headers) ? null : headers.get(key);
}
static byte[] getHexArrayHeaderValue(Map headers, String key) {
String value = getStringHeaderValue(headers, key);
return BufferUtils.decodeHex(BufferUtils.EMPTY_HEX_SEPARATOR, value);
}
static int getIntegerHeaderValue(Map headers, String key) {
String value
= ValidateUtils.checkNotNullAndNotEmpty(getStringHeaderValue(headers, key), "Missing %s header value", key);
return Integer.parseInt(value);
}
/**
* Uses the "legacy" KDF via SHA-1
*
* @param passphrase The Password to be used as seed for the key - ignored if {@code null}/empty
* @param iv Initialization vector to be populated if necessary
* @param key Key to be populated
* @throws GeneralSecurityException If cannot retrieve SHA-1 digest
* @see
* How does Putty derive the encryption key in its .ppk format ?
*/
static void deriveFormat2EncryptionKey(String passphrase, byte[] iv, byte[] key) throws GeneralSecurityException {
Objects.requireNonNull(passphrase, "No passphrase provded");
byte[] passBytes = passphrase.getBytes(StandardCharsets.UTF_8);
try {
MessageDigest hash = SecurityUtils.getMessageDigest(BuiltinDigests.sha1.getAlgorithm());
byte[] stateValue = { 0, 0, 0, 0 };
try {
for (int i = 0, remLen = key.length; remLen > 0; i++) {
hash.reset(); // just making sure
stateValue[3] = (byte) i;
hash.update(stateValue);
hash.update(passBytes);
byte[] digest = hash.digest();
try {
System.arraycopy(digest, 0, key, i * 20, Math.min(20, remLen));
} finally {
Arrays.fill(digest, (byte) 0); // eliminate sensitive data a.s.a.p.
}
remLen -= 20;
}
} finally {
Arrays.fill(stateValue, (byte) 0); // eliminate sensitive data a.s.a.p.
}
Arrays.fill(iv, (byte) 0);
} finally {
Arrays.fill(passBytes, (byte) 0); // eliminate sensitive data a.s.a.p.
}
}
}