feat: extend certificate pinning to all TLS protocols and add SSH
host-key pinning
This commit is contained in:
@@ -6,7 +6,10 @@ package gemini
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -63,6 +66,27 @@ func (p *Protocol) downloadWithRedirects(ctx context.Context, req *core.Download
|
||||
InsecureSkipVerify: p.TLSInsecure,
|
||||
}
|
||||
|
||||
// Certificate pinning: require the leaf certificate to match the
|
||||
// caller-provided SHA-256 hash. The check runs after the standard
|
||||
// verification chain so a pinned self-signed cert still needs
|
||||
// InsecureSkipVerify (or a matching CA) to clear the normal path.
|
||||
if req != nil && req.PinnedCertHash != "" {
|
||||
expected := strings.ToLower(strings.TrimSpace(req.PinnedCertHash))
|
||||
tlsCfg.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
|
||||
for _, rawCert := range rawCerts {
|
||||
cert, err := x509.ParseCertificate(rawCert)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hash := sha256.Sum256(cert.Raw)
|
||||
if hex.EncodeToString(hash[:]) == expected {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("certificate pinning failed: no certificate matches sha-256 %s", expected)
|
||||
}
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{Timeout: 30 * time.Second}
|
||||
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsCfg)
|
||||
if err != nil {
|
||||
@@ -123,11 +147,12 @@ func (p *Protocol) downloadWithRedirects(ctx context.Context, req *core.Download
|
||||
}
|
||||
// Follow redirect with incremented depth
|
||||
redirectReq := &core.DownloadRequest{
|
||||
URL: resolved,
|
||||
Output: req.Output,
|
||||
Verbose: req.Verbose,
|
||||
Writer: req.Writer,
|
||||
Ctx: ctx,
|
||||
URL: resolved,
|
||||
Output: req.Output,
|
||||
Verbose: req.Verbose,
|
||||
Writer: req.Writer,
|
||||
PinnedCertHash: req.PinnedCertHash,
|
||||
Ctx: ctx,
|
||||
}
|
||||
return p.downloadWithRedirects(ctx, redirectReq, redirectCount+1)
|
||||
|
||||
|
||||
@@ -132,3 +132,41 @@ func TestGeminiPermanentFailure(t *testing.T) {
|
||||
t.Error("expected permanent failure error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeminiPinnedCertMismatch exercises the certificate-pinning path.
|
||||
// The server presents a self-signed cert whose hash does NOT match the
|
||||
// pinned value, so the download must fail with a pinning error.
|
||||
func TestGeminiPinnedCertMismatch(t *testing.T) {
|
||||
cfg := testTLSConfig()
|
||||
listener, err := tls.Listen("tcp", "127.0.0.1:0", cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
go func() {
|
||||
conn, _ := listener.Accept()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
r := bufio.NewReader(conn)
|
||||
r.ReadString('\n')
|
||||
conn.Write([]byte("20 text/gemini\r\nhello\n"))
|
||||
}()
|
||||
|
||||
proto := NewProtocol()
|
||||
proto.TLSInsecure = true // accept self-signed, then let pinning reject
|
||||
u, _ := url.Parse("gemini://127.0.0.1:" + strconv.Itoa(port) + "/")
|
||||
_, err = proto.Download(context.Background(), &core.DownloadRequest{
|
||||
URL: u,
|
||||
PinnedCertHash: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected pinning failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "pinning failed") {
|
||||
t.Errorf("expected pinning error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user