]> source.dussan.org Git - jgit.git/commitdiff
SSH signing: implement a Signer 26/1202326/3
authorThomas Wolf <twolf@apache.org>
Sat, 28 Sep 2024 13:58:20 +0000 (15:58 +0200)
committerMatthias Sohn <matthias.sohn@sap.com>
Wed, 23 Oct 2024 14:29:10 +0000 (16:29 +0200)
Implement a Signer and its factory, and publish the factory for the
ServiceLoader. SSH signatures can be created directly if the key is
given via a file in user.signingKey and the private key can be found.
Otherwise, signing is delegated to an SSH agent, if available.

If a certificate is used as public key, the signer verifies the
certificate (correct signature, and valid at the commit time).

SSH signatures are documented at [1].

[1] https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig

Bug: jgit-44
Change-Id: I3848ccc06ba3be5e868f879bd5705fee1b39c632
Signed-off-by: Thomas Wolf <twolf@apache.org>
32 files changed:
org.eclipse.jgit.ssh.apache.test/.classpath
org.eclipse.jgit.ssh.apache.test/.gitattributes [new file with mode: 0644]
org.eclipse.jgit.ssh.apache.test/BUILD
org.eclipse.jgit.ssh.apache.test/META-INF/MANIFEST.MF
org.eclipse.jgit.ssh.apache.test/pom.xml
org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/ca_key [new file with mode: 0644]
org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/ca_key.pub [new file with mode: 0644]
org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/ca_key2 [new file with mode: 0644]
org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/ca_key2.pub [new file with mode: 0644]
org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/certs/expired.cert [new file with mode: 0644]
org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/certs/no_principals.cert [new file with mode: 0644]
org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/certs/other-ca.cert [new file with mode: 0644]
org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/certs/other.cert [new file with mode: 0644]
org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/certs/tester.cert [new file with mode: 0644]
org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/certs/two_principals.cert [new file with mode: 0644]
org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/other_key [new file with mode: 0644]
org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/other_key-cert.pub [new file with mode: 0644]
org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/other_key.pub [new file with mode: 0644]
org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/signing_key [new file with mode: 0644]
org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/signing_key-cert.pub [new file with mode: 0644]
org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/signing_key.pub [new file with mode: 0644]
org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/AbstractSshSignatureTest.java [new file with mode: 0644]
org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/SshCertificateUtilsTest.java [new file with mode: 0644]
org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/SshSignerTest.java [new file with mode: 0644]
org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF
org.eclipse.jgit.ssh.apache/resources/META-INF/services/org.eclipse.jgit.lib.SignerFactory [new file with mode: 0644]
org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties
org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshCertificateUtils.java [new file with mode: 0644]
org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSignatureConstants.java [new file with mode: 0644]
org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSigner.java [new file with mode: 0644]
org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java
org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SshSignerFactory.java [new file with mode: 0644]

index 6fdb99a4b2e7d455e08ad8464bd4428c8ee81d9d..5be47afffbd3ba81f5c891df156885b4564c8955 100644 (file)
                        <attribute name="test" value="true"/>
                </attributes>
        </classpathentry>
+       <classpathentry kind="src" path="tst-rsrc">
+               <attributes>
+                       <attribute name="test" value="true"/>
+               </attributes>
+       </classpathentry>
        <classpathentry kind="output" path="bin"/>
 </classpath>
diff --git a/org.eclipse.jgit.ssh.apache.test/.gitattributes b/org.eclipse.jgit.ssh.apache.test/.gitattributes
new file mode 100644 (file)
index 0000000..c1f1b73
--- /dev/null
@@ -0,0 +1 @@
+/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/repo.bundle binary
\ No newline at end of file
index b38446448472f661c4238fdd8b8fc07815cf76cf..bb31f8b3c7e4e3f25384bae3afcf94415ea500f6 100644 (file)
@@ -1,19 +1,48 @@
+load(
+    "@com_googlesource_gerrit_bazlets//tools:genrule2.bzl",
+    "genrule2",
+)
 load(
     "@com_googlesource_gerrit_bazlets//tools:junit.bzl",
     "junit_tests",
 )
 
+DEPS = [
+    "//lib:eddsa",
+    "//lib:junit",
+    "//lib:sshd-osgi",
+    "//lib:sshd-sftp",
+    "//org.eclipse.jgit:jgit",
+    "//org.eclipse.jgit.junit:junit",
+    "//org.eclipse.jgit.junit.ssh:junit-ssh",
+    "//org.eclipse.jgit.ssh.apache:ssh-apache",
+]
+
+HELPERS = ["tst/org/eclipse/jgit/internal/signing/ssh/AbstractSshSignatureTest.java"]
+
 junit_tests(
     name = "sshd_apache",
-    srcs = glob(["tst/**/*.java"]),
+    srcs = glob(
+        ["tst/**/*.java"],
+        exclude = HELPERS,
+    ),
     tags = ["sshd"],
-    deps = [
-        "//lib:eddsa",
-        "//lib:junit",
-        "//lib:sshd-osgi",
-        "//lib:sshd-sftp",
-        "//org.eclipse.jgit:jgit",
-        "//org.eclipse.jgit.junit.ssh:junit-ssh",
-        "//org.eclipse.jgit.ssh.apache:ssh-apache",
+    runtime_deps = [":tst_rsrc"],
+    deps = DEPS + [
+        ":helpers",
     ],
 )
+
+java_library(
+    name = "helpers",
+    testonly = 1,
+    srcs = HELPERS,
+    deps = DEPS,
+)
+
+genrule2(
+    name = "tst_rsrc",
+    srcs = glob(["tst-rsrc/**"]),
+    outs = ["tst_rsrc.jar"],
+    cmd = "tar cf - $(SRCS) | tar -C $$TMP --strip-components=2 -xf - && cd $$TMP && zip -qr $$ROOT/$@ .",
+)
index 546dfd4a1d28f0b08c37f3c6dc14b4fb39228f62..c8300aa8c0257118906824b43a37f06c8ee8dc00 100644 (file)
@@ -17,6 +17,7 @@ Import-Package: org.apache.sshd.client.config.hosts;version="[2.14.0,2.15.0)",
  org.apache.sshd.common.keyprovider;version="[2.14.0,2.15.0)",
  org.apache.sshd.common.session;version="[2.14.0,2.15.0)",
  org.apache.sshd.common.signature;version="[2.14.0,2.15.0)",
+ org.apache.sshd.common.util.buffer;version="[2.14.0,2.15.0)",
  org.apache.sshd.common.util.net;version="[2.14.0,2.15.0)",
  org.apache.sshd.common.util.security;version="[2.14.0,2.15.0)",
  org.apache.sshd.core;version="[2.14.0,2.15.0)",
@@ -24,15 +25,20 @@ Import-Package: org.apache.sshd.client.config.hosts;version="[2.14.0,2.15.0)",
  org.apache.sshd.server.forward;version="[2.14.0,2.15.0)",
  org.eclipse.jgit.api;version="[7.1.0,7.2.0)",
  org.eclipse.jgit.api.errors;version="[7.1.0,7.2.0)",
+ org.eclipse.jgit.internal.signing.ssh;version="[7.1.0,7.2.0)",
  org.eclipse.jgit.internal.storage.file;version="[7.1.0,7.2.0)",
  org.eclipse.jgit.internal.transport.sshd.proxy;version="[7.1.0,7.2.0)",
  org.eclipse.jgit.junit;version="[7.1.0,7.2.0)",
  org.eclipse.jgit.junit.ssh;version="[7.1.0,7.2.0)",
  org.eclipse.jgit.lib;version="[7.1.0,7.2.0)",
+ org.eclipse.jgit.revwalk;version="[7.1.0,7.2.0)",
  org.eclipse.jgit.transport;version="[7.1.0,7.2.0)",
  org.eclipse.jgit.transport.sshd;version="[7.1.0,7.2.0)",
  org.eclipse.jgit.transport.sshd.agent;version="[7.1.0,7.2.0)",
  org.eclipse.jgit.util;version="[7.1.0,7.2.0)",
  org.junit;version="[4.13,5.0.0)",
  org.junit.experimental.theories;version="[4.13,5.0.0)",
- org.junit.runner;version="[4.13,5.0.0)"
+ org.junit.rules;version="[4.13.0,5.0.0)",
+ org.junit.runner;version="[4.13,5.0.0)",
+ org.junit.runners;version="[4.13.0,5.0.0)",
+ org.slf4j;version="[1.7.0,2.0.0)"
index b3e3d31eea744596358ab05a25f61f212fef5468..8e123867e4db0de8801b550a3920680cdd1f1c5d 100644 (file)
     <sourceDirectory>src/</sourceDirectory>
     <testSourceDirectory>tst/</testSourceDirectory>
 
+    <testResources>
+      <testResource>
+        <directory>tst-rsrc/</directory>
+      </testResource>
+    </testResources>
+
     <plugins>
       <plugin>
         <groupId>org.apache.maven.plugins</groupId>
diff --git a/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/ca_key b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/ca_key
new file mode 100644 (file)
index 0000000..b8de8c3
--- /dev/null
@@ -0,0 +1,7 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACDHRJfojkxG39UQt7onBArYnfkmiDLAMO8K5VoTkE8gVQAAAJAhCMgzIQjI
+MwAAAAtzc2gtZWQyNTUxOQAAACDHRJfojkxG39UQt7onBArYnfkmiDLAMO8K5VoTkE8gVQ
+AAAEBmcXpast20+B4IzA0Xex2CKYiiWJj3NFJ5F0kil113vcdEl+iOTEbf1RC3uicECtid
++SaIMsAw7wrlWhOQTyBVAAAADVRIV09AU0VBR044MDA=
+-----END OPENSSH PRIVATE KEY-----
diff --git a/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/ca_key.pub b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/ca_key.pub
new file mode 100644 (file)
index 0000000..842415b
--- /dev/null
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMdEl+iOTEbf1RC3uicECtid+SaIMsAw7wrlWhOQTyBV
diff --git a/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/ca_key2 b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/ca_key2
new file mode 100644 (file)
index 0000000..a4af047
--- /dev/null
@@ -0,0 +1,7 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACA7S4ycIB6oTx4UN8l9N+u016UgMzkrbT7E+2XbG35jgwAAAJAa2jfBGto3
+wQAAAAtzc2gtZWQyNTUxOQAAACA7S4ycIB6oTx4UN8l9N+u016UgMzkrbT7E+2XbG35jgw
+AAAEBothGMqFaA5aTO8MLx9wm1oDRfzQCSsu7uJwrOiUFTTTtLjJwgHqhPHhQ3yX0367TX
+pSAzOSttPsT7ZdsbfmODAAAADVRIV09AU0VBR044MDA=
+-----END OPENSSH PRIVATE KEY-----
diff --git a/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/ca_key2.pub b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/ca_key2.pub
new file mode 100644 (file)
index 0000000..e46c87e
--- /dev/null
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDtLjJwgHqhPHhQ3yX0367TXpSAzOSttPsT7ZdsbfmOD
diff --git a/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/certs/expired.cert b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/certs/expired.cert
new file mode 100644 (file)
index 0000000..9da63ec
--- /dev/null
@@ -0,0 +1 @@
+ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAAINFZ5NKywAWh1G1P6BiBKArmYKs1BDhJBOawJKlS29VXAAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzOAAAAAAAAAAEAAAABAAAADGV4cGlyZWRfY2VydAAAAAAAAAAAZtOugAAAAABm1QAAAAAAAAAAAIIAAAAVcGVybWl0LVgxMS1mb3J3YXJkaW5nAAAAAAAAABdwZXJtaXQtYWdlbnQtZm9yd2FyZGluZwAAAAAAAAAWcGVybWl0LXBvcnQtZm9yd2FyZGluZwAAAAAAAAAKcGVybWl0LXB0eQAAAAAAAAAOcGVybWl0LXVzZXItcmMAAAAAAAAAAAAAADMAAAALc3NoLWVkMjU1MTkAAAAgx0SX6I5MRt/VELe6JwQK2J35JogywDDvCuVaE5BPIFUAAABTAAAAC3NzaC1lZDI1NTE5AAAAQNf8i5dhRqWRe06epIRrZ5V+QZHq3ZrlJtlx98UJya9GAeCrJ5oHwBjr5O5TL5wNJS5Hz+T1qsJNFU9d1wdcuwI=
diff --git a/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/certs/no_principals.cert b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/certs/no_principals.cert
new file mode 100644 (file)
index 0000000..101e374
--- /dev/null
@@ -0,0 +1 @@
+ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAAILzuED1RSloB/enTghTEKSACVOuEARP0f8UVXSRwEXN6AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzOAAAAAAAAAAIAAAABAAAADW5vX3ByaW5jaXBhbHMAAAAAAAAAAGbTroAAAAAAZyLIgAAAAAAAAACCAAAAFXBlcm1pdC1YMTEtZm9yd2FyZGluZwAAAAAAAAAXcGVybWl0LWFnZW50LWZvcndhcmRpbmcAAAAAAAAAFnBlcm1pdC1wb3J0LWZvcndhcmRpbmcAAAAAAAAACnBlcm1pdC1wdHkAAAAAAAAADnBlcm1pdC11c2VyLXJjAAAAAAAAAAAAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIMdEl+iOTEbf1RC3uicECtid+SaIMsAw7wrlWhOQTyBVAAAAUwAAAAtzc2gtZWQyNTUxOQAAAEBwEQ2D0OHn4QDHnsINlgWUWpmhukseQCJu3Adulz28fFtewp1LLqkBy50wR6vJe1ifYbY4hzReXOSyoTmHSXEN
diff --git a/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/certs/other-ca.cert b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/certs/other-ca.cert
new file mode 100644 (file)
index 0000000..752fee1
--- /dev/null
@@ -0,0 +1 @@
+ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAAIHNW2bzSS61lvgHippv3Ymx4cVEAXBVCb8lFXHnVpsSyAAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzOAAAAAAAAAAYAAAABAAAAB3Rlc3RlcjIAAAAWAAAAEnRlc3RlckBleGFtcGxlLmNvbQAAAABm066AAAAAAGciyIAAAAAAAAAAggAAABVwZXJtaXQtWDExLWZvcndhcmRpbmcAAAAAAAAAF3Blcm1pdC1hZ2VudC1mb3J3YXJkaW5nAAAAAAAAABZwZXJtaXQtcG9ydC1mb3J3YXJkaW5nAAAAAAAAAApwZXJtaXQtcHR5AAAAAAAAAA5wZXJtaXQtdXNlci1yYwAAAAAAAAAAAAAAMwAAAAtzc2gtZWQyNTUxOQAAACA7S4ycIB6oTx4UN8l9N+u016UgMzkrbT7E+2XbG35jgwAAAFMAAAALc3NoLWVkMjU1MTkAAABAuJ8zBazcaYTbUEr9QtoYox0MkVBg+8LANxJxc885M2vmg9yPHpTfV/emupqhBwuYcPJSskTxl7WX4TUNvhMsAA==
diff --git a/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/certs/other.cert b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/certs/other.cert
new file mode 100644 (file)
index 0000000..15825f6
--- /dev/null
@@ -0,0 +1 @@
+ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAAIGKXzyrvDzj9ObQ4SuzqytK6nomOV8DhgdzODfWuup1sAAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzOAAAAAAAAAAQAAAABAAAABW90aGVyAAAAFQAAABFvdGhlckBleGFtcGxlLmNvbQAAAABm066AAAAAAGciyIAAAAAAAAAAggAAABVwZXJtaXQtWDExLWZvcndhcmRpbmcAAAAAAAAAF3Blcm1pdC1hZ2VudC1mb3J3YXJkaW5nAAAAAAAAABZwZXJtaXQtcG9ydC1mb3J3YXJkaW5nAAAAAAAAAApwZXJtaXQtcHR5AAAAAAAAAA5wZXJtaXQtdXNlci1yYwAAAAAAAAAAAAAAMwAAAAtzc2gtZWQyNTUxOQAAACDHRJfojkxG39UQt7onBArYnfkmiDLAMO8K5VoTkE8gVQAAAFMAAAALc3NoLWVkMjU1MTkAAABA1ycFqWehyC6pIISEkXSTtHbatLWl9HHAoUFouQiDdubAnMDRSkyHipXR62rq+8yEAvtqm1mXBzO8nLalkF9xAA==
diff --git a/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/certs/tester.cert b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/certs/tester.cert
new file mode 100644 (file)
index 0000000..a2b241c
--- /dev/null
@@ -0,0 +1 @@
+ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAAICSl1xsyTWb23YlKo21musxOzj4L4eD2coTkHbBw2uOyAAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzOAAAAAAAAAAMAAAABAAAABnRlc3RlcgAAABYAAAASdGVzdGVyQGV4YW1wbGUuY29tAAAAAGbTroAAAAAAZyLIgAAAAAAAAACCAAAAFXBlcm1pdC1YMTEtZm9yd2FyZGluZwAAAAAAAAAXcGVybWl0LWFnZW50LWZvcndhcmRpbmcAAAAAAAAAFnBlcm1pdC1wb3J0LWZvcndhcmRpbmcAAAAAAAAACnBlcm1pdC1wdHkAAAAAAAAADnBlcm1pdC11c2VyLXJjAAAAAAAAAAAAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIMdEl+iOTEbf1RC3uicECtid+SaIMsAw7wrlWhOQTyBVAAAAUwAAAAtzc2gtZWQyNTUxOQAAAEDyjzq/0Egm1OxwrvqPZKUihE3w357Ji9Nd3j7VnUuvSYTXAdB9P0E+a2hyCcemmsil1MsvWTiCSSOsrHVB6FEO
diff --git a/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/certs/two_principals.cert b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/certs/two_principals.cert
new file mode 100644 (file)
index 0000000..5f7164a
--- /dev/null
@@ -0,0 +1 @@
+ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAAIFmWKr9gNSQT0vna7k3uOyUF9CTcMGw2zxTFBf2Ev8TzAAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzOAAAAAAAAAAgAAAABAAAABnRlc3RlcgAAACkAAAAPZm9vQGV4YW1wbGUuY29tAAAAEnRlc3RlckBleGFtcGxlLmNvbQAAAABm066AAAAAAGciyIAAAAAAAAAAggAAABVwZXJtaXQtWDExLWZvcndhcmRpbmcAAAAAAAAAF3Blcm1pdC1hZ2VudC1mb3J3YXJkaW5nAAAAAAAAABZwZXJtaXQtcG9ydC1mb3J3YXJkaW5nAAAAAAAAAApwZXJtaXQtcHR5AAAAAAAAAA5wZXJtaXQtdXNlci1yYwAAAAAAAAAAAAAAMwAAAAtzc2gtZWQyNTUxOQAAACDHRJfojkxG39UQt7onBArYnfkmiDLAMO8K5VoTkE8gVQAAAFMAAAALc3NoLWVkMjU1MTkAAABAqlSX2GzLz5U+hN/gF9UUyAkE6h5BgVFYhsyf1MR/B7Hoxa29wGLbJpUplrqEHMxoud2zfH2Nhj00unc3lr5bBA== ./signing_key.pub
diff --git a/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/other_key b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/other_key
new file mode 100644 (file)
index 0000000..ee3f922
--- /dev/null
@@ -0,0 +1,7 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACA3ivU7wf37jE1ITC5KQjVeVlyFTkgWJxub8t380ovjiwAAAJDdMhQO3TIU
+DgAAAAtzc2gtZWQyNTUxOQAAACA3ivU7wf37jE1ITC5KQjVeVlyFTkgWJxub8t380ovjiw
+AAAEA4NlTFs3h2zqt5pSZ5S3dJb42GE7EjG16coKj70eELNDeK9TvB/fuMTUhMLkpCNV5W
+XIVOSBYnG5vy3fzSi+OLAAAADVRIV09AU0VBR044MDA=
+-----END OPENSSH PRIVATE KEY-----
diff --git a/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/other_key-cert.pub b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/other_key-cert.pub
new file mode 100644 (file)
index 0000000..2be08be
--- /dev/null
@@ -0,0 +1 @@
+ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAAIGXo4+L/NyBl1VQDP39PxJP3LSzaqopqZGVP3cG0WoFAAAAAIDeK9TvB/fuMTUhMLkpCNV5WXIVOSBYnG5vy3fzSi+OLAAAAAAAAAAUAAAABAAAABnRlc3RlcgAAABYAAAASdGVzdGVyQGV4YW1wbGUuY29tAAAAAGbTroAAAAAAZyLIgAAAAAAAAACCAAAAFXBlcm1pdC1YMTEtZm9yd2FyZGluZwAAAAAAAAAXcGVybWl0LWFnZW50LWZvcndhcmRpbmcAAAAAAAAAFnBlcm1pdC1wb3J0LWZvcndhcmRpbmcAAAAAAAAACnBlcm1pdC1wdHkAAAAAAAAADnBlcm1pdC11c2VyLXJjAAAAAAAAAAAAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIMdEl+iOTEbf1RC3uicECtid+SaIMsAw7wrlWhOQTyBVAAAAUwAAAAtzc2gtZWQyNTUxOQAAAEA/HwKB8J/kvkEsdxDou+UebnR9u30xPH6FEnbHLlfKbKMIXwLFIHnf9F6bTL36WhFDEDcSBGS19VBWBDRosM8L
diff --git a/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/other_key.pub b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/other_key.pub
new file mode 100644 (file)
index 0000000..0255005
--- /dev/null
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDeK9TvB/fuMTUhMLkpCNV5WXIVOSBYnG5vy3fzSi+OL
diff --git a/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/signing_key b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/signing_key
new file mode 100644 (file)
index 0000000..3dd37be
--- /dev/null
@@ -0,0 +1,7 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACBgEzmfD3DinWPe/H8yLLZ2dPhbnnyFiqe8EWcp0C3czgAAAJDhSMqA4UjK
+gAAAAAtzc2gtZWQyNTUxOQAAACBgEzmfD3DinWPe/H8yLLZ2dPhbnnyFiqe8EWcp0C3czg
+AAAEB1yC00NMYEAVzhDj9odGVL0EonaIkf5jdUZ/czJ0+SPWATOZ8PcOKdY978fzIstnZ0
++FuefIWKp7wRZynQLdzOAAAADVRIV09AU0VBR044MDA=
+-----END OPENSSH PRIVATE KEY-----
diff --git a/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/signing_key-cert.pub b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/signing_key-cert.pub
new file mode 100644 (file)
index 0000000..de191d1
--- /dev/null
@@ -0,0 +1 @@
+ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAAIFEmoWkYraMju0JI0b/0RQtR6RYo/OVp53EVf48L/Pu/AAAAIBmHlkHFlA7HkoTZcau80PH5zduQu41m8BqnH/1v2BwVAAAAAAAAAAEAAAABAAAACGFfa2V5X2lkAAAAFgAAABJ0ZXN0ZXJAZXhhbXBsZS5jb20AAAAAZtOugAAAAABm1QAAAAAAAAAAAIIAAAAVcGVybWl0LVgxMS1mb3J3YXJkaW5nAAAAAAAAABdwZXJtaXQtYWdlbnQtZm9yd2FyZGluZwAAAAAAAAAWcGVybWl0LXBvcnQtZm9yd2FyZGluZwAAAAAAAAAKcGVybWl0LXB0eQAAAAAAAAAOcGVybWl0LXVzZXItcmMAAAAAAAAAAAAAADMAAAALc3NoLWVkMjU1MTkAAAAg2ifM9NMuXwQf7H/H5LCMhMjVqugyyN+jmcMoJUL2YLAAAABTAAAAC3NzaC1lZDI1NTE5AAAAQG1kXUido46YOnmwvkJuIAKyp6Q9Gr+lbdOQvU0St/Hc9HTTIxgDGyLpv0alIJpHOuSYUUUxDufvGKtLJK1duwg= ./signing_key.pub
diff --git a/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/signing_key.pub b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/signing_key.pub
new file mode 100644 (file)
index 0000000..e1210e7
--- /dev/null
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO
diff --git a/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/AbstractSshSignatureTest.java b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/AbstractSshSignatureTest.java
new file mode 100644 (file)
index 0000000..e13379b
--- /dev/null
@@ -0,0 +1,115 @@
+/*
+ * 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 static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.time.Instant;
+import java.time.ZoneOffset;
+
+import org.eclipse.jgit.api.CommitCommand;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.junit.RepositoryTestCase;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.rules.TemporaryFolder;
+
+/**
+ * Common setup for SSH signature tests.
+ */
+public abstract class AbstractSshSignatureTest extends RepositoryTestCase {
+
+       @Rule
+       public TemporaryFolder keys = new TemporaryFolder();
+
+       protected File certs;
+
+       protected Instant commitTime;
+
+       @Override
+       @Before
+       public void setUp() throws Exception {
+               super.setUp();
+               copyResource("other_key", keys.getRoot());
+               copyResource("other_key.pub", keys.getRoot());
+               copyResource("other_key-cert.pub", keys.getRoot());
+               copyResource("signing_key", keys.getRoot());
+               copyResource("signing_key.pub", keys.getRoot());
+               certs = keys.newFolder("certs");
+               copyResource("certs/expired.cert", certs);
+               copyResource("certs/no_principals.cert", certs);
+               copyResource("certs/other.cert", certs);
+               copyResource("certs/other-ca.cert", certs);
+               copyResource("certs/tester.cert", certs);
+               copyResource("certs/two_principals.cert", certs);
+               Repository repo = db;
+               StoredConfig config = repo.getConfig();
+               config.setString("gpg", null, "format", "ssh");
+               config.save();
+               // Run all tests with commit times on 2024-10-02T12:00:00Z. The test
+               // certificates are valid from 2024-09-01 to 2024-10-31, except the
+               // "expired" certificate which is valid only on 2024-09-01.
+               commitTime = Instant.parse("2024-10-02T12:00:00.00Z");
+       }
+
+       private void copyResource(String name, File directory) throws IOException {
+               try (InputStream in = this.getClass().getResourceAsStream(name)) {
+                       int i = name.lastIndexOf('/');
+                       String fileName = i < 0 ? name : name.substring(i + 1);
+                       Files.copy(in, directory.toPath().resolve(fileName));
+               }
+       }
+
+       protected RevCommit createSignedCommit(String certificate,
+                       String signingKey) throws Exception {
+               Repository repo = db;
+               Path key = keys.getRoot().toPath().resolve(signingKey);
+               if (certificate != null) {
+                       Files.copy(certs.toPath().resolve(certificate),
+                                       keys.getRoot().toPath().resolve(signingKey),
+                                       StandardCopyOption.REPLACE_EXISTING);
+               }
+               PersonIdent commitAuthor = new PersonIdent("tester",
+                               "tester@example.com", commitTime, ZoneOffset.UTC);
+               try (Git git = Git.wrap(repo)) {
+                       writeTrashFile("foo.txt", "foo");
+                       git.add().addFilepattern("foo.txt").call();
+                       CommitCommand commit = git.commit();
+                       commit.setAuthor(commitAuthor);
+                       commit.setCommitter(commitAuthor);
+                       commit.setMessage("Message");
+                       commit.setSign(Boolean.TRUE);
+                       commit.setSigningKey(key.toAbsolutePath().toString());
+                       return commit.call();
+               }
+       }
+
+       protected RevCommit checkSshSignature(RevCommit c) {
+               byte[] sig = c.getRawGpgSignature();
+               assertNotNull(sig);
+               String signature = new String(sig, StandardCharsets.US_ASCII);
+               assertTrue("Not an SSH signature:\n" + signature,
+                               signature.startsWith(Constants.SSH_SIGNATURE_PREFIX));
+               return c;
+       }
+}
diff --git a/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/SshCertificateUtilsTest.java b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/SshCertificateUtilsTest.java
new file mode 100644 (file)
index 0000000..79ca21f
--- /dev/null
@@ -0,0 +1,107 @@
+/*
+ * 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 static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.io.InputStream;
+import java.security.PublicKey;
+import java.time.Instant;
+import java.util.List;
+
+import org.apache.sshd.common.config.keys.AuthorizedKeyEntry;
+import org.apache.sshd.common.config.keys.OpenSshCertificate;
+import org.apache.sshd.common.config.keys.PublicKeyEntryResolver;
+import org.apache.sshd.common.util.buffer.Buffer;
+import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Tests for {@link SshCertificateUtils}. They use a certificate valid from
+ * 2024-09-01 00:00:00 to 2024-09-02 00:00:00 UTC.
+ */
+public class SshCertificateUtilsTest {
+
+       private OpenSshCertificate certificate;
+
+       @Before
+       public void loadCertificate() throws Exception {
+               try (InputStream in = this.getClass().getResourceAsStream(
+                               "certs/expired.cert")) {
+                       List<AuthorizedKeyEntry> keys = AuthorizedKeyEntry
+                                       .readAuthorizedKeys(in, true);
+                       if (keys.isEmpty()) {
+                               certificate = null;
+                       }
+                       PublicKey key = keys.get(0).resolvePublicKey(null,
+                                       PublicKeyEntryResolver.FAILING);
+                       assertTrue(
+                                       "Expected an OpenSshKeyCertificate but got a "
+                                                       + key.getClass().getName(),
+                                       key instanceof OpenSshCertificate);
+                       certificate = (OpenSshCertificate) key;
+               }
+       }
+
+       @Test
+       public void testValidUserCertificate() {
+               assertNull(SshCertificateUtils.verify(certificate, null));
+               Instant validTime = Instant.parse("2024-09-01T00:00:00.00Z");
+               assertNull(SshCertificateUtils.verify(certificate, validTime));
+               assertNull(SshCertificateUtils.checkExpiration(certificate, validTime));
+       }
+
+       @Test
+       public void testCheckTooEarly() {
+               Instant invalidTime = Instant.parse("2024-08-31T23:59:59.00Z");
+               assertNotNull(
+                               SshCertificateUtils.checkExpiration(certificate, invalidTime));
+               assertNotNull(SshCertificateUtils.verify(certificate, invalidTime));
+       }
+
+       @Test
+       public void testCheckExpired() {
+               Instant invalidTime = Instant.parse("2024-09-02T00:00:01.00Z");
+               assertNotNull(
+                               SshCertificateUtils.checkExpiration(certificate, invalidTime));
+               assertNotNull(SshCertificateUtils.verify(certificate, invalidTime));
+       }
+
+       @Test
+       public void testInvalidSignature() throws Exception {
+               // Modify the serialized certificate, then re-load it again. To check that
+               // serialization per se works fine, also check an unmodified version.
+               Buffer buffer = new ByteArrayBuffer();
+               buffer.putPublicKey(certificate);
+               int pos = buffer.rpos();
+               PublicKey unchanged = buffer.getPublicKey();
+               assertTrue(
+                               "Expected an OpenSshCertificate but got a "
+                                               + unchanged.getClass().getName(),
+                               unchanged instanceof OpenSshCertificate);
+               assertNull(SshCertificateUtils.verify((OpenSshCertificate) unchanged,
+                               null));
+               buffer.rpos(pos);
+               // Change a byte. The test certificate has the key ID at offset 128.
+               // Changing a byte in the key ID should still result in a successful
+               // deserialization, but then fail the signature check.
+               buffer.array()[pos + 128]++;
+               PublicKey changed = buffer.getPublicKey();
+               assertTrue(
+                               "Expected an OpenSshCertificate but got a "
+                                               + changed.getClass().getName(),
+                               changed instanceof OpenSshCertificate);
+               assertNotNull(
+                               SshCertificateUtils.verify((OpenSshCertificate) changed, null));
+       }
+}
diff --git a/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/SshSignerTest.java b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/SshSignerTest.java
new file mode 100644 (file)
index 0000000..b3a4482
--- /dev/null
@@ -0,0 +1,62 @@
+/*
+ * 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 static org.junit.Assert.assertThrows;
+import static org.junit.Assert.fail;
+
+import org.junit.Test;
+
+/**
+ * Tests for the {@link SshSigner}.
+ */
+public class SshSignerTest extends AbstractSshSignatureTest {
+
+       @Test
+       public void testPlainSignature() throws Exception {
+               checkSshSignature(createSignedCommit(null, "signing_key.pub"));
+       }
+
+       @Test
+       public void testExpiredSignature() throws Exception {
+               Throwable t = assertThrows(Throwable.class,
+                               () -> createSignedCommit("expired.cert",
+                                               "signing_key-cert.pub"));
+               // The exception or one of its causes should mention "[Ee]xpired" and
+               // "[Cc]ertificate" in the message
+               while (t != null) {
+                       String message = t.getMessage();
+                       if (message.contains("xpired") && message.contains("ertificate")) {
+                               return;
+                       }
+                       t = t.getCause();
+               }
+               fail("Expected exception message not found");
+       }
+
+       @Test
+       public void testCertificateSignature() throws Exception {
+               checkSshSignature(createSignedCommit("tester.cert", "signing_key.pub"));
+       }
+
+       @Test
+       public void testNoPrincipalsSignature() throws Exception {
+               // Certificate has no principals; should still work
+               checkSshSignature(
+                               createSignedCommit("no_principals.cert", "signing_key.pub"));
+       }
+
+       @Test
+       public void testOtherSignature() throws Exception {
+               // Certificate has a principal different that tester@example.com; should
+               // still work
+               checkSshSignature(createSignedCommit("other.cert", "signing_key.pub"));
+       }
+}
index 69f23a5ebe6a5c8000d73dfc8bd05404c1b910ba..512c19f4c29fa5b3f864162a8975ba53f50535cd 100644 (file)
@@ -8,7 +8,8 @@ Bundle-Localization: OSGI-INF/l10n/plugin
 Bundle-ActivationPolicy: lazy
 Bundle-Version: 7.1.0.qualifier
 Bundle-RequiredExecutionEnvironment: JavaSE-17
-Export-Package: org.eclipse.jgit.internal.transport.sshd;version="7.1.0";x-internal:=true;
+Export-Package: org.eclipse.jgit.internal.signing.ssh;version="7.1.0";x-friends:="org.eclipse.jgit.ssh.apache.test",
+ org.eclipse.jgit.internal.transport.sshd;version="7.1.0";x-internal:=true;
   uses:="org.apache.sshd.client,
    org.apache.sshd.client.auth,
    org.apache.sshd.client.auth.keyboard,
@@ -90,10 +91,12 @@ Import-Package: net.i2p.crypto.eddsa;version="[0.3.0,0.4.0)",
  org.apache.sshd.sftp.client;version="[2.14.0,2.15.0)",
  org.apache.sshd.sftp.common;version="[2.14.0,2.15.0)",
  org.eclipse.jgit.annotations;version="[7.1.0,7.2.0)",
+ org.eclipse.jgit.api.errors;version="[7.1.0,7.2.0)",
  org.eclipse.jgit.errors;version="[7.1.0,7.2.0)",
  org.eclipse.jgit.fnmatch;version="[7.1.0,7.2.0)",
  org.eclipse.jgit.internal.storage.file;version="[7.1.0,7.2.0)",
  org.eclipse.jgit.internal.transport.ssh;version="[7.1.0,7.2.0)",
+ org.eclipse.jgit.lib;version="[7.1.0,7.2.0)",
  org.eclipse.jgit.nls;version="[7.1.0,7.2.0)",
  org.eclipse.jgit.transport;version="[7.1.0,7.2.0)",
  org.eclipse.jgit.util;version="[7.1.0,7.2.0)",
diff --git a/org.eclipse.jgit.ssh.apache/resources/META-INF/services/org.eclipse.jgit.lib.SignerFactory b/org.eclipse.jgit.ssh.apache/resources/META-INF/services/org.eclipse.jgit.lib.SignerFactory
new file mode 100644 (file)
index 0000000..80f22c0
--- /dev/null
@@ -0,0 +1 @@
+org.eclipse.jgit.signing.ssh.SshSignerFactory
\ No newline at end of file
index 7da718188718041c05d77cfbee0236e57ca496e9..e51c80a5acfebed9c003c07f801409633ca7a863 100644 (file)
@@ -125,4 +125,26 @@ sshClosingDown=Apache MINA sshd session factory is closing down; cannot create n
 sshCommandTimeout={0} timed out after {1} seconds while opening the channel
 sshProcessStillRunning={0} is not yet completed, cannot get exit code
 sshProxySessionCloseFailed=Error while closing proxy session {0}
+signCertAlgorithmMismatch=Certificate of type {0} with CA key {1} uses an incompatible signature algorithm {2}
+signCertAlgorithmUnknown=Certificate with CA key {0} is signed with an unknown algorithm {1}
+signCertificateExpired=Expired certificate with CA key {0}
+signCertificateInvalid=Certificate signature does not match on certificate with CA key {0}
+signCertificateTooEarly=Certificate with CA key {0} was not valid yet
+signDefaultKeyEmpty=git.ssh.defaultKeyCommand {0} returned no key
+signDefaultKeyFailed=git.ssh.defaultKeyCommand {0} failed with exit code {1}\n{2}
+signDefaultKeyInterrupted=git.ssh.defaultKeyCommand {0} was interrupted
+signInvalidKeyDSA=SSH signatures with DSA keys or certificates are not supported; use a different signing key.
+signLogFailure=SSH signature verification failed
+signNoAgent=No connector for ssh-agent found; maybe include org.eclipse.jgit.ssh.apache.agent in the application.
+signNoPublicKey=No public key found with signing key {0}
+signNoSigningKey=Git config user.signingKey or gpg.ssh.defaultKeyCommand must be set for SSH signing.
+signNotUserCertificate=Certificate with CA key {0} used for the SSH signature is not a user certificate.
+signPublicKeyError=Cannot read public key {0}
+signSeeLog=SSH signature verification failed; see the log for details
+signSignatureError=Could not create the signature
+signStderr=Cannot read stderr
+signTooManyPrivateKeys=Private key file {0} must contain exactly one private key
+signTooManyPublicKeys=Public key file {0} must contain exactly one public key
+signUnknownHashAlgorithm=SSH Signature has an unknown hash algorithm {0}
+signUnknownSignatureAlgorithm=SSH Signature has an unknown signature algorithm {0}
 unknownProxyProtocol=Ignoring unknown proxy protocol {0}
\ No newline at end of file
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 (file)
index 0000000..040c6d4
--- /dev/null
@@ -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 (file)
index 0000000..bc72196
--- /dev/null
@@ -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/SshSigner.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSigner.java
new file mode 100644 (file)
index 0000000..8cfe5f4
--- /dev/null
@@ -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);
+                       }
+               }
+       }
+}
index 05f04ac5b2219af9563ee35463d1be89eca5b446..6f7a4e9ef4830a46638365012ee59462e54710ac 100644 (file)
@@ -147,6 +147,28 @@ public final class SshdText extends TranslationBundle {
        /***/ public String sshCommandTimeout;
        /***/ public String sshProcessStillRunning;
        /***/ public String sshProxySessionCloseFailed;
+       /***/ public String signCertAlgorithmMismatch;
+       /***/ public String signCertAlgorithmUnknown;
+       /***/ public String signCertificateExpired;
+       /***/ public String signCertificateInvalid;
+       /***/ public String signCertificateTooEarly;
+       /***/ public String signDefaultKeyEmpty;
+       /***/ public String signDefaultKeyFailed;
+       /***/ public String signDefaultKeyInterrupted;
+       /***/ public String signInvalidKeyDSA;
+       /***/ public String signLogFailure;
+       /***/ public String signNoAgent;
+       /***/ 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 unknownProxyProtocol;
 
 }
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 (file)
index 0000000..5459b53
--- /dev/null
@@ -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();
+       }
+}