NAME

App::WebPerimeterScan::TLS - certificate and protocol inspection

VERSION

Version 0.001.0

SYNOPSIS

Check when the certificate expires:

use App::WebPerimeterScan::TLS;

my $tls = App::WebPerimeterScan::TLS->inspect(host => 'example.com');
if($tls->{ok} && defined $tls->{days_left}) {
	printf "expires in %d days\n", $tls->{days_left};
}

Check that a browser would trust the certificate:

if(!$tls->{ok}) {
	print "no TLS handshake: $tls->{error}\n";
} elsif(defined $tls->{verify_error}) {
	print "not trusted: $tls->{verify_error}\n";
} else {
	print "trusted; $tls->{protocol} with $tls->{cipher}\n";
}

Find old protocol versions that the server still accepts, on a port that is not 443:

my $mail = App::WebPerimeterScan::TLS->inspect(host => 'mail.example.com', port => 993);
my @old = grep { $_ eq 'TLSv1' || $_ eq 'TLSv1_1' } @{ $mail->{protocols} };
print "old protocols: @old\n" if @old;

DESCRIPTION

This module connects to a TLS service (for example HTTPS on port 443) and describes it. It answers four questions:

1. Which certificate does the server send? The subject, issuer, fingerprint and expiry date are read, without checking if the certificate is trusted.
2. Would a browser trust it? A second handshake checks the certificate chain and the host name. Any problem is put in verify_error.
3. Which protocol and cipher does the server choose by default?
4. Which protocol versions does the server accept? One handshake is tried for each of TLS 1.0, 1.1, 1.2 and 1.3. The names in protocols are TLSv1, TLSv1_1, TLSv1_2 and TLSv1_3.

That is up to six handshakes for each call. The host name is sent in every handshake (as SNI), so a server that hosts many sites sends the certificate for this one.

REQUIREMENTS

IO::Socket::SSL 2.000 and Net::SSLeay 1.92, the first release with OpenSSL 3 support. The expiry date is read through Net::SSLeay when IO::Socket::SSL does not offer it, so the build must provide X509_get_notAfter and P_ASN1_TIME_put2string.

SUBROUTINES/METHODS

inspect

Purpose: describe the TLS service on a host and port.

Args: named host (required), port (default 443) and timeout (seconds per handshake, default 10). A class method.

Returns: a hash reference. Always: host, port, protocols (array reference of the versions a version-pinned handshake completed with; this is filled in even when the default handshake fails). When the default handshake fails: error. Otherwise ok (1), protocol, cipher, self_signed, subject, issuer, fingerprint, verify_error (undef when the chain verifies) and, when the expiry could be read, not_after, expires_at (epoch) and days_left (negative once expired).

Side Effects: up to six TLS handshakes with the host (five when the default handshake fails, as verification is then skipped).

Usage: my $tls = App::WebPerimeterScan::TLS->inspect(host => $host);

EXAMPLE

my $tls = App::WebPerimeterScan::TLS->inspect(host => 'example.com', timeout => 5);
if(!$tls->{ok}) {
	print "no TLS: $tls->{error}\n";
} elsif($tls->{verify_error}) {
	print "untrusted: $tls->{verify_error}\n";
} else {
	print "accepts: @{ $tls->{protocols} }\n";
}

API SPECIFICATION

INPUT

{
	host    => { type => 'string', min => 1 },
	port    => { type => 'integer', min => 1, max => 65535, optional => 1 },
	timeout => { type => 'number', min => 0, optional => 1 },
}

OUTPUT

{
	type => 'hashref',
	schema => {
		host      => { type => 'string' },
		port      => { type => 'integer' },
		protocols => { type => 'arrayref' },
		ok        => { type => 'boolean', optional => 1 },
		error     => { type => 'string', optional => 1 },
	},
}

MESSAGES

Returned in error or verify_error, not thrown:

Message                            Meaning / resolution
---------------------------------  ----------------------------------
(OpenSSL or socket text)           The handshake failed; the text is
                                   IO::Socket::SSL's reason.
certificate verification failed    Verification failed and OpenSSL
                                   gave no more specific reason.

PSEUDOCODE

handshake without verification
if it worked
	record protocol, cipher, subject, issuer, fingerprint, self-signed
	read the expiry, from IO::Socket::SSL or else Net::SSLeay
	handshake again with verification; record any error
otherwise record the error
handshake once per protocol version; record those that succeed

COMMON PITFALLS

  • ok does not mean "trusted". ok is 1 when a handshake worked, even with an expired or self-signed certificate. The certificate is trusted only when ok is 1 and verify_error is undef.

  • verify_error is also undef when ok is not set. When the first handshake fails, no verification is tried. Always test ok first.

  • days_left may be missing. When the expiry date cannot be read, days_left, expires_at and not_after are not set. Test with defined. days_left is negative after the certificate has expired.

  • protocols is filled in even when ok is not set. A server that speaks only TLS 1.0 fails the default handshake (modern OpenSSL will not choose TLS 1.0) but still shows TLSv1 in protocols.

  • A missing version is not proof. If the local OpenSSL was built without TLS 1.0, a server that accepts TLS 1.0 is not detected. See "LIMITATIONS".

  • The scope is not checked here. Check it before you call inspect, as the main scanner does.

  • It is a class method. Call it as App::WebPerimeterScan::TLS->inspect(...).

ENCODING

  • host must be an ASCII host name or an IP address. Write an international name in its Punycode form; the same form is sent as SNI and used for the name check.

  • subject and issuer are the names from the certificate, as OpenSSL prints them (for example /CN=example.com/O=Example Ltd). A name with non-ASCII characters is returned in OpenSSL's escaped form, not as UTF-8.

  • fingerprint, protocol, cipher and the protocols names are always ASCII.

  • error and verify_error are messages for people. They may contain OpenSSL's English text. Do not compare them with a fixed string.

LIMITATIONS

  • Self-signed detection compares the subject and issuer names. A certificate issued by a private CA whose name happens to match is reported as self-signed, and a self-signed certificate whose names differ is not. Comparing the issuer's key identifier with the subject's would be exact.

  • Only the leaf certificate is inspected. An intermediate that is about to expire is not reported until verification starts to fail.

  • The protocol probes depend on the local OpenSSL: a build compiled without TLS 1.0 support cannot offer it at any security level, and the server's acceptance then cannot be detected.

  • Cipher strength is judged from the one suite negotiated by default, not by enumerating every suite the server accepts.

FORMAL SPECIFICATION

This section states exactly what each method does, in the Z notation. You do not need it to use the module: the method descriptions above say the same thing in words. It is here for reviewers and for anyone who writes tests.

How to read a schema box:

  • The part above the middle line declares names. A name that ends in ? is an input. A name that ends in ! is an output.

  • The part below the middle line lists facts that are true after the call.

  • A name with a dash after it, such as items', is the value after the call. The same name without the dash is the value before the call.

  • Delta X (written with the Greek capital delta) means the call may change the state X. Xi X (the Greek capital xi) means the call does not change it.

The symbols used are:

Δ   may change            Ξ   does not change      •   such that
∀   for all               ∃   there is             ¬   not
∧   and                   ∨   or                   ⇒   implies
⇔   if and only if        ≠   not equal            ≤ ≥ at most, at least
∈   is in                 ¬in;   is not in            ⊆   is a subset of
∪   union                 ∩   intersection         ∅   the empty set
ℙ X sets of X             ⊥   undefined (undef)    …   and so on
⟨⟩  the empty sequence    ⁀   join sequences       ⇸   partial mapping
↦   maps to               ⊕   override a mapping   ↾   keep only these keys
∘   compose               →   function             × ÷ − times, divide, minus
ℕ   0, 1, 2, ...          ℕ₁  1, 2, 3, ...         ℝ   real numbers
𝔹   true or false         ⌊x⌋ x rounded down
dom M  the keys of M      ran M  the values of M   #S  the size of S

inspect

┌─ Inspect ───────────────────────────────────────────
│ host? : HOST; port? : PORT; result! : TLSRESULT
├─────────────────────────────────────────────────────
│ ran result!.protocols = { v : VERSIONS | handshake_v(host?, port?, v) }
│ ¬ handshake(host?, port?) ⇒ result!.error ≠ ⊥ ∧ result!.ok = ⊥
│ handshake(host?, port?) ⇒
│     result!.ok = 1
│   ∧ (result!.verify_error = ⊥ ⇔ chain_valid(host?, port?))
│   ∧ (expiry known ⇒ result!.days_left = ⌊(not_after − now) ÷ 86400⌋)
└─────────────────────────────────────────────────────

STATE DIAGRAM

The module keeps no state between calls. One call to inspect goes through these steps. Every step makes its own connection.

inspect(host, port, timeout)
      |
      v
+---------------+
|   VALIDATE    |--- unknown argument, or no host
+---------------+       -> croak
      |
      v
+---------------+
|   HANDSHAKE   |  default settings; certificate not checked
+---------------+
   |          |
   | failed   | worked
   |          v
   |    +---------------+
   |    |   DESCRIBE    |  ok = 1; protocol, cipher, subject,
   |    +---------------+  issuer, fingerprint, self_signed;
   |          |            expiry when it can be read
   |          v
   |    +---------------+
   |    |    VERIFY     |  second handshake that checks the chain
   |    +---------------+  and the name; verify_error = undef if
   |          |            trusted, else the reason
   |          |
   | error =  |
   | reason   |
   v          v
+---------------------+
|   PROBE VERSIONS    |  one handshake each for TLSv1, TLSv1_1,
+---------------------+  TLSv1_2 and TLSv1_3; each that works is
      |                  added to protocols
      v
result { host, port, protocols, ok or error, ... }

AUTHOR

Nigel Horne, <njh at nigelhorne.com>

LICENCE AND COPYRIGHT

Copyright 2026 Nigel Horne.

Usage is subject to the GPL2 licence terms. If you use it, please let me know.