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
protocolsareTLSv1,TLSv1_1,TLSv1_2andTLSv1_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
okdoes not mean "trusted".okis 1 when a handshake worked, even with an expired or self-signed certificate. The certificate is trusted only whenokis 1 andverify_errorisundef.verify_erroris alsoundefwhenokis not set. When the first handshake fails, no verification is tried. Always testokfirst.days_leftmay be missing. When the expiry date cannot be read,days_left,expires_atandnot_afterare not set. Test withdefined.days_leftis negative after the certificate has expired.protocolsis filled in even whenokis not set. A server that speaks only TLS 1.0 fails the default handshake (modern OpenSSL will not choose TLS 1.0) but still showsTLSv1inprotocols.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
hostmust 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.subjectandissuerare 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,cipherand theprotocolsnames are always ASCII.errorandverify_errorare 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 stateX.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.