Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
Expand All @@ -60,7 +60,7 @@
designated in writing by the copyright owner as "Not a Contribution."

"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.

2. Grant of Copyright License. Subject to the terms and conditions of
Expand Down Expand Up @@ -106,7 +106,7 @@
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ tlsc ca export # print PEM to stdout
tlsc ca export | ssh remote "cat > /etc/ssl/ca.pem" # pipe anywhere
```

> **Note:** The CA private key (`rootCA-key.pem`) is stored as unencrypted PEM, which is standard for local development CAs. Do not use the generated CA for production environments.

### Generate certificates

Output defaults to `~/.tlsc/certs/`.
Expand Down
5 changes: 5 additions & 0 deletions internal/adapters/http/hsts.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import (

var maxAgeRe = regexp.MustCompile(`max-age=(\d+)`)

// FetchHSTS retrieves the Strict-Transport-Security header for the given host.
// InsecureSkipVerify is enabled because this is an inspection tool — the caller
// validates the certificate separately. Note that a MITM attacker could strip
// the HSTS header in this configuration; the result should be treated as
// informational rather than a security guarantee.
func FetchHSTS(ctx context.Context, host string, port int, timeout time.Duration, serverName string) *domain.HSTS {
client := &http.Client{
Timeout: timeout,
Expand Down
31 changes: 25 additions & 6 deletions internal/adapters/tls/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"html"
"io"
"net"
"strings"
"time"

"github.com/dyaa/tlsc/internal/domain"
Expand Down Expand Up @@ -115,31 +116,31 @@ func smtpHandshake(conn net.Conn, scanner *bufio.Scanner, _ string) error {
return err
}
fmt.Fprintf(conn, "STARTTLS\r\n")
return readLine(scanner)
return readLineExpect(scanner, "220")
}

func imapHandshake(conn net.Conn, scanner *bufio.Scanner, _ string) error {
if err := readLine(scanner); err != nil {
return err
}
fmt.Fprintf(conn, "a001 STARTTLS\r\n")
return readLine(scanner)
return readLineExpect(scanner, "a001 OK")
}

func pop3Handshake(conn net.Conn, scanner *bufio.Scanner, _ string) error {
if err := readLine(scanner); err != nil {
return err
}
fmt.Fprintf(conn, "STLS\r\n")
return readLine(scanner)
return readLineExpect(scanner, "+OK")
}

func ftpHandshake(conn net.Conn, scanner *bufio.Scanner, _ string) error {
if err := readLine(scanner); err != nil {
return err
}
fmt.Fprintf(conn, "AUTH TLS\r\n")
return readLine(scanner)
return readLineExpect(scanner, "234")
}

func ldapHandshake(conn net.Conn, _ *bufio.Scanner, _ string) error {
Expand Down Expand Up @@ -258,9 +259,13 @@ func xmppHandshake(conn net.Conn, _ *bufio.Scanner, serverName string) error {
fmt.Fprintf(conn, "<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>")

resp := make([]byte, 4096)
if _, err := io.ReadAtLeast(conn, resp, 1); err != nil {
n, err := io.ReadAtLeast(conn, resp, 1)
if err != nil {
return fmt.Errorf("failed to read XMPP STARTTLS response: %w", err)
}
if !strings.Contains(string(resp[:n]), "<proceed") {
return fmt.Errorf("XMPP STARTTLS rejected: %s", string(resp[:n]))
}

return nil
}
Expand All @@ -282,7 +287,7 @@ func sieveHandshake(conn net.Conn, scanner *bufio.Scanner, _ string) error {
}

fmt.Fprintf(conn, "STARTTLS\r\n")
return readLine(scanner)
return readLineExpect(scanner, "OK")
}

func readLine(scanner *bufio.Scanner) error {
Expand All @@ -295,6 +300,20 @@ func readLine(scanner *bufio.Scanner) error {
return nil
}

func readLineExpect(scanner *bufio.Scanner, prefix string) error {
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return err
}
return fmt.Errorf("connection closed")
}
line := scanner.Text()
if !strings.HasPrefix(line, prefix) {
return fmt.Errorf("unexpected response: %s", line)
}
return nil
}

func readMultiLine(scanner *bufio.Scanner) error {
for i := 0; i < maxMultiLineLines; i++ {
if !scanner.Scan() {
Expand Down
206 changes: 206 additions & 0 deletions internal/adapters/tls/connect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package tls

import (
"bufio"
"fmt"
"net"
"testing"
)

func TestReadLineExpect(t *testing.T) {
tests := []struct {
name string
input string
prefix string
wantErr bool
}{
{"smtp ok", "220 Ready to start TLS\r\n", "220", false},
{"smtp reject", "454 TLS not available\r\n", "220", true},
{"imap ok", "a001 OK Begin TLS\r\n", "a001 OK", false},
{"imap no", "a001 NO STARTTLS not supported\r\n", "a001 OK", true},
{"pop3 ok", "+OK Begin TLS\r\n", "+OK", false},
{"pop3 err", "-ERR command not supported\r\n", "+OK", true},
{"ftp ok", "234 Proceed with negotiation\r\n", "234", false},
{"ftp reject", "502 Command not implemented\r\n", "234", true},
{"sieve ok", "OK\r\n", "OK", false},
{"sieve err", "NO\r\n", "OK", true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()

go func() {
fmt.Fprint(server, tt.input)
server.Close()
}()

scanner := bufio.NewScanner(client)
err := readLineExpect(scanner, tt.prefix)
if (err != nil) != tt.wantErr {
t.Errorf("readLineExpect() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

func TestReadLineExpectClosed(t *testing.T) {
client, server := net.Pipe()
server.Close()
defer client.Close()

scanner := bufio.NewScanner(client)
err := readLineExpect(scanner, "220")
if err == nil {
t.Error("expected error for closed connection")
}
}

func TestSmtpHandshakeReject(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()

go func() {
scanner := bufio.NewScanner(server)
// Read and discard stream opening, send greeting
fmt.Fprint(server, "220 mail.example.com ESMTP\r\n")
// Read EHLO
scanner.Scan()
fmt.Fprint(server, "250 mail.example.com\r\n")
// Read STARTTLS, reject
scanner.Scan()
fmt.Fprint(server, "454 TLS not available\r\n")
server.Close()
}()

scanner := bufio.NewScanner(client)
err := smtpHandshake(client, scanner, "")
if err == nil {
t.Error("expected error when SMTP rejects STARTTLS")
}
}

func TestSmtpHandshakeAccept(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()

go func() {
scanner := bufio.NewScanner(server)
fmt.Fprint(server, "220 mail.example.com ESMTP\r\n")
scanner.Scan()
fmt.Fprint(server, "250 mail.example.com\r\n")
scanner.Scan()
fmt.Fprint(server, "220 Ready to start TLS\r\n")
server.Close()
}()

scanner := bufio.NewScanner(client)
err := smtpHandshake(client, scanner, "")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}

func TestImapHandshakeReject(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()

go func() {
scanner := bufio.NewScanner(server)
fmt.Fprint(server, "* OK IMAP4rev1 Service Ready\r\n")
scanner.Scan()
fmt.Fprint(server, "a001 NO STARTTLS not supported\r\n")
server.Close()
}()

scanner := bufio.NewScanner(client)
err := imapHandshake(client, scanner, "")
if err == nil {
t.Error("expected error when IMAP rejects STARTTLS")
}
}

func TestPop3HandshakeReject(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()

go func() {
scanner := bufio.NewScanner(server)
fmt.Fprint(server, "+OK POP3 server ready\r\n")
scanner.Scan()
fmt.Fprint(server, "-ERR command not supported\r\n")
server.Close()
}()

scanner := bufio.NewScanner(client)
err := pop3Handshake(client, scanner, "")
if err == nil {
t.Error("expected error when POP3 rejects STLS")
}
}

func TestFtpHandshakeReject(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()

go func() {
scanner := bufio.NewScanner(server)
fmt.Fprint(server, "220 FTP server ready\r\n")
scanner.Scan()
fmt.Fprint(server, "502 Command not implemented\r\n")
server.Close()
}()

scanner := bufio.NewScanner(client)
err := ftpHandshake(client, scanner, "")
if err == nil {
t.Error("expected error when FTP rejects AUTH TLS")
}
}

func TestXmppHandshakeReject(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()

go func() {
buf := make([]byte, 4096)
server.Read(buf) // stream header
fmt.Fprint(server, "<?xml version='1.0'?><stream:stream xmlns='jabber:client'>")
server.Read(buf) // starttls request
fmt.Fprint(server, "<failure xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>")
server.Close()
}()

err := xmppHandshake(client, nil, "example.com")
if err == nil {
t.Error("expected error when XMPP rejects STARTTLS")
}
}

func TestXmppHandshakeAccept(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()

go func() {
buf := make([]byte, 4096)
server.Read(buf)
fmt.Fprint(server, "<?xml version='1.0'?><stream:stream xmlns='jabber:client'>")
server.Read(buf)
fmt.Fprint(server, "<proceed xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>")
server.Close()
}()

err := xmppHandshake(client, nil, "example.com")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
2 changes: 2 additions & 0 deletions internal/crypto/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ func KeyBits(pub crypto.PublicKey) int {
return 0
}
switch k := pub.(type) {
case ed25519.PublicKey:
return 256
case interface{ Size() int }:
return k.Size() * 8
case interface{ Params() *elliptic.CurveParams }:
Expand Down
5 changes: 5 additions & 0 deletions internal/crypto/keys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,11 @@ func TestKeyBits(t *testing.T) {
t.Errorf("expected 2048 bits for RSA-2048, got %d", bits)
}

ed25519Key, _ := GenerateKey("ed25519")
if bits := KeyBits(ed25519Key.Public()); bits != 256 {
t.Errorf("expected 256 bits for Ed25519, got %d", bits)
}

if bits := KeyBits(nil); bits != 0 {
t.Errorf("expected 0 bits for nil key, got %d", bits)
}
Expand Down
4 changes: 4 additions & 0 deletions internal/domain/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ type SANs struct {

// Result holds the full inspection outcome for a single TLS certificate.
type Result struct {
// Valid indicates whether the certificate passed validation. For remote checks
// (Check/CheckBatch), this reflects full chain and hostname verification. For
// local file inspection (InspectFile), this only checks that the certificate
// has not expired. For CSR inspection, this checks the request signature.
Valid bool `json:"valid"`
ValidationError string `json:"validationError,omitempty"`
ValidFrom string `json:"validFrom"`
Expand Down
2 changes: 2 additions & 0 deletions internal/services/checker/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"github.com/dyaa/tlsc/internal/fileutil"
)

// InspectFile reads a PEM-encoded certificate or CSR from path and returns
// inspection metadata. ctx is accepted for API consistency and future use.
func (s *Service) InspectFile(ctx context.Context, path string) (*domain.Result, error) {
data, err := fileutil.ReadLimited(path, fileutil.MaxCertFileSize)
if err != nil {
Expand Down
2 changes: 2 additions & 0 deletions internal/services/checker/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import (
"github.com/dyaa/tlsc/internal/fileutil"
)

// ListCerts returns summaries of all PEM-encoded certificates in certsDir.
// ctx is accepted for API consistency and future use.
func (s *Service) ListCerts(ctx context.Context, certsDir string) ([]domain.CertSummary, error) {
if certsDir == "" {
certsDir = domain.DefaultCertsPath()
Expand Down
Loading
Loading