From d1a14b8fff84c57f5bd15cf6ecd36d0ecee3d4aa Mon Sep 17 00:00:00 2001 From: Thomas Wolf Date: Sat, 28 Sep 2024 16:11:45 +0200 Subject: [PATCH] SSH signing: implement a SignatureVerifier Signature verification needs quite a bit of infrastructure. There are two files to read: a list of allowed signers, and a list of revoked keys or certificates. Introduce a SigningKeyDatabase abstraction for these, and give client code the possibility to plug in its own implementation. Loading these files afresh for every signature to be checked would be prohibitively expensive. Introduce a cache of SigningKeyDatabases, and have them reload the files only when they have changed. Include a default implementation that works with the OpenSSH allowed signers file and with OpenSSH revocation lists. Binary KRLs are parsed according to [1]; the test data was generated using the OpenSSH test script[2]. [1] https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.krl [2] https://github.com/openssh/openssh-portable/blob/67a115e/regress/krl.sh Bug: jgit-44 Change-Id: I6a2fa24f38a2f2fe63ffb353da5b6665ca7277e1 Signed-off-by: Thomas Wolf --- .../.gitattributes | 3 +- org.eclipse.jgit.ssh.apache.test/BUILD | 1 + .../jgit/internal/signing/ssh/allowed_signers | 2 + .../eclipse/jgit/internal/signing/ssh/krl/krl | Bin 0 -> 17185 bytes .../jgit/internal/signing/ssh/krl/krl-all | Bin 0 -> 792 bytes .../jgit/internal/signing/ssh/krl/krl-ca | Bin 0 -> 104 bytes .../jgit/internal/signing/ssh/krl/krl-cert | Bin 0 -> 182 bytes .../jgit/internal/signing/ssh/krl/krl-empty | Bin 0 -> 44 bytes .../jgit/internal/signing/ssh/krl/krl-hash | Bin 0 -> 445 bytes .../jgit/internal/signing/ssh/krl/krl-keyid | Bin 0 -> 7783 bytes .../internal/signing/ssh/krl/krl-keyid-wild | Bin 0 -> 7732 bytes .../jgit/internal/signing/ssh/krl/krl-keys | Bin 0 -> 654 bytes .../jgit/internal/signing/ssh/krl/krl-serial | Bin 0 -> 382 bytes .../internal/signing/ssh/krl/krl-serial-wild | Bin 0 -> 162 bytes .../jgit/internal/signing/ssh/krl/krl-sha1 | Bin 0 -> 313 bytes .../jgit/internal/signing/ssh/krl/krl-sha256 | Bin 0 -> 445 bytes .../jgit/internal/signing/ssh/krl/krl-text | 11 + .../internal/signing/ssh/krl/revoked-0001 | 7 + .../signing/ssh/krl/revoked-0001-cert.pub | 1 + .../internal/signing/ssh/krl/revoked-0001.pub | 1 + .../internal/signing/ssh/krl/revoked-0004 | 7 + .../signing/ssh/krl/revoked-0004-cert.pub | 1 + .../internal/signing/ssh/krl/revoked-0004.pub | 1 + .../internal/signing/ssh/krl/revoked-0010 | 7 + .../signing/ssh/krl/revoked-0010-cert.pub | 1 + .../internal/signing/ssh/krl/revoked-0010.pub | 1 + .../internal/signing/ssh/krl/revoked-0050 | 7 + .../signing/ssh/krl/revoked-0050-cert.pub | 1 + .../internal/signing/ssh/krl/revoked-0050.pub | 1 + .../internal/signing/ssh/krl/revoked-0090 | 7 + .../signing/ssh/krl/revoked-0090-cert.pub | 1 + .../internal/signing/ssh/krl/revoked-0090.pub | 1 + .../internal/signing/ssh/krl/revoked-0500 | 7 + .../signing/ssh/krl/revoked-0500-cert.pub | 1 + .../internal/signing/ssh/krl/revoked-0500.pub | 1 + .../internal/signing/ssh/krl/revoked-0510 | 7 + .../signing/ssh/krl/revoked-0510-cert.pub | 1 + .../internal/signing/ssh/krl/revoked-0510.pub | 1 + .../internal/signing/ssh/krl/revoked-0520 | 7 + .../signing/ssh/krl/revoked-0520-cert.pub | 1 + .../internal/signing/ssh/krl/revoked-0520.pub | 1 + .../internal/signing/ssh/krl/revoked-0550 | 7 + .../signing/ssh/krl/revoked-0550-cert.pub | 1 + .../internal/signing/ssh/krl/revoked-0550.pub | 1 + .../internal/signing/ssh/krl/revoked-0799 | 7 + .../signing/ssh/krl/revoked-0799-cert.pub | 1 + .../internal/signing/ssh/krl/revoked-0799.pub | 1 + .../internal/signing/ssh/krl/revoked-0999 | 7 + .../signing/ssh/krl/revoked-0999-cert.pub | 1 + .../internal/signing/ssh/krl/revoked-0999.pub | 1 + .../jgit/internal/signing/ssh/krl/revoked-ca | 7 + .../internal/signing/ssh/krl/revoked-ca.pub | 1 + .../jgit/internal/signing/ssh/krl/revoked-ca2 | 7 + .../internal/signing/ssh/krl/revoked-ca2.pub | 1 + .../internal/signing/ssh/krl/revoked-hash | 11 + .../internal/signing/ssh/krl/revoked-keyid | 512 +++++++++++++++++ .../internal/signing/ssh/krl/revoked-serials | 19 + .../internal/signing/ssh/krl/revoked-sha1 | 11 + .../internal/signing/ssh/krl/revoked-sha256 | 11 + .../internal/signing/ssh/krl/unrevoked-0005 | 7 + .../signing/ssh/krl/unrevoked-0005-cert.pub | 1 + .../signing/ssh/krl/unrevoked-0005.pub | 1 + .../internal/signing/ssh/krl/unrevoked-0009 | 7 + .../signing/ssh/krl/unrevoked-0009-cert.pub | 1 + .../signing/ssh/krl/unrevoked-0009.pub | 1 + .../internal/signing/ssh/krl/unrevoked-0014 | 7 + .../signing/ssh/krl/unrevoked-0014-cert.pub | 1 + .../signing/ssh/krl/unrevoked-0014.pub | 1 + .../internal/signing/ssh/krl/unrevoked-0016 | 7 + .../signing/ssh/krl/unrevoked-0016-cert.pub | 1 + .../signing/ssh/krl/unrevoked-0016.pub | 1 + .../internal/signing/ssh/krl/unrevoked-0029 | 7 + .../signing/ssh/krl/unrevoked-0029-cert.pub | 1 + .../signing/ssh/krl/unrevoked-0029.pub | 1 + .../internal/signing/ssh/krl/unrevoked-0049 | 7 + .../signing/ssh/krl/unrevoked-0049-cert.pub | 1 + .../signing/ssh/krl/unrevoked-0049.pub | 1 + .../internal/signing/ssh/krl/unrevoked-0051 | 7 + .../signing/ssh/krl/unrevoked-0051-cert.pub | 1 + .../signing/ssh/krl/unrevoked-0051.pub | 1 + .../internal/signing/ssh/krl/unrevoked-0499 | 7 + .../signing/ssh/krl/unrevoked-0499-cert.pub | 1 + .../signing/ssh/krl/unrevoked-0499.pub | 1 + .../internal/signing/ssh/krl/unrevoked-0800 | 7 + .../signing/ssh/krl/unrevoked-0800-cert.pub | 1 + .../signing/ssh/krl/unrevoked-0800.pub | 1 + .../internal/signing/ssh/krl/unrevoked-1010 | 7 + .../signing/ssh/krl/unrevoked-1010-cert.pub | 1 + .../signing/ssh/krl/unrevoked-1010.pub | 1 + .../internal/signing/ssh/krl/unrevoked-1011 | 7 + .../signing/ssh/krl/unrevoked-1011-cert.pub | 1 + .../signing/ssh/krl/unrevoked-1011.pub | 1 + .../jgit/internal/signing/ssh/repo.bundle | Bin 0 -> 5402 bytes .../signing/ssh/AbstractSshSignatureTest.java | 4 + .../signing/ssh/AllowedSignersParseTest.java | 201 +++++++ .../signing/ssh/OpenSshBinaryKrlLoadTest.java | 32 ++ .../internal/signing/ssh/OpenSshKrlTest.java | 146 +++++ .../signing/ssh/SerialRangeSetTest.java | 124 ++++ .../signing/ssh/SshSignatureVerifierTest.java | 151 +++++ .../signing/ssh/VerifyGitSignaturesTest.java | 154 +++++ .../META-INF/MANIFEST.MF | 1 + ....eclipse.jgit.lib.SignatureVerifierFactory | 1 + .../transport/sshd/SshdText.properties | 43 ++ .../internal/signing/ssh/AllowedSigners.java | 535 ++++++++++++++++++ .../signing/ssh/OpenSshBinaryKrl.java | 490 ++++++++++++++++ .../jgit/internal/signing/ssh/OpenSshKrl.java | 120 ++++ .../ssh/OpenSshSigningKeyDatabase.java | 161 ++++++ .../internal/signing/ssh/SerialRangeSet.java | 138 +++++ .../internal/signing/ssh/SigningDatabase.java | 59 ++ .../signing/ssh/SshSignatureVerifier.java | 319 +++++++++++ .../internal/transport/sshd/SshdText.java | 45 +- .../ssh/CachingSigningKeyDatabase.java | 49 ++ .../jgit/signing/ssh/SigningKeyDatabase.java | 94 +++ .../ssh/SshSignatureVerifierFactory.java | 34 ++ .../signing/ssh/VerificationException.java | 63 +++ 115 files changed, 3757 insertions(+), 2 deletions(-) create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/allowed_signers create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-all create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-ca create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-cert create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-empty create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-hash create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-keyid create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-keyid-wild create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-keys create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-serial create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-serial-wild create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-sha1 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-sha256 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-text create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0001 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0001-cert.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0001.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0004 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0004-cert.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0004.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0010 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0010-cert.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0010.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0050 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0050-cert.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0050.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0090 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0090-cert.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0090.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0500 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0500-cert.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0500.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0510 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0510-cert.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0510.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0520 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0520-cert.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0520.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0550 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0550-cert.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0550.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0799 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0799-cert.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0799.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0999 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0999-cert.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-0999.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-ca create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-ca.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-ca2 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-ca2.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-hash create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-keyid create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-serials create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-sha1 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/revoked-sha256 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0005 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0005-cert.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0005.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0009 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0009-cert.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0009.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0014 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0014-cert.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0014.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0016 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0016-cert.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0016.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0029 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0029-cert.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0029.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0049 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0049-cert.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0049.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0051 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0051-cert.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0051.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0499 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0499-cert.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0499.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0800 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0800-cert.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-0800.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-1010 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-1010-cert.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-1010.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-1011 create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-1011-cert.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/unrevoked-1011.pub create mode 100644 org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/repo.bundle create mode 100644 org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/AllowedSignersParseTest.java create mode 100644 org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/OpenSshBinaryKrlLoadTest.java create mode 100644 org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/OpenSshKrlTest.java create mode 100644 org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/SerialRangeSetTest.java create mode 100644 org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/SshSignatureVerifierTest.java create mode 100644 org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/VerifyGitSignaturesTest.java create mode 100644 org.eclipse.jgit.ssh.apache/resources/META-INF/services/org.eclipse.jgit.lib.SignatureVerifierFactory create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/AllowedSigners.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshBinaryKrl.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshKrl.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshSigningKeyDatabase.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SerialRangeSet.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SigningDatabase.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSignatureVerifier.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/CachingSigningKeyDatabase.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SigningKeyDatabase.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SshSignatureVerifierFactory.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/VerificationException.java diff --git a/org.eclipse.jgit.ssh.apache.test/.gitattributes b/org.eclipse.jgit.ssh.apache.test/.gitattributes index c1f1b7371a..b5b937561d 100644 --- a/org.eclipse.jgit.ssh.apache.test/.gitattributes +++ b/org.eclipse.jgit.ssh.apache.test/.gitattributes @@ -1 +1,2 @@ -/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/repo.bundle binary \ No newline at end of file +/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/repo.bundle binary +/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl* binary diff --git a/org.eclipse.jgit.ssh.apache.test/BUILD b/org.eclipse.jgit.ssh.apache.test/BUILD index bb31f8b3c7..dfc059f0a3 100644 --- a/org.eclipse.jgit.ssh.apache.test/BUILD +++ b/org.eclipse.jgit.ssh.apache.test/BUILD @@ -10,6 +10,7 @@ load( DEPS = [ "//lib:eddsa", "//lib:junit", + "//lib:slf4j-api", "//lib:sshd-osgi", "//lib:sshd-sftp", "//org.eclipse.jgit:jgit", diff --git a/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/allowed_signers b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/allowed_signers new file mode 100644 index 0000000000..ec74409e54 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/allowed_signers @@ -0,0 +1,2 @@ +tester@example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO +*@example.com cert-authority ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMdEl+iOTEbf1RC3uicECtid+SaIMsAw7wrlWhOQTyBV diff --git a/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl new file mode 100644 index 0000000000000000000000000000000000000000..9469340ed398614e1d5b82015216d670d9dbb086 GIT binary patch literal 17185 zcmeI)d303e8OQOk6M`CzC@m#Kz*-P+X5ROCo(u@ouow!6B~TG8pb|~Mg&jq!f`9^6 zrN~k?MYgaNsVkLDqvBGvguQ^m0TChWtDw@kQ9`~Pb58&2=^vAGLgu}5@67MKne+Kw z?#m@BE3<9Kc2yE$UuA+n&VSvTkeq-1@%f*z9a3M64P5LyX~2M-hS|L$BH?Ikn0D3L zhbJv*Fwc!#H0IWXwF8s3?3NAxESsBgbD16a+cOS*7Q9C7*mw2dLF_Adv#Mo-N3lQB z5;88kCiXBn7{{JF{^QSbwbEh_D*rV2y2KvW{j}#TX(cue3jMg}wBOcANT_)-Hc0HB zJ@~;s*}c-ju|bvML9k!d3!~7*<6!TS&qr{y#74z096VJ#2V-&Sr?8CGh~>l!b}xrsxVW9nJUawVWtW* zRnbT2LaGQ;MVKnWR1v0%Fja)9B1{!wst8j>I9C__LLE~%rgBW>n94DgV=Bi~j;S0| zdCrw*D$i7&sXSA8rt(bXnaVSjXDVSTVJcxNVJcxNVJcxNVJcxNVJc-RWh!MVWh!MV zWh!MVWh!MVWh!GTV=7}RV=7}RV=7}RV=7}RW2$tfN@uEcrb=h3bf!vYs&uAGXR36j zN@uDlQ$?97%2ZLNiZWG{siI63WvVDsMT1nu?BjJkq4yy~^dUs_Aw=|{i(U#5eFzbK z2oZe<5q$^~=Lx+h^q$arLhlK^C-k1sdqVFCy(jdZ(0fAf3B4!up3r+j?+Lvp^q$ar zLhlK^C-k1sdqVFCy(jdZ(0fAf3B4!up3r+j?+Lvp^q$arLhlK^C-k1sdqVFCy(jdZ z(0fAf3B4!up3r+j?+Lvp^q$arLhlK^C-k1sdqVFCy(jdZ(0fAf3B4!up3r+j?+Lvp z^q$arLhlK^C-k1sdqVFCy(jdZ(0fAf3B4!up3r+j?+Lvp^q$arLhlK^C-k1sdqVGv zpf5g$=cijpyO4Gv?Lyjxv`YxI3uzb9E~H&ZyO4Gv?LyjxvAAZs0?MmB~wkvH{+OD)+ zX}i*PrR_@Fm9{HS+m*H}ZCBc^v|VYt(srfoO52sTD{WWWuC!fgyV7>0?MmB~wkvH{ z+OD)+X}i*PrR_@Fm9{HwSK6+$U1_`0cBSn~+m*H}ZCBc^v|VYt(srfoO52sTD{WWW zuC!fgyV7>0?MmB~wkvH{+OD)+X}i*PrR_@Fm9{HwSK6+$U1_`0cBSn~+m*H}ZCBc^ zv|VYt(srfoO52sTD{WWWuC!fgyV7>0?MmB?wi|6X+HSPnXuHvNqwPl9jkX(YH`;Er z-DtbfcBAb^+l{sxZ8zF(wB2aC(RQQlM%#_H8*Mk*ZnWKKyU})|?MB;;wi|6X+HSPn zXuHvNqwPl9jkX(YH`;Er-DtbfcBAb^+l{sxZ8zF(wB2aC(RQQlM%#_H8*Mk*ZnWKK zyU})|?MB;;wi|6X+HSPnXuHvNqwPl9jkX(YH`;Er-DtbfcBAb^+l{sxZ8zF(wB2aC z(RQQlM%#_H8*Mk*ZnWKKyU})|?MB;;wi|6X+HOU*7hj=asAx1=CbmX>U+n69(N}!I z`NA49)*&2A&$09zOV6?N981r!^c+jivGg2E&$09zOV6?N981r!^c+jivGg2E&$09z zOV6?N981r!^c+jivGg2E&$09zOV6?N981r!^c+jivGg2E&$09zOV6?N981r!^c+ji zvGg2E&$09zOV6?N981r!^c+jivGg2E&$09zOV6?N981r!^c+jivGg2E&$09zOV6?N z981r!^c+jivGg2E&$09zOV6?N981r!^c+jivGg2E&$09zOV6?N981r!^c+jivGg2E z&$09zOV6?N981r!^c+jivGg4K#j*4pOV6?N981r!^c+jivGg2E&&3yA@uPDXG{*Ek1sfvASb@yT=J6=UvS23@EktA;Ec)PIedJ< z8I!|v`1pcz$;pW?IAb<=eI~x(jLG5knfQV;CWqH&;tS509A2M^FF0dzczq_m;9PQY z;tS504PKIoFF0dzcu6L{;Ec)PC7JkwGbV?ZWd5HPoD*ipf)?G$bEWX1Qf&_1Qgj;nf>&Ufe42 zlNyySnU&My)2a@gjbHJ%$Wz3TFw{R0o=~V{3*TE@=4Oy%Ud4&l&R9 z)+gpAb`Q2UoBPUvr5`_;@>boty&LB3Y2UkL_uOE6cE!-anb$5losd2C`V+%9evx!r zo6KN)?t{&1H=1_l+Ig4u2%oy6eag(y@7D;nzw~zX^vAb6v|;%pubwL0xn}R8gYAO< zE8)sbzt3H{?)vcfFSq{cz7uo0r>9@j?Ve!!md2w7?K^4>-x*M)@{*xh+oz1(et)q2 zK-XxmazoCpZm{&sMR zrsWTqbwh=`BNMa6ozZ8K7q&V&v_fjI&(Qa)oU45Mp5fhFW_548^y3Y0963=i{LH}2 z_A?I7Tl!H>&tRVkQwlzMcUt+a>vK;u{bawWlo_M|goHm&;I zuEgD^#~ym4$(*}7M2qugZMzX zFF=Bw1xN_6Fe-p3AO-?vc_{rHsD~*Q$ie0k=I%GG+*u*tl&x(2SmLkUZ8w`%8KLGW zKTDAFzPEOPFAi-wMcER+mx?-9$v-$Uec2bGhYMY}q>84?et6CPaV>XHBMxo4dmZO} zle^PYx;W$2+&200lS_S;AM$?lf!$#Ky0*714{>O-uaa1u{Fu4Un)|iW3I6-rFNC^Y zZc*lbe{a=_VDAuv1{~Tv%2~pN&sHwD60uY7>!JYDKDUFP!$g^8A78S@$?f$!T-s8j zJfA1pd6voTDV_7U%RkZb2E!ZHXSWV>+q}74`s3d`9Jb|o$Y1#_+Giay;mTs=rR+ae zaop3iKAvE;CuUkw?^C$~9NMNn`?W%N^J~7}ciu0YP&kp{`&Zf3PD_|V-+XjxT&(qC zCk}0UCAr&QHEu|7c|QHx;TF4ndWOOAwwJ1IZq4{_eaTS75Qnz?c9~3zv!BdfEq={? ztt;ngX?EVl8S$s&zH62IZOxlE0f)9552gEC=JM=GD0)-lZ>Pw^8#y)oN4u`v*H7Z+#frluH~ni^UHxe8(zE2pp5 gS#I3ArYnHqVhQ)nC#F}=Fl9QqGd?(c*XjK^0K^Oz3;+NC literal 0 HcmV?d00001 diff --git a/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-cert b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-cert new file mode 100644 index 0000000000000000000000000000000000000000..26f29b2ba5053887545f93044a694f5a6b797b1e GIT binary patch literal 182 zcmWFz_V5n!;bH&+Mi2o5=?nsgVQd%;QqT&-#z4$nT%4hsnqp*XYG?`MDu`XIoW5RX zxpC{7t^kIMCEPckm|i`@lqugZMzX YFF=Bw1xN_6Fe-p3AO-?vc_{rH06IV#H2?qr literal 0 HcmV?d00001 diff --git a/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-empty b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-empty new file mode 100644 index 0000000000000000000000000000000000000000..78e5187f29c274202cf47f003913d29f6b09f5d5 GIT binary patch literal 44 ccmWFz_V5n!;bH&+Mi2o5=?nsgVQd%;0Ay4H9RL6T literal 0 HcmV?d00001 diff --git a/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-hash b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-hash new file mode 100644 index 0000000000000000000000000000000000000000..cdd135159807aa64749246a256f7f1f5f0685a4b GIT binary patch literal 445 zcmWFz_V5n!;bH&+Mi2o5=?nsgVQd)93RKer-k@|1*viS z?8@S9Z+_#=?CK0YkF`zvKg`YE|7vNYT%Y(XxyYc&AT|EG&0b9Yd{+2dMDd)%#f$Y> zSA3inJn@%VkHBizPgSgPAT?D-x&CuRK5L3|368Vcct8=CZ1ZI}(9eik~n*LZS>;m7lr=dAR+1XA-fF8pw2LILB% zkC*or?hu@zoZ5O|4U6>uwnUvRtF^TDfzBNozsMQkd< z1_lO#fw`HnT_e-e&os>R9IE=ScdDC*f9Bb<4?p_!Ge*ERVpO)``{^66WSKnO!@ppdz^|ddae(>n8 zmw!I}=eKQ*cdxGA{`d6!`qww#e)Z+e^C$b$@c2CJPSYFr)9&HCoqc)yz^ikb&I@mc z&cl{_zRI$#dVP15<@O>M<93aUaa-qNy#2-RUt1pC3NB9G?$pINjz`Ds9^Rk4H*#;) z-m1M-d#mKR+Uv{RasS5#j02pt728GidC^HR>i7V)mF7tZB<*< zR<%`aRa@0owN+!)ST$CSRb$myHCByPW7Sx7wCZTp(W;|WN2`uj9j!WAb+qbe)oRsh z)oRsh)oRsh)oRsh)oRtcRh`*q-4goRkytwtYe!=39xO#-?MSR0iM1oKcHFoU`V#sQ z`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ z`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ z`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ`V#sQ z`V#sQ`V#sQ`V#sQ`V#uOh5o#U_tPC|kF-bHBkhs)?4Ui;9%+xXN7^Ipk@iS?q&?Ce zX^*r=+9U1%|8;iW!$;a9?UD9Kd!#+m9%+xXN7^Ipk@iS?q&?CeX^*r=+9U0e_DFlA zJ<=X&kF-bHBkhs)NPDC`(w+w&erb=iN7^Ipk@iS?q&?CeX^*r=+9U0e_DFlAJ<=X& zkF-bHBkhs)NPDC`(jIA#v`5+_?UD9Kd!#+m9%+xXN7^Ipk@iS?q&?CeX^*r=+9U0e z_DFlAJ?`OiyN5q})!N?L-rC;U-rC;U-rC;U-rC;U-rC;U-rC;U-rC;U-rC;U-rC-7 z+gsaP+gsaP+gsaP+gsaP+gsaP+gsaP+gsaP+gsaP+gsaP+gsaP+gsaP+gsaP+gsaP z+gsaP+gsaP+gsaP+gsaP+gsaP+gsaP+gsaP+gsaP+gsaP+gsaP+gsaP+gsaP+gsaP z+gsaP+gsaP+gsaP+gsaP+gsaP+gsaP+gsaP+gsaP+gsaP+gsaP+gsaP+gsaP+gsaP z+gsaP+eh0++eh0++eh0++eh0++eh0++eh0++eh0++eh0++eh0++eh0++eh0++eh0+ z+eh0++eh0++eh0++eh0++eh0++eh0++eh0++eh0++eh0++eh0++eh0++eh0++eh0+ z+eh0++eh0++eh0++eh0++eh0++eh0++eh0++eh0++eh0++eh0++eh0++eh0++eh0+ z+eh0++eh0++eh0++eh0++eh0++eh0++eh0++eh0++eh0++eh0++eh0++eh2y*7oy% JXpCCd^*>Z|h++T$ literal 0 HcmV?d00001 diff --git a/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-keyid-wild b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-keyid-wild new file mode 100644 index 0000000000000000000000000000000000000000..9ba549f363b789e44228c978e4a7be742bb99ae5 GIT binary patch literal 7732 zcmZA5J&q$~6ouhNL<|IM0EEN_{B!R)t{osj#B6~P0Ra*L8)Q$!v|DQ9XB^nRs`6K_ z%GRTDef;>-&%gfi;pO%5#r^61@%ziS|Gd9{e|_EI`!C;zA75U+`}cMD>aS0K|M|zK z-+nm14j&%F>2><%|LOGkeBb+d`-M;M>-1RozUeVspZB}oSN;0^DA%`xJdO8bJdO8t zp2qu1Pvbb=3!b*dv5q&#gYj(S*{ZWuXRFRuovk`sb++nk)!C|xRTrx+R$Z*RSaq@L zV%5c}i&Yn^KKnwiR$Z;ST6MMRYSq=Mt5sL4u2zlbYOETo#;UPutQxDvsi7V6{~7ht*TYEs#eviT2-rRRjs;Nb+hVb z)y=A#RX3|{R^6<+S#`7Ouwny8e?a}sVd$c{;9&L}dN86+A(e`M2v_0A$ZI8A`+oSE# z_Go*wJ=z{^kG4nKqwUf5XnV9h+8%9>wny8e?a}sVd$c{;9&L}dN86+A(e`M2v_0A$ zZI8A`+oSE#_Go*wJ=z{^kG4nKqwUf5XnV9h+8%9>wny8e?a}sVd$c{;9&L}dN86+A z(e`M2v_0A$ZI8A`+oSE#_Go*wz1m)FueMj)tL@eHYJ0W4+Fot1wpZJ$?bY^bd$qmV zUTv?oSKF)Y)%I$8wY}P2ZLhXh+pF!>_G){zz1m)FueMj)tL@eHYJ0W4+Fot1wpZJ$ z?bY^bd$qmVUTv?oSKF)Y)%I$8wY}P2ZLhXh+pF!>_G){zz1m)FueMj)tL@eHYJ0W4 z+Fot1wpZJ$?bY^bd$qmVUTv?oSKF)Y)%I$8wY}P2ZLhXh+pF!>_G){zz1m)FueSHi M_QxF>qmJYF53VtEc>n+a literal 0 HcmV?d00001 diff --git a/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-keys b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-keys new file mode 100644 index 0000000000000000000000000000000000000000..8dd496d7c15c40e8b77189d703c69afdb1bb0f57 GIT binary patch literal 654 zcmWFz_V5n!;bH&+Mi2o5=?nsgVQd)9#K6E53*;CBF?VrshHh$#k*TSnC6KGY-2JAN zJ1gXyvX!kLOZ>IF?Pk*|Bh)qB`3Fa)FZ&|&aG?vA zRMB+V53kujuH_DD#Gy@huj9OLa(9|a7iYYh+a_Ora;eYqL*8#bup7)@*Y>vMAr5W! zRT8U{A2ZikbH8>v!GC}Ig;3YaEy~>Q@2y%9>>XmzfJ2){IZL?k*~$f1B6jM1T@+y2 z=XUUOm?+ci<4e{!xxIdeOIvD`=kr86&oa3^rE?y4`6pW5V0gp&?ABp!n>UwBfBc(= z!?rvR`76Ig`>aDITv@EVl>Nslj(eKc#}lmf#7s--eJWRgL)+A6zg7rue$Ds$&ijQE z3MVpr|0=uMX$e#4n~zS7i?v?t#G!4kBzOC(#tjKB&!=BI++w#+&oDUN_EOc&tr`EV zFBys$;?TC=E|Y0-_LJGG#jm-qb>%!Q&Ca_xBmR`!cde4Yt$Fh%;LvvCp>%)CT%J7% cMQ>{S?G$-&aIeop( za^u!DT>%UiOSo@7F}-?*DbvZF@xkG{PVdhF)i5Xlu>^<#1R%3`m_RHbp}^q8uLxuc zfcQYUFA$npUIED9fO4KAsZfWi^ix0*EH!|roa4X8v%jo&kPEhI5oGo-i$q6 bTJyr0?eznlyG*>ZeN#k#(~7`mTh>u@l><$4i%Lw-4)M#vs~gbLE@=w2JaTCuKB@` zI$Qf|)3tlt;hr8K@yvXCCA~SnRG0I`8-9=R6<*kRR2n3{V7G)t{mqK2d#X2ofAjG8 zi#6|jK%Nr09+tc^)&!(l6CC> literal 0 HcmV?d00001 diff --git a/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-sha256 b/org.eclipse.jgit.ssh.apache.test/tst-rsrc/org/eclipse/jgit/internal/signing/ssh/krl/krl-sha256 new file mode 100644 index 0000000000000000000000000000000000000000..cdd135159807aa64749246a256f7f1f5f0685a4b GIT binary patch literal 445 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 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.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +import java.io.StreamCorruptedException; +import java.time.Instant; + +import org.eclipse.jgit.junit.MockSystemReader; +import org.eclipse.jgit.util.SystemReader; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Tests for the line parsing in {@link AllowedSigners}. + */ +public class AllowedSignersParseTest { + + @Before + public void setup() { + // Uses GMT-03:30 as time zone. + SystemReader.setInstance(new MockSystemReader()); + } + + @After + public void tearDown() { + SystemReader.setInstance(null); + } + + @Test + public void testValidDate() { + assertEquals(Instant.parse("2024-09-01T00:00:00.00Z"), + AllowedSigners.parseDate("20240901Z")); + assertEquals(Instant.parse("2024-09-01T01:02:00.00Z"), + AllowedSigners.parseDate("202409010102Z")); + assertEquals(Instant.parse("2024-09-01T01:02:03.00Z"), + AllowedSigners.parseDate("20240901010203Z")); + assertEquals(Instant.parse("2024-09-01T03:30:00.00Z"), + AllowedSigners.parseDate("20240901")); + assertEquals(Instant.parse("2024-09-01T04:32:00.00Z"), + AllowedSigners.parseDate("202409010102")); + assertEquals(Instant.parse("2024-09-01T04:32:03.00Z"), + AllowedSigners.parseDate("20240901010203")); + } + + @Test + public void testInvalidDate() { + assertThrows(Exception.class, () -> AllowedSigners.parseDate("1234")); + assertThrows(Exception.class, + () -> AllowedSigners.parseDate("09/01/2024")); + assertThrows(Exception.class, + () -> AllowedSigners.parseDate("2024-09-01")); + } + + private void checkValidKey(String expected, String input, int from) + throws StreamCorruptedException { + assertEquals(expected, AllowedSigners.parsePublicKey(input, from)); + } + @Test + public void testValidPublicKey() throws StreamCorruptedException { + checkValidKey( + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO", + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO", + 0); + checkValidKey( + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO", + "xyzssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO", + 3); + checkValidKey( + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO", + "xyz ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO abc", + 3); + checkValidKey( + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO", + "xyz\tssh-ed25519 \tAAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO abc", + 3); + } + + @Test + public void testInvalidPublicKey() { + assertThrows(Exception.class, + () -> AllowedSigners.parsePublicKey(null, 0)); + assertThrows(Exception.class, + () -> AllowedSigners.parsePublicKey("", 0)); + assertThrows(Exception.class, + () -> AllowedSigners.parsePublicKey("foo", 0)); + assertThrows(Exception.class, + () -> AllowedSigners.parsePublicKey("ssh-ed25519 bar", -1)); + assertThrows(Exception.class, + () -> AllowedSigners.parsePublicKey("ssh-ed25519 bar", 12)); + assertThrows(Exception.class, + () -> AllowedSigners.parsePublicKey("ssh-ed25519 bar", 13)); + assertThrows(Exception.class, + () -> AllowedSigners.parsePublicKey("ssh-ed25519 bar", 16)); + } + + @Test + public void testValidDequote() { + assertEquals(new AllowedSigners.Dequoted("a\\bc", 4), + AllowedSigners.dequote("a\\bc", 0)); + assertEquals(new AllowedSigners.Dequoted("a\\bc\"", 5), + AllowedSigners.dequote("a\\bc\"", 0)); + assertEquals(new AllowedSigners.Dequoted("a\\b\"c", 5), + AllowedSigners.dequote("a\\b\"c", 0)); + assertEquals(new AllowedSigners.Dequoted("a\\b\"c", 8), + AllowedSigners.dequote("\"a\\b\\\"c\"", 0)); + assertEquals(new AllowedSigners.Dequoted("a\\b\"c", 11), + AllowedSigners.dequote("xyz\"a\\b\\\"c\"", 3)); + assertEquals(new AllowedSigners.Dequoted("abc", 6), + AllowedSigners.dequote(" abc def", 3)); + } + + @Test + public void testInvalidDequote() { + assertThrows(Exception.class, () -> AllowedSigners.dequote("\"abc", 0)); + assertThrows(Exception.class, + () -> AllowedSigners.dequote("\"abc\\\"", 0)); + } + + @Test + public void testValidLine() throws Exception { + assertEquals(new AllowedSigners.AllowedEntry( + new String[] { "*@a.com" }, + true, null, null, null, + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO"), + AllowedSigners.parseLine( + "*@a.com cert-authority ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO")); + assertEquals(new AllowedSigners.AllowedEntry( + new String[] { "*@a.com", "*@b.a.com" }, + true, null, null, null, + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO"), + AllowedSigners.parseLine( + "*@a.com,*@b.a.com cert-authority ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO")); + assertEquals(new AllowedSigners.AllowedEntry( + new String[] { "foo@a.com" }, + false, null, null, null, + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO"), + AllowedSigners.parseLine( + "foo@a.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO")); + assertEquals(new AllowedSigners.AllowedEntry( + new String[] { "foo@a.com" }, + false, new String[] { "foo", "bar" }, null, null, + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO"), + AllowedSigners.parseLine( + "foo@a.com namespaces=\"foo,bar\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO")); + assertEquals(new AllowedSigners.AllowedEntry( + new String[] { "foo@a.com" }, + false, null, Instant.parse("2024-09-01T03:30:00.00Z"), null, + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO"), + AllowedSigners.parseLine( + "foo@a.com valid-After=\"20240901\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO")); + assertEquals(new AllowedSigners.AllowedEntry( + new String[] { "*@a.com", "*@b.a.com" }, + true, new String[] { "git" }, + Instant.parse("2024-09-01T03:30:00.00Z"), + Instant.parse("2024-09-01T12:00:00.00Z"), + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO"), + AllowedSigners.parseLine( + "*@a.com,*@b.a.com cert-authority namespaces=\"git\" valid-after=\"20240901\" valid-before=\"202409011200Z\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO")); + } + + @Test + public void testInvalidLine() { + assertThrows(Exception.class, () -> AllowedSigners.parseLine( + "cert-authority ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO")); + assertThrows(Exception.class, () -> AllowedSigners.parseLine( + "namespaces=\"git\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO")); + assertThrows(Exception.class, () -> AllowedSigners.parseLine( + "valid-after=\"20240901\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO")); + assertThrows(Exception.class, () -> AllowedSigners.parseLine( + "valid-before=\"20240901\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO")); + assertThrows(Exception.class, () -> AllowedSigners.parseLine( + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO")); + assertThrows(Exception.class, () -> AllowedSigners.parseLine( + "AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO")); + assertThrows(Exception.class, () -> AllowedSigners.parseLine( + "a@a.com namespaces=\"\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO")); + assertThrows(Exception.class, () -> AllowedSigners.parseLine( + "a@a.com namespaces=\",,,\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO")); + assertThrows(Exception.class, () -> AllowedSigners.parseLine( + "a@a.com,,b@a.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGATOZ8PcOKdY978fzIstnZ0+FuefIWKp7wRZynQLdzO")); + } + + @Test + public void testSkippedLine() throws Exception { + assertNull(AllowedSigners.parseLine(null)); + assertNull(AllowedSigners.parseLine("")); + assertNull(AllowedSigners.parseLine("# Comment")); + } +} diff --git a/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/OpenSshBinaryKrlLoadTest.java b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/OpenSshBinaryKrlLoadTest.java new file mode 100644 index 0000000000..9f9c3ca303 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/OpenSshBinaryKrlLoadTest.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024, Thomas Wolf 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 java.io.BufferedInputStream; +import java.io.InputStream; + +import org.junit.Test; + +/** + * Tests loading an {@link OpenSshBinaryKrl}. + */ +public class OpenSshBinaryKrlLoadTest { + + @Test + public void testLoad() throws Exception { + try (InputStream in = new BufferedInputStream( + this.getClass().getResourceAsStream("krl/krl"))) { + OpenSshBinaryKrl krl = OpenSshBinaryKrl.load(in, false); + assertNotNull(krl); + } + } +} diff --git a/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/OpenSshKrlTest.java b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/OpenSshKrlTest.java new file mode 100644 index 0000000000..2fd7756d10 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/OpenSshKrlTest.java @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2024, Thomas Wolf 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.assertEquals; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.PublicKey; +import java.util.ArrayList; +import java.util.List; + +import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; +import org.apache.sshd.common.config.keys.PublicKeyEntryResolver; +import org.eclipse.jgit.util.FileUtils; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +/** + * Tests for {@link OpenSshKrl} using binary KRLs. + */ +@RunWith(Parameterized.class) +public class OpenSshKrlTest { + + // The test data was generated using the public domain OpenSSH test script + // with some minor modifications (une ed25519 always, generate a "includes + // everything KRl, name the unrekoked keys "unrevoked*", generate a plain + // text KRL, and don't run the ssh-keygen tests). The original script is + // available at + // https://github.com/openssh/openssh-portable/blob/67a115e/regress/krl.sh + + private static final String[] KRLS = { + "krl-empty", "krl-keys", "krl-all", + "krl-sha1", "krl-sha256", "krl-hash", + "krl-serial", "krl-keyid", "krl-cert", "krl-ca", + "krl-serial-wild", "krl-keyid-wild", "krl-text" }; + + private static final int[] REVOKED = { 1, 4, 10, 50, 90, 500, 510, 520, 550, + 799, 999 }; + + private static final int[] UNREVOKED = { 5, 9, 14, 16, 29, 49, 51, 499, 800, + 1010, 1011 }; + + private static class TestData { + + String key; + + String krl; + + Boolean expected; + + TestData(String key, String krl, boolean expected) { + this.key = key; + this.krl = krl; + this.expected = Boolean.valueOf(expected); + } + + @Override + public String toString() { + return key + '-' + krl; + } + } + + @Parameters(name = "{0}") + public static List initTestData() { + List tests = new ArrayList<>(); + for (int i = 0; i < REVOKED.length; i++) { + String key = String.format("revoked-%04d", + Integer.valueOf(REVOKED[i])); + for (String krl : KRLS) { + boolean expected = !krl.endsWith("-empty"); + tests.add(new TestData(key + "-cert.pub", krl, expected)); + expected = krl.endsWith("-keys") || krl.endsWith("-all") + || krl.endsWith("-hash") || krl.endsWith("-sha1") + || krl.endsWith("-sha256") || krl.endsWith("-text"); + tests.add(new TestData(key + ".pub", krl, expected)); + } + } + for (int i = 0; i < UNREVOKED.length; i++) { + String key = String.format("unrevoked-%04d", + Integer.valueOf(UNREVOKED[i])); + for (String krl : KRLS) { + boolean expected = false; + tests.add(new TestData(key + ".pub", krl, expected)); + expected = krl.endsWith("-ca"); + tests.add(new TestData(key + "-cert.pub", krl, expected)); + } + } + return tests; + } + + private static Path tmp; + + @BeforeClass + public static void setUp() throws IOException { + tmp = Files.createTempDirectory("krls"); + for (String krl : KRLS) { + copyResource("krl/" + krl, tmp); + } + } + + private static void copyResource(String name, Path directory) + throws IOException { + try (InputStream in = OpenSshKrlTest.class + .getResourceAsStream(name)) { + int i = name.lastIndexOf('/'); + String fileName = i < 0 ? name : name.substring(i + 1); + Files.copy(in, directory.resolve(fileName)); + } + } + + @AfterClass + public static void cleanUp() throws Exception { + FileUtils.delete(tmp.toFile(), FileUtils.RECURSIVE); + } + + // Injected by JUnit + @Parameter + public TestData data; + + @Test + public void testIsRevoked() throws Exception { + OpenSshKrl krl = new OpenSshKrl(tmp.resolve(data.krl)); + try (InputStream in = this.getClass() + .getResourceAsStream("krl/" + data.key)) { + PublicKey key = AuthorizedKeyEntry.readAuthorizedKeys(in, true) + .get(0) + .resolvePublicKey(null, PublicKeyEntryResolver.FAILING); + assertEquals(data.expected, Boolean.valueOf(krl.isRevoked(key))); + } + } +} diff --git a/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/SerialRangeSetTest.java b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/SerialRangeSetTest.java new file mode 100644 index 0000000000..e6709adebc --- /dev/null +++ b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/SerialRangeSetTest.java @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2024, Thomas Wolf 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.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** + * Tests for the set of serial number ranges. + */ +public class SerialRangeSetTest { + + private SerialRangeSet ranges = new SerialRangeSet(); + + @Test + public void testInsertSimple() { + ranges.add(1); + ranges.add(3); + ranges.add(5); + assertEquals(3, ranges.size()); + assertFalse(ranges.contains(0)); + assertTrue(ranges.contains(1)); + assertFalse(ranges.contains(2)); + assertTrue(ranges.contains(3)); + assertFalse(ranges.contains(4)); + assertTrue(ranges.contains(5)); + assertFalse(ranges.contains(6)); + } + + @Test + public void testInsertSimpleRanges() { + ranges.add(1, 2); + ranges.add(4, 5); + ranges.add(7, 8); + assertEquals(3, ranges.size()); + assertFalse(ranges.contains(0)); + assertTrue(ranges.contains(1)); + assertTrue(ranges.contains(2)); + assertFalse(ranges.contains(3)); + assertTrue(ranges.contains(4)); + assertTrue(ranges.contains(5)); + assertFalse(ranges.contains(6)); + assertTrue(ranges.contains(7)); + assertTrue(ranges.contains(8)); + assertFalse(ranges.contains(9)); + } + + @Test + public void testInsertCoalesce() { + ranges.add(5); + ranges.add(1); + ranges.add(2); + ranges.add(4); + ranges.add(7); + ranges.add(3); + assertEquals(2, ranges.size()); + assertFalse(ranges.contains(0)); + assertTrue(ranges.contains(1)); + assertTrue(ranges.contains(2)); + assertTrue(ranges.contains(3)); + assertTrue(ranges.contains(4)); + assertTrue(ranges.contains(5)); + assertFalse(ranges.contains(6)); + assertTrue(ranges.contains(7)); + assertFalse(ranges.contains(8)); + } + + @Test + public void testInsertOverlap() { + ranges.add(1, 3); + ranges.add(6); + ranges.add(2, 5); + assertEquals(1, ranges.size()); + assertFalse(ranges.contains(0)); + assertTrue(ranges.contains(1)); + assertTrue(ranges.contains(2)); + assertTrue(ranges.contains(3)); + assertTrue(ranges.contains(4)); + assertTrue(ranges.contains(5)); + assertTrue(ranges.contains(6)); + assertFalse(ranges.contains(7)); + } + + @Test + public void testInsertOverlapMultiple() { + ranges.add(1, 3); + ranges.add(5, 6); + ranges.add(8); + ranges.add(2, 5); + assertEquals(2, ranges.size()); + assertFalse(ranges.contains(0)); + assertTrue(ranges.contains(1)); + assertTrue(ranges.contains(2)); + assertTrue(ranges.contains(3)); + assertTrue(ranges.contains(4)); + assertTrue(ranges.contains(5)); + assertTrue(ranges.contains(6)); + assertFalse(ranges.contains(7)); + assertTrue(ranges.contains(8)); + assertFalse(ranges.contains(9)); + } + + @Test + public void testInsertOverlapTotal() { + ranges.add(1, 3); + ranges.add(2, 3); + assertEquals(1, ranges.size()); + assertFalse(ranges.contains(0)); + assertTrue(ranges.contains(1)); + assertTrue(ranges.contains(2)); + assertTrue(ranges.contains(3)); + assertFalse(ranges.contains(4)); + } +} diff --git a/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/SshSignatureVerifierTest.java b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/SshSignatureVerifierTest.java new file mode 100644 index 0000000000..e5dfe497ca --- /dev/null +++ b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/SshSignatureVerifierTest.java @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2024, Thomas Wolf 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.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.Map; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.VerificationResult; +import org.eclipse.jgit.lib.SignatureVerifier; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.util.StringUtils; +import org.junit.Test; + +/** + * Tests for the {@link SshSignatureVerifier}. + */ +public class SshSignatureVerifierTest extends AbstractSshSignatureTest { + + @Test + public void testPlainSignature() throws Exception { + RevCommit c = checkSshSignature( + createSignedCommit(null, "signing_key.pub")); + try (Git git = new Git(db)) { + Map results = git.verifySignature() + .addName(c.getName()).call(); + assertEquals(1, results.size()); + VerificationResult verified = results.get(c.getName()); + assertNotNull(verified); + assertNull(verified.getException()); + SignatureVerifier.SignatureVerification v = verified + .getVerification(); + assertTrue(v.verified()); + assertFalse(v.expired()); + assertTrue(StringUtils.isEmptyOrNull(v.message())); + assertEquals("tester@example.com", v.keyUser()); + assertEquals("SHA256:GKW0xy+XKnJGs0CJqP6j5bd4FdiwWNaUbwvUbHvhQKo", + v.keyFingerprint()); + assertEquals(SignatureVerifier.TrustLevel.FULL, v.trustLevel()); + assertEquals(commitTime, v.creationDate().toInstant()); + } + } + + @Test + public void testCertificateSignature() throws Exception { + RevCommit c = checkSshSignature( + createSignedCommit("tester.cert", "signing_key-cert.pub")); + try (Git git = new Git(db)) { + Map results = git.verifySignature() + .addName(c.getName()).call(); + assertEquals(1, results.size()); + VerificationResult verified = results.get(c.getName()); + assertNotNull(verified); + assertNull(verified.getException()); + SignatureVerifier.SignatureVerification v = verified + .getVerification(); + assertTrue(v.verified()); + assertFalse(v.expired()); + assertTrue(StringUtils.isEmptyOrNull(v.message())); + assertEquals("tester@example.com", v.keyUser()); + assertEquals("SHA256:GKW0xy+XKnJGs0CJqP6j5bd4FdiwWNaUbwvUbHvhQKo", + v.keyFingerprint()); + assertEquals(SignatureVerifier.TrustLevel.FULL, v.trustLevel()); + assertEquals(commitTime, v.creationDate().toInstant()); + } + } + + @Test + public void testNoPrincipalsSignature() throws Exception { + RevCommit c = checkSshSignature(createSignedCommit("no_principals.cert", + "signing_key-cert.pub")); + try (Git git = new Git(db)) { + Map results = git.verifySignature() + .addName(c.getName()).call(); + assertEquals(1, results.size()); + VerificationResult verified = results.get(c.getName()); + assertNotNull(verified); + assertNull(verified.getException()); + SignatureVerifier.SignatureVerification v = verified + .getVerification(); + assertFalse(v.verified()); + assertFalse(v.expired()); + assertNull(v.keyUser()); + assertEquals("SHA256:GKW0xy+XKnJGs0CJqP6j5bd4FdiwWNaUbwvUbHvhQKo", + v.keyFingerprint()); + assertEquals(SignatureVerifier.TrustLevel.NEVER, v.trustLevel()); + assertTrue(v.message().contains("*@example.com")); + assertEquals(commitTime, v.creationDate().toInstant()); + } + } + + @Test + public void testOtherCertificateSignature() throws Exception { + RevCommit c = checkSshSignature( + createSignedCommit("other.cert", "signing_key-cert.pub")); + try (Git git = new Git(db)) { + Map results = git.verifySignature() + .addName(c.getName()).call(); + assertEquals(1, results.size()); + VerificationResult verified = results.get(c.getName()); + assertNotNull(verified); + assertNull(verified.getException()); + SignatureVerifier.SignatureVerification v = verified + .getVerification(); + assertTrue(v.verified()); + assertFalse(v.expired()); + assertTrue(StringUtils.isEmptyOrNull(v.message())); + assertEquals("other@example.com", v.keyUser()); + assertEquals("SHA256:GKW0xy+XKnJGs0CJqP6j5bd4FdiwWNaUbwvUbHvhQKo", + v.keyFingerprint()); + assertEquals(SignatureVerifier.TrustLevel.FULL, v.trustLevel()); + assertEquals(commitTime, v.creationDate().toInstant()); + } + } + + @Test + public void testTwoPrincipalsCertificateSignature() throws Exception { + RevCommit c = checkSshSignature(createSignedCommit( + "two_principals.cert", "signing_key-cert.pub")); + try (Git git = new Git(db)) { + Map results = git.verifySignature() + .addName(c.getName()).call(); + assertEquals(1, results.size()); + VerificationResult verified = results.get(c.getName()); + assertNotNull(verified); + assertNull(verified.getException()); + SignatureVerifier.SignatureVerification v = verified + .getVerification(); + assertTrue(v.verified()); + assertFalse(v.expired()); + assertTrue(StringUtils.isEmptyOrNull(v.message())); + assertEquals("foo@example.com,tester@example.com", v.keyUser()); + assertEquals("SHA256:GKW0xy+XKnJGs0CJqP6j5bd4FdiwWNaUbwvUbHvhQKo", + v.keyFingerprint()); + assertEquals(SignatureVerifier.TrustLevel.FULL, v.trustLevel()); + assertEquals(commitTime, v.creationDate().toInstant()); + } + } +} diff --git a/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/VerifyGitSignaturesTest.java b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/VerifyGitSignaturesTest.java new file mode 100644 index 0000000000..30ddee559c --- /dev/null +++ b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/internal/signing/ssh/VerifyGitSignaturesTest.java @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2024, Thomas Wolf 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.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import java.io.File; +import java.io.InputStream; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.VerificationResult; +import org.eclipse.jgit.junit.LocalDiskRepositoryTestCase; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.SignatureVerifier; +import org.eclipse.jgit.lib.StoredConfig; +import org.eclipse.jgit.util.GitDateFormatter; +import org.eclipse.jgit.util.SignatureUtils; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Verifies signatures made with C git and OpenSSH 9.0 to ensure we arrive at + * the same good/bad decisions, and that we can verify signatures not created by + * ourselves. + *

+ * Clones a JGit repo from a git bundle file created with C git, then checks all + * the commits and their signatures. (All commits in that bundle have SSH + * signatures.) + *

+ */ +public class VerifyGitSignaturesTest extends LocalDiskRepositoryTestCase { + + private static final Logger LOG = LoggerFactory + .getLogger(VerifyGitSignaturesTest.class); + + @Rule + public TemporaryFolder bundleDir = new TemporaryFolder(); + + @Before + @Override + public void setUp() throws Exception { + super.setUp(); + try (InputStream in = this.getClass() + .getResourceAsStream("repo.bundle")) { + Files.copy(in, bundleDir.getRoot().toPath().resolve("repo.bundle")); + } + try (InputStream in = this.getClass() + .getResourceAsStream("allowed_signers")) { + Files.copy(in, + bundleDir.getRoot().toPath().resolve("allowed_signers")); + } + } + + /** + * Tests signatures created by C git using OpenSSH 9.0. + */ + @Test + public void testGitSignatures() throws Exception { + File gitDir = new File(getTemporaryDirectory(), "repo.git"); + try (Git git = Git.cloneRepository().setBare(true) + .setGitDir(gitDir) + .setURI(new File(bundleDir.getRoot(), "repo.bundle").toURI() + .toString()) + .setBranch("master") + .call()) { + StoredConfig config = git.getRepository().getConfig(); + config.setString("gpg", "ssh", "allowedSignersFile", + bundleDir.getRoot().toPath().resolve("allowed_signers") + .toAbsolutePath().toString().replace('\\', '/')); + config.save(); + List commits = new ArrayList<>(); + Map committers = new HashMap<>(); + git.log().all().call().forEach(c -> { + commits.add(c.getName()); + committers.put(c.getName(), c.getCommitterIdent()); + }); + Map expected = new HashMap<>(); + // These two commits do have multiple principals. GIT just reports + // the first one; we report both. + expected.put("9f79a7b661a22ab1ddf8af880d23678ae7696b71", + Boolean.TRUE); + expected.put("435108d157440e77d61a914b6a5736bc831c874d", + Boolean.TRUE); + // This commit has a wrong commit message; the certificate used + // did _not_ have two principals, but only a single principal + // foo@example.org. + expected.put("779dac7de40ebc3886af87d5e6680a09f8b13a3e", + Boolean.TRUE); + // Signed with other_key-cert.pub: we still don't know the key, + // but we do know the certificate's CA key, and trust it, so it's + // accepted as a signature from the principal(s) listed in the + // certificate. + expected.put("951f06d5b5598b721b98d98b04e491f234c1926a", + Boolean.TRUE); + // Signature with other_key.pub not listed in allowed_signers + expected.put("984e629c6d543a7f77eb49a8c9316f2ae4416375", + Boolean.FALSE); + // Signed with other-ca.cert (CA key not in allowed_signers), but + // the certified key _is_ listed in allowed_signers. + expected.put("1d7ac6d91747a9c9a777df238fbdaeffa7731a6c", + Boolean.FALSE); + expected.put("a297bcfbf5c4a850f9770655fef7315328a4b3fb", + Boolean.TRUE); + expected.put("852729d54676cb83826ed821dc7734013e97950d", + Boolean.TRUE); + // Signature with a certificate without principals. + expected.put("e39a049f75fe127eb74b30aba4b64e171d4281dd", + Boolean.FALSE); + // Signature made with expired.cert (expired at the commit time). + // git/OpenSSH 9.0 allows to create such signatures, but reports + // them as FALSE. Our SshSigner doesn't allow creating such + // signatures. + expected.put("303ea5e61feacdad4cb012b4cb6b0cea3fbcef9f", + Boolean.FALSE); + expected.put("1ae4b120a869b72a7a2d4ad4d7a8c9d454384333", + Boolean.TRUE); + Map results = git.verifySignature() + .addNames(commits).call(); + GitDateFormatter dateFormat = new GitDateFormatter( + GitDateFormatter.Format.ISO); + for (String oid : commits) { + VerificationResult v = results.get(oid); + assertNotNull(v); + assertNull(v.getException()); + SignatureVerifier.SignatureVerification sv = v + .getVerification(); + assertNotNull(sv); + LOG.info("Commit {}\n{}", oid, SignatureUtils.toString(sv, + committers.get(oid), dateFormat)); + Boolean wanted = expected.get(oid); + assertNotNull(wanted); + assertEquals(wanted, Boolean.valueOf(sv.verified())); + } + } + } +} diff --git a/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF b/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF index 512c19f4c2..826d33009b 100644 --- a/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF +++ b/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF @@ -28,6 +28,7 @@ Export-Package: org.eclipse.jgit.internal.signing.ssh;version="7.1.0";x-friends: org.eclipse.jgit.internal.transport.sshd.auth;version="7.1.0";x-internal:=true, org.eclipse.jgit.internal.transport.sshd.pkcs11;version="7.1.0";x-internal:=true, org.eclipse.jgit.internal.transport.sshd.proxy;version="7.1.0";x-friends:="org.eclipse.jgit.ssh.apache.test", + org.eclipse.jgit.signing.ssh;version="7.1.0";uses:="org.eclipse.jgit.lib", org.eclipse.jgit.transport.sshd;version="7.1.0"; uses:="org.eclipse.jgit.transport, org.apache.sshd.client.config.hosts, diff --git a/org.eclipse.jgit.ssh.apache/resources/META-INF/services/org.eclipse.jgit.lib.SignatureVerifierFactory b/org.eclipse.jgit.ssh.apache/resources/META-INF/services/org.eclipse.jgit.lib.SignatureVerifierFactory new file mode 100644 index 0000000000..4a0f553c81 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/resources/META-INF/services/org.eclipse.jgit.lib.SignatureVerifierFactory @@ -0,0 +1 @@ +org.eclipse.jgit.signing.ssh.SshSignatureVerifierFactory \ No newline at end of file diff --git a/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties b/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties index e51c80a5ac..6048239391 100644 --- a/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties +++ b/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties @@ -125,17 +125,59 @@ 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} +signAllowedSignersCertAuthorityError=Garbage after cert-authority +signAllowedSignersEmptyIdentity=Identities contains an empty identity; check for spurious extra commas: {0} +signAllowedSignersEmptyNamespaces=Empty namespaces= is not allowed; to allow a key for any namespace, omit the namespaces option +signAllowedSignersFormatError=Cannot parse allowed signers file {0}, problem at line {1}: {2} +signAllowedSignersInvalidDate=Cannot parse valid-before or valid-after date {0} +signAllowedSignersLineFormat=Invalid line format +signAllowedSignersMultiple={0} is allowed only once +signAllowedSignersNoIdentities=Line has no identity patterns +signAllowedSignersPublicKeyParsing=Cannot parse public key {0} +signAllowedSignersUnterminatedQuote=Unterminated double quote 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} +signCertificateNotForName=Certificate with CA key {0} does not apply for name ''{1}'' +signCertificateRevoked=Certificate with CA key {0} was revoked signCertificateTooEarly=Certificate with CA key {0} was not valid yet +signCertificateWithoutPrincipals=Certificate with CA key {0} has no principals; identities from gpg.ssh.allowedSignersFile: {1} 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 +signGarbageAtEnd=SSH signature has extra bytes at the end +signInvalidAlgorithm=SSH signature has invalid signature algorithm {0} signInvalidKeyDSA=SSH signatures with DSA keys or certificates are not supported; use a different signing key. +signInvalidMagic=SSH signature does not start with "SSHSIG" +signInvalidNamespace=Namespace of SSH signature should be ''git'' but is ''{0}'' +signInvalidSignature=SSH signature is invalid: {0} +signInvalidVersion=Cannot verify signature with version {0} +signKeyExpired=Expired key used for SSH signature +signKeyRevoked=Key used for the SSH signature was revoked +signKeyTooEarly=Key used for the SSH signature was not valid yet +signKrlBlobLeftover=gpg.ssh.revocationFile has invalid blob section {0} with {1} leftover bytes +signKrlBlobLengthInvalid=gpg.ssh.revocationFile has invalid blob length {1} in section {0} +signKrlBlobLengthInvalidExpected=gpg.ssh.revocationFile has invalid blob length {1} (expected {2}) in section {0} +signKrlCaKeyLengthInvalid=gpg.ssh.revocationFile has invalid CA key length {0} in certificates section +signKrlCertificateLeftover=gpg.ssh.revocationFile has invalid certificates section with {0} leftover bytes +signKrlCertificateSubsectionLeftover=gpg.ssh.revocationFile has invalid certificates subsection with {0} leftover bytes +signKrlCertificateSubsectionLength=gpg.ssh.revocationFile has invalid certificates subsection length {0} +signKrlEmptyRange=gpg.ssh.revocationFile has an empty range of certificate serial numbers +signKrlInvalidBitSetLength=gpg.ssh.revocationFile has invalid certificate serial number bit set length {0} +signKrlInvalidKeyIdLength=gpg.ssh.revocationFile has invalid certificate key ID length {0} +signKrlInvalidMagic=gpg.ssh.revocationFile is not a binary OpenSSH key revocation list +signKrlInvalidReservedLength=gpg.ssh.revocationFile has an invalid reserved string length {0} +signKrlInvalidVersion=gpg.ssh.revocationFile: cannot read KRLs with FORMAT_VERSION {0} +signKrlNoCertificateSubsection=gpg.ssh.revocationFile has certificate section without subsections +signKrlSerialZero=gpg.ssh.revocationFile: certificate serial number zero cannot be revoked +signKrlShortRange=gpg.ssh.revocationFile: short certificate serial number range, need at least 8 more bytes, got only {0} +signKrlUnknownSection=gpg.ssh.revocationFile has an unknown section type {0} +signKrlUnknownSubsection=gpg.ssh.revocationFile has an unknown certificates subsection type {0} signLogFailure=SSH signature verification failed +signMismatchedSignatureAlgorithm=SSH signature made with an ''{0}'' key has incompatible signature algorithm ''{1}'' signNoAgent=No connector for ssh-agent found; maybe include org.eclipse.jgit.ssh.apache.agent in the application. +signNoPrincipalMatched=No principal matched in gpg.ssh.allowedSignersFile 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. @@ -147,4 +189,5 @@ 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} +signWrongNamespace=Key may not be used in namespace "{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/AllowedSigners.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/AllowedSigners.java new file mode 100644 index 0000000000..92cf1faec9 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/AllowedSigners.java @@ -0,0 +1,535 @@ +/* + * Copyright (C) 2024, Thomas Wolf 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.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.TimeZone; +import java.util.concurrent.TimeUnit; +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 String SSH_KEY_PREFIX = "ssh-"; //$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 CERTIFICATES = AllowedEntry::isCA; + + private static final Predicate PLAIN_KEYS = Predicate + .not(CERTIFICATES); + + 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> 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 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 filtered = new LinkedHashSet<>(); + List 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 filter) + throws VerificationException { + String k = PublicKeyEntry.toString(key); + VerificationException v = null; + List 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> 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) + || matches(line, SSH_KEY_PREFIX, 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 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); + if (!keyType.startsWith(SSH_KEY_PREFIX)) { + 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(); + } + TimeZone tz = SystemReader.getInstance().getTimeZone(); + // Since there are a few TimeZone IDs that are not recognized by ZoneId, + // use offsets. + return time.atOffset(ZoneOffset.ofTotalSeconds( + (int) TimeUnit.MILLISECONDS.toSeconds(tz.getRawOffset()))) + .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..46518d8c84 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshBinaryKrl.java @@ -0,0 +1,490 @@ +/* + * Copyright (C) 2024, Thomas Wolf 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 PROTOCOL.krl + */ +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 certificates = new HashMap<>(); + + private static class CertificateRevocation { + + final SerialRangeSet ranges = new SerialRangeSet(); + + final Set 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 + */ + 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 blobs = new HashSet<>(); + + private final Set sha1 = new HashSet<>(); + + private final Set 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 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 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 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 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 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 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 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 extends LinkedHashMap { + + private static final long serialVersionUID = 1L; + + LRU() { + super(DEFAULT_CACHE_SIZE, 0.75f, true); + } + + @Override + protected boolean removeEldestEntry(java.util.Map.Entry eldest) { + return size() > cacheSize.get(); + } + } + + private final HashMap allowedSigners = new LRU<>(); + + private final HashMap 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..6a0cec8821 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SerialRangeSet.java @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2024, Thomas Wolf 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.SortedMap; +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 elements. 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 will return true if there is any (partially) overlapping + // range in the TreeMap. + private final TreeMap 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 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 + SortedMap head = ranges.headMap(newRange); + if (!head.isEmpty()) { + SerialRange prev = head.lastKey(); + if (newRange.from() - prev.to() == 1) { + ranges.remove(prev); + newRange = new Range(prev.from(), newRange.to()); + } + } + SortedMap tail = ranges.tailMap(newRange); + if (!tail.isEmpty()) { + SerialRange next = tail.firstKey(); + if (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 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/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 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/transport/sshd/SshdText.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java index 6f7a4e9ef4..0533b651e0 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 and others + * Copyright (C) 2018, 2024 Thomas Wolf 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 @@ -147,17 +147,59 @@ 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; @@ -169,6 +211,7 @@ public final class SshdText extends TranslationBundle { /***/ 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/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 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. + *

+ * 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. + *

+ * + * @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 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 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/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 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; + } +} -- 2.39.5