diff options
Diffstat (limited to 'org.eclipse.jgit.ssh.apache/src')
24 files changed, 3112 insertions, 80 deletions
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/AllowedSigners.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/AllowedSigners.java new file mode 100644 index 0000000000..80b171f216 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/AllowedSigners.java @@ -0,0 +1,530 @@ +/* + * Copyright (C) 2024, Thomas Wolf <twolf@apache.org> and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Distribution License v. 1.0 which is available at + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.internal.signing.ssh; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.StreamCorruptedException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.security.PublicKey; +import java.text.MessageFormat; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.temporal.ChronoField; +import java.time.temporal.TemporalAccessor; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import org.apache.sshd.common.config.keys.KeyUtils; +import org.apache.sshd.common.config.keys.OpenSshCertificate; +import org.apache.sshd.common.config.keys.PublicKeyEntry; +import org.apache.sshd.common.util.io.ModifiableFileWatcher; +import org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile; +import org.eclipse.jgit.internal.transport.sshd.SshdText; +import org.eclipse.jgit.signing.ssh.VerificationException; +import org.eclipse.jgit.util.StringUtils; +import org.eclipse.jgit.util.SystemReader; + +/** + * Encapsulates the allowed signers handling. + */ +final class AllowedSigners extends ModifiableFileWatcher { + + private static final String CERT_AUTHORITY = "cert-authority"; //$NON-NLS-1$ + + private static final String NAMESPACES = "namespaces="; //$NON-NLS-1$ + + private static final String VALID_AFTER = "valid-after="; //$NON-NLS-1$ + + private static final String VALID_BEFORE = "valid-before="; //$NON-NLS-1$ + + private static final DateTimeFormatter SSH_DATE_FORMAT = new DateTimeFormatterBuilder() + .appendValue(ChronoField.YEAR, 4) + .appendValue(ChronoField.MONTH_OF_YEAR, 2) + .appendValue(ChronoField.DAY_OF_MONTH, 2) + .optionalStart() + .appendValue(ChronoField.HOUR_OF_DAY, 2) + .appendValue(ChronoField.MINUTE_OF_HOUR, 2) + .optionalStart() + .appendValue(ChronoField.SECOND_OF_MINUTE, 2) + .toFormatter(Locale.ROOT); + + private static final Predicate<AllowedEntry> CERTIFICATES = AllowedEntry::isCA; + + private static final Predicate<AllowedEntry> PLAIN_KEYS = Predicate + .not(CERTIFICATES); + + @SuppressWarnings("ArrayRecordComponent") + static record AllowedEntry(String[] identities, boolean isCA, + String[] namespaces, Instant validAfter, Instant validBefore, + String key) { + // Empty + + @Override + public final boolean equals(Object any) { + if (this == any) { + return true; + } + if (any == null || !(any instanceof AllowedEntry)) { + return false; + } + AllowedEntry other = (AllowedEntry) any; + return isCA == other.isCA + && Arrays.equals(identities, other.identities) + && Arrays.equals(namespaces, other.namespaces) + && Objects.equals(validAfter, other.validAfter) + && Objects.equals(validBefore, other.validBefore) + && Objects.equals(key, other.key); + } + + @Override + public final int hashCode() { + int hash = Boolean.hashCode(isCA); + hash = hash * 31 + Arrays.hashCode(identities); + hash = hash * 31 + Arrays.hashCode(namespaces); + return hash * 31 + Objects.hash(validAfter, validBefore, key); + } + } + + private static record State(Map<String, List<AllowedEntry>> entries) { + // Empty + } + + private State state; + + public AllowedSigners(Path path) { + super(path); + state = new State(new HashMap<>()); + } + + public String isAllowed(PublicKey key, String namespace, String name, + Instant time) throws IOException, VerificationException { + State currentState = refresh(); + PublicKey keyToCheck = key; + if (key instanceof OpenSshCertificate certificate) { + AllowedEntry entry = find(currentState, certificate.getCaPubKey(), + namespace, name, time, CERTIFICATES); + if (entry != null) { + Collection<String> principals = certificate.getPrincipals(); + if (principals.isEmpty()) { + // According to the OpenSSH documentation, a certificate + // without principals is valid for anyone. + // + // See https://man.openbsd.org/ssh-keygen.1#CERTIFICATES . + // + // However, the same documentation also says that a name + // must match both the entry's patterns and be listed in the + // certificate's principals. + // + // See https://man.openbsd.org/ssh-keygen.1#ALLOWED_SIGNERS + // + // git/OpenSSH considers signatures made by such + // certificates untrustworthy. + String identities; + if (!StringUtils.isEmptyOrNull(name)) { + // The name must have matched entry.identities. + identities = name; + } else { + identities = Arrays.stream(entry.identities()) + .collect(Collectors.joining(",")); //$NON-NLS-1$ + } + throw new VerificationException(false, MessageFormat.format( + SshdText.get().signCertificateWithoutPrincipals, + KeyUtils.getFingerPrint(certificate.getCaPubKey()), + identities)); + } + if (!StringUtils.isEmptyOrNull(name)) { + if (!principals.contains(name)) { + throw new VerificationException(false, + MessageFormat.format(SshdText + .get().signCertificateNotForName, + KeyUtils.getFingerPrint( + certificate.getCaPubKey()), + name)); + } + return name; + } + // Filter the principals listed in the certificate by + // the patterns defined in the file. + Set<String> filtered = new LinkedHashSet<>(); + List<String> patterns = Arrays.asList(entry.identities()); + for (String principal : principals) { + if (OpenSshConfigFile.patternMatch(patterns, principal)) { + filtered.add(principal); + } + } + return filtered.stream().collect(Collectors.joining(",")); //$NON-NLS-1$ + } + // Certificate not found. git/OpenSSH considers this untrustworthy, + // even if the certified key itself might be listed. + return null; + // Alternative: go check for the certified key itself: + // keyToCheck = certificate.getCertPubKey(); + } + AllowedEntry entry = find(currentState, keyToCheck, namespace, name, + time, PLAIN_KEYS); + if (entry != null) { + if (!StringUtils.isEmptyOrNull(name)) { + // The name must have matched entry.identities. + return name; + } + // No name given, but we consider the key valid: report the + // identities. + return Arrays.stream(entry.identities()) + .collect(Collectors.joining(",")); //$NON-NLS-1$ + } + return null; + } + + private AllowedEntry find(State current, PublicKey key, + String namespace, String name, Instant time, + Predicate<AllowedEntry> filter) + throws VerificationException { + String k = PublicKeyEntry.toString(key); + VerificationException v = null; + List<AllowedEntry> candidates = current.entries().get(k); + if (candidates == null) { + return null; + } + for (AllowedEntry entry : candidates) { + if (!filter.test(entry)) { + continue; + } + if (name != null && !OpenSshConfigFile + .patternMatch(Arrays.asList(entry.identities()), name)) { + continue; + } + if (entry.namespaces() != null) { + if (!OpenSshConfigFile.patternMatch( + Arrays.asList(entry.namespaces()), + namespace)) { + if (v == null) { + v = new VerificationException(false, + MessageFormat.format( + SshdText.get().signWrongNamespace, + KeyUtils.getFingerPrint(key), + namespace)); + } + continue; + } + } + if (time != null) { + if (entry.validAfter() != null + && time.isBefore(entry.validAfter())) { + if (v == null) { + v = new VerificationException(true, + MessageFormat.format( + SshdText.get().signKeyTooEarly, + KeyUtils.getFingerPrint(key))); + } + continue; + } else if (entry.validBefore() != null + && time.isAfter(entry.validBefore())) { + if (v == null) { + v = new VerificationException(true, + MessageFormat.format( + SshdText.get().signKeyTooEarly, + KeyUtils.getFingerPrint(key))); + } + continue; + } + } + return entry; + } + if (v != null) { + throw v; + } + return null; + } + + private synchronized State refresh() throws IOException { + if (checkReloadRequired()) { + updateReloadAttributes(); + try { + state = reload(getPath()); + } catch (NoSuchFileException e) { + // File disappeared + resetReloadAttributes(); + state = new State(new HashMap<>()); + } + } + return state; + } + + private static State reload(Path path) throws IOException { + Map<String, List<AllowedEntry>> entries = new HashMap<>(); + try (BufferedReader r = Files.newBufferedReader(path, + StandardCharsets.UTF_8)) { + String line; + for (int lineNumber = 1;; lineNumber++) { + line = r.readLine(); + if (line == null) { + break; + } + line = line.strip(); + try { + AllowedEntry entry = parseLine(line); + if (entry != null) { + entries.computeIfAbsent(entry.key(), + k -> new ArrayList<>()).add(entry); + } + } catch (IOException | RuntimeException e) { + throw new IOException(MessageFormat.format( + SshdText.get().signAllowedSignersFormatError, path, + Integer.toString(lineNumber), line), e); + } + } + } + return new State(entries); + } + + private static boolean matches(String src, String other, int offset) { + return src.regionMatches(true, offset, other, 0, other.length()); + } + + // Things below have package visibility for testing. + + static AllowedEntry parseLine(String line) + throws IOException { + if (StringUtils.isEmptyOrNull(line) || line.charAt(0) == '#') { + return null; + } + int length = line.length(); + if ((matches(line, CERT_AUTHORITY, 0) + && CERT_AUTHORITY.length() < length + && Character.isWhitespace(line.charAt(CERT_AUTHORITY.length()))) + || matches(line, NAMESPACES, 0) + || matches(line, VALID_AFTER, 0) + || matches(line, VALID_BEFORE, 0)) { + throw new StreamCorruptedException( + SshdText.get().signAllowedSignersNoIdentities); + } + int i = 0; + while (i < length && !Character.isWhitespace(line.charAt(i))) { + i++; + } + if (i >= length) { + throw new StreamCorruptedException(SshdText.get().signAllowedSignersLineFormat); + } + String[] identities = line.substring(0, i).split(","); //$NON-NLS-1$ + if (Arrays.stream(identities).anyMatch(String::isEmpty)) { + throw new StreamCorruptedException(MessageFormat.format( + SshdText.get().signAllowedSignersEmptyIdentity, + line.substring(0, i))); + } + // Parse the options + i++; + boolean isCA = false; + List<String> namespaces = null; + Instant validAfter = null; + Instant validBefore = null; + while (i < length) { + // Skip whitespace + if (Character.isSpaceChar(line.charAt(i))) { + i++; + continue; + } + if (matches(line, CERT_AUTHORITY, i)) { + i += CERT_AUTHORITY.length(); + isCA = true; + if (!Character.isWhitespace(line.charAt(i))) { + throw new StreamCorruptedException(SshdText.get().signAllowedSignersCertAuthorityError); + } + i++; + } else if (matches(line, NAMESPACES, i)) { + if (namespaces != null) { + throw new StreamCorruptedException(MessageFormat.format( + SshdText.get().signAllowedSignersMultiple, + NAMESPACES)); + } + i += NAMESPACES.length(); + Dequoted parsed = dequote(line, i); + i = parsed.after(); + String ns = parsed.value(); + String[] items = ns.split(","); //$NON-NLS-1$ + namespaces = new ArrayList<>(items.length); + for (int j = 0; j < items.length; j++) { + String n = items[j].strip(); + if (!n.isEmpty()) { + namespaces.add(n); + } + } + if (namespaces.isEmpty()) { + throw new StreamCorruptedException( + SshdText.get().signAllowedSignersEmptyNamespaces); + } + } else if (matches(line, VALID_AFTER, i)) { + if (validAfter != null) { + throw new StreamCorruptedException(MessageFormat.format( + SshdText.get().signAllowedSignersMultiple, + VALID_AFTER)); + } + i += VALID_AFTER.length(); + Dequoted parsed = dequote(line, i); + i = parsed.after(); + validAfter = parseDate(parsed.value()); + } else if (matches(line, VALID_BEFORE, i)) { + if (validBefore != null) { + throw new StreamCorruptedException(MessageFormat.format( + SshdText.get().signAllowedSignersMultiple, + VALID_BEFORE)); + } + i += VALID_BEFORE.length(); + Dequoted parsed = dequote(line, i); + i = parsed.after(); + validBefore = parseDate(parsed.value()); + } else { + break; + } + } + // Now we should be at the key + String key = parsePublicKey(line, i); + return new AllowedEntry(identities, isCA, + namespaces == null ? null : namespaces.toArray(new String[0]), + validAfter, validBefore, key); + } + + static String parsePublicKey(String s, int from) + throws StreamCorruptedException { + int i = from; + int length = s.length(); + while (i < length && Character.isWhitespace(s.charAt(i))) { + i++; + } + if (i >= length) { + throw new StreamCorruptedException(MessageFormat.format( + SshdText.get().signAllowedSignersPublicKeyParsing, + s.substring(from))); + } + int start = i; + while (i < length && !Character.isWhitespace(s.charAt(i))) { + i++; + } + if (i >= length) { + throw new StreamCorruptedException(MessageFormat.format( + SshdText.get().signAllowedSignersPublicKeyParsing, + s.substring(start))); + } + int endOfKeyType = i; + i = endOfKeyType + 1; + while (i < length && Character.isWhitespace(s.charAt(i))) { + i++; + } + int startOfKey = i; + while (i < length && !Character.isWhitespace(s.charAt(i))) { + i++; + } + if (i == startOfKey) { + throw new StreamCorruptedException(MessageFormat.format( + SshdText.get().signAllowedSignersPublicKeyParsing, + s.substring(start))); + } + String keyType = s.substring(start, endOfKeyType); + String key = s.substring(startOfKey, i); + if (!key.startsWith("AAAA")) { //$NON-NLS-1$ + // base64 encoded SSH keys always start with four 'A's. + throw new StreamCorruptedException(MessageFormat.format( + SshdText.get().signAllowedSignersPublicKeyParsing, + s.substring(start))); + } + return keyType + ' ' + s.substring(startOfKey, i); + } + + static Instant parseDate(String input) { + // Allowed formats are YYYYMMDD[Z] or YYYYMMDDHHMM[SS][Z]. If 'Z', it's + // UTC, otherwise local time. + String timeSpec = input; + int length = input.length(); + if (length < 8) { + throw new IllegalArgumentException(MessageFormat.format( + SshdText.get().signAllowedSignersInvalidDate, input)); + } + boolean isUTC = false; + if (timeSpec.charAt(length - 1) == 'Z') { + isUTC = true; + timeSpec = timeSpec.substring(0, length - 1); + } + LocalDateTime time; + TemporalAccessor temporalAccessor = SSH_DATE_FORMAT.parseBest(timeSpec, + LocalDateTime::from, LocalDate::from); + if (temporalAccessor instanceof LocalDateTime) { + time = (LocalDateTime) temporalAccessor; + } else { + time = ((LocalDate) temporalAccessor).atStartOfDay(); + } + if (isUTC) { + return time.atOffset(ZoneOffset.UTC).toInstant(); + } + ZoneId tz = SystemReader.getInstance().getTimeZoneId(); + return time.atZone(tz).toInstant(); + } + + // OpenSSH uses the backslash *only* to quote the double-quote. + static Dequoted dequote(String line, int from) { + int length = line.length(); + int i = from; + if (line.charAt(i) == '"') { + boolean quoted = false; + i++; + StringBuilder b = new StringBuilder(); + while (i < length) { + char ch = line.charAt(i); + if (ch == '"') { + if (quoted) { + b.append(ch); + quoted = false; + } else { + break; + } + } else if (ch == '\\') { + quoted = true; + } else { + if (quoted) { + b.append('\\'); + } + b.append(ch); + quoted = false; + } + i++; + } + if (i >= length) { + throw new IllegalArgumentException( + SshdText.get().signAllowedSignersUnterminatedQuote); + } + return new Dequoted(b.toString(), i + 1); + } + while (i < length && !Character.isWhitespace(line.charAt(i))) { + i++; + } + return new Dequoted(line.substring(from, i), i); + } + + static record Dequoted(String value, int after) { + // Empty + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshBinaryKrl.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshBinaryKrl.java new file mode 100644 index 0000000000..6b19eb3295 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshBinaryKrl.java @@ -0,0 +1,491 @@ +/* + * Copyright (C) 2024, Thomas Wolf <twolf@apache.org> and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Distribution License v. 1.0 which is available at + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.internal.signing.ssh; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.StreamCorruptedException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.text.MessageFormat; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.apache.sshd.common.config.keys.OpenSshCertificate; +import org.apache.sshd.common.util.buffer.BufferUtils; +import org.apache.sshd.common.util.buffer.ByteArrayBuffer; +import org.eclipse.jgit.annotations.NonNull; +import org.eclipse.jgit.api.errors.JGitInternalException; +import org.eclipse.jgit.internal.transport.sshd.SshdText; +import org.eclipse.jgit.util.IO; +import org.eclipse.jgit.util.StringUtils; + +/** + * An implementation of OpenSSH binary format key revocation lists (KRLs). + * + * @see <a href= + * "https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.krl">PROTOCOL.krl</a> + */ +class OpenSshBinaryKrl { + + /** + * The "magic" bytes at the start of an OpenSSH binary KRL. + */ + static final byte[] MAGIC = { 'S', 'S', 'H', 'K', 'R', 'L', '\n', 0 }; + + private static final int FORMAT_VERSION = 1; + + private static final int SECTION_CERTIFICATES = 1; + + private static final int SECTION_KEY = 2; + + private static final int SECTION_SHA1 = 3; + + private static final int SECTION_SIGNATURE = 4; // Skipped + + private static final int SECTION_SHA256 = 5; + + private static final int SECTION_EXTENSION = 255; // Skipped + + // Certificates + + private static final int CERT_SERIAL_LIST = 0x20; + + private static final int CERT_SERIAL_RANGES = 0x21; + + private static final int CERT_SERIAL_BITS = 0x22; + + private static final int CERT_KEY_IDS = 0x23; + + private static final int CERT_EXTENSIONS = 0x39; // Skipped + + private final Map<Blob, CertificateRevocation> certificates = new HashMap<>(); + + private static class CertificateRevocation { + + final SerialRangeSet ranges = new SerialRangeSet(); + + final Set<String> keyIds = new HashSet<>(); + } + + // Plain keys + + /** + * A byte array that can be used as a key in a {@link Map} or {@link Set}. + * {@link #equals(Object)} and {@link #hashCode()} are based on the content. + * + * @param blob + * the array to wrap + */ + @SuppressWarnings("ArrayRecordComponent") + private static record Blob(byte[] blob) { + + @Override + public final boolean equals(Object any) { + if (this == any) { + return true; + } + if (any == null || !(any instanceof Blob)) { + return false; + } + Blob other = (Blob) any; + return Arrays.equals(blob, other.blob); + } + + @Override + public final int hashCode() { + return Arrays.hashCode(blob); + } + } + + private final Set<Blob> blobs = new HashSet<>(); + + private final Set<Blob> sha1 = new HashSet<>(); + + private final Set<Blob> sha256 = new HashSet<>(); + + private OpenSshBinaryKrl() { + // No public instantiation, use load(InputStream, boolean) instead. + } + + /** + * Tells whether the given key has been revoked. + * + * @param key + * {@link PublicKey} to check + * @return {@code true} if the key was revoked, {@code false} otherwise + */ + boolean isRevoked(PublicKey key) { + if (key instanceof OpenSshCertificate certificate) { + if (certificates.isEmpty()) { + return false; + } + // These apply to all certificates + if (isRevoked(certificate, certificates.get(null))) { + return true; + } + if (isRevoked(certificate, + certificates.get(blob(certificate.getCaPubKey())))) { + return true; + } + // Keys themselves are checked in OpenSshKrl. + return false; + } + if (!blobs.isEmpty() && blobs.contains(blob(key))) { + return true; + } + if (!sha256.isEmpty() && sha256.contains(hash("SHA256", key))) { //$NON-NLS-1$ + return true; + } + if (!sha1.isEmpty() && sha1.contains(hash("SHA1", key))) { //$NON-NLS-1$ + return true; + } + return false; + } + + private boolean isRevoked(OpenSshCertificate certificate, + CertificateRevocation revocations) { + if (revocations == null) { + return false; + } + String id = certificate.getId(); + if (!StringUtils.isEmptyOrNull(id) && revocations.keyIds.contains(id)) { + return true; + } + long serial = certificate.getSerial(); + if (serial != 0 && revocations.ranges.contains(serial)) { + return true; + } + return false; + } + + private Blob blob(PublicKey key) { + ByteArrayBuffer buf = new ByteArrayBuffer(); + buf.putRawPublicKey(key); + return new Blob(buf.getCompactData()); + } + + private Blob hash(String algorithm, PublicKey key) { + ByteArrayBuffer buf = new ByteArrayBuffer(); + buf.putRawPublicKey(key); + try { + return new Blob(MessageDigest.getInstance(algorithm) + .digest(buf.getCompactData())); + } catch (NoSuchAlgorithmException e) { + throw new JGitInternalException(e.getMessage(), e); + } + } + + /** + * Loads a binary KRL from the given stream. + * + * @param in + * {@link InputStream} to read from + * @param magicSkipped + * whether the {@link #MAGIC} bytes at the beginning have already + * been skipped + * @return a new {@link OpenSshBinaryKrl}. + * @throws IOException + * if the stream cannot be read as an OpenSSH binary KRL + */ + @NonNull + static OpenSshBinaryKrl load(InputStream in, boolean magicSkipped) + throws IOException { + if (!magicSkipped) { + byte[] magic = new byte[MAGIC.length]; + IO.readFully(in, magic); + if (!Arrays.equals(magic, MAGIC)) { + throw new StreamCorruptedException( + SshdText.get().signKrlInvalidMagic); + } + } + skipHeader(in); + return load(in); + } + + private static long getUInt(InputStream in) throws IOException { + byte[] buf = new byte[Integer.BYTES]; + IO.readFully(in, buf); + return BufferUtils.getUInt(buf); + } + + private static long getLong(InputStream in) throws IOException { + byte[] buf = new byte[Long.BYTES]; + IO.readFully(in, buf); + return BufferUtils.getLong(buf, 0, Long.BYTES); + } + + private static void skipHeader(InputStream in) throws IOException { + long version = getUInt(in); + if (version != FORMAT_VERSION) { + throw new StreamCorruptedException( + MessageFormat.format(SshdText.get().signKrlInvalidVersion, + Long.valueOf(version))); + } + // krl_version, generated_date, flags (none defined in version 1) + in.skip(24); + in.skip(getUInt(in)); // reserved + in.skip(getUInt(in)); // comment + } + + private static OpenSshBinaryKrl load(InputStream in) throws IOException { + OpenSshBinaryKrl krl = new OpenSshBinaryKrl(); + for (;;) { + int sectionType = in.read(); + if (sectionType < 0) { + break; // EOF + } + switch (sectionType) { + case SECTION_CERTIFICATES: + readCertificates(krl.certificates, in, getUInt(in)); + break; + case SECTION_KEY: + readBlobs("explicit_keys", krl.blobs, in, getUInt(in), 0); //$NON-NLS-1$ + break; + case SECTION_SHA1: + readBlobs("fingerprint_sha1", krl.sha1, in, getUInt(in), 20); //$NON-NLS-1$ + break; + case SECTION_SIGNATURE: + // Unsupported as of OpenSSH 9.4. It even refuses to load such + // KRLs. Just skip it. + in.skip(getUInt(in)); + break; + case SECTION_SHA256: + readBlobs("fingerprint_sha256", krl.sha256, in, getUInt(in), //$NON-NLS-1$ + 32); + break; + case SECTION_EXTENSION: + // No extensions are defined for version 1 KRLs. + in.skip(getUInt(in)); + break; + default: + throw new StreamCorruptedException(MessageFormat.format( + SshdText.get().signKrlUnknownSection, + Integer.valueOf(sectionType))); + } + } + return krl; + } + + private static void readBlobs(String sectionName, Set<Blob> blobs, + InputStream in, long sectionLength, long expectedBlobLength) + throws IOException { + while (sectionLength >= Integer.BYTES) { + // Read blobs. + long blobLength = getUInt(in); + sectionLength -= Integer.BYTES; + if (blobLength > sectionLength) { + throw new StreamCorruptedException(MessageFormat.format( + SshdText.get().signKrlBlobLengthInvalid, sectionName, + Long.valueOf(blobLength))); + } + if (expectedBlobLength != 0 && blobLength != expectedBlobLength) { + throw new StreamCorruptedException(MessageFormat.format( + SshdText.get().signKrlBlobLengthInvalidExpected, + sectionName, Long.valueOf(blobLength), + Long.valueOf(expectedBlobLength))); + } + byte[] blob = new byte[(int) blobLength]; + IO.readFully(in, blob); + sectionLength -= blobLength; + blobs.add(new Blob(blob)); + } + if (sectionLength != 0) { + throw new StreamCorruptedException( + MessageFormat.format(SshdText.get().signKrlBlobLeftover, + sectionName, Long.valueOf(sectionLength))); + } + } + + private static void readCertificates(Map<Blob, CertificateRevocation> certs, + InputStream in, long sectionLength) throws IOException { + long keyLength = getUInt(in); + sectionLength -= Integer.BYTES; + if (keyLength > sectionLength) { + throw new StreamCorruptedException(MessageFormat.format( + SshdText.get().signKrlCaKeyLengthInvalid, + Long.valueOf(keyLength))); + } + Blob key = null; + if (keyLength > 0) { + byte[] blob = new byte[(int) keyLength]; + IO.readFully(in, blob); + key = new Blob(blob); + sectionLength -= keyLength; + } + CertificateRevocation rev = certs.computeIfAbsent(key, + k -> new CertificateRevocation()); + long reservedLength = getUInt(in); + sectionLength -= Integer.BYTES; + if (reservedLength > sectionLength) { + throw new StreamCorruptedException(MessageFormat.format( + SshdText.get().signKrlCaKeyLengthInvalid, + Long.valueOf(reservedLength))); + } + in.skip(reservedLength); + sectionLength -= reservedLength; + if (sectionLength == 0) { + throw new StreamCorruptedException( + SshdText.get().signKrlNoCertificateSubsection); + } + while (sectionLength > 0) { + int subSection = in.read(); + if (subSection < 0) { + throw new EOFException(); + } + sectionLength--; + if (sectionLength < Integer.BYTES) { + throw new StreamCorruptedException(MessageFormat.format( + SshdText.get().signKrlCertificateLeftover, + Long.valueOf(sectionLength))); + } + long subLength = getUInt(in); + sectionLength -= Integer.BYTES; + if (subLength > sectionLength) { + throw new StreamCorruptedException(MessageFormat.format( + SshdText.get().signKrlCertificateSubsectionLength, + Long.valueOf(subLength))); + } + if (subLength > 0) { + switch (subSection) { + case CERT_SERIAL_LIST: + readSerials(rev.ranges, in, subLength, false); + break; + case CERT_SERIAL_RANGES: + readSerials(rev.ranges, in, subLength, true); + break; + case CERT_SERIAL_BITS: + readSerialBitSet(rev.ranges, in, subLength); + break; + case CERT_KEY_IDS: + readIds(rev.keyIds, in, subLength); + break; + case CERT_EXTENSIONS: + in.skip(subLength); + break; + default: + throw new StreamCorruptedException(MessageFormat.format( + SshdText.get().signKrlUnknownSubsection, + Long.valueOf(subSection))); + } + } + sectionLength -= subLength; + } + } + + private static void readSerials(SerialRangeSet set, InputStream in, + long length, boolean ranges) throws IOException { + while (length >= Long.BYTES) { + long a = getLong(in); + length -= Long.BYTES; + if (a == 0) { + throw new StreamCorruptedException( + SshdText.get().signKrlSerialZero); + } + if (!ranges) { + set.add(a); + continue; + } + if (length < Long.BYTES) { + throw new StreamCorruptedException( + MessageFormat.format(SshdText.get().signKrlShortRange, + Long.valueOf(length))); + } + long b = getLong(in); + length -= Long.BYTES; + if (Long.compareUnsigned(a, b) > 0) { + throw new StreamCorruptedException( + SshdText.get().signKrlEmptyRange); + } + set.add(a, b); + } + if (length != 0) { + throw new StreamCorruptedException(MessageFormat.format( + SshdText.get().signKrlCertificateSubsectionLeftover, + Long.valueOf(length))); + } + } + + private static void readSerialBitSet(SerialRangeSet set, InputStream in, + long subLength) throws IOException { + while (subLength > 0) { + if (subLength < Long.BYTES) { + throw new StreamCorruptedException(MessageFormat.format( + SshdText.get().signKrlCertificateSubsectionLeftover, + Long.valueOf(subLength))); + } + long base = getLong(in); + subLength -= Long.BYTES; + if (subLength < Integer.BYTES) { + throw new StreamCorruptedException(MessageFormat.format( + SshdText.get().signKrlCertificateSubsectionLeftover, + Long.valueOf(subLength))); + } + long setLength = getUInt(in); + subLength -= Integer.BYTES; + if (setLength == 0 || setLength > subLength) { + throw new StreamCorruptedException(MessageFormat.format( + SshdText.get().signKrlInvalidBitSetLength, + Long.valueOf(setLength))); + } + // Now process the bits. Note that the mpint is stored MSB first. + // + // We set individual serial numbers (one for each set bit) and let + // the SerialRangeSet take care of coalescing for successive runs + // of set bits. + int n = (int) setLength; + for (int i = n - 1; i >= 0; i--) { + int b = in.read(); + if (b < 0) { + throw new EOFException(); + } else if (b == 0) { + // Stored as an mpint: may have leading zero bytes (actually + // at most one; if the high bit of the first byte is set). + continue; + } + for (int bit = 0, + mask = 1; bit < Byte.SIZE; bit++, mask <<= 1) { + if ((b & mask) != 0) { + set.add(base + (i * Byte.SIZE) + bit); + } + } + } + subLength -= setLength; + } + } + + private static void readIds(Set<String> ids, InputStream in, long subLength) + throws IOException { + while (subLength >= Integer.BYTES) { + long length = getUInt(in); + subLength -= Integer.BYTES; + if (length > subLength) { + throw new StreamCorruptedException(MessageFormat.format( + SshdText.get().signKrlInvalidKeyIdLength, + Long.valueOf(length))); + } + byte[] bytes = new byte[(int) length]; + IO.readFully(in, bytes); + ids.add(new String(bytes, StandardCharsets.UTF_8)); + subLength -= length; + } + if (subLength != 0) { + throw new StreamCorruptedException(MessageFormat.format( + SshdText.get().signKrlCertificateSubsectionLeftover, + Long.valueOf(subLength))); + } + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshKrl.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshKrl.java new file mode 100644 index 0000000000..7993def90c --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshKrl.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2024, Thomas Wolf <twolf@apache.org> and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Distribution License v. 1.0 which is available at + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.internal.signing.ssh; + +import java.io.BufferedInputStream; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.security.PublicKey; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import org.apache.sshd.common.config.keys.OpenSshCertificate; +import org.apache.sshd.common.config.keys.PublicKeyEntry; +import org.apache.sshd.common.util.io.ModifiableFileWatcher; +import org.eclipse.jgit.util.IO; + +/** + * An implementation of an OpenSSH key revocation list (KRL), either a binary + * KRL or a simple list of public keys. + */ +class OpenSshKrl extends ModifiableFileWatcher { + + private static record State(Set<String> keys, OpenSshBinaryKrl krl) { + // Empty + } + + private State state; + + public OpenSshKrl(Path path) { + super(path); + state = new State(Set.of(), null); + } + + public boolean isRevoked(PublicKey key) throws IOException { + State current = refresh(); + return isRevoked(current, key); + } + + private boolean isRevoked(State current, PublicKey key) { + if (key instanceof OpenSshCertificate cert) { + OpenSshBinaryKrl krl = current.krl(); + if (krl != null && krl.isRevoked(cert)) { + return true; + } + if (isRevoked(current, cert.getCaPubKey()) + || isRevoked(current, cert.getCertPubKey())) { + return true; + } + return false; + } + OpenSshBinaryKrl krl = current.krl(); + if (krl != null) { + return krl.isRevoked(key); + } + return current.keys().contains(PublicKeyEntry.toString(key)); + } + + private synchronized State refresh() throws IOException { + if (checkReloadRequired()) { + updateReloadAttributes(); + try { + state = reload(getPath()); + } catch (NoSuchFileException e) { + // File disappeared + resetReloadAttributes(); + state = new State(Set.of(), null); + } + } + return state; + } + + private static State reload(Path path) throws IOException { + try (BufferedInputStream in = new BufferedInputStream( + Files.newInputStream(path))) { + byte[] magic = new byte[OpenSshBinaryKrl.MAGIC.length]; + in.mark(magic.length); + IO.readFully(in, magic); + if (Arrays.equals(magic, OpenSshBinaryKrl.MAGIC)) { + return new State(null, OpenSshBinaryKrl.load(in, true)); + } + // Otherwise try reading it textually + in.reset(); + return loadTextKrl(in); + } + } + + private static State loadTextKrl(InputStream in) throws IOException { + Set<String> keys = new HashSet<>(); + try (BufferedReader r = new BufferedReader( + new InputStreamReader(in, StandardCharsets.UTF_8))) { + String line; + for (;;) { + line = r.readLine(); + if (line == null) { + break; + } + line = line.strip(); + if (line.isEmpty() || line.charAt(0) == '#') { + continue; + } + keys.add(AllowedSigners.parsePublicKey(line, 0)); + } + } + return new State(keys, null); + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshSigningKeyDatabase.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshSigningKeyDatabase.java new file mode 100644 index 0000000000..aa26886839 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshSigningKeyDatabase.java @@ -0,0 +1,161 @@ +/* + * Copyright (C) 2024, Thomas Wolf <twolf@apache.org> and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Distribution License v. 1.0 which is available at + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.internal.signing.ssh; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.security.PublicKey; +import java.time.Instant; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import org.eclipse.jgit.annotations.NonNull; +import org.eclipse.jgit.lib.GpgConfig; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.signing.ssh.CachingSigningKeyDatabase; +import org.eclipse.jgit.signing.ssh.VerificationException; +import org.eclipse.jgit.util.FS; +import org.eclipse.jgit.util.StringUtils; + +/** + * A {@link CachingSigningKeyDatabase} using the OpenSSH allowed signers file + * and the OpenSSH key revocation list. + */ +public class OpenSshSigningKeyDatabase implements CachingSigningKeyDatabase { + + // Keep caches of allowed signers and KRLs. Cache by canonical path. + + private static final int DEFAULT_CACHE_SIZE = 5; + + private AtomicInteger cacheSize = new AtomicInteger(DEFAULT_CACHE_SIZE); + + private class LRU<K, V> extends LinkedHashMap<K, V> { + + private static final long serialVersionUID = 1L; + + LRU() { + super(DEFAULT_CACHE_SIZE, 0.75f, true); + } + + @Override + protected boolean removeEldestEntry(java.util.Map.Entry<K, V> eldest) { + return size() > cacheSize.get(); + } + } + + private final HashMap<Path, AllowedSigners> allowedSigners = new LRU<>(); + + private final HashMap<Path, OpenSshKrl> revocations = new LRU<>(); + + @Override + public boolean isRevoked(Repository repository, GpgConfig config, + PublicKey key) throws IOException { + String fileName = config.getSshRevocationFile(); + if (StringUtils.isEmptyOrNull(fileName)) { + return false; + } + File file = getFile(repository, fileName); + OpenSshKrl revocationList; + synchronized (revocations) { + revocationList = revocations.computeIfAbsent(file.toPath(), + OpenSshKrl::new); + } + return revocationList.isRevoked(key); + } + + @Override + public String isAllowed(Repository repository, GpgConfig config, + PublicKey key, String namespace, PersonIdent ident) + throws IOException, VerificationException { + String fileName = config.getSshAllowedSignersFile(); + if (StringUtils.isEmptyOrNull(fileName)) { + // No file configured. Git would error out. + return null; + } + File file = getFile(repository, fileName); + AllowedSigners allowed; + synchronized (allowedSigners) { + allowed = allowedSigners.computeIfAbsent(file.toPath(), + AllowedSigners::new); + } + Instant gitTime = null; + if (ident != null) { + gitTime = ident.getWhenAsInstant(); + } + return allowed.isAllowed(key, namespace, null, gitTime); + } + + private File getFile(@NonNull Repository repository, String fileName) + throws IOException { + File file; + if (fileName.startsWith("~/") //$NON-NLS-1$ + || fileName.startsWith('~' + File.separator)) { + file = FS.DETECTED.resolve(FS.DETECTED.userHome(), + fileName.substring(2)); + } else { + file = new File(fileName); + if (!file.isAbsolute()) { + file = new File(repository.getWorkTree(), fileName); + } + } + return file.getCanonicalFile(); + } + + @Override + public int getCacheSize() { + return cacheSize.get(); + } + + @Override + public void setCacheSize(int size) { + if (size > 0) { + cacheSize.set(size); + pruneCache(size); + } + } + + private void pruneCache(int size) { + prune(allowedSigners, size); + prune(revocations, size); + } + + private void prune(HashMap<?, ?> map, int size) { + synchronized (map) { + if (map.size() <= size) { + return; + } + Iterator<?> iter = map.entrySet().iterator(); + int i = 0; + while (iter.hasNext() && i < size) { + iter.next(); + i++; + } + while (iter.hasNext()) { + iter.next(); + iter.remove(); + } + } + } + + @Override + public void clearCache() { + synchronized (allowedSigners) { + allowedSigners.clear(); + } + synchronized (revocations) { + revocations.clear(); + } + } + +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SerialRangeSet.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SerialRangeSet.java new file mode 100644 index 0000000000..f4eb884239 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SerialRangeSet.java @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2024, Thomas Wolf <twolf@apache.org> and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Distribution License v. 1.0 which is available at + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.internal.signing.ssh; + +import java.util.TreeMap; + +import org.eclipse.jgit.internal.transport.sshd.SshdText; + +/** + * Encapsulates the storage for revoked certificate serial numbers. + */ +class SerialRangeSet { + + /** + * A range of certificate serial numbers [from..to], i.e., with both range + * limits included. + */ + private interface SerialRange { + + long from(); + + long to(); + } + + private static record Singleton(long from) implements SerialRange { + + @Override + public long to() { + return from; + } + } + + private static record Range(long from, long to) implements SerialRange { + + public Range(long from, long to) { + if (Long.compareUnsigned(from, to) > 0) { + throw new IllegalArgumentException( + SshdText.get().signKrlEmptyRange); + } + this.from = from; + this.to = to; + } + } + + // We use the same data structure as OpenSSH; basically a TreeSet of mutable + // SerialRanges. To get "mutability", the set is implemented as a TreeMap + // with the same elements as keys and values. + // + // get(x) will return null if none of the serial numbers in the range x is + // in the set, and some range (partially) overlapping with x otherwise. + // + // containsKey(x) will return true if there is any (partially) overlapping + // range in the TreeMap. + private final TreeMap<SerialRange, SerialRange> ranges = new TreeMap<>( + SerialRangeSet::compare); + + private static int compare(SerialRange a, SerialRange b) { + // Return == if they overlap + if (Long.compareUnsigned(a.to(), b.from()) >= 0 + && Long.compareUnsigned(a.from(), b.to()) <= 0) { + return 0; + } + return Long.compareUnsigned(a.from(), b.from()); + } + + void add(long serial) { + add(ranges, new Singleton(serial)); + } + + void add(long from, long to) { + add(ranges, new Range(from, to)); + } + + boolean contains(long serial) { + return ranges.containsKey(new Singleton(serial)); + } + + int size() { + return ranges.size(); + } + + boolean isEmpty() { + return ranges.isEmpty(); + } + + private static void add(TreeMap<SerialRange, SerialRange> ranges, + SerialRange newRange) { + for (;;) { + SerialRange existing = ranges.get(newRange); + if (existing == null) { + break; + } + if (Long.compareUnsigned(existing.from(), newRange.from()) <= 0 + && Long.compareUnsigned(existing.to(), + newRange.to()) >= 0) { + // newRange completely contained in existing + return; + } + ranges.remove(existing); + long newFrom = newRange.from(); + if (Long.compareUnsigned(existing.from(), newFrom) < 0) { + newFrom = existing.from(); + } + long newTo = newRange.to(); + if (Long.compareUnsigned(existing.to(), newTo) > 0) { + newTo = existing.to(); + } + newRange = new Range(newFrom, newTo); + } + // No overlapping range exists: check for coalescing with the + // previous/next range + SerialRange prev = ranges.floorKey(newRange); + if (prev != null && newRange.from() - prev.to() == 1) { + ranges.remove(prev); + newRange = new Range(prev.from(), newRange.to()); + } + SerialRange next = ranges.ceilingKey(newRange); + if (next != null && next.from() - newRange.to() == 1) { + ranges.remove(next); + newRange = new Range(newRange.from(), next.to()); + } + ranges.put(newRange, newRange); + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SigningDatabase.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SigningDatabase.java new file mode 100644 index 0000000000..e2e1a36840 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SigningDatabase.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2024, Thomas Wolf <twolf@apache.org> and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Distribution License v. 1.0 which is available at + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.internal.signing.ssh; + +import org.eclipse.jgit.signing.ssh.CachingSigningKeyDatabase; +import org.eclipse.jgit.signing.ssh.SigningKeyDatabase; + +/** + * A global {@link SigningKeyDatabase} instance. + */ +public final class SigningDatabase { + + private static SigningKeyDatabase INSTANCE = new OpenSshSigningKeyDatabase(); + + private SigningDatabase() { + // No instantiation + } + + /** + * Obtains the current instance. + * + * @return the global {@link SigningKeyDatabase} + */ + public static synchronized SigningKeyDatabase getInstance() { + return INSTANCE; + } + + /** + * Sets the global {@link SigningKeyDatabase}. + * + * @param database + * to set; if {@code null} a default database using the OpenSSH + * allowed signers file and the OpenSSH revocation list mechanism + * is used. + * @return the previously set {@link SigningKeyDatabase} + */ + public static synchronized SigningKeyDatabase setInstance( + SigningKeyDatabase database) { + SigningKeyDatabase previous = INSTANCE; + if (database != INSTANCE) { + if (INSTANCE instanceof CachingSigningKeyDatabase caching) { + caching.clearCache(); + } + if (database == null) { + INSTANCE = new OpenSshSigningKeyDatabase(); + } else { + INSTANCE = database; + } + } + return previous; + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshCertificateUtils.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshCertificateUtils.java new file mode 100644 index 0000000000..040c6d4368 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshCertificateUtils.java @@ -0,0 +1,175 @@ +/* + * Copyright (C) 2024, Thomas Wolf <twolf@apache.org> and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Distribution License v. 1.0 which is available at + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.internal.signing.ssh; + +import java.security.PublicKey; +import java.text.MessageFormat; +import java.time.Instant; + +import org.apache.sshd.common.config.keys.KeyUtils; +import org.apache.sshd.common.config.keys.OpenSshCertificate; +import org.apache.sshd.common.signature.BuiltinSignatures; +import org.apache.sshd.common.signature.Signature; +import org.apache.sshd.common.util.buffer.Buffer; +import org.apache.sshd.common.util.buffer.ByteArrayBuffer; +import org.eclipse.jgit.annotations.NonNull; +import org.eclipse.jgit.internal.transport.sshd.SshdText; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Utility methods for working with OpenSSH certificates. + */ +final class SshCertificateUtils { + + private static final Logger LOG = LoggerFactory + .getLogger(SshCertificateUtils.class); + + /** + * Verifies a certificate: checks that it is a user certificate and has a + * valid signature, and if a time is given, that the certificate is valid at + * that time. + * + * @param certificate + * {@link OpenSshCertificate} to verify + * @param signatureTime + * {@link Instant} to check whether the certificate is valid at + * that time; maybe {@code null}, in which case the valid-time + * check is skipped. + * @return {@code null} if the certificate is valid; otherwise a descriptive + * message + */ + static String verify(OpenSshCertificate certificate, + Instant signatureTime) { + if (!OpenSshCertificate.Type.USER.equals(certificate.getType())) { + return MessageFormat.format(SshdText.get().signNotUserCertificate, + KeyUtils.getFingerPrint(certificate.getCaPubKey())); + } + String message = verifySignature(certificate); + if (message == null && signatureTime != null) { + message = checkExpiration(certificate, signatureTime); + } + return message; + } + + /** + * Verifies the signature on a certificate. + * + * @param certificate + * {@link OpenSshCertificate} to verify + * @return {@code null} if the signature is valid; otherwise a descriptive + * message + */ + static String verifySignature(OpenSshCertificate certificate) { + // Verify the signature on the certificate. + // + // Note that OpenSSH certificates do not support chaining. + // + // ssh-keygen refuses to create a certificate for a certificate, so the + // certified key cannot be another OpenSshCertificate. Additionally, + // when creating a certificate ssh-keygen loads the CA private key to + // make the signature and reconstructs the public key that it stores in + // the certificate from that, so the CA public key also cannot be an + // OpenSshCertificate. + PublicKey caKey = certificate.getCaPubKey(); + PublicKey certifiedKey = certificate.getCertPubKey(); + if (caKey == null + || caKey instanceof OpenSshCertificate + || certifiedKey == null + || certifiedKey instanceof OpenSshCertificate) { + return SshdText.get().signCertificateInvalid; + } + // Verify that key type and algorithm match + String keyType = KeyUtils.getKeyType(caKey); + String certAlgorithm = certificate.getSignatureAlgorithm(); + if (!KeyUtils.getCanonicalKeyType(keyType) + .equals(KeyUtils.getCanonicalKeyType(certAlgorithm))) { + return MessageFormat.format( + SshdText.get().signCertAlgorithmMismatch, keyType, + KeyUtils.getFingerPrint(certificate.getCaPubKey()), + certAlgorithm); + } + BuiltinSignatures factory = BuiltinSignatures + .fromFactoryName(certAlgorithm); + if (factory == null || !factory.isSupported()) { + return MessageFormat.format(SshdText.get().signCertAlgorithmUnknown, + KeyUtils.getFingerPrint(certificate.getCaPubKey()), + certAlgorithm); + } + Signature signer = factory.create(); + try { + signer.initVerifier(null, caKey); + signer.update(null, getBlob(certificate)); + if (signer.verify(null, certificate.getRawSignature())) { + return null; + } + } catch (Exception e) { + LOG.warn("{}", SshdText.get().signLogFailure, e); //$NON-NLS-1$ + return SshdText.get().signSeeLog; + } + return MessageFormat.format(SshdText.get().signCertificateInvalid, + KeyUtils.getFingerPrint(certificate.getCaPubKey())); + } + + private static byte[] getBlob(OpenSshCertificate certificate) { + // Theoretically, this should be just certificate.getMessage(). But + // Apache MINA sshd has a bug and may return additional bytes if the + // certificate is not the first thing in the buffer it was read from. + // As a work-around, re-create the signed blob from scratch. + // + // This may be replaced by return certificate.getMessage() once the + // upstream bug is fixed. + // + // See https://github.com/apache/mina-sshd/issues/618 + Buffer tmp = new ByteArrayBuffer(); + tmp.putString(certificate.getKeyType()); + tmp.putBytes(certificate.getNonce()); + tmp.putRawPublicKeyBytes(certificate.getCertPubKey()); + tmp.putLong(certificate.getSerial()); + tmp.putInt(certificate.getType().getCode()); + tmp.putString(certificate.getId()); + Buffer list = new ByteArrayBuffer(); + list.putStringList(certificate.getPrincipals(), false); + tmp.putBytes(list.getCompactData()); + tmp.putLong(certificate.getValidAfter()); + tmp.putLong(certificate.getValidBefore()); + tmp.putCertificateOptions(certificate.getCriticalOptions()); + tmp.putCertificateOptions(certificate.getExtensions()); + tmp.putString(certificate.getReserved()); + Buffer inner = new ByteArrayBuffer(); + inner.putRawPublicKey(certificate.getCaPubKey()); + tmp.putBytes(inner.getCompactData()); + return tmp.getCompactData(); + } + + /** + * Checks whether a certificate is valid at a given time. + * + * @param certificate + * {@link OpenSshCertificate} to check + * @param signatureTime + * {@link Instant} to check + * @return {@code null} if the certificate is valid at the given instant; + * otherwise a descriptive message + */ + static String checkExpiration(OpenSshCertificate certificate, + @NonNull Instant signatureTime) { + long instant = signatureTime.getEpochSecond(); + if (Long.compareUnsigned(instant, certificate.getValidAfter()) < 0) { + return MessageFormat.format(SshdText.get().signCertificateTooEarly, + KeyUtils.getFingerPrint(certificate.getCaPubKey())); + } else if (Long.compareUnsigned(instant, + certificate.getValidBefore()) > 0) { + return MessageFormat.format(SshdText.get().signCertificateExpired, + KeyUtils.getFingerPrint(certificate.getCaPubKey())); + } + return null; + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSignatureConstants.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSignatureConstants.java new file mode 100644 index 0000000000..bc72196a22 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSignatureConstants.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2024, Thomas Wolf <twolf@apache.org> and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Distribution License v. 1.0 which is available at + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.internal.signing.ssh; + +import java.nio.charset.StandardCharsets; + +import org.eclipse.jgit.lib.Constants; + +/** + * Defines common constants for SSH signatures. + */ +final class SshSignatureConstants { + + private static final String SIGNATURE_END = "-----END SSH SIGNATURE-----"; //$NON-NLS-1$ + + static final byte[] MAGIC = { 'S', 'S', 'H', 'S', 'I', 'G' }; + + static final int VERSION = 1; + + static final String NAMESPACE = "git"; //$NON-NLS-1$ + + static final byte[] ARMOR_HEAD = Constants.SSH_SIGNATURE_PREFIX + .getBytes(StandardCharsets.US_ASCII); + + static final byte[] ARMOR_END = SIGNATURE_END + .getBytes(StandardCharsets.US_ASCII); + + private SshSignatureConstants() { + // No instantiation + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSignatureVerifier.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSignatureVerifier.java new file mode 100644 index 0000000000..76be340bc7 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSignatureVerifier.java @@ -0,0 +1,319 @@ +/* + * Copyright (C) 2024, Thomas Wolf <twolf@apache.org> and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Distribution License v. 1.0 which is available at + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.internal.signing.ssh; + +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.text.MessageFormat; +import java.time.Instant; +import java.util.Date; +import java.util.Locale; + +import org.apache.sshd.common.config.keys.KeyUtils; +import org.apache.sshd.common.config.keys.OpenSshCertificate; +import org.apache.sshd.common.keyprovider.KeyPairProvider; +import org.apache.sshd.common.signature.BuiltinSignatures; +import org.apache.sshd.common.signature.Signature; +import org.apache.sshd.common.util.buffer.Buffer; +import org.apache.sshd.common.util.buffer.ByteArrayBuffer; +import org.eclipse.jgit.internal.transport.sshd.SshdText; +import org.eclipse.jgit.lib.GpgConfig; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.SignatureVerifier; +import org.eclipse.jgit.signing.ssh.CachingSigningKeyDatabase; +import org.eclipse.jgit.signing.ssh.SigningKeyDatabase; +import org.eclipse.jgit.signing.ssh.VerificationException; +import org.eclipse.jgit.util.Base64; +import org.eclipse.jgit.util.RawParseUtils; +import org.eclipse.jgit.util.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A {@link SignatureVerifier} for SSH signatures. + */ +public class SshSignatureVerifier implements SignatureVerifier { + + private static final Logger LOG = LoggerFactory + .getLogger(SshSignatureVerifier.class); + + private static final byte[] OBJECT = { 'o', 'b', 'j', 'e', 'c', 't', ' ' }; + + private static final byte[] TREE = { 't', 'r', 'e', 'e', ' ' }; + + private static final byte[] TYPE = { 't', 'y', 'p', 'e', ' ' }; + + @Override + public String getName() { + return "ssh"; //$NON-NLS-1$ + } + + @Override + public SignatureVerification verify(Repository repository, GpgConfig config, + byte[] data, byte[] signatureData) throws IOException { + // This is a bit stupid. SSH signatures do not store a signer, nor a + // time the signature was created. So we must use the committer's or + // tagger's PersonIdent, but here we have neither. But... if we see + // that the data is a commit or tag, then we can parse the PersonIdent + // from the data. + // + // Note: we cannot assume that absent a principal recorded in the + // allowedSignersFile or on a certificate that the key used to sign the + // commit belonged to the committer. + PersonIdent gitIdentity = getGitIdentity(data); + Date signatureDate = null; + Instant signatureInstant = null; + if (gitIdentity != null) { + signatureDate = gitIdentity.getWhen(); + signatureInstant = gitIdentity.getWhenAsInstant(); + } + + TrustLevel trust = TrustLevel.NEVER; + byte[] decodedSignature; + try { + decodedSignature = dearmor(signatureData); + } catch (IllegalArgumentException e) { + return new SignatureVerification(getName(), signatureDate, null, + null, null, false, false, trust, + MessageFormat.format(SshdText.get().signInvalidSignature, + e.getLocalizedMessage())); + } + int start = RawParseUtils.match(decodedSignature, 0, + SshSignatureConstants.MAGIC); + if (start < 0) { + return new SignatureVerification(getName(), signatureDate, null, + null, null, false, false, trust, + SshdText.get().signInvalidMagic); + } + ByteArrayBuffer signature = new ByteArrayBuffer(decodedSignature, start, + decodedSignature.length - start); + + long version = signature.getUInt(); + if (version != SshSignatureConstants.VERSION) { + return new SignatureVerification(getName(), signatureDate, null, + null, null, false, false, trust, + MessageFormat.format(SshdText.get().signInvalidVersion, + Long.toString(version))); + } + + PublicKey key = signature.getPublicKey(); + String fingerprint; + if (key instanceof OpenSshCertificate cert) { + fingerprint = KeyUtils.getFingerPrint(cert.getCertPubKey()); + String message = SshCertificateUtils.verify(cert, signatureInstant); + if (message != null) { + return new SignatureVerification(getName(), signatureDate, null, + fingerprint, null, false, false, trust, message); + } + } else { + fingerprint = KeyUtils.getFingerPrint(key); + } + + String namespace = signature.getString(); + if (!SshSignatureConstants.NAMESPACE.equals(namespace)) { + return new SignatureVerification(getName(), signatureDate, null, + fingerprint, null, false, false, trust, + MessageFormat.format(SshdText.get().signInvalidNamespace, + namespace)); + } + + signature.getString(); // Skip the reserved field + String hashAlgorithm = signature.getString(); + byte[] hash; + try { + hash = MessageDigest + .getInstance(hashAlgorithm.toUpperCase(Locale.ROOT)) + .digest(data); + } catch (NoSuchAlgorithmException e) { + return new SignatureVerification(getName(), signatureDate, null, + fingerprint, null, false, false, trust, + MessageFormat.format( + SshdText.get().signUnknownHashAlgorithm, + hashAlgorithm)); + } + ByteArrayBuffer rawSignature = new ByteArrayBuffer( + signature.getBytes()); + if (signature.available() > 0) { + return new SignatureVerification(getName(), signatureDate, null, + fingerprint, null, false, false, trust, + SshdText.get().signGarbageAtEnd); + } + + String signatureAlgorithm = rawSignature.getString(); + switch (signatureAlgorithm) { + case KeyPairProvider.SSH_DSS: + case KeyPairProvider.SSH_DSS_CERT: + case KeyPairProvider.SSH_RSA: + case KeyPairProvider.SSH_RSA_CERT: + return new SignatureVerification(getName(), signatureDate, null, + fingerprint, null, false, false, trust, + MessageFormat.format(SshdText.get().signInvalidAlgorithm, + signatureAlgorithm)); + } + + String keyType = KeyUtils + .getSignatureAlgorithm(KeyUtils.getKeyType(key), key); + if (!KeyUtils.getCanonicalKeyType(keyType) + .equals(KeyUtils.getCanonicalKeyType(signatureAlgorithm))) { + return new SignatureVerification(getName(), signatureDate, null, + fingerprint, null, false, false, trust, + MessageFormat.format( + SshdText.get().signMismatchedSignatureAlgorithm, + keyType, signatureAlgorithm)); + } + + BuiltinSignatures factory = BuiltinSignatures + .fromFactoryName(signatureAlgorithm); + if (factory == null || !factory.isSupported()) { + return new SignatureVerification(getName(), signatureDate, null, + fingerprint, null, false, false, trust, + MessageFormat.format( + SshdText.get().signUnknownSignatureAlgorithm, + signatureAlgorithm)); + } + + boolean valid; + String message = null; + try { + Signature verifier = factory.create(); + verifier.initVerifier(null, + key instanceof OpenSshCertificate cert + ? cert.getCertPubKey() + : key); + // Feed it the data + Buffer toSign = new ByteArrayBuffer(); + toSign.putRawBytes(SshSignatureConstants.MAGIC); + toSign.putString(SshSignatureConstants.NAMESPACE); + toSign.putUInt(0); // reserved: zero-length string + toSign.putString(hashAlgorithm); + toSign.putBytes(hash); + verifier.update(null, toSign.getCompactData()); + valid = verifier.verify(null, rawSignature.getBytes()); + } catch (Exception e) { + LOG.warn("{}", SshdText.get().signLogFailure, e); //$NON-NLS-1$ + valid = false; + message = SshdText.get().signSeeLog; + } + boolean expired = false; + String principal = null; + if (valid) { + if (rawSignature.available() > 0) { + valid = false; + message = SshdText.get().signGarbageAtEnd; + } else { + SigningKeyDatabase database = SigningKeyDatabase.getInstance(); + if (database.isRevoked(repository, config, key)) { + valid = false; + if (key instanceof OpenSshCertificate certificate) { + message = MessageFormat.format( + SshdText.get().signCertificateRevoked, + KeyUtils.getFingerPrint( + certificate.getCaPubKey())); + } else { + message = SshdText.get().signKeyRevoked; + } + } else { + // This may turn a positive verification into a failed one. + try { + principal = database.isAllowed(repository, config, key, + SshSignatureConstants.NAMESPACE, gitIdentity); + if (!StringUtils.isEmptyOrNull(principal)) { + trust = TrustLevel.FULL; + } else { + valid = false; + message = SshdText.get().signNoPrincipalMatched; + trust = TrustLevel.UNKNOWN; + } + } catch (VerificationException e) { + valid = false; + message = e.getMessage(); + expired = e.isExpired(); + } catch (IOException e) { + LOG.warn("{}", SshdText.get().signLogFailure, e); //$NON-NLS-1$ + valid = false; + message = SshdText.get().signSeeLog; + } + } + } + } + return new SignatureVerification(getName(), signatureDate, null, + fingerprint, principal, valid, expired, trust, message); + } + + private static PersonIdent getGitIdentity(byte[] rawObject) { + // Data from a commit will start with "tree ID\n". + int i = RawParseUtils.match(rawObject, 0, TREE); + if (i > 0) { + i = RawParseUtils.committer(rawObject, 0); + if (i < 0) { + return null; + } + return RawParseUtils.parsePersonIdent(rawObject, i); + } + // Data from a tag will start with "object ID\ntype ". + i = RawParseUtils.match(rawObject, 0, OBJECT); + if (i > 0) { + i = RawParseUtils.nextLF(rawObject, i); + i = RawParseUtils.match(rawObject, i, TYPE); + if (i > 0) { + i = RawParseUtils.tagger(rawObject, 0); + if (i < 0) { + return null; + } + return RawParseUtils.parsePersonIdent(rawObject, i); + } + } + return null; + } + + private static byte[] dearmor(byte[] data) { + int start = RawParseUtils.match(data, 0, + SshSignatureConstants.ARMOR_HEAD); + if (start > 0) { + if (data[start] == '\r') { + start++; + } + if (data[start] == '\n') { + start++; + } + } + int end = data.length; + if (end > start + 1 && data[end - 1] == '\n') { + end--; + if (end > start + 1 && data[end - 1] == '\r') { + end--; + } + } + end = end - SshSignatureConstants.ARMOR_END.length; + if (end >= 0 && end >= start + && RawParseUtils.match(data, end, + SshSignatureConstants.ARMOR_END) >= 0) { + // end is fine: on the first the character of the end marker + } else { + // No end marker. + end = data.length; + } + if (start < 0) { + start = 0; + } + return Base64.decode(data, start, end - start); + } + + @Override + public void clear() { + SigningKeyDatabase database = SigningKeyDatabase.getInstance(); + if (database instanceof CachingSigningKeyDatabase caching) { + caching.clearCache(); + } + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSigner.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSigner.java new file mode 100644 index 0000000000..8cfe5f4766 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSigner.java @@ -0,0 +1,485 @@ +/* + * Copyright (C) 2024, Thomas Wolf <twolf@apache.org> and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Distribution License v. 1.0 which is available at + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.internal.signing.ssh; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.StreamCorruptedException; +import java.io.StringReader; +import java.nio.file.Files; +import java.nio.file.Path; +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.text.MessageFormat; +import java.util.AbstractMap.SimpleImmutableEntry; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +import org.apache.sshd.client.auth.pubkey.PublicKeyIdentity; +import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; +import org.apache.sshd.common.config.keys.KeyUtils; +import org.apache.sshd.common.config.keys.OpenSshCertificate; +import org.apache.sshd.common.config.keys.PublicKeyEntryResolver; +import org.apache.sshd.common.config.keys.loader.KeyPairResourceParser; +import org.apache.sshd.common.keyprovider.KeyPairProvider; +import org.apache.sshd.common.session.SessionContext; +import org.apache.sshd.common.signature.BuiltinSignatures; +import org.apache.sshd.common.signature.Signature; +import org.apache.sshd.common.util.buffer.Buffer; +import org.apache.sshd.common.util.buffer.ByteArrayBuffer; +import org.apache.sshd.common.util.security.SecurityUtils; +import org.eclipse.jgit.annotations.NonNull; +import org.eclipse.jgit.api.errors.CanceledException; +import org.eclipse.jgit.api.errors.UnsupportedSigningFormatException; +import org.eclipse.jgit.internal.transport.sshd.AuthenticationCanceledException; +import org.eclipse.jgit.internal.transport.sshd.PasswordProviderWrapper; +import org.eclipse.jgit.internal.transport.sshd.SshdText; +import org.eclipse.jgit.internal.transport.sshd.agent.SshAgentClient; +import org.eclipse.jgit.lib.GpgConfig; +import org.eclipse.jgit.lib.GpgSignature; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.Signer; +import org.eclipse.jgit.transport.CredentialsProvider; +import org.eclipse.jgit.transport.sshd.KeyPasswordProviderFactory; +import org.eclipse.jgit.transport.sshd.agent.Connector; +import org.eclipse.jgit.transport.sshd.agent.ConnectorFactory; +import org.eclipse.jgit.util.Base64; +import org.eclipse.jgit.util.FS; +import org.eclipse.jgit.util.FS.ExecutionResult; +import org.eclipse.jgit.util.StringUtils; +import org.eclipse.jgit.util.SystemReader; +import org.eclipse.jgit.util.TemporaryBuffer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A {@link Signer} to create SSH signatures. + * + * @see <a href= + * "https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig">PROTOCOL.sshsig</a> + */ +public class SshSigner implements Signer { + + private static final Logger LOG = LoggerFactory.getLogger(SshSigner.class); + + private static final String GIT_KEY_PREFIX = "key::"; //$NON-NLS-1$ + + // Base64 encoded lines should not be longer than 75 characters, plus the + // newline. + private static final int LINE_LENGTH = 75; + + @Override + public GpgSignature sign(Repository repository, GpgConfig config, + byte[] data, PersonIdent committer, String signingKey, + CredentialsProvider credentialsProvider) throws CanceledException, + IOException, UnsupportedSigningFormatException { + byte[] hash; + try { + hash = MessageDigest.getInstance("SHA512").digest(data); //$NON-NLS-1$ + } catch (NoSuchAlgorithmException e) { + throw new UnsupportedSigningFormatException( + MessageFormat.format( + SshdText.get().signUnknownHashAlgorithm, "SHA512"), //$NON-NLS-1$ + e); + } + Buffer toSign = new ByteArrayBuffer(); + toSign.putRawBytes(SshSignatureConstants.MAGIC); + toSign.putString(SshSignatureConstants.NAMESPACE); + toSign.putUInt(0); // reserved: zero-length string + toSign.putString("sha512"); //$NON-NLS-1$ + toSign.putBytes(hash); + String key = signingKey; + if (StringUtils.isEmptyOrNull(key)) { + key = config.getSigningKey(); + } + if (StringUtils.isEmptyOrNull(key)) { + key = defaultKeyCommand(repository, config); + // According to documentation, this is supposed to return a + // valid SSH public key prefixed with "key::". We don't enforce + // this: there might be older command implementations (like just + // calling "ssh-add -L") that return keys without prefix. + } + PublicKeyIdentity identity; + try { + identity = getIdentity(key, committer, credentialsProvider); + } catch (GeneralSecurityException e) { + throw new UnsupportedSigningFormatException(MessageFormat + .format(SshdText.get().signPublicKeyError, key), e); + } + String algorithm = KeyUtils + .getKeyType(identity.getKeyIdentity().getPublic()); + switch (algorithm) { + case KeyPairProvider.SSH_DSS: + case KeyPairProvider.SSH_DSS_CERT: + throw new UnsupportedSigningFormatException( + SshdText.get().signInvalidKeyDSA); + case KeyPairProvider.SSH_RSA: + algorithm = KeyUtils.RSA_SHA512_KEY_TYPE_ALIAS; + break; + case KeyPairProvider.SSH_RSA_CERT: + algorithm = KeyUtils.RSA_SHA512_CERT_TYPE_ALIAS; + break; + default: + break; + } + + Map.Entry<String, byte[]> rawSignature; + try { + rawSignature = identity.sign(null, algorithm, + toSign.getCompactData()); + } catch (Exception e) { + throw new UnsupportedSigningFormatException( + SshdText.get().signSignatureError, e); + } + algorithm = rawSignature.getKey(); + Buffer signature = new ByteArrayBuffer(); + signature.putRawBytes(SshSignatureConstants.MAGIC); + signature.putUInt(SshSignatureConstants.VERSION); + signature.putPublicKey(identity.getKeyIdentity().getPublic()); + signature.putString(SshSignatureConstants.NAMESPACE); + signature.putUInt(0); // reserved: zero-length string + signature.putString("sha512"); //$NON-NLS-1$ + Buffer sig = new ByteArrayBuffer(); + sig.putString(KeyUtils.getSignatureAlgorithm(algorithm, + identity.getKeyIdentity().getPublic())); + sig.putBytes(rawSignature.getValue()); + signature.putBytes(sig.getCompactData()); + return armor(signature.getCompactData()); + } + + private static String defaultKeyCommand(@NonNull Repository repository, + @NonNull GpgConfig config) throws IOException { + String command = config.getSshDefaultKeyCommand(); + if (StringUtils.isEmptyOrNull(command)) { + return null; + } + FS fileSystem = repository.getFS(); + if (fileSystem == null) { + fileSystem = FS.DETECTED; + } + ProcessBuilder builder = fileSystem.runInShell(command, + new String[] {}); + ExecutionResult result = null; + try { + result = fileSystem.execute(builder, null); + int exitCode = result.getRc(); + if (exitCode == 0) { + // The command is supposed to return a public key in its first + // line on stdout. + try (BufferedReader r = new BufferedReader( + new InputStreamReader( + result.getStdout().openInputStream(), + SystemReader.getInstance() + .getDefaultCharset()))) { + String line = r.readLine(); + if (line != null) { + line = line.strip(); + } + if (StringUtils.isEmptyOrNull(line)) { + throw new IOException(MessageFormat.format( + SshdText.get().signDefaultKeyEmpty, command)); + } + return line; + } + } + TemporaryBuffer stderr = result.getStderr(); + throw new IOException(MessageFormat.format( + SshdText.get().signDefaultKeyFailed, command, + Integer.toString(exitCode), toString(stderr))); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException( + MessageFormat.format( + SshdText.get().signDefaultKeyInterrupted, command), + e); + } finally { + if (result != null) { + if (result.getStderr() != null) { + result.getStderr().destroy(); + } + if (result.getStdout() != null) { + result.getStdout().destroy(); + } + } + } + } + + private static String toString(TemporaryBuffer b) { + if (b != null) { + try { + return new String(b.toByteArray(4000), + SystemReader.getInstance().getDefaultCharset()); + } catch (IOException e) { + LOG.warn("{}", SshdText.get().signStderr, e); //$NON-NLS-1$ + } + } + return ""; //$NON-NLS-1$ + } + + private static PublicKeyIdentity getIdentity(String signingKey, + PersonIdent committer, CredentialsProvider credentials) + throws CanceledException, GeneralSecurityException, IOException { + if (StringUtils.isEmptyOrNull(signingKey)) { + throw new IllegalArgumentException(SshdText.get().signNoSigningKey); + } + PublicKey publicKey = null; + PrivateKey privateKey = null; + File keyFile = null; + if (signingKey.startsWith(GIT_KEY_PREFIX)) { + try (StringReader r = new StringReader( + signingKey.substring(GIT_KEY_PREFIX.length()))) { + publicKey = fromEntry( + AuthorizedKeyEntry.readAuthorizedKeys(r, true)); + } + } else if (signingKey.startsWith("~/") //$NON-NLS-1$ + || signingKey.startsWith('~' + File.separator)) { + keyFile = new File(FS.DETECTED.userHome(), signingKey.substring(2)); + } else { + try (StringReader r = new StringReader(signingKey)) { + publicKey = fromEntry( + AuthorizedKeyEntry.readAuthorizedKeys(r, true)); + } catch (IOException e) { + // Ignore and try to read as a file + keyFile = new File(signingKey); + } + } + if (keyFile != null && keyFile.isFile()) { + try { + publicKey = fromEntry(AuthorizedKeyEntry + .readAuthorizedKeys(keyFile.toPath())); + if (publicKey == null) { + throw new IOException(MessageFormat.format( + SshdText.get().signTooManyPublicKeys, keyFile)); + } + // Try to find the private key so we don't go looking for + // the agent (or PKCS#11) in vain. + keyFile = getPrivateKeyFile(keyFile.getParentFile(), + keyFile.getName()); + if (keyFile != null) { + try { + KeyPair pair = loadPrivateKey(keyFile.toPath(), + credentials); + if (pair != null) { + PublicKey pk = pair.getPublic(); + if (pk == null) { + privateKey = pair.getPrivate(); + } else { + PublicKey original = publicKey; + if (publicKey instanceof OpenSshCertificate cert) { + original = cert.getCertPubKey(); + } + if (KeyUtils.compareKeys(original, pk)) { + privateKey = pair.getPrivate(); + } + } + } + } catch (IOException e) { + // Apparently it wasn't a private key file. Ignore. + } + } + } catch (StreamCorruptedException e) { + // File is readable, but apparently not a public key. Try to + // load it as a private key. + KeyPair pair = loadPrivateKey(keyFile.toPath(), credentials); + if (pair != null) { + publicKey = pair.getPublic(); + privateKey = pair.getPrivate(); + } + } + } + if (publicKey == null) { + throw new IOException(MessageFormat + .format(SshdText.get().signNoPublicKey, signingKey)); + } + if (publicKey instanceof OpenSshCertificate cert) { + String message = SshCertificateUtils.verify(cert, + committer.getWhenAsInstant()); + if (message != null) { + throw new IOException(message); + } + } + if (privateKey == null) { + // Could be in the agent, or a PKCS#11 key. The normal procedure + // with PKCS#11 keys is to put them in the agent and let the agent + // deal with it. + // + // This may or may not work well. For instance, the agent might ask + // for a passphrase for PKCS#11 keys... also, the OpenSSH ssh-agent + // had a bug with signing using PKCS#11 certificates in the agent; + // see https://bugzilla.mindrot.org/show_bug.cgi?id=3613 . If there + // are troubles, we might do the PKCS#11 dance ourselves, but we'd + // need additional configuration for the PKCS#11 library. (Plus + // some refactoring in the Pkcs11Provider.) + return new AgentIdentity(publicKey); + + } + return new KeyPairIdentity(new KeyPair(publicKey, privateKey)); + } + + private static File getPrivateKeyFile(File directory, + String publicKeyName) { + if (publicKeyName.endsWith(".pub")) { //$NON-NLS-1$ + String privateKeyName = publicKeyName.substring(0, + publicKeyName.length() - 4); + if (!privateKeyName.isEmpty()) { + File keyFile = new File(directory, privateKeyName); + if (keyFile.isFile()) { + return keyFile; + } + if (privateKeyName.endsWith("-cert")) { //$NON-NLS-1$ + privateKeyName = privateKeyName.substring(0, + privateKeyName.length() - 5); + if (!privateKeyName.isEmpty()) { + keyFile = new File(directory, privateKeyName); + if (keyFile.isFile()) { + return keyFile; + } + } + } + } + } + return null; + } + + private static KeyPair loadPrivateKey(Path path, + CredentialsProvider credentials) + throws CanceledException, GeneralSecurityException, IOException { + if (!Files.isRegularFile(path)) { + return null; + } + KeyPairResourceParser parser = SecurityUtils.getKeyPairResourceParser(); + if (parser != null) { + PasswordProviderWrapper provider = null; + if (credentials != null) { + provider = new PasswordProviderWrapper( + () -> KeyPasswordProviderFactory.getInstance() + .apply(credentials)); + } + try { + Collection<KeyPair> keyPairs = parser.loadKeyPairs(null, path, + provider); + if (keyPairs.size() != 1) { + throw new GeneralSecurityException(MessageFormat.format( + SshdText.get().signTooManyPrivateKeys, path)); + } + return keyPairs.iterator().next(); + } catch (AuthenticationCanceledException e) { + throw new CanceledException(e.getMessage()); + } + } + return null; + } + + private static GpgSignature armor(byte[] data) throws IOException { + try (ByteArrayOutputStream b = new ByteArrayOutputStream()) { + b.write(SshSignatureConstants.ARMOR_HEAD); + b.write('\n'); + String encoded = Base64.encodeBytes(data); + int length = encoded.length(); + int column = 0; + for (int i = 0; i < length; i++) { + b.write(encoded.charAt(i)); + column++; + if (column == LINE_LENGTH) { + b.write('\n'); + column = 0; + } + } + if (column > 0) { + b.write('\n'); + } + b.write(SshSignatureConstants.ARMOR_END); + b.write('\n'); + return new GpgSignature(b.toByteArray()); + } + } + + private static PublicKey fromEntry(List<AuthorizedKeyEntry> entries) + throws GeneralSecurityException, IOException { + if (entries == null || entries.size() != 1) { + return null; + } + return entries.get(0).resolvePublicKey(null, + PublicKeyEntryResolver.FAILING); + } + + @Override + public boolean canLocateSigningKey(Repository repository, GpgConfig config, + PersonIdent committer, String signingKey, + CredentialsProvider credentialsProvider) throws CanceledException { + String key = signingKey; + if (key == null) { + key = config.getSigningKey(); + } + return !(StringUtils.isEmptyOrNull(key) + && StringUtils.isEmptyOrNull(config.getSshDefaultKeyCommand())); + } + + private static class KeyPairIdentity implements PublicKeyIdentity { + + private final @NonNull KeyPair pair; + + KeyPairIdentity(@NonNull KeyPair pair) { + this.pair = pair; + } + + @Override + public KeyPair getKeyIdentity() { + return pair; + } + + @Override + public Entry<String, byte[]> sign(SessionContext session, String algo, + byte[] data) throws Exception { + BuiltinSignatures factory = BuiltinSignatures.fromFactoryName(algo); + if (factory == null || !factory.isSupported()) { + throw new GeneralSecurityException(MessageFormat.format( + SshdText.get().signUnknownSignatureAlgorithm, algo)); + } + Signature signer = factory.create(); + signer.initSigner(null, pair.getPrivate()); + signer.update(null, data); + return new SimpleImmutableEntry<>(factory.getName(), + signer.sign(null)); + } + } + + private static class AgentIdentity extends KeyPairIdentity { + + AgentIdentity(PublicKey publicKey) { + super(new KeyPair(publicKey, null)); + } + + @Override + public Entry<String, byte[]> sign(SessionContext session, String algo, + byte[] data) throws Exception { + ConnectorFactory factory = ConnectorFactory.getDefault(); + Connector connector = factory == null ? null + : factory.create("", null); //$NON-NLS-1$ + if (connector == null) { + throw new IOException(SshdText.get().signNoAgent); + } + try (SshAgentClient agent = new SshAgentClient(connector)) { + return agent.sign(null, getKeyIdentity().getPublic(), algo, + data); + } + } + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPublicKeyAuthentication.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPublicKeyAuthentication.java index b0b1028daa..6aace4753a 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPublicKeyAuthentication.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPublicKeyAuthentication.java @@ -17,6 +17,7 @@ import static org.eclipse.jgit.transport.SshConstants.PUBKEY_ACCEPTED_ALGORITHMS import java.io.File; import java.io.IOException; +import java.io.StreamCorruptedException; import java.net.URISyntaxException; import java.nio.file.Files; import java.nio.file.InvalidPathException; @@ -355,20 +356,20 @@ public class JGitPublicKeyAuthentication extends UserAuthPublicKey { // only warn about non-existing files in case the key file is // not derived if (!isDerived) { - log.warn("{}", //$NON-NLS-1$ + log.warn(LOG_FORMAT, format(SshdText.get().cannotReadPublicKey, keyFile)); } - } catch (InvalidPathException | IOException e) { - log.warn("{}", //$NON-NLS-1$ - format(SshdText.get().cannotReadPublicKey, keyFile), e); - } catch (GeneralSecurityException e) { + } catch (GeneralSecurityException | StreamCorruptedException e) { // ignore in case this is not a derived key path, as in most // cases this specifies a private key if (isDerived) { - log.warn("{}", //$NON-NLS-1$ + log.warn(LOG_FORMAT, format(SshdText.get().cannotReadPublicKey, keyFile), e); } + } catch (InvalidPathException | IOException e) { + log.warn(LOG_FORMAT, + format(SshdText.get().cannotReadPublicKey, keyFile), e); } return null; } diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/KnownHostEntryReader.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/KnownHostEntryReader.java index 96829b7365..6b2345df1b 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/KnownHostEntryReader.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/KnownHostEntryReader.java @@ -29,6 +29,7 @@ import org.apache.sshd.client.config.hosts.HostPatternsHolder; import org.apache.sshd.client.config.hosts.KnownHostEntry; import org.apache.sshd.client.config.hosts.KnownHostHashValue; import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; +import org.apache.sshd.common.config.keys.PublicKeyEntry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -97,7 +98,7 @@ public class KnownHostEntryReader { return i < 0 ? line.trim() : line.substring(0, i).trim(); } - private static KnownHostEntry parseHostEntry(String line) { + static KnownHostEntry parseHostEntry(String line) { KnownHostEntry entry = new KnownHostEntry(); entry.setConfigLine(line); String tmp = line; @@ -135,8 +136,8 @@ public class KnownHostEntryReader { entry.setPatterns(patterns); } tmp = tmp.substring(i + 1).trim(); - AuthorizedKeyEntry key = AuthorizedKeyEntry - .parseAuthorizedKeyEntry(tmp); + AuthorizedKeyEntry key = PublicKeyEntry + .parsePublicKeyEntry(new AuthorizedKeyEntry(), tmp); if (key == null) { return null; } diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/OpenSshServerKeyDatabase.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/OpenSshServerKeyDatabase.java index 2b4f7e50ff..acb77c5bb7 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/OpenSshServerKeyDatabase.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/OpenSshServerKeyDatabase.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, 2021 Thomas Wolf <thomas.wolf@paranor.ch> and others + * Copyright (C) 2018, 2025 Thomas Wolf <twolf@apache.org> and others * * This program and the accompanying materials are made available under the * terms of the Eclipse Distribution License v. 1.0 which is available at @@ -31,9 +31,11 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Random; +import java.util.Set; import java.util.TreeSet; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; @@ -45,10 +47,13 @@ import org.apache.sshd.client.config.hosts.KnownHostHashValue; import org.apache.sshd.client.keyverifier.KnownHostsServerKeyVerifier.HostEntryPair; import org.apache.sshd.client.session.ClientSession; import org.apache.sshd.common.NamedFactory; +import org.apache.sshd.common.SshConstants; import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; import org.apache.sshd.common.config.keys.KeyUtils; +import org.apache.sshd.common.config.keys.OpenSshCertificate; import org.apache.sshd.common.config.keys.PublicKeyEntry; import org.apache.sshd.common.config.keys.PublicKeyEntryResolver; +import org.apache.sshd.common.config.keys.UnsupportedSshPublicKey; import org.apache.sshd.common.digest.BuiltinDigests; import org.apache.sshd.common.mac.Mac; import org.apache.sshd.common.util.io.ModifiableFileWatcher; @@ -126,6 +131,9 @@ public class OpenSshServerKeyDatabase /** Can be used to mark revoked known host lines. */ private static final String MARKER_REVOKED = "revoked"; //$NON-NLS-1$ + /** Marks CA keys used for SSH certificates. */ + private static final String MARKER_CA = "cert-authority"; //$NON-NLS-1$ + private final boolean askAboutNewFile; private final Map<Path, HostKeyFile> knownHostsFiles = new ConcurrentHashMap<>(); @@ -178,7 +186,10 @@ public class OpenSshServerKeyDatabase for (HostKeyFile file : filesToUse) { for (HostEntryPair current : file.get()) { KnownHostEntry entry = current.getHostEntry(); - if (!isRevoked(entry)) { + if (current.getServerKey() instanceof UnsupportedSshPublicKey) { + continue; + } + if (!isRevoked(entry) && !isCertificateAuthority(entry)) { for (SshdSocketAddress host : candidates) { if (entry.isHostMatch(host.getHostName(), host.getPort())) { @@ -204,6 +215,7 @@ public class OpenSshServerKeyDatabase Collection<SshdSocketAddress> candidates = getCandidates(connectAddress, remoteAddress); for (HostKeyFile file : filesToUse) { + HostEntryPair lastModified = modified[0]; try { if (find(candidates, serverKey, file.get(), modified)) { return true; @@ -212,24 +224,35 @@ public class OpenSshServerKeyDatabase ask.revokedKey(remoteAddress, serverKey, file.getPath()); return false; } - if (path == null && modified[0] != null) { + if (modified[0] != lastModified) { // Remember the file in which we might need to update the // entry path = file.getPath(); } } + if (serverKey instanceof OpenSshCertificate) { + return false; + } if (modified[0] != null) { - // We found an entry, but with a different key + // We found an entry, but with a different key. AskUser.ModifiedKeyHandling toDo = ask.acceptModifiedServerKey( remoteAddress, modified[0].getServerKey(), serverKey, path); if (toDo == AskUser.ModifiedKeyHandling.ALLOW_AND_STORE) { - try { - updateModifiedServerKey(serverKey, modified[0], path); - knownHostsFiles.get(path).resetReloadAttributes(); - } catch (IOException e) { - LOG.warn(format(SshdText.get().knownHostsCouldNotUpdate, - path)); + if (modified[0] + .getServerKey() instanceof UnsupportedSshPublicKey) { + // Never update a line containing an unknown key type, + // always add. + addKeyToFile(filesToUse.get(0), candidates, serverKey, ask, + config); + } else { + try { + updateModifiedServerKey(serverKey, modified[0], path); + knownHostsFiles.get(path).resetReloadAttributes(); + } catch (IOException e) { + LOG.warn(format(SshdText.get().knownHostsCouldNotUpdate, + path)); + } } } if (toDo == AskUser.ModifiedKeyHandling.DENY) { @@ -242,19 +265,8 @@ public class OpenSshServerKeyDatabase return true; } else if (ask.acceptUnknownKey(remoteAddress, serverKey)) { if (!filesToUse.isEmpty()) { - HostKeyFile toUpdate = filesToUse.get(0); - path = toUpdate.getPath(); - try { - if (Files.exists(path) || !askAboutNewFile - || ask.createNewFile(path)) { - updateKnownHostsFile(candidates, serverKey, path, - config); - toUpdate.resetReloadAttributes(); - } - } catch (Exception e) { - LOG.warn(format(SshdText.get().knownHostsCouldNotUpdate, - path), e); - } + addKeyToFile(filesToUse.get(0), candidates, serverKey, ask, + config); } return true; } @@ -265,39 +277,90 @@ public class OpenSshServerKeyDatabase private static final long serialVersionUID = 1L; } - private boolean isRevoked(KnownHostEntry entry) { + private static boolean isRevoked(KnownHostEntry entry) { return MARKER_REVOKED.equals(entry.getMarker()); } + private static boolean isCertificateAuthority(KnownHostEntry entry) { + return MARKER_CA.equals(entry.getMarker()); + } + private boolean find(Collection<SshdSocketAddress> candidates, PublicKey serverKey, List<HostEntryPair> entries, HostEntryPair[] modified) throws RevokedKeyException { + PublicKey keyToCheck = serverKey; + boolean isCert = false; + String keyType = KeyUtils.getKeyType(keyToCheck); + String modifiedKeyType = null; + if (modified[0] != null) { + modifiedKeyType = modified[0].getHostEntry().getKeyEntry() + .getKeyType(); + } + if (serverKey instanceof OpenSshCertificate) { + keyToCheck = ((OpenSshCertificate) serverKey).getCaPubKey(); + isCert = true; + } for (HostEntryPair current : entries) { KnownHostEntry entry = current.getHostEntry(); - for (SshdSocketAddress host : candidates) { - if (entry.isHostMatch(host.getHostName(), host.getPort())) { - boolean revoked = isRevoked(entry); - if (KeyUtils.compareKeys(serverKey, - current.getServerKey())) { - // Exact match - if (revoked) { - throw new RevokedKeyException(); - } + if (candidates.stream().anyMatch(host -> entry + .isHostMatch(host.getHostName(), host.getPort()))) { + boolean revoked = isRevoked(entry); + boolean haveCert = isCertificateAuthority(entry); + if (KeyUtils.compareKeys(keyToCheck, current.getServerKey())) { + // Exact match + if (revoked) { + throw new RevokedKeyException(); + } + if (haveCert == isCert) { modified[0] = null; return true; - } else if (!revoked) { - // Server sent a different key + } + } + if (haveCert == isCert && !haveCert && !revoked) { + // Server sent a different key. + if (modifiedKeyType == null) { modified[0] = current; - // Keep going -- maybe there's another entry for this - // host + modifiedKeyType = entry.getKeyEntry().getKeyType(); + } else if (!keyType.equals(modifiedKeyType)) { + String thisKeyType = entry.getKeyEntry().getKeyType(); + if (isBetterMatch(keyType, thisKeyType, + modifiedKeyType)) { + // Since we may replace the modified[0] key, + // prefer to report a key of the same key type + // as having been modified. + modified[0] = current; + modifiedKeyType = keyType; + } } - break; + // Keep going -- maybe there's another entry for this + // host } } } return false; } + private static boolean isBetterMatch(String keyType, String thisType, + String modifiedType) { + if (keyType.equals(thisType)) { + return true; + } + // EC keys are a bit special because they encode the curve in the key + // type. If we have no exactly matching EC key type in known_hosts, we + // still prefer to update an existing EC key type over some other key + // type. + if (!keyType.startsWith("ecdsa") || !thisType.startsWith("ecdsa")) { //$NON-NLS-1$ //$NON-NLS-2$ + return false; + } + if (!modifiedType.startsWith("ecdsa")) { //$NON-NLS-1$ + return true; + } + // All three are EC keys. thisType doesn't match the size of keyType + // (otherwise the two would have compared equal above already), so it is + // not better than modifiedType. + return false; + } + private List<HostKeyFile> addUserHostKeyFiles(List<String> fileNames) { if (fileNames == null || fileNames.isEmpty()) { return Collections.emptyList(); @@ -317,6 +380,21 @@ public class OpenSshServerKeyDatabase return userFiles; } + private void addKeyToFile(HostKeyFile file, + Collection<SshdSocketAddress> candidates, PublicKey serverKey, + AskUser ask, Configuration config) { + Path path = file.getPath(); + try { + if (Files.exists(path) || !askAboutNewFile + || ask.createNewFile(path)) { + updateKnownHostsFile(candidates, serverKey, path, config); + file.resetReloadAttributes(); + } + } catch (Exception e) { + LOG.warn(format(SshdText.get().knownHostsCouldNotUpdate, path), e); + } + } + private void updateKnownHostsFile(Collection<SshdSocketAddress> candidates, PublicKey serverKey, Path path, Configuration config) throws Exception { @@ -453,15 +531,22 @@ public class OpenSshServerKeyDatabase return; } InetSocketAddress remote = (InetSocketAddress) remoteAddress; + boolean isCert = serverKey instanceof OpenSshCertificate; + PublicKey keyToReport = isCert + ? ((OpenSshCertificate) serverKey).getCaPubKey() + : serverKey; URIish uri = JGitUserInteraction.toURI(config.getUsername(), remote); String sha256 = KeyUtils.getFingerPrint(BuiltinDigests.sha256, - serverKey); - String md5 = KeyUtils.getFingerPrint(BuiltinDigests.md5, serverKey); - String keyAlgorithm = serverKey.getAlgorithm(); + keyToReport); + String md5 = KeyUtils.getFingerPrint(BuiltinDigests.md5, + keyToReport); + String keyAlgorithm = keyToReport.getAlgorithm(); + String msg = isCert + ? SshdText.get().knownHostsRevokedCertificateMsg + : SshdText.get().knownHostsRevokedKeyMsg; askUser(provider, uri, null, // - format(SshdText.get().knownHostsRevokedKeyMsg, - remote.getHostString(), path), + format(msg, remote.getHostString(), path), format(SshdText.get().knownHostsKeyFingerprints, keyAlgorithm), md5, sha256); @@ -594,7 +679,7 @@ public class OpenSshServerKeyDatabase } try { PublicKey serverKey = keyPart.resolvePublicKey(null, - PublicKeyEntryResolver.IGNORING); + PublicKeyEntryResolver.UNSUPPORTED); if (serverKey == null) { LOG.warn(format( SshdText.get().knownHostsUnknownKeyType, @@ -625,7 +710,7 @@ public class OpenSshServerKeyDatabase private SshdSocketAddress toSshdSocketAddress(@NonNull String address) { String host = null; - int port = 0; + int port = SshConstants.DEFAULT_PORT; if (HostPatternsHolder.NON_STANDARD_PORT_PATTERN_ENCLOSURE_START_DELIM == address .charAt(0)) { int end = address.indexOf( @@ -665,12 +750,23 @@ public class OpenSshServerKeyDatabase if (address != null) { candidates.add(address); } - return candidates; + List<SshdSocketAddress> result = new ArrayList<>(); + result.addAll(candidates); + if (!remoteAddress.isUnresolved()) { + SshdSocketAddress ip = new SshdSocketAddress( + remoteAddress.getAddress().getHostAddress(), + remoteAddress.getPort()); + if (candidates.add(ip)) { + result.add(ip); + } + } + return result; } private String createHostKeyLine(Collection<SshdSocketAddress> patterns, PublicKey key, Configuration config) throws Exception { StringBuilder result = new StringBuilder(); + Set<String> knownNames = new HashSet<>(); if (config.getHashKnownHosts()) { // SHA1 is the only algorithm for host name hashing known to OpenSSH // or to Apache MINA sshd. @@ -680,10 +776,10 @@ public class OpenSshServerKeyDatabase prng = new SecureRandom(); } byte[] salt = new byte[mac.getDefaultBlockSize()]; - for (SshdSocketAddress address : patterns) { - if (result.length() > 0) { - result.append(','); - } + // For hashed hostnames, only one hashed pattern is allowed per + // https://man.openbsd.org/sshd.8#SSH_KNOWN_HOSTS_FILE_FORMAT + if (!patterns.isEmpty()) { + SshdSocketAddress address = patterns.iterator().next(); prng.nextBytes(salt); KnownHostHashValue.append(result, digester, salt, KnownHostHashValue.calculateHashValue( @@ -692,6 +788,10 @@ public class OpenSshServerKeyDatabase } } else { for (SshdSocketAddress address : patterns) { + String tgt = address.getHostName() + ':' + address.getPort(); + if (!knownNames.add(tgt)) { + continue; + } if (result.length() > 0) { result.append(','); } diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/PasswordProviderWrapper.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/PasswordProviderWrapper.java index 2cd0669842..900c9fba24 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/PasswordProviderWrapper.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/PasswordProviderWrapper.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, 2020 Thomas Wolf <thomas.wolf@paranor.ch> and others + * Copyright (C) 2018, 2024 Thomas Wolf <twolf@apache.org> and others * * This program and the accompanying materials are made available under the * terms of the Eclipse Distribution License v. 1.0 which is available at @@ -47,6 +47,8 @@ public class PasswordProviderWrapper implements FilePasswordProvider { private final Supplier<KeyPasswordProvider> factory; + private PerSessionState noSessionState; + /** * Creates a new {@link PasswordProviderWrapper}. * @@ -59,13 +61,18 @@ public class PasswordProviderWrapper implements FilePasswordProvider { } private PerSessionState getState(SessionContext context) { - PerSessionState state = context.getAttribute(STATE); + PerSessionState state = context != null ? context.getAttribute(STATE) + : noSessionState; if (state == null) { state = new PerSessionState(); state.delegate = factory.get(); state.delegate.setAttempts( PASSWORD_PROMPTS.getRequiredDefault().intValue()); - context.setAttribute(STATE, state); + if (context != null) { + context.setAttribute(STATE, state); + } else { + noSessionState = state; + } } return state; } diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java index 05f04ac5b2..e40137870b 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, 2022 Thomas Wolf <thomas.wolf@paranor.ch> and others + * Copyright (C) 2018, 2024 Thomas Wolf <twolf@apache.org> and others * * This program and the accompanying materials are made available under the * terms of the Eclipse Distribution License v. 1.0 which is available at @@ -83,6 +83,7 @@ public final class SshdText extends TranslationBundle { /***/ public String knownHostsModifiedKeyDenyMsg; /***/ public String knownHostsModifiedKeyStorePrompt; /***/ public String knownHostsModifiedKeyWarning; + /***/ public String knownHostsRevokedCertificateMsg; /***/ public String knownHostsRevokedKeyMsg; /***/ public String knownHostsUnknownKeyMsg; /***/ public String knownHostsUnknownKeyPrompt; @@ -147,6 +148,71 @@ public final class SshdText extends TranslationBundle { /***/ public String sshCommandTimeout; /***/ public String sshProcessStillRunning; /***/ public String sshProxySessionCloseFailed; + /***/ public String signAllowedSignersCertAuthorityError; + /***/ public String signAllowedSignersEmptyIdentity; + /***/ public String signAllowedSignersEmptyNamespaces; + /***/ public String signAllowedSignersFormatError; + /***/ public String signAllowedSignersInvalidDate; + /***/ public String signAllowedSignersLineFormat; + /***/ public String signAllowedSignersMultiple; + /***/ public String signAllowedSignersNoIdentities; + /***/ public String signAllowedSignersPublicKeyParsing; + /***/ public String signAllowedSignersUnterminatedQuote; + /***/ public String signCertAlgorithmMismatch; + /***/ public String signCertAlgorithmUnknown; + /***/ public String signCertificateExpired; + /***/ public String signCertificateInvalid; + /***/ public String signCertificateNotForName; + /***/ public String signCertificateRevoked; + /***/ public String signCertificateTooEarly; + /***/ public String signCertificateWithoutPrincipals; + /***/ public String signDefaultKeyEmpty; + /***/ public String signDefaultKeyFailed; + /***/ public String signDefaultKeyInterrupted; + /***/ public String signGarbageAtEnd; + /***/ public String signInvalidAlgorithm; + /***/ public String signInvalidKeyDSA; + /***/ public String signInvalidMagic; + /***/ public String signInvalidNamespace; + /***/ public String signInvalidSignature; + /***/ public String signInvalidVersion; + /***/ public String signKeyExpired; + /***/ public String signKeyRevoked; + /***/ public String signKeyTooEarly; + /***/ public String signKrlBlobLeftover; + /***/ public String signKrlBlobLengthInvalid; + /***/ public String signKrlBlobLengthInvalidExpected; + /***/ public String signKrlCaKeyLengthInvalid; + /***/ public String signKrlCertificateLeftover; + /***/ public String signKrlCertificateSubsectionLeftover; + /***/ public String signKrlCertificateSubsectionLength; + /***/ public String signKrlEmptyRange; + /***/ public String signKrlInvalidBitSetLength; + /***/ public String signKrlInvalidKeyIdLength; + /***/ public String signKrlInvalidMagic; + /***/ public String signKrlInvalidReservedLength; + /***/ public String signKrlInvalidVersion; + /***/ public String signKrlNoCertificateSubsection; + /***/ public String signKrlSerialZero; + /***/ public String signKrlShortRange; + /***/ public String signKrlUnknownSection; + /***/ public String signKrlUnknownSubsection; + /***/ public String signLogFailure; + /***/ public String signMismatchedSignatureAlgorithm; + /***/ public String signNoAgent; + /***/ public String signNoPrincipalMatched; + /***/ public String signNoPublicKey; + /***/ public String signNoSigningKey; + /***/ public String signNotUserCertificate; + /***/ public String signPublicKeyError; + /***/ public String signSeeLog; + /***/ public String signSignatureError; + /***/ public String signStderr; + /***/ public String signTooManyPrivateKeys; + /***/ public String signTooManyPublicKeys; + /***/ public String signUnknownHashAlgorithm; + /***/ public String signUnknownSignatureAlgorithm; + /***/ public String signWrongNamespace; /***/ public String unknownProxyProtocol; } diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/BasicAuthentication.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/BasicAuthentication.java index 8866976c89..3e1fab34d9 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/BasicAuthentication.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/BasicAuthentication.java @@ -17,8 +17,6 @@ import java.net.InetSocketAddress; import java.net.PasswordAuthentication; import java.nio.ByteBuffer; import java.nio.CharBuffer; -import java.security.AccessController; -import java.security.PrivilegedAction; import java.util.Arrays; import java.util.concurrent.CancellationException; @@ -113,13 +111,12 @@ public abstract class BasicAuthentication<ParameterType, TokenType> */ protected void askCredentials() { clearPassword(); - PasswordAuthentication auth = AccessController.doPrivileged( - (PrivilegedAction<PasswordAuthentication>) () -> Authenticator - .requestPasswordAuthentication(proxy.getHostString(), - proxy.getAddress(), proxy.getPort(), - SshConstants.SSH_SCHEME, - SshdText.get().proxyPasswordPrompt, "Basic", //$NON-NLS-1$ - null, RequestorType.PROXY)); + PasswordAuthentication auth = Authenticator + .requestPasswordAuthentication(proxy.getHostString(), + proxy.getAddress(), proxy.getPort(), + SshConstants.SSH_SCHEME, + SshdText.get().proxyPasswordPrompt, "Basic", //$NON-NLS-1$ + null, RequestorType.PROXY); if (auth == null) { user = ""; //$NON-NLS-1$ throw new CancellationException( diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/CachingSigningKeyDatabase.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/CachingSigningKeyDatabase.java new file mode 100644 index 0000000000..4d2d8b6797 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/CachingSigningKeyDatabase.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2024, Thomas Wolf <twolf@apache.org> and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Distribution License v. 1.0 which is available at + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.signing.ssh; + +/** + * A {@link SigningKeyDatabase} that caches data. + * <p> + * A signing key database may be used to check keys frequently; it may thus need + * to cache some data and it may need to cache data per repository. If an + * implementation does cache data, it is responsible itself for refreshing that + * cache at appropriate times. Clients can control the cache size somewhat via + * {@link #setCacheSize(int)}, although the meaning of the cache size (i.e., its + * unit) is left undefined here. + * </p> + * + * @since 7.1 + */ +public interface CachingSigningKeyDatabase extends SigningKeyDatabase { + + /** + * Retrieves the current cache size. + * + * @return the cache size, or -1 if this database has no cache. + */ + int getCacheSize(); + + /** + * Sets the cache size to use. + * + * @param size + * the cache size, ignored if this database does not have a + * cache. + * @throws IllegalArgumentException + * if {@code size < 0} + */ + void setCacheSize(int size); + + /** + * Discards any cached data. A no-op if the database has no cache. + */ + void clearCache(); +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SigningKeyDatabase.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SigningKeyDatabase.java new file mode 100644 index 0000000000..eec64c3abd --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SigningKeyDatabase.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2024, Thomas Wolf <twolf@apache.org> and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Distribution License v. 1.0 which is available at + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.signing.ssh; + +import java.io.IOException; +import java.security.PublicKey; + +import org.eclipse.jgit.annotations.NonNull; +import org.eclipse.jgit.internal.signing.ssh.SigningDatabase; +import org.eclipse.jgit.lib.GpgConfig; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.Repository; + +/** + * A database storing meta-information about signing keys and certificates. + * + * @since 7.1 + */ +public interface SigningKeyDatabase { + + /** + * Obtains the current global instance. + * + * @return the global {@link SigningKeyDatabase} + */ + static SigningKeyDatabase getInstance() { + return SigningDatabase.getInstance(); + } + + /** + * Sets the global {@link SigningKeyDatabase}. + * + * @param database + * to set; if {@code null} a default database using the OpenSSH + * allowed signers file and the OpenSSH revocation list mechanism + * is used. + * @return the previously set {@link SigningKeyDatabase} + */ + static SigningKeyDatabase setInstance(SigningKeyDatabase database) { + return SigningDatabase.setInstance(database); + } + + /** + * Determines whether the gives key has been revoked. + * + * @param repository + * {@link Repository} the key is being used in + * @param config + * {@link GpgConfig} to use + * @param key + * {@link PublicKey} to check + * @return {@code true} if the key has been revoked, {@code false} otherwise + * @throws IOException + * if an I/O problem occurred + */ + boolean isRevoked(@NonNull Repository repository, @NonNull GpgConfig config, + @NonNull PublicKey key) throws IOException; + + /** + * Checks whether the given key is allowed to be used for signing, and if + * allowed returns the principal. + * + * @param repository + * {@link Repository} the key is being used in + * @param config + * {@link GpgConfig} to use + * @param key + * {@link PublicKey} to check + * @param namespace + * of the signature + * @param ident + * optional {@link PersonIdent} giving a signer's e-mail address + * and a signature time + * @return {@code null} if the database does not contain any information + * about the given key; the principal if it does and all checks + * passed + * @throws IOException + * if an I/O problem occurred + * @throws VerificationException + * if the database contains information about the key and the + * checks determined that the key is not allowed to be used for + * signing + */ + String isAllowed(@NonNull Repository repository, @NonNull GpgConfig config, + @NonNull PublicKey key, @NonNull String namespace, + PersonIdent ident) throws IOException, VerificationException; +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SshSignatureVerifierFactory.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SshSignatureVerifierFactory.java new file mode 100644 index 0000000000..c315428c33 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SshSignatureVerifierFactory.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2024, Thomas Wolf <twolf@apache.org> and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Distribution License v. 1.0 which is available at + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.signing.ssh; + +import org.eclipse.jgit.lib.GpgConfig.GpgFormat; +import org.eclipse.jgit.lib.SignatureVerifier; +import org.eclipse.jgit.internal.signing.ssh.SshSignatureVerifier; +import org.eclipse.jgit.lib.SignatureVerifierFactory; + +/** + * Factory creating {@link SshSignatureVerifier}s. + * + * @since 7.1 + */ +public final class SshSignatureVerifierFactory + implements SignatureVerifierFactory { + + @Override + public GpgFormat getType() { + return GpgFormat.SSH; + } + + @Override + public SignatureVerifier create() { + return new SshSignatureVerifier(); + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SshSignerFactory.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SshSignerFactory.java new file mode 100644 index 0000000000..5459b5360a --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SshSignerFactory.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2024, Thomas Wolf <twolf@apache.org> and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Distribution License v. 1.0 which is available at + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.signing.ssh; + +import org.eclipse.jgit.lib.GpgConfig.GpgFormat; +import org.eclipse.jgit.lib.Signer; +import org.eclipse.jgit.internal.signing.ssh.SshSigner; +import org.eclipse.jgit.lib.SignerFactory; + +/** + * Factory creating {@link SshSigner}s. + * + * @since 7.1 + */ +public final class SshSignerFactory implements SignerFactory { + + @Override + public GpgFormat getType() { + return GpgFormat.SSH; + } + + @Override + public Signer create() { + return new SshSigner(); + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/VerificationException.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/VerificationException.java new file mode 100644 index 0000000000..cd77111813 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/VerificationException.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2024, Thomas Wolf <twolf@apache.org> and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Distribution License v. 1.0 which is available at + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.signing.ssh; + +/** + * An exception giving details about a failed + * {@link SigningKeyDatabase#isAllowed(org.eclipse.jgit.lib.Repository, org.eclipse.jgit.lib.GpgConfig, java.security.PublicKey, String, org.eclipse.jgit.lib.PersonIdent)} + * validation. + * + * @since 7.1 + */ +public class VerificationException extends Exception { + + private static final long serialVersionUID = 313760495170326160L; + + private final boolean expired; + + private final String reason; + + /** + * Creates a new instance. + * + * @param expired + * whether the checked public key or certificate was expired + * @param reason + * describing the check failure + */ + public VerificationException(boolean expired, String reason) { + this.expired = expired; + this.reason = reason; + } + + @Override + public String getMessage() { + return reason; + } + + /** + * Tells whether the check failed because the public key was expired. + * + * @return {@code true} if the check failed because the public key was + * expired, {@code false} otherwise + */ + public boolean isExpired() { + return expired; + } + + /** + * Retrieves the check failure reason. + * + * @return the reason description + */ + public String getReason() { + return reason; + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/KeyPasswordProviderFactory.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/KeyPasswordProviderFactory.java new file mode 100644 index 0000000000..0537300b24 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/KeyPasswordProviderFactory.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2024, Thomas Wolf <twolf@apache.org> and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Distribution License v. 1.0 which is available at + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.transport.sshd; + +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +import org.eclipse.jgit.annotations.NonNull; +import org.eclipse.jgit.transport.CredentialsProvider; + +/** + * Maintains a static singleton instance of a factory to create a + * {@link KeyPasswordProvider} from a {@link CredentialsProvider}. + * + * @since 7.1 + */ +public final class KeyPasswordProviderFactory { + + /** + * Creates a {@link KeyPasswordProvider} from a {@link CredentialsProvider}. + */ + @FunctionalInterface + public interface KeyPasswordProviderCreator + extends Function<CredentialsProvider, KeyPasswordProvider> { + // Nothing + } + + private static final KeyPasswordProviderCreator DEFAULT = IdentityPasswordProvider::new; + + private static AtomicReference<KeyPasswordProviderCreator> INSTANCE = new AtomicReference<>( + DEFAULT); + + private KeyPasswordProviderFactory() { + // No instantiation + } + + /** + * Retrieves the currently set {@link KeyPasswordProviderCreator}. + * + * @return the {@link KeyPasswordProviderCreator} + */ + @NonNull + public static KeyPasswordProviderCreator getInstance() { + return INSTANCE.get(); + } + + /** + * Sets a new {@link KeyPasswordProviderCreator}. + * + * @param provider + * to set; if {@code null}, sets a default provider. + * @return the previously set {@link KeyPasswordProviderCreator} + */ + @NonNull + public static KeyPasswordProviderCreator setInstance( + KeyPasswordProviderCreator provider) { + if (provider == null) { + return INSTANCE.getAndSet(DEFAULT); + } + return INSTANCE.getAndSet(provider); + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java index 2c3cbe55c9..4a2eb9c3dd 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, 2022 Thomas Wolf <thomas.wolf@paranor.ch> and others + * Copyright (C) 2018, 2024 Thomas Wolf <twolf@apache.org> and others * * This program and the accompanying materials are made available under the * terms of the Eclipse Distribution License v. 1.0 which is available at @@ -210,7 +210,7 @@ public class SshdSessionFactory extends SshSessionFactory implements Closeable { home, sshDir); KeyIdentityProvider defaultKeysProvider = toKeyIdentityProvider( getDefaultKeys(sshDir)); - Supplier<KeyPasswordProvider> keyPasswordProvider = () -> createKeyPasswordProvider( + Supplier<KeyPasswordProvider> keyPasswordProvider = newKeyPasswordProvider( credentialsProvider); SshClient client = ClientBuilder.builder() .factory(JGitSshClient::new) @@ -574,12 +574,24 @@ public class SshdSessionFactory extends SshSessionFactory implements Closeable { * @param provider * the {@link CredentialsProvider} to delegate to for user * interactions - * @return a new {@link KeyPasswordProvider} + * @return a new {@link KeyPasswordProvider}, or {@code null} to use the + * global {@link KeyPasswordProviderFactory} */ - @NonNull protected KeyPasswordProvider createKeyPasswordProvider( CredentialsProvider provider) { - return new IdentityPasswordProvider(provider); + return null; + } + + private Supplier<KeyPasswordProvider> newKeyPasswordProvider( + CredentialsProvider credentials) { + return () -> { + KeyPasswordProvider provider = createKeyPasswordProvider( + credentials); + if (provider != null) { + return provider; + } + return KeyPasswordProviderFactory.getInstance().apply(credentials); + }; } /** diff --git a/org.eclipse.jgit.ssh.apache/src/sun/security/x509/README.md b/org.eclipse.jgit.ssh.apache/src/sun/security/x509/README.md deleted file mode 100644 index a84ee37ffb..0000000000 --- a/org.eclipse.jgit.ssh.apache/src/sun/security/x509/README.md +++ /dev/null @@ -1,3 +0,0 @@ -This dummy package is used to fix the error -"Missing requirement: net.i2p.crypto.eddsa 0.3.0 requires 'java.package; sun.security.x509 0.0.0'" -raised since eddsa falsely requires this import
\ No newline at end of file |