aboutsummaryrefslogtreecommitdiffstats
path: root/org.eclipse.jgit.ssh.apache/src
diff options
context:
space:
mode:
Diffstat (limited to 'org.eclipse.jgit.ssh.apache/src')
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/AllowedSigners.java530
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshBinaryKrl.java491
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshKrl.java120
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshSigningKeyDatabase.java161
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SerialRangeSet.java131
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SigningDatabase.java59
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshCertificateUtils.java175
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSignatureConstants.java38
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSignatureVerifier.java319
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSigner.java485
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPublicKeyAuthentication.java13
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/KnownHostEntryReader.java7
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/OpenSshServerKeyDatabase.java200
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/PasswordProviderWrapper.java13
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java68
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/BasicAuthentication.java15
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/CachingSigningKeyDatabase.java49
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SigningKeyDatabase.java94
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SshSignatureVerifierFactory.java34
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SshSignerFactory.java33
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/VerificationException.java63
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/KeyPasswordProviderFactory.java69
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java22
-rw-r--r--org.eclipse.jgit.ssh.apache/src/sun/security/x509/README.md3
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