pax_global_header00006660000000000000000000000064145245140120014510gustar00rootroot0000000000000052 comment=1da3a9e305a339a8c9e20f1a64634df37721de38 certspotter-0.18.0/000077500000000000000000000000001452451401200141545ustar00rootroot00000000000000certspotter-0.18.0/CHANGELOG.md000066400000000000000000000204251452451401200157700ustar00rootroot00000000000000# Change Log ## v0.18.0 (2023-11-13) - Fix bug with downloading entries that did not materialize in practice with any of the current logs. - Include `Message-ID` and `Date` in outbound emails. ## v0.17.0 (2023-10-26) - Allow sendmail path to be configured with `$SENDMAIL_PATH`. - Minor improvements to documentation, efficiency. ## v0.16.0 (2023-02-21) - Write malformed certs and failed healthchecks to filesystem so scripts can access them. - Automatically execute scripts under `$CERTSPOTTER_CONFIG_DIR/hooks.d` if it exists. - Automatically email addresses listed in `$CERTSPOTTER_CONFIG_DIR/email_recipients` if it exists. ## v0.15.1 (2023-02-09) - Fix some typos in help and error messages. - Allow version to be set via linker flag, to facilitate distro package building. ## v0.15.0 (2023-02-08) - **Significant behavior change**: certspotter is now intended to run as a daemon instead of a cron job. Specifically, certspotter no longer terminates unless it receives SIGTERM or SIGINT or there is a serious error. You should remove certspotter from your crontab and arrange to run it as a daemon, passing either the `-email` option or `-script` option to configure how you want to be notified about certificates. Reason for this change: although using cron made sense in the early days of Certificate Transparency, certspotter now needs to run continuously to reliably keep up with the high growth rate of contemporary CT logs, and to gracefully handle the many transient errors that can arise when monitoring CT. See for background. - `-script` is now officially supported and can be used to execute a command when a certificate is discovered or there is an error. For details, see the [certspotter-script(8) man page](man/certspotter-script.md). Note the following changes from the experimental, undocumented `-script` option found in previous versions: - The script is also executed when there is an error. Consult the `$EVENT` variable to determine why the script was executed. - The `$DNS_NAMES` and `$IP_ADDRESSES` variables have been removed because the OS limits the size of environment variables and some certificates have too many identifiers. To determine a certificate's identifiers, you can read the JSON file specified by the `$JSON_FILENAME` variable, as explained in the [certspotter-script(8) man page](man/certspotter-script.md). - The `$CERT_TYPE` variable has been removed because it is almost always a serious mistake (that can make you miss malicious certificates) to treat certificates and precertificates differently. If you are currently using this variable to skip precertificates, stop doing that because precertificates imply the existence of a corresponding certificate that you **might not** be separately notified about. For more details, see . - New variable `$WATCH_ITEM` contains the first watch list item which matched the certificate. - New `-email` option can be used to send an email when a certificate is discovered or there is an error. Your system must have a working `sendmail` command. - (Behavior change) You must specify the `-stdout` option if you want discovered certificates to be written to stdout. This only makes sense when running certspotter from the terminal; when running as a daemon you probably want to use `-email` or `-script` instead. - Once a day, certspotter will send you a notification (per `-email` or `-script`) if any problems are preventing it from detecting all certificates. As in previous versions of certspotter, errors are written to stderr when they occur, but since most errors are transient, you can now ignore stderr and rely on the daily health check to notify you about any persistent problems that require your attention. - certspotter now saves `.json` and `.txt` files alongside the `.pem` files containing parsed representations of the certificate. - `.pem` files no longer have `.cert` or `.precert` in the filename. - certspotter will save its state periodically, and before terminating due to SIGTERM or SIGINT, meaning it can resume monitoring without having to re-download entries it has already processed. - The experimental "BygoneSSL" feature has been removed due to limited utility. - The `-num_workers` option has been removed. - The `-all_time` option has been removed. You can remove the certspotter state directory if you want to re-download all entries. - The minimum supported Go version is now 1.19. ## v0.14.0 (2022-06-13) - Switch to Go module versioning conventions. ## v0.13 (2022-06-13) - Reduce minimum Go version to 1.17. - Update install instructions. ## v0.12 (2022-06-07) - Retry failed log requests. This should make certspotter resilient to rate limiting by logs. - Add `-version` flag. - Eliminate unnecessary dependency. certspotter now depends only on golang.org/x packages. - Switch to Go modules. ## v0.11 (2021-08-17) - Add support for contacting logs via HTTP proxies; just set the appropriate environment variable as documented at . - Work around RFC 6962 ambiguity related to consistency proofs for empty trees. ## v0.10 (2020-04-29) - Improve speed by processing logs in parallel - Add `-start_at_end` option to begin monitoring new logs at the end, which significantly speeds up Cert Spotter, at the cost of missing certificates that were added to a log before Cert Spotter starts monitoring it - (Behavior change) Scan logs in their entirety the first time Cert Spotter is run, unless `-start_at_end` specified (behavior change) - The log list is now retrieved from certspotter.org at startup instead of being embedded in the source. This will allow Cert Spotter to react more quickly to the frequent changes in logs. - (Behavior change) the `-logs` option now expects a JSON file in the v2 log list format. See and . - `-logs` now accepts an HTTPS URL in addition to a file path. - (Behavior change) the `-underwater` option has been removed. If you want its behavior, specify `https://loglist.certspotter.org/underwater.json` to the `-logs` option. ## v0.9 (2018-04-19) - Add Cloudflare Nimbus logs - Remove Google Argon 2017 log - Remove WoSign and StartCom logs due to disqualification by Chromium and extended downtime ## v0.8 (2017-12-08) - Add Symantec Sirius log - Add DigiCert 2 log ## v0.7 (2017-11-13) - Add Google Argon logs - Fix bug that caused crash on 32 bit architectures ## v0.6 (2017-10-19) - Add Comodo Mammoth and Comodo Sabre logs - Minor bug fixes and improvements ## v0.5 (2017-05-18) - Remove PuChuangSiDa 1 log due to excessive downtime and presumptive disqualification from Chrome - Add Venafi Gen2 log - Improve monitoring robustness under certain pathological behavior by logs - Minor documentation improvements ## v0.4 (2017-04-03) - Add PuChuangSiDa 1 log - Remove Venafi log due to fork and disqualification from Chrome ## v0.3 (2017-02-20) - Revise `-all_time` flag (behavior change): - If `-all_time` is specified, scan the entirety of all logs, even existing logs - When a new log is added, scan it in its entirety even if `-all_time` is not specified - Add new logs: - Google Icarus - Google Skydiver - StartCom - WoSign - Overhaul log processing and auditing logic: - STHs are never deleted unless they can be verified - Multiple unverified STHs can be queued per log, laying groundwork for STH pollination support - New state directory layout; current state directories will be migrated, but migration will be removed in a future version - Persist condensed Merkle Tree state between runs, instead of reconstructing it from consistency proof every time - Use a lock file to prevent multiple instances of Cert Spotter from running concurrently (which could clobber the state directory). - Minor bug fixes and improvements ## v0.2 (2016-08-25) - Suppress duplicate identifiers in output. - Fix "EOF" error when running under Go 1.7. - Fix bug where hook script could fail silently. - Fix compilation under Go 1.5. ## v0.1 (2016-07-27) - Initial release. certspotter-0.18.0/CONTRIBUTING000066400000000000000000000033071452451401200160110ustar00rootroot00000000000000All contributors to Cert Spotter must accept the Developer Certificate of Origin (DCO): Developer Certificate of Origin Version 1.1 Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 660 York Street, Suite 102, San Francisco, CA 94110 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Developer's Certificate of Origin 1.1 By making a contribution to this project, I certify that: (a) The contribution was created in whole or in part by me and I have the right to submit it under the open source license indicated in the file; or (b) The contribution is based upon previous work that, to the best of my knowledge, is covered under an appropriate open source license and I have the right under that license to submit that work with modifications, whether created in whole or in part by me, under the same open source license (unless I am permitted to submit under a different license), as indicated in the file; or (c) The contribution was provided directly to me by some other person who certified (a), (b) or (c) and I have not modified it. (d) I understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information I submit with it, including my sign-off) is maintained indefinitely and may be redistributed consistent with this project or the open source license(s) involved. To accept the DCO, add the following line to each commit message with your real name and email address (`git commit -s` will do this for you): Signed-off-by: Alex Smith certspotter-0.18.0/LICENSE000066400000000000000000000405261452451401200151700ustar00rootroot00000000000000Mozilla Public License Version 2.0 ================================== 1. Definitions -------------- 1.1. "Contributor" means each individual or legal entity that creates, contributes to the creation of, or owns Covered Software. 1.2. "Contributor Version" means the combination of the Contributions of others (if any) used by a Contributor and that particular Contributor's Contribution. 1.3. "Contribution" means Covered Software of a particular Contributor. 1.4. "Covered Software" means Source Code Form to which the initial Contributor has attached the notice in Exhibit A, the Executable Form of such Source Code Form, and Modifications of such Source Code Form, in each case including portions thereof. 1.5. "Incompatible With Secondary Licenses" means (a) that the initial Contributor has attached the notice described in Exhibit B to the Covered Software; or (b) that the Covered Software was made available under the terms of version 1.1 or earlier of the License, but not also under the terms of a Secondary License. 1.6. "Executable Form" means any form of the work other than Source Code Form. 1.7. "Larger Work" means a work that combines Covered Software with other material, in a separate file or files, that is not Covered Software. 1.8. "License" means this document. 1.9. "Licensable" means having the right to grant, to the maximum extent possible, whether at the time of the initial grant or subsequently, any and all of the rights conveyed by this License. 1.10. "Modifications" means any of the following: (a) any file in Source Code Form that results from an addition to, deletion from, or modification of the contents of Covered Software; or (b) any new file in Source Code Form that contains any Covered Software. 1.11. "Patent Claims" of a Contributor means any patent claim(s), including without limitation, method, process, and apparatus claims, in any patent Licensable by such Contributor that would be infringed, but for the grant of the License, by the making, using, selling, offering for sale, having made, import, or transfer of either its Contributions or its Contributor Version. 1.12. "Secondary License" means either the GNU General Public License, Version 2.0, the GNU Lesser General Public License, Version 2.1, the GNU Affero General Public License, Version 3.0, or any later versions of those licenses. 1.13. "Source Code Form" means the form of the work preferred for making modifications. 1.14. "You" (or "Your") means an individual or a legal entity exercising rights under this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with You. For purposes of this definition, "control" means (a) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (b) ownership of more than fifty percent (50%) of the outstanding shares or beneficial ownership of such entity. 2. License Grants and Conditions -------------------------------- 2.1. Grants Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive license: (a) under intellectual property rights (other than patent or trademark) Licensable by such Contributor to use, reproduce, make available, modify, display, perform, distribute, and otherwise exploit its Contributions, either on an unmodified basis, with Modifications, or as part of a Larger Work; and (b) under Patent Claims of such Contributor to make, use, sell, offer for sale, have made, import, and otherwise transfer either its Contributions or its Contributor Version. 2.2. Effective Date The licenses granted in Section 2.1 with respect to any Contribution become effective for each Contribution on the date the Contributor first distributes such Contribution. 2.3. Limitations on Grant Scope The licenses granted in this Section 2 are the only rights granted under this License. No additional rights or licenses will be implied from the distribution or licensing of Covered Software under this License. Notwithstanding Section 2.1(b) above, no patent license is granted by a Contributor: (a) for any code that a Contributor has removed from Covered Software; or (b) for infringements caused by: (i) Your and any other third party's modifications of Covered Software, or (ii) the combination of its Contributions with other software (except as part of its Contributor Version); or (c) under Patent Claims infringed by Covered Software in the absence of its Contributions. This License does not grant any rights in the trademarks, service marks, or logos of any Contributor (except as may be necessary to comply with the notice requirements in Section 3.4). 2.4. Subsequent Licenses No Contributor makes additional grants as a result of Your choice to distribute the Covered Software under a subsequent version of this License (see Section 10.2) or under the terms of a Secondary License (if permitted under the terms of Section 3.3). 2.5. Representation Each Contributor represents that the Contributor believes its Contributions are its original creation(s) or it has sufficient rights to grant the rights to its Contributions conveyed by this License. 2.6. Fair Use This License is not intended to limit any rights You have under applicable copyright doctrines of fair use, fair dealing, or other equivalents. 2.7. Conditions Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in Section 2.1. 3. Responsibilities ------------------- 3.1. Distribution of Source Form All distribution of Covered Software in Source Code Form, including any Modifications that You create or to which You contribute, must be under the terms of this License. You must inform recipients that the Source Code Form of the Covered Software is governed by the terms of this License, and how they can obtain a copy of this License. You may not attempt to alter or restrict the recipients' rights in the Source Code Form. 3.2. Distribution of Executable Form If You distribute Covered Software in Executable Form then: (a) such Covered Software must also be made available in Source Code Form, as described in Section 3.1, and You must inform recipients of the Executable Form how they can obtain a copy of such Source Code Form by reasonable means in a timely manner, at a charge no more than the cost of distribution to the recipient; and (b) You may distribute such Executable Form under the terms of this License, or sublicense it under different terms, provided that the license for the Executable Form does not attempt to limit or alter the recipients' rights in the Source Code Form under this License. 3.3. Distribution of a Larger Work You may create and distribute a Larger Work under terms of Your choice, provided that You also comply with the requirements of this License for the Covered Software. If the Larger Work is a combination of Covered Software with a work governed by one or more Secondary Licenses, and the Covered Software is not Incompatible With Secondary Licenses, this License permits You to additionally distribute such Covered Software under the terms of such Secondary License(s), so that the recipient of the Larger Work may, at their option, further distribute the Covered Software under the terms of either this License or such Secondary License(s). 3.4. Notices You may not remove or alter the substance of any license notices (including copyright notices, patent notices, disclaimers of warranty, or limitations of liability) contained within the Source Code Form of the Covered Software, except that You may alter any license notices to the extent required to remedy known factual inaccuracies. 3.5. Application of Additional Terms You may choose to offer, and to charge a fee for, warranty, support, indemnity or liability obligations to one or more recipients of Covered Software. However, You may do so only on Your own behalf, and not on behalf of any Contributor. You must make it absolutely clear that any such warranty, support, indemnity, or liability obligation is offered by You alone, and You hereby agree to indemnify every Contributor for any liability incurred by such Contributor as a result of warranty, support, indemnity or liability terms You offer. You may include additional disclaimers of warranty and limitations of liability specific to any jurisdiction. 4. Inability to Comply Due to Statute or Regulation --------------------------------------------------- If it is impossible for You to comply with any of the terms of this License with respect to some or all of the Covered Software due to statute, judicial order, or regulation then You must: (a) comply with the terms of this License to the maximum extent possible; and (b) describe the limitations and the code they affect. Such description must be placed in a text file included with all distributions of the Covered Software under this License. Except to the extent prohibited by statute or regulation, such description must be sufficiently detailed for a recipient of ordinary skill to be able to understand it. 5. Termination -------------- 5.1. The rights granted under this License will terminate automatically if You fail to comply with any of its terms. However, if You become compliant, then the rights granted under this License from a particular Contributor are reinstated (a) provisionally, unless and until such Contributor explicitly and finally terminates Your grants, and (b) on an ongoing basis, if such Contributor fails to notify You of the non-compliance by some reasonable means prior to 60 days after You have come back into compliance. Moreover, Your grants from a particular Contributor are reinstated on an ongoing basis if such Contributor notifies You of the non-compliance by some reasonable means, this is the first time You have received notice of non-compliance with this License from such Contributor, and You become compliant prior to 30 days after Your receipt of the notice. 5.2. If You initiate litigation against any entity by asserting a patent infringement claim (excluding declaratory judgment actions, counter-claims, and cross-claims) alleging that a Contributor Version directly or indirectly infringes any patent, then the rights granted to You by any and all Contributors for the Covered Software under Section 2.1 of this License shall terminate. 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user license agreements (excluding distributors and resellers) which have been validly granted by You or Your distributors under this License prior to termination shall survive termination. ************************************************************************ * * * 6. Disclaimer of Warranty * * ------------------------- * * * * Covered Software is provided under this License on an "as is" * * basis, without warranty of any kind, either expressed, implied, or * * statutory, including, without limitation, warranties that the * * Covered Software is free of defects, merchantable, fit for a * * particular purpose or non-infringing. The entire risk as to the * * quality and performance of the Covered Software is with You. * * Should any Covered Software prove defective in any respect, You * * (not any Contributor) assume the cost of any necessary servicing, * * repair, or correction. This disclaimer of warranty constitutes an * * essential part of this License. No use of any Covered Software is * * authorized under this License except under this disclaimer. * * * ************************************************************************ ************************************************************************ * * * 7. Limitation of Liability * * -------------------------- * * * * Under no circumstances and under no legal theory, whether tort * * (including negligence), contract, or otherwise, shall any * * Contributor, or anyone who distributes Covered Software as * * permitted above, be liable to You for any direct, indirect, * * special, incidental, or consequential damages of any character * * including, without limitation, damages for lost profits, loss of * * goodwill, work stoppage, computer failure or malfunction, or any * * and all other commercial damages or losses, even if such party * * shall have been informed of the possibility of such damages. This * * limitation of liability shall not apply to liability for death or * * personal injury resulting from such party's negligence to the * * extent applicable law prohibits such limitation. Some * * jurisdictions do not allow the exclusion or limitation of * * incidental or consequential damages, so this exclusion and * * limitation may not apply to You. * * * ************************************************************************ 8. Litigation ------------- Any litigation relating to this License may be brought only in the courts of a jurisdiction where the defendant maintains its principal place of business and such litigation shall be governed by laws of that jurisdiction, without reference to its conflict-of-law provisions. Nothing in this Section shall prevent a party's ability to bring cross-claims or counter-claims. 9. Miscellaneous ---------------- This License represents the complete agreement concerning the subject matter hereof. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. Any law or regulation which provides that the language of a contract shall be construed against the drafter shall not be used to construe this License against a Contributor. 10. Versions of the License --------------------------- 10.1. New Versions Mozilla Foundation is the license steward. Except as provided in Section 10.3, no one other than the license steward has the right to modify or publish new versions of this License. Each version will be given a distinguishing version number. 10.2. Effect of New Versions You may distribute the Covered Software under the terms of the version of the License under which You originally received the Covered Software, or under the terms of any subsequent version published by the license steward. 10.3. Modified Versions If you create software not governed by this License, and you want to create a new license for such software, you may create and use a modified version of this License if you rename the license and remove any references to the name of the license steward (except to note that such modified license differs from this License). 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses If You choose to distribute Source Code Form that is Incompatible With Secondary Licenses under the terms of this version of the License, the notice described in Exhibit B of this License must be attached. Exhibit A - Source Code Form License Notice ------------------------------------------- This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. If it is not possible or desirable to put the notice in a particular file, then You may include the notice in a location (such as a LICENSE file in a relevant directory) where a recipient would be likely to look for such a notice. You may add additional accurate notices of copyright ownership. Exhibit B - "Incompatible With Secondary Licenses" Notice --------------------------------------------------------- This Source Code Form is "Incompatible With Secondary Licenses", as defined by the Mozilla Public License, v. 2.0. certspotter-0.18.0/README.md000066400000000000000000000115641452451401200154420ustar00rootroot00000000000000# Cert Spotter - Certificate Transparency Monitor **Cert Spotter** is a Certificate Transparency log monitor from SSLMate that alerts you when an SSL/TLS certificate is issued for one of your domains. Cert Spotter is easier to use than other open source CT monitors, since it does not require a database. It's also more robust, since it uses a special certificate parser that ensures it won't miss certificates. Cert Spotter is also available as a hosted service by SSLMate that requires zero setup and provides an easy web dashboard to centrally manage your certificates. Visit to sign up. You can use Cert Spotter to detect: * Certificates issued to attackers who have compromised your DNS and are redirecting your visitors to their malicious site. * Certificates issued to attackers who have taken over an abandoned sub-domain in order to serve malware under your name. * Certificates issued to attackers who have compromised a certificate authority and want to impersonate your site. * Certificates issued in violation of your corporate policy or outside of your centralized certificate procurement process. ## Quickstart Cert Spotter requires Go version 1.19 or higher. 1. Install the certspotter command using the `go` command: ``` go install software.sslmate.com/src/certspotter/cmd/certspotter@latest ``` 2. Create a watch list file `$HOME/.certspotter/watchlist` containing the DNS names you want to monitor, one per line. To monitor an entire domain tree (including the domain itself and all sub-domains) prefix the domain name with a dot (e.g. `.example.com`). To monitor a single DNS name only, do not prefix the name with a dot. 3. Place one or more email addresses in the `$HOME/.certspotter/email_recipients` file (one per line), and/or place one or more executable scripts in the `$HOME/.certspotter/hooks.d` directory. certspotter will email the listed addresses (requires your system to have a working sendmail command) and execute the provided scripts when it detects a certificate for a domain on your watch list. 4. Configure your system to run `certspotter` as a daemon. You may want to specify the `-start_at_end` command line option to tell certspotter to start monitoring logs at the end instead of the beginning. This saves significant bandwidth, but you won't be notified about certificates which were logged before you started using certspotter. ## Documentation * Command line options and operational details: [certspotter(8) man page](man/certspotter.md) * The script interface: [certspotter-script(8) man page](man/certspotter-script.md) * [Change Log](CHANGELOG.md) ## What certificates are detected by Cert Spotter? In the default configuration, any certificate that is logged to a Certificate Transparency log recognized by Google Chrome or Apple will be detected by Cert Spotter. By default, Google Chrome and Apple only accept certificates that are logged, so any certificate that works in Chrome or Safari will be detected by Cert Spotter. ## Security Cert Spotter assumes an adversarial model in which an attacker produces a certificate that is accepted by at least some clients but goes undetected because of an encoding error that prevents CT monitors from understanding it. To defend against this attack, Cert Spotter uses a special certificate parser that keeps the certificate unparsed except for the identifiers. If one of the identifiers matches a domain on your watchlist, you will be notified, even if other parts of the certificate are unparsable. Cert Spotter takes special precautions to ensure identifiers are parsed correctly, and implements defenses against identifier-based attacks. For instance, if a DNS identifier contains a null byte, Cert Spotter interprets it as two identifiers: the complete identifier, and the identifier formed by truncating at the first null byte. For example, a certificate for `example.org\0.example.com` will alert the owners of both `example.org` and `example.com`. This defends against [null prefix attacks]( http://www.thoughtcrime.org/papers/null-prefix-attacks.pdf). SSLMate continuously monitors CT logs to make sure every certificate's identifiers can be successfully parsed, and will release updates to Cert Spotter as necessary to fix parsing failures. Cert Spotter understands wildcard DNS names, and will alert you if a wildcard certificate might match an identifier on your watchlist. For example, a watchlist entry for `sub.example.com` would match certificates for `*.example.com`. Cert Spotter is not just a log monitor, but also a log auditor which checks that the log is obeying its append-only property. A future release of Cert Spotter will support gossiping with other log monitors to ensure the log is presenting a single view. ## Copyright Copyright © 2016-2023 Opsmate, Inc. Licensed under the [Mozilla Public License Version 2.0](LICENSE). certspotter-0.18.0/asn1.go000066400000000000000000000046031452451401200153500ustar00rootroot00000000000000// Copyright (C) 2016 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package certspotter import ( "bytes" "encoding/asn1" "encoding/binary" "errors" "unicode/utf8" ) func stringFromByteSlice(chars []byte) string { runes := make([]rune, len(chars)) for i, ch := range chars { runes[i] = rune(ch) } return string(runes) } func stringFromUint16Slice(chars []uint16) string { runes := make([]rune, len(chars)) for i, ch := range chars { runes[i] = rune(ch) } return string(runes) } func stringFromUint32Slice(chars []uint32) string { runes := make([]rune, len(chars)) for i, ch := range chars { runes[i] = rune(ch) } return string(runes) } func decodeASN1String(value *asn1.RawValue) (string, error) { if !value.IsCompound && value.Class == 0 { if value.Tag == 12 { // UTF8String if !utf8.Valid(value.Bytes) { return "", errors.New("Malformed UTF8String") } return string(value.Bytes), nil } else if value.Tag == 19 || value.Tag == 22 || value.Tag == 20 || value.Tag == 26 { // * PrintableString - subset of ASCII // * IA5String - ASCII // * TeletexString - 8 bit charset; not quite ISO-8859-1, but often treated as such // * VisibleString - subset of ASCII // Don't enforce character set rules. Allow any 8 bit character, since // CAs routinely mess this up return stringFromByteSlice(value.Bytes), nil } else if value.Tag == 30 { // BMPString - Unicode, encoded in big-endian format using two octets runes := make([]uint16, len(value.Bytes)/2) if err := binary.Read(bytes.NewReader(value.Bytes), binary.BigEndian, runes); err != nil { return "", errors.New("Malformed BMPString: " + err.Error()) } return stringFromUint16Slice(runes), nil } else if value.Tag == 28 { // UniversalString - Unicode, encoded in big-endian format using four octets runes := make([]uint32, len(value.Bytes)/4) if err := binary.Read(bytes.NewReader(value.Bytes), binary.BigEndian, runes); err != nil { return "", errors.New("Malformed UniversalString: " + err.Error()) } return stringFromUint32Slice(runes), nil } } return "", errors.New("Not a string") } certspotter-0.18.0/asn1time.go000066400000000000000000000154241452451401200162320ustar00rootroot00000000000000// Copyright (C) 2016 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package certspotter import ( "encoding/asn1" "errors" "strconv" "time" "unicode" ) const ( tagUTCTime = 23 tagGeneralizedTime = 24 ) func isDigit(b byte) bool { return unicode.IsDigit(rune(b)) } func bytesToInt(bytes []byte) (int, error) { return strconv.Atoi(string(bytes)) } func parseUTCTime(bytes []byte) (time.Time, error) { var err error var year, month, day int var hour, min, sec int var tz *time.Location // YYMMDDhhmm if len(bytes) < 10 { return time.Time{}, errors.New("UTCTime is too short") } year, err = bytesToInt(bytes[0:2]) if err != nil { return time.Time{}, errors.New("UTCTime contains invalid integer: " + err.Error()) } month, err = bytesToInt(bytes[2:4]) if err != nil { return time.Time{}, errors.New("UTCTime contains invalid integer: " + err.Error()) } day, err = bytesToInt(bytes[4:6]) if err != nil { return time.Time{}, errors.New("UTCTime contains invalid integer: " + err.Error()) } hour, err = bytesToInt(bytes[6:8]) if err != nil { return time.Time{}, errors.New("UTCTime contains invalid integer: " + err.Error()) } min, err = bytesToInt(bytes[8:10]) if err != nil { return time.Time{}, errors.New("UTCTime contains invalid integer: " + err.Error()) } bytes = bytes[10:] // (optional) ss if len(bytes) >= 2 && isDigit(bytes[0]) { sec, err = bytesToInt(bytes[0:2]) if err != nil { return time.Time{}, errors.New("UTCTime contains invalid integer: " + err.Error()) } bytes = bytes[2:] } // timezone (required but allow it to be omitted, since this is a common error) if len(bytes) >= 1 { if bytes[0] == 'Z' { tz = time.UTC bytes = bytes[1:] } else if bytes[0] == '+' { // +hhmm if len(bytes) < 5 { return time.Time{}, errors.New("UTCTime positive timezone offset is too short") } tzHour, err := bytesToInt(bytes[1:3]) if err != nil { return time.Time{}, errors.New("UTCTime contains invalid integer: " + err.Error()) } tzMin, err := bytesToInt(bytes[3:5]) if err != nil { return time.Time{}, errors.New("UTCTime contains invalid integer: " + err.Error()) } tz = time.FixedZone("", tzHour*3600+tzMin*60) bytes = bytes[5:] } else if bytes[0] == '-' { // -hhmm if len(bytes) < 5 { return time.Time{}, errors.New("UTCTime negative timezone offset is too short") } tzHour, err := bytesToInt(bytes[1:3]) if err != nil { return time.Time{}, errors.New("UTCTime contains invalid integer: " + err.Error()) } tzMin, err := bytesToInt(bytes[3:5]) if err != nil { return time.Time{}, errors.New("UTCTime contains invalid integer: " + err.Error()) } tz = time.FixedZone("", -1*(tzHour*3600+tzMin*60)) bytes = bytes[5:] } } else { tz = time.UTC } if len(bytes) > 0 { return time.Time{}, errors.New("UTCTime has trailing garbage") } // https://tools.ietf.org/html/rfc5280#section-4.1.2.5.1 if year >= 50 { year = 1900 + year } else { year = 2000 + year } return time.Date(year, time.Month(month), day, hour, min, sec, 0, tz), nil } func parseGeneralizedTime(bytes []byte) (time.Time, error) { var err error var year, month, day int var hour, min, sec, ms int var tz *time.Location // YYYYMMDDHH if len(bytes) < 10 { return time.Time{}, errors.New("GeneralizedTime is too short") } year, err = bytesToInt(bytes[0:4]) if err != nil { return time.Time{}, errors.New("GeneralizedTime contains invalid integer: " + err.Error()) } month, err = bytesToInt(bytes[4:6]) if err != nil { return time.Time{}, errors.New("GeneralizedTime contains invalid integer: " + err.Error()) } day, err = bytesToInt(bytes[6:8]) if err != nil { return time.Time{}, errors.New("GeneralizedTime contains invalid integer: " + err.Error()) } hour, err = bytesToInt(bytes[8:10]) if err != nil { return time.Time{}, errors.New("GeneralizedTime contains invalid integer: " + err.Error()) } bytes = bytes[10:] // (optional) MM if len(bytes) >= 2 && isDigit(bytes[0]) { min, err = bytesToInt(bytes[0:2]) if err != nil { return time.Time{}, errors.New("GeneralizedTime contains invalid integer: " + err.Error()) } bytes = bytes[2:] // (optional) SS if len(bytes) >= 2 && isDigit(bytes[0]) { sec, err = bytesToInt(bytes[0:2]) if err != nil { return time.Time{}, errors.New("GeneralizedTime contains invalid integer: " + err.Error()) } bytes = bytes[2:] // (optional) .fff if len(bytes) >= 1 && bytes[0] == '.' { if len(bytes) < 4 { return time.Time{}, errors.New("GeneralizedTime fractional seconds is too short") } ms, err = bytesToInt(bytes[1:4]) if err != nil { return time.Time{}, errors.New("GeneralizedTime contains invalid integer: " + err.Error()) } bytes = bytes[4:] } } } // timezone (Z or +hhmm or -hhmm or nothing) if len(bytes) >= 1 { if bytes[0] == 'Z' { bytes = bytes[1:] tz = time.UTC } else if bytes[0] == '+' { // +hhmm if len(bytes) < 5 { return time.Time{}, errors.New("GeneralizedTime positive timezone offset is too short") } tzHour, err := bytesToInt(bytes[1:3]) if err != nil { return time.Time{}, errors.New("GeneralizedTime contains invalid integer: " + err.Error()) } tzMin, err := bytesToInt(bytes[3:5]) if err != nil { return time.Time{}, errors.New("GeneralizedTime contains invalid integer: " + err.Error()) } tz = time.FixedZone("", tzHour*3600+tzMin*60) bytes = bytes[5:] } else if bytes[0] == '-' { // -hhmm if len(bytes) < 5 { return time.Time{}, errors.New("GeneralizedTime negative timezone offset is too short") } tzHour, err := bytesToInt(bytes[1:3]) if err != nil { return time.Time{}, errors.New("GeneralizedTime contains invalid integer: " + err.Error()) } tzMin, err := bytesToInt(bytes[3:5]) if err != nil { return time.Time{}, errors.New("GeneralizedTime contains invalid integer: " + err.Error()) } tz = time.FixedZone("", -1*(tzHour*3600+tzMin*60)) bytes = bytes[5:] } } else { tz = time.UTC } if len(bytes) > 0 { return time.Time{}, errors.New("GeneralizedTime has trailing garbage") } return time.Date(year, time.Month(month), day, hour, min, sec, ms*1000*1000, tz), nil } func decodeASN1Time(value *asn1.RawValue) (time.Time, error) { if !value.IsCompound && value.Class == 0 { if value.Tag == tagUTCTime { return parseUTCTime(value.Bytes) } else if value.Tag == tagGeneralizedTime { return parseGeneralizedTime(value.Bytes) } } return time.Time{}, errors.New("Not a time value") } certspotter-0.18.0/asn1time_test.go000066400000000000000000000126311452451401200172660ustar00rootroot00000000000000// Copyright (C) 2016 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package certspotter import ( "testing" "time" ) type timeTest struct { in string ok bool out time.Time } var utcTimeTests = []timeTest{ {"9502101525Z", true, time.Date(1995, time.February, 10, 15, 25, 0, 0, time.UTC)}, {"950210152542Z", true, time.Date(1995, time.February, 10, 15, 25, 42, 0, time.UTC)}, {"1502101525Z", true, time.Date(2015, time.February, 10, 15, 25, 0, 0, time.UTC)}, {"150210152542Z", true, time.Date(2015, time.February, 10, 15, 25, 42, 0, time.UTC)}, {"1502101525+1000", true, time.Date(2015, time.February, 10, 15, 25, 0, 0, time.FixedZone("", 10*3600))}, {"1502101525-1000", true, time.Date(2015, time.February, 10, 15, 25, 0, 0, time.FixedZone("", -1*(10*3600)))}, {"1502101525+1035", true, time.Date(2015, time.February, 10, 15, 25, 0, 0, time.FixedZone("", 10*3600+35*60))}, {"1502101525-1035", true, time.Date(2015, time.February, 10, 15, 25, 0, 0, time.FixedZone("", -1*(10*3600+35*60)))}, {"150210152542+1000", true, time.Date(2015, time.February, 10, 15, 25, 42, 0, time.FixedZone("", 10*3600))}, {"150210152542-1000", true, time.Date(2015, time.February, 10, 15, 25, 42, 0, time.FixedZone("", -1*(10*3600)))}, {"150210152542+1035", true, time.Date(2015, time.February, 10, 15, 25, 42, 0, time.FixedZone("", 10*3600+35*60))}, {"150210152542-1035", true, time.Date(2015, time.February, 10, 15, 25, 42, 0, time.FixedZone("", -1*(10*3600+35*60)))}, {"1502101525", true, time.Date(2015, time.February, 10, 15, 25, 0, 0, time.UTC)}, {"150210152542", true, time.Date(2015, time.February, 10, 15, 25, 42, 0, time.UTC)}, {"", false, time.Time{}}, {"123", false, time.Time{}}, {"150210152542-10", false, time.Time{}}, {"150210152542F", false, time.Time{}}, {"150210152542ZF", false, time.Time{}}, } func TestUTCTime(t *testing.T) { for i, test := range utcTimeTests { ret, err := parseUTCTime([]byte(test.in)) if err != nil { if test.ok { t.Errorf("#%d: parseUTCTime(%q) failed with error %v", i, test.in, err) } continue } if !test.ok { t.Errorf("#%d: parseUTCTime(%q) succeeded, should have failed", i, test.in) continue } if !test.out.Equal(ret) { t.Errorf("#%d: parseUTCTime(%q) = %v, want %v", i, test.in, ret, test.out) } } } var generalizedTimeTests = []timeTest{ {"2015021015", true, time.Date(2015, time.February, 10, 15, 0, 0, 0, time.UTC)}, {"201502101525", true, time.Date(2015, time.February, 10, 15, 25, 0, 0, time.UTC)}, {"20150210152542", true, time.Date(2015, time.February, 10, 15, 25, 42, 0, time.UTC)}, {"20150210152542.123", true, time.Date(2015, time.February, 10, 15, 25, 42, 123000000, time.UTC)}, {"20150210152542.12", false, time.Time{}}, {"20150210152542.1", false, time.Time{}}, {"20150210152542.", false, time.Time{}}, {"2015021015Z", true, time.Date(2015, time.February, 10, 15, 0, 0, 0, time.UTC)}, {"201502101525Z", true, time.Date(2015, time.February, 10, 15, 25, 0, 0, time.UTC)}, {"20150210152542Z", true, time.Date(2015, time.February, 10, 15, 25, 42, 0, time.UTC)}, {"20150210152542.123Z", true, time.Date(2015, time.February, 10, 15, 25, 42, 123000000, time.UTC)}, {"20150210152542.12Z", false, time.Time{}}, {"20150210152542.1Z", false, time.Time{}}, {"20150210152542.Z", false, time.Time{}}, {"2015021015+1000", true, time.Date(2015, time.February, 10, 15, 0, 0, 0, time.FixedZone("", 10*3600))}, {"201502101525+1000", true, time.Date(2015, time.February, 10, 15, 25, 0, 0, time.FixedZone("", 10*3600))}, {"20150210152542+1000", true, time.Date(2015, time.February, 10, 15, 25, 42, 0, time.FixedZone("", 10*3600))}, {"20150210152542.123+1000", true, time.Date(2015, time.February, 10, 15, 25, 42, 123000000, time.FixedZone("", 10*3600))}, {"20150210152542.12+1000", false, time.Time{}}, {"20150210152542.1+1000", false, time.Time{}}, {"20150210152542.+1000", false, time.Time{}}, {"2015021015-0835", true, time.Date(2015, time.February, 10, 15, 0, 0, 0, time.FixedZone("", -1*(8*3600+35*60)))}, {"201502101525-0835", true, time.Date(2015, time.February, 10, 15, 25, 0, 0, time.FixedZone("", -1*(8*3600+35*60)))}, {"20150210152542-0835", true, time.Date(2015, time.February, 10, 15, 25, 42, 0, time.FixedZone("", -1*(8*3600+35*60)))}, {"20150210152542.123-0835", true, time.Date(2015, time.February, 10, 15, 25, 42, 123000000, time.FixedZone("", -1*(8*3600+35*60)))}, {"20150210152542.12-0835", false, time.Time{}}, {"20150210152542.1-0835", false, time.Time{}}, {"20150210152542.-0835", false, time.Time{}}, {"", false, time.Time{}}, {"123", false, time.Time{}}, {"2015021015+1000Z", false, time.Time{}}, {"2015021015x", false, time.Time{}}, {"201502101525Zf", false, time.Time{}}, } func TestGeneralizedTime(t *testing.T) { for i, test := range generalizedTimeTests { ret, err := parseGeneralizedTime([]byte(test.in)) if err != nil { if test.ok { t.Errorf("#%d: parseGeneralizedTime(%q) failed with error %v", i, test.in, err) } continue } if !test.ok { t.Errorf("#%d: parseGeneralizedTime(%q) succeeded, should have failed", i, test.in) continue } if !test.out.Equal(ret) { t.Errorf("#%d: parseGeneralizedTime(%q) = %v, want %v", i, test.in, ret, test.out) } } } certspotter-0.18.0/canonicalize.go000066400000000000000000000045651452451401200171540ustar00rootroot00000000000000// Copyright (C) 2019 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package certspotter import ( "encoding/asn1" ) func canonicalizeRDNString(fromStr string) string { from := []byte(fromStr) to := []byte{} inWhitespace := true for _, ch := range from { if ch == ' ' || ch == '\f' || ch == '\n' || ch == '\r' || ch == '\t' || ch == '\v' { if !inWhitespace { to = append(to, ' ') } inWhitespace = true } else { if ch >= 'A' && ch <= 'Z' { to = append(to, ch+32) // convert to lowercase } else { to = append(to, ch) } inWhitespace = false } } if inWhitespace && len(to) > 0 { // whack off the space character that we appended to = to[:len(to)-1] } return string(to) } func shouldCanonicalizeASN1String(value *asn1.RawValue) bool { if !value.IsCompound && value.Class == 0 { return value.Tag == 12 || value.Tag == 19 || value.Tag == 22 || value.Tag == 20 || value.Tag == 26 || value.Tag == 30 || value.Tag == 28 } return false } func canonicalizeATV(oldATV AttributeTypeAndValue) (AttributeTypeAndValue, error) { if shouldCanonicalizeASN1String(&oldATV.Value) { str, err := decodeASN1String(&oldATV.Value) if err != nil { return AttributeTypeAndValue{}, err } str = canonicalizeRDNString(str) return AttributeTypeAndValue{ Type: oldATV.Type, Value: asn1.RawValue{ Class: 0, Tag: asn1.TagUTF8String, IsCompound: false, Bytes: []byte(str), }, }, nil } else { return oldATV, nil } } func canonicalizeRDNSet(oldSet RelativeDistinguishedNameSET) (RelativeDistinguishedNameSET, error) { newSet := make([]AttributeTypeAndValue, len(oldSet)) for i := range oldSet { var err error newSet[i], err = canonicalizeATV(oldSet[i]) if err != nil { return nil, err } } return newSet, nil } func CanonicalizeRDNSequence(oldSequence RDNSequence) (RDNSequence, error) { newSequence := make([]RelativeDistinguishedNameSET, len(oldSequence)) for i := range oldSequence { var err error newSequence[i], err = canonicalizeRDNSet(oldSequence[i]) if err != nil { return nil, err } } return newSequence, nil } certspotter-0.18.0/canonicalize_test.go000066400000000000000000000021161452451401200202010ustar00rootroot00000000000000// Copyright (C) 2019 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package certspotter import ( "testing" ) type stringCanonTest struct { in string out string } var stringCanonTests = []stringCanonTest{ {"", ""}, {" ", ""}, {" ", ""}, {"abc", "abc"}, {"aBc", "abc"}, {"ab c", "ab c"}, {"ab c", "ab c"}, {"ab\n c", "ab c"}, {" ab c ", "ab c"}, {" ab c ", "ab c"}, {" ab c", "ab c"}, {"ab c ", "ab c"}, {"abc ", "abc"}, {"abc ", "abc"}, {" abc ", "abc"}, {" abc ", "abc"}, {" abc", "abc"}, {" aBc de f g\n", "abc de f g"}, } func TestCanonicalizeRDNString(t *testing.T) { for i, test := range stringCanonTests { ret := canonicalizeRDNString(test.in) if test.out != ret { t.Errorf("#%d: canonicalizeRDNString(%q) = %q, want %q", i, test.in, ret, test.out) } } } certspotter-0.18.0/cmd/000077500000000000000000000000001452451401200147175ustar00rootroot00000000000000certspotter-0.18.0/cmd/certspotter/000077500000000000000000000000001452451401200172755ustar00rootroot00000000000000certspotter-0.18.0/cmd/certspotter/.gitignore000066400000000000000000000000151452451401200212610ustar00rootroot00000000000000/certspotter certspotter-0.18.0/cmd/certspotter/main.go000066400000000000000000000171641452451401200205610ustar00rootroot00000000000000// Copyright (C) 2016, 2023 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package main import ( "bufio" "context" "errors" "flag" "fmt" "io/fs" insecurerand "math/rand" "os" "os/signal" "path/filepath" "runtime" "runtime/debug" "strings" "syscall" "time" "software.sslmate.com/src/certspotter/loglist" "software.sslmate.com/src/certspotter/monitor" ) var programName = os.Args[0] var Version = "" const defaultLogList = "https://loglist.certspotter.org/monitor.json" func certspotterVersion() string { if Version != "" { return Version + "?" } info, ok := debug.ReadBuildInfo() if !ok { return "unknown" } if strings.HasPrefix(info.Main.Version, "v") { return info.Main.Version } var vcs, vcsRevision, vcsModified string for _, s := range info.Settings { switch s.Key { case "vcs": vcs = s.Value case "vcs.revision": vcsRevision = s.Value case "vcs.modified": vcsModified = s.Value } } if vcs == "git" && vcsRevision != "" && vcsModified == "true" { return vcsRevision + "+" } else if vcs == "git" && vcsRevision != "" { return vcsRevision } return "unknown" } func fileExists(filename string) bool { _, err := os.Lstat(filename) return err == nil } func homedir() string { homedir, err := os.UserHomeDir() if err != nil { panic(fmt.Errorf("unable to determine home directory: %w", err)) } return homedir } func defaultStateDir() string { if envVar := os.Getenv("CERTSPOTTER_STATE_DIR"); envVar != "" { return envVar } else { return filepath.Join(homedir(), ".certspotter") } } func defaultConfigDir() string { if envVar := os.Getenv("CERTSPOTTER_CONFIG_DIR"); envVar != "" { return envVar } else { return filepath.Join(homedir(), ".certspotter") } } func defaultWatchListPath() string { return filepath.Join(defaultConfigDir(), "watchlist") } func defaultWatchListPathIfExists() string { if fileExists(defaultWatchListPath()) { return defaultWatchListPath() } else { return "" } } func defaultScriptDir() string { return filepath.Join(defaultConfigDir(), "hooks.d") } func defaultEmailFile() string { return filepath.Join(defaultConfigDir(), "email_recipients") } func simplifyError(err error) error { var pathErr *fs.PathError if errors.As(err, &pathErr) { return pathErr.Err } return err } func readWatchListFile(filename string) (monitor.WatchList, error) { file, err := os.Open(filename) if err != nil { return nil, simplifyError(err) } defer file.Close() return monitor.ReadWatchList(file) } func readEmailFile(filename string) ([]string, error) { file, err := os.Open(filename) if err != nil { return nil, simplifyError(err) } defer file.Close() var emails []string scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() if line == "" { continue } emails = append(emails, line) } return emails, err } func appendFunc(slice *[]string) func(string) error { return func(value string) error { *slice = append(*slice, value) return nil } } func main() { insecurerand.Seed(time.Now().UnixNano()) // TODO: remove after upgrading to Go 1.20 loglist.UserAgent = fmt.Sprintf("certspotter/%s (%s; %s; %s)", certspotterVersion(), runtime.Version(), runtime.GOOS, runtime.GOARCH) var flags struct { batchSize int // TODO-4: respect this option email []string healthcheck time.Duration logs string noSave bool script string startAtEnd bool stateDir string stdout bool verbose bool version bool watchlist string } flag.IntVar(&flags.batchSize, "batch_size", 1000, "Max number of entries to request per call to get-entries (advanced)") flag.Func("email", "Email address to contact when matching certificate is discovered (repeatable)", appendFunc(&flags.email)) flag.DurationVar(&flags.healthcheck, "healthcheck", 24*time.Hour, "How frequently to perform a health check") flag.StringVar(&flags.logs, "logs", defaultLogList, "File path or URL of JSON list of logs to monitor") flag.BoolVar(&flags.noSave, "no_save", false, "Do not save a copy of matching certificates in state directory") flag.StringVar(&flags.script, "script", "", "Program to execute when a matching certificate is discovered") flag.BoolVar(&flags.startAtEnd, "start_at_end", false, "Start monitoring logs from the end rather than the beginning (saves considerable bandwidth)") flag.StringVar(&flags.stateDir, "state_dir", defaultStateDir(), "Directory for storing log position and discovered certificates") flag.BoolVar(&flags.stdout, "stdout", false, "Write matching certificates to stdout") flag.BoolVar(&flags.verbose, "verbose", false, "Be verbose") flag.BoolVar(&flags.version, "version", false, "Print version and exit") flag.StringVar(&flags.watchlist, "watchlist", defaultWatchListPathIfExists(), "File containing domain names to watch") flag.Parse() if flags.version { fmt.Fprintf(os.Stdout, "certspotter version %s\n", certspotterVersion()) os.Exit(0) } if flags.watchlist == "" { fmt.Fprintf(os.Stderr, "%s: watch list not found: please create %s or specify alternative path using -watchlist\n", programName, defaultWatchListPath()) os.Exit(2) } config := &monitor.Config{ LogListSource: flags.logs, StateDir: flags.stateDir, SaveCerts: !flags.noSave, StartAtEnd: flags.startAtEnd, Verbose: flags.verbose, Script: flags.script, ScriptDir: defaultScriptDir(), Email: flags.email, Stdout: flags.stdout, HealthCheckInterval: flags.healthcheck, } emailFileExists := false if emailRecipients, err := readEmailFile(defaultEmailFile()); err == nil { emailFileExists = true config.Email = append(config.Email, emailRecipients...) } else if !errors.Is(err, fs.ErrNotExist) { fmt.Fprintf(os.Stderr, "%s: error reading email recipients file %q: %s\n", programName, defaultEmailFile(), err) os.Exit(1) } if len(config.Email) == 0 && !emailFileExists && config.Script == "" && !fileExists(config.ScriptDir) && config.Stdout == false { fmt.Fprintf(os.Stderr, "%s: no notification methods were specified\n", programName) fmt.Fprintf(os.Stderr, "Please specify at least one of the following notification methods:\n") fmt.Fprintf(os.Stderr, " - Place one or more email addresses in %s (one address per line)\n", defaultEmailFile()) fmt.Fprintf(os.Stderr, " - Place one or more executable scripts in the %s directory\n", config.ScriptDir) fmt.Fprintf(os.Stderr, " - Specify an email address using the -email flag\n") fmt.Fprintf(os.Stderr, " - Specify the path to an executable script using the -script flag\n") fmt.Fprintf(os.Stderr, " - Specify the -stdout flag\n") os.Exit(2) } if flags.watchlist == "-" { watchlist, err := monitor.ReadWatchList(os.Stdin) if err != nil { fmt.Fprintf(os.Stderr, "%s: error reading watchlist from standard in: %s\n", programName, err) os.Exit(1) } config.WatchList = watchlist } else { watchlist, err := readWatchListFile(flags.watchlist) if err != nil { fmt.Fprintf(os.Stderr, "%s: error reading watchlist from %q: %s\n", programName, flags.watchlist, err) os.Exit(1) } config.WatchList = watchlist } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() if err := monitor.Run(ctx, config); err != nil && !errors.Is(err, context.Canceled) { fmt.Fprintf(os.Stderr, "%s: %s\n", programName, err) os.Exit(1) } } certspotter-0.18.0/cmd/submitct/000077500000000000000000000000001452451401200165515ustar00rootroot00000000000000certspotter-0.18.0/cmd/submitct/.gitignore000066400000000000000000000000121452451401200205320ustar00rootroot00000000000000/submitct certspotter-0.18.0/cmd/submitct/main.go000066400000000000000000000131431452451401200200260ustar00rootroot00000000000000// Copyright (C) 2017 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package main import ( "software.sslmate.com/src/certspotter" "software.sslmate.com/src/certspotter/ct" "software.sslmate.com/src/certspotter/ct/client" "software.sslmate.com/src/certspotter/loglist" "bytes" "context" "crypto/sha256" "crypto/x509" "encoding/pem" "flag" "fmt" "io" "log" "os" "strings" "sync" "sync/atomic" "time" ) const defaultLogList = "https://loglist.certspotter.org/submit.json" var verbose = flag.Bool("v", false, "Enable verbose output") var logsURL = flag.String("logs", defaultLogList, "File path or URL of JSON list of logs to submit to") type Certificate struct { Subject []byte Issuer []byte Raw []byte Expiration time.Time } func (cert *Certificate) Fingerprint() [32]byte { return sha256.Sum256(cert.Raw) } func (cert *Certificate) CommonName() string { subject, err := certspotter.ParseRDNSequence(cert.Subject) if err != nil { return "???" } cns, err := subject.ParseCNs() if err != nil || len(cns) == 0 { return "???" } return cns[0] } func parseCertificate(data []byte) (*Certificate, error) { crt, err := certspotter.ParseCertificate(data) if err != nil { return nil, err } tbs, err := crt.ParseTBSCertificate() if err != nil { return nil, err } validity, err := tbs.ParseValidity() if err != nil { return nil, err } return &Certificate{ Subject: tbs.Subject.FullBytes, Issuer: tbs.Issuer.FullBytes, Raw: data, Expiration: validity.NotAfter, }, nil } type Chain []*Certificate func (c Chain) GetRawCerts() [][]byte { rawCerts := make([][]byte, len(c)) for i := range c { rawCerts[i] = c[i].Raw } return rawCerts } type CertificateBunch struct { byFingerprint map[[32]byte]*Certificate bySubject map[[32]byte]*Certificate } func MakeCertificateBunch() CertificateBunch { return CertificateBunch{ byFingerprint: make(map[[32]byte]*Certificate), bySubject: make(map[[32]byte]*Certificate), } } func (certs *CertificateBunch) Add(cert *Certificate) { certs.byFingerprint[cert.Fingerprint()] = cert certs.bySubject[sha256.Sum256(cert.Subject)] = cert } func (certs *CertificateBunch) FindBySubject(subject []byte) *Certificate { return certs.bySubject[sha256.Sum256(subject)] } type Log struct { *loglist.Log *ct.SignatureVerifier *client.LogClient } func (ctlog *Log) SubmitChain(chain Chain) (*ct.SignedCertificateTimestamp, error) { rawCerts := chain.GetRawCerts() sct, err := ctlog.AddChain(context.Background(), rawCerts) if err != nil { return nil, err } if err := certspotter.VerifyX509SCT(sct, rawCerts[0], ctlog.SignatureVerifier); err != nil { return nil, fmt.Errorf("Bad SCT signature: %s", err) } return sct, nil } func buildChain(cert *Certificate, certs *CertificateBunch) Chain { chain := make([]*Certificate, 0) for len(chain) < 16 && cert != nil && !bytes.Equal(cert.Subject, cert.Issuer) { chain = append(chain, cert) cert = certs.FindBySubject(cert.Issuer) } return chain } func main() { flag.Parse() log.SetPrefix("submitct: ") certsPem, err := io.ReadAll(os.Stdin) if err != nil { log.Fatalf("Error reading stdin: %s", err) } list, err := loglist.Load(context.Background(), *logsURL) if err != nil { log.Fatalf("Error loading log list: %s", err) } var logs []Log for _, ctlog := range list.AllLogs() { pubkey, err := x509.ParsePKIXPublicKey(ctlog.Key) if err != nil { log.Fatalf("%s: Failed to parse log public key: %s", ctlog.URL, err) } verifier, err := ct.NewSignatureVerifier(pubkey) if err != nil { log.Fatalf("%s: Failed to create signature verifier for log: %s", ctlog.URL, err) } logs = append(logs, Log{ Log: ctlog, SignatureVerifier: verifier, LogClient: client.New(strings.TrimRight(ctlog.URL, "/")), }) } certs := MakeCertificateBunch() var parseErrors uint32 var submitErrors uint32 for len(certsPem) > 0 { var pemBlock *pem.Block pemBlock, certsPem = pem.Decode(certsPem) if pemBlock == nil { log.Fatalf("Invalid PEM read from stdin") } if pemBlock.Type != "CERTIFICATE" { log.Printf("Ignoring non-certificate read from stdin") continue } cert, err := parseCertificate(pemBlock.Bytes) if err != nil { log.Printf("Ignoring un-parseable certificate read from stdin: %s", err) parseErrors++ continue } certs.Add(cert) } wg := sync.WaitGroup{} for fingerprint, cert := range certs.byFingerprint { cn := cert.CommonName() chain := buildChain(cert, &certs) if len(chain) == 0 { continue } for _, ctlog := range logs { if !ctlog.AcceptsExpiration(chain[0].Expiration) { continue } wg.Add(1) go func(fingerprint [32]byte, ctlog Log) { sct, err := ctlog.SubmitChain(chain) if err != nil { log.Printf("%x (%s): %s: Submission Error: %s", fingerprint, cn, ctlog.URL, err) atomic.AddUint32(&submitErrors, 1) } else if *verbose { timestamp := time.Unix(int64(sct.Timestamp)/1000, int64(sct.Timestamp%1000)*1000000) log.Printf("%x (%s): %s: Submitted at %s", fingerprint, cn, ctlog.URL, timestamp) } wg.Done() }(fingerprint, ctlog) } } wg.Wait() exitStatus := 0 if parseErrors > 0 { log.Printf("%d certificates failed to parse and were ignored", parseErrors) exitStatus |= 4 } if submitErrors > 0 { log.Printf("%d submission errors occurred", submitErrors) exitStatus |= 8 } os.Exit(exitStatus) } certspotter-0.18.0/ct/000077500000000000000000000000001452451401200145625ustar00rootroot00000000000000certspotter-0.18.0/ct/AUTHORS000066400000000000000000000014241452451401200156330ustar00rootroot00000000000000# This is the official list of benchmark authors for copyright purposes. # This file is distinct from the CONTRIBUTORS files. # See the latter for an explanation. # # Names should be added to this file as: # Name or Organization # The email address is not required for organizations. # # Please keep the list sorted. Comodo CA Limited Ed Maste Fiaz Hossain Google Inc. Jeff Trawick Katriel Cohn-Gordon Mark Schloesser NORDUnet A/S Nicholas Galbreath Oliver Weidner Ruslan Kovalov Venafi, Inc. Vladimir Rutsky Ximin Luo certspotter-0.18.0/ct/LICENSE000066400000000000000000000261361452451401200155770ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "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 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 to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise 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 Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (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 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 documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. certspotter-0.18.0/ct/README000066400000000000000000000004561452451401200154470ustar00rootroot00000000000000The code in this directory is based on Google's Certificiate Transparency Go library (originally at ; now at ). See AUTHORS for the copyright holders, and LICENSE for the license. certspotter-0.18.0/ct/client/000077500000000000000000000000001452451401200160405ustar00rootroot00000000000000certspotter-0.18.0/ct/client/logclient.go000066400000000000000000000325201452451401200203510ustar00rootroot00000000000000// Package client is a CT log client implementation and contains types and code // for interacting with RFC6962-compliant CT Log instances. // See http://tools.ietf.org/html/rfc6962 for details package client import ( "bytes" "context" "crypto/sha256" "crypto/tls" "encoding/base64" "encoding/json" "errors" "fmt" "io" insecurerand "math/rand" "net/http" "net/url" "strconv" "time" "software.sslmate.com/src/certspotter/ct" ) const ( baseRetryDelay = 1 * time.Second maxRetryDelay = 120 * time.Second maxRetries = 10 ) func isRetryableStatusCode(code int) bool { return code/100 == 5 || code == http.StatusTooManyRequests } func randomDuration(min, max time.Duration) time.Duration { return min + time.Duration(insecurerand.Int63n(int64(max)-int64(min)+1)) } func getRetryAfter(resp *http.Response) (time.Duration, bool) { if resp == nil { return 0, false } seconds, err := strconv.ParseUint(resp.Header.Get("Retry-After"), 10, 16) if err != nil { return 0, false } return time.Duration(seconds) * time.Second, true } func sleep(ctx context.Context, duration time.Duration) { timer := time.NewTimer(duration) defer timer.Stop() select { case <-ctx.Done(): case <-timer.C: } } // URI paths for CT Log endpoints const ( GetSTHPath = "/ct/v1/get-sth" GetEntriesPath = "/ct/v1/get-entries" GetSTHConsistencyPath = "/ct/v1/get-sth-consistency" GetProofByHashPath = "/ct/v1/get-proof-by-hash" AddChainPath = "/ct/v1/add-chain" ) // LogClient represents a client for a given CT Log instance type LogClient struct { uri string // the base URI of the log. e.g. http://ct.googleapis/pilot httpClient *http.Client // used to interact with the log via HTTP verifier *ct.SignatureVerifier // if non-nil, used to verify STH signatures } ////////////////////////////////////////////////////////////////////////////////// // JSON structures follow. // These represent the structures returned by the CT Log server. ////////////////////////////////////////////////////////////////////////////////// // getSTHResponse represents the JSON response to the get-sth CT method type getSTHResponse struct { TreeSize uint64 `json:"tree_size"` // Number of certs in the current tree Timestamp uint64 `json:"timestamp"` // Time that the tree was created SHA256RootHash []byte `json:"sha256_root_hash"` // Root hash of the tree TreeHeadSignature []byte `json:"tree_head_signature"` // Log signature for this STH } // base64LeafEntry represents a Base64 encoded leaf entry type base64LeafEntry struct { LeafInput []byte `json:"leaf_input"` ExtraData []byte `json:"extra_data"` } // getEntriesReponse represents the JSON response to the CT get-entries method type getEntriesResponse struct { Entries []base64LeafEntry `json:"entries"` // the list of returned entries } // getConsistencyProofResponse represents the JSON response to the CT get-consistency-proof method type getConsistencyProofResponse struct { Consistency [][]byte `json:"consistency"` } // getAuditProofResponse represents the JSON response to the CT get-proof-by-hash method type getAuditProofResponse struct { LeafIndex uint64 `json:"leaf_index"` AuditPath [][]byte `json:"audit_path"` } type addChainRequest struct { Chain [][]byte `json:"chain"` } type addChainResponse struct { SCTVersion uint8 `json:"sct_version"` ID []byte `json:"id"` Timestamp uint64 `json:"timestamp"` Extensions []byte `json:"extensions"` Signature []byte `json:"signature"` } // New constructs a new LogClient instance. // |uri| is the base URI of the CT log instance to interact with, e.g. // http://ct.googleapis.com/pilot func New(uri string) *LogClient { return NewWithVerifier(uri, nil) } func NewWithVerifier(uri string, verifier *ct.SignatureVerifier) *LogClient { var c LogClient c.uri = uri c.verifier = verifier transport := &http.Transport{ Proxy: http.ProxyFromEnvironment, TLSHandshakeTimeout: 15 * time.Second, ResponseHeaderTimeout: 30 * time.Second, MaxIdleConnsPerHost: 10, DisableKeepAlives: false, MaxIdleConns: 100, IdleConnTimeout: 15 * time.Second, ExpectContinueTimeout: 1 * time.Second, TLSClientConfig: &tls.Config{ // We have to disable TLS certificate validation because because several logs // (WoSign, StartCom, GDCA) use certificates that are not widely trusted. // Since we verify that every response we receive from the log is signed // by the log's CT public key (either directly, or indirectly via the Merkle Tree), // TLS certificate validation is not actually necessary. (We don't want to ship // our own trust store because that adds undesired complexity and would require // updating should a log ever change to a different CA.) InsecureSkipVerify: true, }, } c.httpClient = &http.Client{Timeout: 60 * time.Second, Transport: transport} return &c } func (c *LogClient) fetchAndParse(ctx context.Context, uri string, respBody interface{}) error { return c.doAndParse(ctx, "GET", uri, nil, respBody) } func (c *LogClient) postAndParse(ctx context.Context, uri string, body interface{}, respBody interface{}) error { return c.doAndParse(ctx, "POST", uri, body, respBody) } func (c *LogClient) makeRequest(ctx context.Context, method string, uri string, body interface{}) (*http.Request, error) { if body == nil { return http.NewRequestWithContext(ctx, method, uri, nil) } else { bodyBytes, err := json.Marshal(body) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, method, uri, bytes.NewReader(bodyBytes)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") return req, nil } } func (c *LogClient) doAndParse(ctx context.Context, method string, uri string, reqBody interface{}, respBody interface{}) error { numRetries := 0 retry: if ctx.Err() != nil { return ctx.Err() } req, err := c.makeRequest(ctx, method, uri, reqBody) if err != nil { return fmt.Errorf("%s %s: error creating request: %w", method, uri, err) } req.Header.Set("User-Agent", "") // Don't send a User-Agent to make life harder for malicious logs resp, err := c.httpClient.Do(req) if err != nil { if c.shouldRetry(ctx, numRetries, nil) { numRetries++ goto retry } return err } respBodyBytes, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { if c.shouldRetry(ctx, numRetries, nil) { numRetries++ goto retry } return fmt.Errorf("%s %s: error reading response: %w", method, uri, err) } if resp.StatusCode/100 != 2 { if c.shouldRetry(ctx, numRetries, resp) { numRetries++ goto retry } return fmt.Errorf("%s %s: %s (%s)", method, uri, resp.Status, string(respBodyBytes)) } if err := json.Unmarshal(respBodyBytes, respBody); err != nil { return fmt.Errorf("%s %s: error parsing response JSON: %w", method, uri, err) } return nil } func (c *LogClient) shouldRetry(ctx context.Context, numRetries int, resp *http.Response) bool { if numRetries == maxRetries { return false } if resp != nil && !isRetryableStatusCode(resp.StatusCode) { return false } var delay time.Duration if retryAfter, hasRetryAfter := getRetryAfter(resp); hasRetryAfter { delay = retryAfter } else { delay = baseRetryDelay * (1 << numRetries) if delay > maxRetryDelay { delay = maxRetryDelay } delay += randomDuration(0, delay/2) } if deadline, hasDeadline := ctx.Deadline(); hasDeadline && time.Now().Add(delay).After(deadline) { return false } sleep(ctx, delay) return true } // GetSTH retrieves the current STH from the log. // Returns a populated SignedTreeHead, or a non-nil error. func (c *LogClient) GetSTH(ctx context.Context) (sth *ct.SignedTreeHead, err error) { var resp getSTHResponse if err = c.fetchAndParse(ctx, c.uri+GetSTHPath, &resp); err != nil { return } sth = &ct.SignedTreeHead{ TreeSize: resp.TreeSize, Timestamp: resp.Timestamp, } if len(resp.SHA256RootHash) != sha256.Size { return nil, fmt.Errorf("STH returned by server has invalid sha256_root_hash (expected length %d got %d)", sha256.Size, len(resp.SHA256RootHash)) } copy(sth.SHA256RootHash[:], resp.SHA256RootHash) ds, err := ct.UnmarshalDigitallySigned(bytes.NewReader(resp.TreeHeadSignature)) if err != nil { return nil, err } sth.TreeHeadSignature = *ds if c.verifier != nil { if err := c.verifier.VerifySTHSignature(*sth); err != nil { return nil, fmt.Errorf("STH returned by server has invalid signature: %w", err) } } return } type GetEntriesItem struct { LeafInput []byte `json:"leaf_input"` ExtraData []byte `json:"extra_data"` } // Retrieve the entries in the sequence [start, end] from the CT log server. // If error is nil, at least one entry is returned, and no excess entries are returned. // Fewer entries than requested may be returned. func (c *LogClient) GetRawEntries(ctx context.Context, start, end uint64) ([]GetEntriesItem, error) { if end < start { panic("LogClient.GetRawEntries: end < start") } var response struct { Entries []GetEntriesItem `json:"entries"` } uri := fmt.Sprintf("%s%s?start=%d&end=%d", c.uri, GetEntriesPath, start, end) err := c.fetchAndParse(ctx, uri, &response) if err != nil { return nil, err } if len(response.Entries) == 0 { return nil, fmt.Errorf("GET %s: log server returned an empty get-entries response", uri) } if uint64(len(response.Entries)) > end-start+1 { return nil, fmt.Errorf("GET %s: log server returned a get-entries response with extraneous entries", uri) } return response.Entries, nil } // GetEntries attempts to retrieve the entries in the sequence [|start|, |end|] from the CT // log server. (see section 4.6.) // Returns a slice of LeafInputs or a non-nil error. func (c *LogClient) GetEntries(ctx context.Context, start, end int64) ([]ct.LogEntry, error) { if end < 0 { return nil, errors.New("GetEntries: end should be >= 0") } if end < start { return nil, errors.New("GetEntries: start should be <= end") } var resp getEntriesResponse err := c.fetchAndParse(ctx, fmt.Sprintf("%s%s?start=%d&end=%d", c.uri, GetEntriesPath, start, end), &resp) if err != nil { return nil, err } entries := make([]ct.LogEntry, len(resp.Entries)) for index, entry := range resp.Entries { leaf, err := ct.ReadMerkleTreeLeaf(bytes.NewBuffer(entry.LeafInput)) if err != nil { return nil, fmt.Errorf("Reading Merkle Tree Leaf at index %d failed: %s", start+int64(index), err) } entries[index].LeafBytes = entry.LeafInput entries[index].Leaf = *leaf var chain []ct.ASN1Cert switch leaf.TimestampedEntry.EntryType { case ct.X509LogEntryType: chain, err = ct.UnmarshalX509ChainArray(entry.ExtraData) case ct.PrecertLogEntryType: chain, err = ct.UnmarshalPrecertChainArray(entry.ExtraData) default: return nil, fmt.Errorf("Unknown entry type at index %d: %v", start+int64(index), leaf.TimestampedEntry.EntryType) } if err != nil { return nil, fmt.Errorf("Parsing entry of type %d at index %d failed: %s", leaf.TimestampedEntry.EntryType, start+int64(index), err) } entries[index].Chain = chain entries[index].Index = start + int64(index) } return entries, nil } // GetConsistencyProof retrieves a Merkle Consistency Proof between two STHs (|first| and |second|) // from the log. Returns a slice of MerkleTreeNodes (a ct.ConsistencyProof) or a non-nil error. func (c *LogClient) GetConsistencyProof(ctx context.Context, first, second int64) (ct.ConsistencyProof, error) { if second < 0 { return nil, errors.New("GetConsistencyProof: second should be >= 0") } if second < first { return nil, errors.New("GetConsistencyProof: first should be <= second") } var resp getConsistencyProofResponse err := c.fetchAndParse(ctx, fmt.Sprintf("%s%s?first=%d&second=%d", c.uri, GetSTHConsistencyPath, first, second), &resp) if err != nil { return nil, err } nodes := make([]ct.MerkleTreeNode, len(resp.Consistency)) for index, nodeBytes := range resp.Consistency { nodes[index] = nodeBytes } return nodes, nil } // GetAuditProof retrieves a Merkle Audit Proof (aka Inclusion Proof) for the given // |hash| based on the STH at |treeSize| from the log. Returns a slice of MerkleTreeNodes // and the index of the leaf. func (c *LogClient) GetAuditProof(ctx context.Context, hash ct.MerkleTreeNode, treeSize uint64) (ct.AuditPath, uint64, error) { var resp getAuditProofResponse err := c.fetchAndParse(ctx, fmt.Sprintf("%s%s?hash=%s&tree_size=%d", c.uri, GetProofByHashPath, url.QueryEscape(base64.StdEncoding.EncodeToString(hash)), treeSize), &resp) if err != nil { return nil, 0, err } path := make([]ct.MerkleTreeNode, len(resp.AuditPath)) for index, nodeBytes := range resp.AuditPath { path[index] = nodeBytes } return path, resp.LeafIndex, nil } func (c *LogClient) AddChain(ctx context.Context, chain [][]byte) (*ct.SignedCertificateTimestamp, error) { req := addChainRequest{Chain: chain} var resp addChainResponse if err := c.postAndParse(ctx, c.uri+AddChainPath, &req, &resp); err != nil { return nil, err } sct := &ct.SignedCertificateTimestamp{ SCTVersion: ct.Version(resp.SCTVersion), Timestamp: resp.Timestamp, Extensions: resp.Extensions, } if len(resp.ID) != sha256.Size { return nil, fmt.Errorf("SCT returned by server has invalid id (expected length %d got %d)", sha256.Size, len(resp.ID)) } copy(sct.LogID[:], resp.ID) ds, err := ct.UnmarshalDigitallySigned(bytes.NewReader(resp.Signature)) if err != nil { return nil, err } sct.Signature = *ds return sct, nil } certspotter-0.18.0/ct/serialization.go000066400000000000000000000331421452451401200177710ustar00rootroot00000000000000package ct import ( "bytes" "container/list" "crypto" "encoding/binary" "errors" "fmt" "io" ) // Variable size structure prefix-header byte lengths const ( CertificateLengthBytes = 3 PreCertificateLengthBytes = 3 ExtensionsLengthBytes = 2 CertificateChainLengthBytes = 3 SignatureLengthBytes = 2 ) // Max lengths const ( MaxCertificateLength = (1 << 24) - 1 MaxExtensionsLength = (1 << 16) - 1 ) func writeUint(w io.Writer, value uint64, numBytes int) error { buf := make([]uint8, numBytes) for i := 0; i < numBytes; i++ { buf[numBytes-i-1] = uint8(value & 0xff) value >>= 8 } if value != 0 { return errors.New("numBytes was insufficiently large to represent value") } if _, err := w.Write(buf); err != nil { return err } return nil } func writeVarBytes(w io.Writer, value []byte, numLenBytes int) error { if err := writeUint(w, uint64(len(value)), numLenBytes); err != nil { return err } if _, err := w.Write(value); err != nil { return err } return nil } func readUint(r io.Reader, numBytes int) (uint64, error) { var l uint64 for i := 0; i < numBytes; i++ { l <<= 8 var t uint8 if err := binary.Read(r, binary.BigEndian, &t); err != nil { return 0, err } l |= uint64(t) } return l, nil } // Reads a variable length array of bytes from |r|. |numLenBytes| specifies the // number of (BigEndian) prefix-bytes which contain the length of the actual // array data bytes that follow. // Allocates an array to hold the contents and returns a slice view into it if // the read was successful, or an error otherwise. func readVarBytes(r io.Reader, numLenBytes int) ([]byte, error) { switch { case numLenBytes > 8: return nil, fmt.Errorf("numLenBytes too large (%d)", numLenBytes) case numLenBytes == 0: return nil, errors.New("numLenBytes should be > 0") } l, err := readUint(r, numLenBytes) if err != nil { return nil, err } data := make([]byte, l) if n, err := io.ReadFull(r, data); err != nil { if err == io.EOF || err == io.ErrUnexpectedEOF { return nil, fmt.Errorf("short read: expected %d but got %d", l, n) } return nil, err } return data, nil } // Reads a list of ASN1Cert types from |r| func readASN1CertList(r io.Reader, totalLenBytes int, elementLenBytes int) ([]ASN1Cert, error) { listBytes, err := readVarBytes(r, totalLenBytes) if err != nil { return []ASN1Cert{}, err } list := list.New() listReader := bytes.NewReader(listBytes) var entry []byte for err == nil { entry, err = readVarBytes(listReader, elementLenBytes) if err != nil { if err != io.EOF { return []ASN1Cert{}, err } } else { list.PushBack(entry) } } ret := make([]ASN1Cert, list.Len()) i := 0 for e := list.Front(); e != nil; e = e.Next() { ret[i] = e.Value.([]byte) i++ } return ret, nil } // ReadTimestampedEntryInto parses the byte-stream representation of a // TimestampedEntry from |r| and populates the struct |t| with the data. See // RFC section 3.4 for details on the format. // Returns a non-nil error if there was a problem. func ReadTimestampedEntryInto(r io.Reader, t *TimestampedEntry) error { var err error if err = binary.Read(r, binary.BigEndian, &t.Timestamp); err != nil { return err } if err = binary.Read(r, binary.BigEndian, &t.EntryType); err != nil { return err } switch t.EntryType { case X509LogEntryType: if t.X509Entry, err = readVarBytes(r, CertificateLengthBytes); err != nil { return err } case PrecertLogEntryType: if err := binary.Read(r, binary.BigEndian, &t.PrecertEntry.IssuerKeyHash); err != nil { return err } if t.PrecertEntry.TBSCertificate, err = readVarBytes(r, PreCertificateLengthBytes); err != nil { return err } default: return fmt.Errorf("unknown EntryType: %d", t.EntryType) } t.Extensions, err = readVarBytes(r, ExtensionsLengthBytes) return nil } // ReadMerkleTreeLeaf parses the byte-stream representation of a MerkleTreeLeaf // and returns a pointer to a new MerkleTreeLeaf structure containing the // parsed data. // See RFC section 3.4 for details on the format. // Returns a pointer to a new MerkleTreeLeaf or non-nil error if there was a // problem func ReadMerkleTreeLeaf(r io.Reader) (*MerkleTreeLeaf, error) { var m MerkleTreeLeaf if err := binary.Read(r, binary.BigEndian, &m.Version); err != nil { return nil, err } if m.Version != V1 { return nil, fmt.Errorf("unknown Version %d", m.Version) } if err := binary.Read(r, binary.BigEndian, &m.LeafType); err != nil { return nil, err } if m.LeafType != TimestampedEntryLeafType { return nil, fmt.Errorf("unknown LeafType %d", m.LeafType) } if err := ReadTimestampedEntryInto(r, &m.TimestampedEntry); err != nil { return nil, err } return &m, nil } // UnmarshalX509ChainArray unmarshalls the contents of the "chain:" entry in a // GetEntries response in the case where the entry refers to an X509 leaf. func UnmarshalX509ChainArray(b []byte) ([]ASN1Cert, error) { return readASN1CertList(bytes.NewReader(b), CertificateChainLengthBytes, CertificateLengthBytes) } // UnmarshalPrecertChainArray unmarshalls the contents of the "chain:" entry in // a GetEntries response in the case where the entry refers to a Precertificate // leaf. func UnmarshalPrecertChainArray(b []byte) ([]ASN1Cert, error) { var chain []ASN1Cert reader := bytes.NewReader(b) // read the pre-cert entry: precert, err := readVarBytes(reader, CertificateLengthBytes) if err != nil { return chain, err } chain = append(chain, precert) // and then read and return the chain up to the root: remainingChain, err := readASN1CertList(reader, CertificateChainLengthBytes, CertificateLengthBytes) if err != nil { return chain, err } chain = append(chain, remainingChain...) return chain, nil } // UnmarshalDigitallySigned reconstructs a DigitallySigned structure from a Reader func UnmarshalDigitallySigned(r io.Reader) (*DigitallySigned, error) { var h byte if err := binary.Read(r, binary.BigEndian, &h); err != nil { return nil, fmt.Errorf("failed to read HashAlgorithm: %v", err) } var s byte if err := binary.Read(r, binary.BigEndian, &s); err != nil { return nil, fmt.Errorf("failed to read SignatureAlgorithm: %v", err) } sig, err := readVarBytes(r, SignatureLengthBytes) if err != nil { return nil, fmt.Errorf("failed to read Signature bytes: %v", err) } return &DigitallySigned{ HashAlgorithm: HashAlgorithm(h), SignatureAlgorithm: SignatureAlgorithm(s), Signature: sig, }, nil } // MarshalDigitallySigned marshalls a DigitallySigned structure into a byte array func MarshalDigitallySigned(ds DigitallySigned) ([]byte, error) { var b bytes.Buffer if err := b.WriteByte(byte(ds.HashAlgorithm)); err != nil { return nil, fmt.Errorf("failed to write HashAlgorithm: %v", err) } if err := b.WriteByte(byte(ds.SignatureAlgorithm)); err != nil { return nil, fmt.Errorf("failed to write SignatureAlgorithm: %v", err) } if err := writeVarBytes(&b, ds.Signature, SignatureLengthBytes); err != nil { return nil, fmt.Errorf("failed to write HashAlgorithm: %v", err) } return b.Bytes(), nil } func checkCertificateFormat(cert ASN1Cert) error { if len(cert) == 0 { return errors.New("certificate is zero length") } if len(cert) > MaxCertificateLength { return errors.New("certificate too large") } return nil } func checkExtensionsFormat(ext CTExtensions) error { if len(ext) > MaxExtensionsLength { return errors.New("extensions too large") } return nil } func serializeV1CertSCTSignatureInput(timestamp uint64, cert ASN1Cert, ext CTExtensions) ([]byte, error) { if err := checkCertificateFormat(cert); err != nil { return nil, err } if err := checkExtensionsFormat(ext); err != nil { return nil, err } var buf bytes.Buffer if err := binary.Write(&buf, binary.BigEndian, V1); err != nil { return nil, err } if err := binary.Write(&buf, binary.BigEndian, CertificateTimestampSignatureType); err != nil { return nil, err } if err := binary.Write(&buf, binary.BigEndian, timestamp); err != nil { return nil, err } if err := binary.Write(&buf, binary.BigEndian, X509LogEntryType); err != nil { return nil, err } if err := writeVarBytes(&buf, cert, CertificateLengthBytes); err != nil { return nil, err } if err := writeVarBytes(&buf, ext, ExtensionsLengthBytes); err != nil { return nil, err } return buf.Bytes(), nil } func serializeV1PrecertSCTSignatureInput(timestamp uint64, issuerKeyHash [issuerKeyHashLength]byte, tbs []byte, ext CTExtensions) ([]byte, error) { if err := checkCertificateFormat(tbs); err != nil { return nil, err } if err := checkExtensionsFormat(ext); err != nil { return nil, err } var buf bytes.Buffer if err := binary.Write(&buf, binary.BigEndian, V1); err != nil { return nil, err } if err := binary.Write(&buf, binary.BigEndian, CertificateTimestampSignatureType); err != nil { return nil, err } if err := binary.Write(&buf, binary.BigEndian, timestamp); err != nil { return nil, err } if err := binary.Write(&buf, binary.BigEndian, PrecertLogEntryType); err != nil { return nil, err } if _, err := buf.Write(issuerKeyHash[:]); err != nil { return nil, err } if err := writeVarBytes(&buf, tbs, CertificateLengthBytes); err != nil { return nil, err } if err := writeVarBytes(&buf, ext, ExtensionsLengthBytes); err != nil { return nil, err } return buf.Bytes(), nil } func serializeV1SCTSignatureInput(sct SignedCertificateTimestamp, entry LogEntry) ([]byte, error) { if sct.SCTVersion != V1 { return nil, fmt.Errorf("unsupported SCT version, expected V1, but got %s", sct.SCTVersion) } if entry.Leaf.LeafType != TimestampedEntryLeafType { return nil, fmt.Errorf("Unsupported leaf type %s", entry.Leaf.LeafType) } switch entry.Leaf.TimestampedEntry.EntryType { case X509LogEntryType: return serializeV1CertSCTSignatureInput(sct.Timestamp, entry.Leaf.TimestampedEntry.X509Entry, entry.Leaf.TimestampedEntry.Extensions) case PrecertLogEntryType: return serializeV1PrecertSCTSignatureInput(sct.Timestamp, entry.Leaf.TimestampedEntry.PrecertEntry.IssuerKeyHash, entry.Leaf.TimestampedEntry.PrecertEntry.TBSCertificate, entry.Leaf.TimestampedEntry.Extensions) default: return nil, fmt.Errorf("unknown TimestampedEntryLeafType %s", entry.Leaf.TimestampedEntry.EntryType) } } // SerializeSCTSignatureInput serializes the passed in sct and log entry into // the correct format for signing. func SerializeSCTSignatureInput(sct SignedCertificateTimestamp, entry LogEntry) ([]byte, error) { switch sct.SCTVersion { case V1: return serializeV1SCTSignatureInput(sct, entry) default: return nil, fmt.Errorf("unknown SCT version %d", sct.SCTVersion) } } func serializeV1SCT(sct SignedCertificateTimestamp) ([]byte, error) { if err := checkExtensionsFormat(sct.Extensions); err != nil { return nil, err } var buf bytes.Buffer if err := binary.Write(&buf, binary.BigEndian, V1); err != nil { return nil, err } if err := binary.Write(&buf, binary.BigEndian, sct.LogID); err != nil { return nil, err } if err := binary.Write(&buf, binary.BigEndian, sct.Timestamp); err != nil { return nil, err } if err := writeVarBytes(&buf, sct.Extensions, ExtensionsLengthBytes); err != nil { return nil, err } sig, err := MarshalDigitallySigned(sct.Signature) if err != nil { return nil, err } if err := binary.Write(&buf, binary.BigEndian, sig); err != nil { return nil, err } return buf.Bytes(), nil } // SerializeSCT serializes the passed in sct into the format specified // by RFC6962 section 3.2 func SerializeSCT(sct SignedCertificateTimestamp) ([]byte, error) { switch sct.SCTVersion { case V1: return serializeV1SCT(sct) default: return nil, fmt.Errorf("unknown SCT version %d", sct.SCTVersion) } } func deserializeSCTV1(r io.Reader, sct *SignedCertificateTimestamp) error { if err := binary.Read(r, binary.BigEndian, &sct.LogID); err != nil { return err } if err := binary.Read(r, binary.BigEndian, &sct.Timestamp); err != nil { return err } ext, err := readVarBytes(r, ExtensionsLengthBytes) if err != nil { return err } sct.Extensions = ext ds, err := UnmarshalDigitallySigned(r) if err != nil { return err } sct.Signature = *ds return nil } func DeserializeSCT(r io.Reader) (*SignedCertificateTimestamp, error) { var sct SignedCertificateTimestamp if err := binary.Read(r, binary.BigEndian, &sct.SCTVersion); err != nil { return nil, err } switch sct.SCTVersion { case V1: return &sct, deserializeSCTV1(r, &sct) default: return nil, fmt.Errorf("unknown SCT version %d", sct.SCTVersion) } } func serializeV1STHSignatureInput(sth SignedTreeHead) ([]byte, error) { if sth.Version != V1 { return nil, fmt.Errorf("invalid STH version %d", sth.Version) } if sth.TreeSize < 0 { return nil, fmt.Errorf("invalid tree size %d", sth.TreeSize) } if len(sth.SHA256RootHash) != crypto.SHA256.Size() { return nil, fmt.Errorf("invalid TreeHash length, got %d expected %d", len(sth.SHA256RootHash), crypto.SHA256.Size()) } var buf bytes.Buffer if err := binary.Write(&buf, binary.BigEndian, V1); err != nil { return nil, err } if err := binary.Write(&buf, binary.BigEndian, TreeHashSignatureType); err != nil { return nil, err } if err := binary.Write(&buf, binary.BigEndian, sth.Timestamp); err != nil { return nil, err } if err := binary.Write(&buf, binary.BigEndian, sth.TreeSize); err != nil { return nil, err } if err := binary.Write(&buf, binary.BigEndian, sth.SHA256RootHash); err != nil { return nil, err } return buf.Bytes(), nil } // SerializeSTHSignatureInput serializes the passed in sth into the correct // format for signing. func SerializeSTHSignatureInput(sth SignedTreeHead) ([]byte, error) { switch sth.Version { case V1: return serializeV1STHSignatureInput(sth) default: return nil, fmt.Errorf("unsupported STH version %d", sth.Version) } } certspotter-0.18.0/ct/signatures.go000066400000000000000000000063521452451401200173030ustar00rootroot00000000000000package ct import ( "crypto" "crypto/ecdsa" "crypto/rsa" "crypto/sha256" "crypto/x509" "encoding/asn1" "encoding/pem" "errors" "fmt" "math/big" ) // PublicKeyFromPEM parses a PEM formatted block and returns the public key contained within and any remaining unread bytes, or an error. func PublicKeyFromPEM(b []byte) (crypto.PublicKey, SHA256Hash, []byte, error) { p, rest := pem.Decode(b) if p == nil { return nil, [sha256.Size]byte{}, rest, fmt.Errorf("no PEM block found in %s", string(b)) } k, err := x509.ParsePKIXPublicKey(p.Bytes) return k, sha256.Sum256(p.Bytes), rest, err } // SignatureVerifier can verify signatures on SCTs and STHs type SignatureVerifier struct { pubKey crypto.PublicKey } // NewSignatureVerifier creates a new SignatureVerifier using the passed in PublicKey. func NewSignatureVerifier(pk crypto.PublicKey) (*SignatureVerifier, error) { switch pkType := pk.(type) { case *rsa.PublicKey: case *ecdsa.PublicKey: default: return nil, fmt.Errorf("Unsupported public key type %v", pkType) } return &SignatureVerifier{ pubKey: pk, }, nil } // verifySignature verifies that the passed in signature over data was created by our PublicKey. // Currently, only SHA256 is supported as a HashAlgorithm, and only ECDSA and RSA signatures are supported. func (s SignatureVerifier) verifySignature(data []byte, sig DigitallySigned) error { if sig.HashAlgorithm != SHA256 { return fmt.Errorf("unsupported HashAlgorithm in signature: %v", sig.HashAlgorithm) } hasherType := crypto.SHA256 hasher := hasherType.New() if _, err := hasher.Write(data); err != nil { return fmt.Errorf("failed to write to hasher: %v", err) } hash := hasher.Sum([]byte{}) switch sig.SignatureAlgorithm { case RSA: rsaKey, ok := s.pubKey.(*rsa.PublicKey) if !ok { return fmt.Errorf("cannot verify RSA signature with %T key", s.pubKey) } if err := rsa.VerifyPKCS1v15(rsaKey, hasherType, hash, sig.Signature); err != nil { return fmt.Errorf("failed to verify rsa signature: %v", err) } case ECDSA: ecdsaKey, ok := s.pubKey.(*ecdsa.PublicKey) if !ok { return fmt.Errorf("cannot verify ECDSA signature with %T key", s.pubKey) } var ecdsaSig struct { R, S *big.Int } rest, err := asn1.Unmarshal(sig.Signature, &ecdsaSig) if err != nil { return fmt.Errorf("failed to unmarshal ECDSA signature: %v", err) } if len(rest) != 0 { return fmt.Errorf("Garbage following signature %v", rest) } if !ecdsa.Verify(ecdsaKey, hash, ecdsaSig.R, ecdsaSig.S) { return errors.New("failed to verify ecdsa signature") } default: return fmt.Errorf("unsupported signature type %v", sig.SignatureAlgorithm) } return nil } // VerifySCTSignature verifies that the SCT's signature is valid for the given LogEntry func (s SignatureVerifier) VerifySCTSignature(sct SignedCertificateTimestamp, entry LogEntry) error { sctData, err := SerializeSCTSignatureInput(sct, entry) if err != nil { return err } return s.verifySignature(sctData, sct.Signature) } // VerifySTHSignature verifies that the STH's signature is valid. func (s SignatureVerifier) VerifySTHSignature(sth SignedTreeHead) error { sthData, err := SerializeSTHSignatureInput(sth) if err != nil { return err } return s.verifySignature(sthData, sth.TreeHeadSignature) } certspotter-0.18.0/ct/types.go000066400000000000000000000232761452451401200162670ustar00rootroot00000000000000package ct import ( "bytes" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "time" ) const ( issuerKeyHashLength = 32 ) /////////////////////////////////////////////////////////////////////////////// // The following structures represent those outlined in the RFC6962 document: /////////////////////////////////////////////////////////////////////////////// // LogEntryType represents the LogEntryType enum from section 3.1 of the RFC: // enum { x509_entry(0), precert_entry(1), (65535) } LogEntryType; type LogEntryType uint16 func (e LogEntryType) String() string { switch e { case X509LogEntryType: return "X509LogEntryType" case PrecertLogEntryType: return "PrecertLogEntryType" } panic(fmt.Sprintf("No string defined for LogEntryType constant value %d", e)) } // LogEntryType constants, see section 3.1 of RFC6962. const ( X509LogEntryType LogEntryType = 0 PrecertLogEntryType LogEntryType = 1 ) // MerkleLeafType represents the MerkleLeafType enum from section 3.4 of the // RFC: enum { timestamped_entry(0), (255) } MerkleLeafType; type MerkleLeafType uint8 func (m MerkleLeafType) String() string { switch m { case TimestampedEntryLeafType: return "TimestampedEntryLeafType" default: return fmt.Sprintf("UnknownLeafType(%d)", m) } } // MerkleLeafType constants, see section 3.4 of the RFC. const ( TimestampedEntryLeafType MerkleLeafType = 0 // Entry type for an SCT ) // Version represents the Version enum from section 3.2 of the RFC: // enum { v1(0), (255) } Version; type Version uint8 func (v Version) String() string { switch v { case V1: return "V1" default: return fmt.Sprintf("UnknownVersion(%d)", v) } } // CT Version constants, see section 3.2 of the RFC. const ( V1 Version = 0 ) // SignatureType differentiates STH signatures from SCT signatures, see RFC // section 3.2 type SignatureType uint8 func (st SignatureType) String() string { switch st { case CertificateTimestampSignatureType: return "CertificateTimestamp" case TreeHashSignatureType: return "TreeHash" default: return fmt.Sprintf("UnknownSignatureType(%d)", st) } } // SignatureType constants, see RFC section 3.2 const ( CertificateTimestampSignatureType SignatureType = 0 TreeHashSignatureType SignatureType = 1 ) // ASN1Cert type for holding the raw DER bytes of an ASN.1 Certificate // (section 3.1) type ASN1Cert []byte // PreCert represents a Precertificate (section 3.2) type PreCert struct { IssuerKeyHash [issuerKeyHashLength]byte TBSCertificate []byte } // CTExtensions is a representation of the raw bytes of any CtExtension // structure (see section 3.2) type CTExtensions []byte // MerkleTreeNode represents an internal node in the CT tree type MerkleTreeNode []byte // ConsistencyProof represents a CT consistency proof (see sections 2.1.2 and // 4.4) type ConsistencyProof []MerkleTreeNode // AuditPath represents a CT inclusion proof (see sections 2.1.1 and 4.5) type AuditPath []MerkleTreeNode // LeafInput represents a serialized MerkleTreeLeaf structure type LeafInput []byte // HashAlgorithm from the DigitallySigned struct type HashAlgorithm byte // HashAlgorithm constants const ( None HashAlgorithm = 0 MD5 HashAlgorithm = 1 SHA1 HashAlgorithm = 2 SHA224 HashAlgorithm = 3 SHA256 HashAlgorithm = 4 SHA384 HashAlgorithm = 5 SHA512 HashAlgorithm = 6 ) func (h HashAlgorithm) String() string { switch h { case None: return "None" case MD5: return "MD5" case SHA1: return "SHA1" case SHA224: return "SHA224" case SHA256: return "SHA256" case SHA384: return "SHA384" case SHA512: return "SHA512" default: return fmt.Sprintf("UNKNOWN(%d)", h) } } // SignatureAlgorithm from the DigitallySigned struct type SignatureAlgorithm byte // SignatureAlgorithm constants const ( Anonymous SignatureAlgorithm = 0 RSA SignatureAlgorithm = 1 DSA SignatureAlgorithm = 2 ECDSA SignatureAlgorithm = 3 ) func (s SignatureAlgorithm) String() string { switch s { case Anonymous: return "Anonymous" case RSA: return "RSA" case DSA: return "DSA" case ECDSA: return "ECDSA" default: return fmt.Sprintf("UNKNOWN(%d)", s) } } // DigitallySigned represents an RFC5246 DigitallySigned structure type DigitallySigned struct { HashAlgorithm HashAlgorithm SignatureAlgorithm SignatureAlgorithm Signature []byte } // FromBase64String populates the DigitallySigned structure from the base64 data passed in. // Returns an error if the base64 data is invalid. func (d *DigitallySigned) FromBase64String(b64 string) error { raw, err := base64.StdEncoding.DecodeString(b64) if err != nil { return fmt.Errorf("failed to unbase64 DigitallySigned: %v", err) } ds, err := UnmarshalDigitallySigned(bytes.NewReader(raw)) if err != nil { return fmt.Errorf("failed to unmarshal DigitallySigned: %v", err) } *d = *ds return nil } // Base64String returns the base64 representation of the DigitallySigned struct. func (d DigitallySigned) Base64String() (string, error) { b, err := MarshalDigitallySigned(d) if err != nil { return "", err } return base64.StdEncoding.EncodeToString(b), nil } // MarshalJSON implements the json.Marshaller interface. func (d DigitallySigned) MarshalJSON() ([]byte, error) { b64, err := d.Base64String() if err != nil { return []byte{}, err } return []byte(`"` + b64 + `"`), nil } // UnmarshalJSON implements the json.Unmarshaler interface. func (d *DigitallySigned) UnmarshalJSON(b []byte) error { var content string if err := json.Unmarshal(b, &content); err != nil { return fmt.Errorf("failed to unmarshal DigitallySigned: %v", err) } return d.FromBase64String(content) } // LogEntry represents the contents of an entry in a CT log, see section 3.1. type LogEntry struct { Index int64 Leaf MerkleTreeLeaf Chain []ASN1Cert LeafBytes []byte } // SHA256Hash represents the output from the SHA256 hash function. type SHA256Hash [sha256.Size]byte // FromBase64String populates the SHA256 struct with the contents of the base64 data passed in. func (s *SHA256Hash) FromBase64String(b64 string) error { bs, err := base64.StdEncoding.DecodeString(b64) if err != nil { return fmt.Errorf("failed to unbase64 LogID: %v", err) } if len(bs) != sha256.Size { return fmt.Errorf("invalid SHA256 length, expected 32 but got %d", len(bs)) } copy(s[:], bs) return nil } // Base64String returns the base64 representation of this SHA256Hash. func (s SHA256Hash) Base64String() string { return base64.StdEncoding.EncodeToString(s[:]) } // Returns the raw base64url representation of this SHA256Hash. func (s SHA256Hash) Base64URLString() string { return base64.RawURLEncoding.EncodeToString(s[:]) } // MarshalJSON implements the json.Marshaller interface for SHA256Hash. func (s SHA256Hash) MarshalJSON() ([]byte, error) { return []byte(`"` + s.Base64String() + `"`), nil } // UnmarshalJSON implements the json.Unmarshaller interface. func (s *SHA256Hash) UnmarshalJSON(b []byte) error { var content string if err := json.Unmarshal(b, &content); err != nil { return fmt.Errorf("failed to unmarshal SHA256Hash: %v", err) } return s.FromBase64String(content) } // SignedTreeHead represents the structure returned by the get-sth CT method // after base64 decoding. See sections 3.5 and 4.3 in the RFC) type SignedTreeHead struct { Version Version `json:"sth_version"` // The version of the protocol to which the STH conforms TreeSize uint64 `json:"tree_size"` // The number of entries in the new tree Timestamp uint64 `json:"timestamp"` // The time at which the STH was created SHA256RootHash SHA256Hash `json:"sha256_root_hash"` // The root hash of the log's Merkle tree TreeHeadSignature DigitallySigned `json:"tree_head_signature"` // The Log's signature for this STH (see RFC section 3.5) LogID SHA256Hash `json:"log_id"` // The SHA256 hash of the log's public key } func (sth *SignedTreeHead) TimestampTime() time.Time { return time.Unix(int64(sth.Timestamp/1000), int64(sth.Timestamp%1000)*1_000_000).UTC() } // SignedCertificateTimestamp represents the structure returned by the // add-chain and add-pre-chain methods after base64 decoding. (see RFC sections // 3.2 ,4.1 and 4.2) type SignedCertificateTimestamp struct { SCTVersion Version `json:"sct_version"` // The version of the protocol to which the SCT conforms LogID SHA256Hash `json:"id"` // the SHA-256 hash of the log's public key, calculated over // the DER encoding of the key represented as SubjectPublicKeyInfo. Timestamp uint64 `json:"timestamp"` // Timestamp (in ms since unix epoch) at which the SCT was issued Extensions CTExtensions `json:"extensions"` // For future extensions to the protocol Signature DigitallySigned `json:"signature"` // The Log's signature for this SCT } func (s SignedCertificateTimestamp) String() string { return fmt.Sprintf("{Version:%d LogId:%s Timestamp:%d Extensions:'%s' Signature:%v}", s.SCTVersion, base64.StdEncoding.EncodeToString(s.LogID[:]), s.Timestamp, s.Extensions, s.Signature) } // TimestampedEntry is part of the MerkleTreeLeaf structure. // See RFC section 3.4 type TimestampedEntry struct { Timestamp uint64 EntryType LogEntryType X509Entry ASN1Cert PrecertEntry PreCert Extensions CTExtensions } // MerkleTreeLeaf represents the deserialized structure of the hash input for the // leaves of a log's Merkle tree. See RFC section 3.4 type MerkleTreeLeaf struct { Version Version // the version of the protocol to which the MerkleTreeLeaf corresponds LeafType MerkleLeafType // The type of the leaf input, currently only TimestampedEntry can exist TimestampedEntry TimestampedEntry // The entry data itself } certspotter-0.18.0/go.mod000066400000000000000000000002451452451401200152630ustar00rootroot00000000000000module software.sslmate.com/src/certspotter go 1.21 require ( golang.org/x/net v0.17.0 golang.org/x/sync v0.4.0 ) require golang.org/x/text v0.13.0 // indirect certspotter-0.18.0/go.sum000066400000000000000000000007151452451401200153120ustar00rootroot00000000000000golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= certspotter-0.18.0/helpers.go000066400000000000000000000054471452451401200161570ustar00rootroot00000000000000// Copyright (C) 2016 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package certspotter import ( "fmt" "math/big" "software.sslmate.com/src/certspotter/ct" ) func IsPrecert(entry *ct.LogEntry) bool { return entry.Leaf.TimestampedEntry.EntryType == ct.PrecertLogEntryType } type CertInfo struct { TBS *TBSCertificate Subject RDNSequence SubjectParseError error Issuer RDNSequence IssuerParseError error SANs []SubjectAltName SANsParseError error SerialNumber *big.Int SerialNumberParseError error Validity *CertValidity ValidityParseError error IsCA *bool IsCAParseError error IsPreCert bool } func MakeCertInfoFromTBS(tbs *TBSCertificate) *CertInfo { info := &CertInfo{TBS: tbs} info.Subject, info.SubjectParseError = tbs.ParseSubject() info.Issuer, info.IssuerParseError = tbs.ParseIssuer() info.SANs, info.SANsParseError = tbs.ParseSubjectAltNames() info.SerialNumber, info.SerialNumberParseError = tbs.ParseSerialNumber() info.Validity, info.ValidityParseError = tbs.ParseValidity() info.IsCA, info.IsCAParseError = tbs.ParseBasicConstraints() info.IsPreCert = len(tbs.GetExtension(oidExtensionCTPoison)) > 0 return info } func MakeCertInfoFromRawTBS(tbsBytes []byte) (*CertInfo, error) { tbs, err := ParseTBSCertificate(tbsBytes) if err != nil { return nil, err } return MakeCertInfoFromTBS(tbs), nil } func MakeCertInfoFromRawCert(certBytes []byte) (*CertInfo, error) { cert, err := ParseCertificate(certBytes) if err != nil { return nil, err } return MakeCertInfoFromRawTBS(cert.GetRawTBSCertificate()) } func MakeCertInfoFromLogEntry(entry *ct.LogEntry) (*CertInfo, error) { switch entry.Leaf.TimestampedEntry.EntryType { case ct.X509LogEntryType: return MakeCertInfoFromRawCert(entry.Leaf.TimestampedEntry.X509Entry) case ct.PrecertLogEntryType: return MakeCertInfoFromRawTBS(entry.Leaf.TimestampedEntry.PrecertEntry.TBSCertificate) default: return nil, fmt.Errorf("MakeCertInfoFromCTEntry: unknown CT entry type (neither X509 nor precert)") } } func MatchesWildcard(dnsName string, pattern string) bool { for len(pattern) > 0 { if pattern[0] == '*' { if len(dnsName) > 0 && dnsName[0] != '.' && MatchesWildcard(dnsName[1:], pattern) { return true } pattern = pattern[1:] } else { if len(dnsName) == 0 || pattern[0] != dnsName[0] { return false } pattern = pattern[1:] dnsName = dnsName[1:] } } return len(dnsName) == 0 } certspotter-0.18.0/helpers_test.go000066400000000000000000000036151452451401200172110ustar00rootroot00000000000000// Copyright (C) 2016 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package certspotter import ( "testing" ) func doWildcardTest(t *testing.T, dnsName string, wildcard string, expected bool) { if MatchesWildcard(dnsName, wildcard) != expected { t.Errorf("MatchesWildcard(%q, %q) != %v", dnsName, wildcard, expected) } } func TestMatchesWildcard(t *testing.T) { doWildcardTest(t, "", "", true) doWildcardTest(t, "example.com", "example.com", true) doWildcardTest(t, "example.org", "example.com", false) doWildcardTest(t, "example.com", "", false) doWildcardTest(t, "", "example.com", false) doWildcardTest(t, "", "*.example.com", false) doWildcardTest(t, "", "exam*ple.com", false) doWildcardTest(t, "", "exam*ple.co*m", false) doWildcardTest(t, "example.org", "example.com", false) doWildcardTest(t, "example.org", "*.example.com", false) doWildcardTest(t, "example.org", "exam*ple.com", false) doWildcardTest(t, "example.org", "exam*ple.co*m", false) doWildcardTest(t, "example.com", "*.example.com", false) doWildcardTest(t, "www.example.com", "*.example.com", true) doWildcardTest(t, "", "*", true) doWildcardTest(t, "", "****", true) doWildcardTest(t, "a", "****", true) doWildcardTest(t, "a", "*", true) doWildcardTest(t, "a", "****", true) doWildcardTest(t, "abcd", "****", true) doWildcardTest(t, "abcdef", "****", true) doWildcardTest(t, "www-example.com", "*-example.com", true) doWildcardTest(t, "www-example-www.com", "*-example-*.com", true) doWildcardTest(t, "examplecom", "example*", true) doWildcardTest(t, "example.com", "example*", false) doWildcardTest(t, "examplea.com", "example*", false) } certspotter-0.18.0/identifiers.go000066400000000000000000000216031452451401200170120ustar00rootroot00000000000000// Copyright (C) 2016 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package certspotter import ( "bytes" "golang.org/x/net/idna" "net" "strings" "unicode/utf8" ) const UnparsableDNSLabelPlaceholder = "" /* const ( IdentifierSourceSubjectCN = iota IdentifierSourceDNSName IdentifierSourceIPAddr ) type IdentifierSource int type UnknownIdentifier struct { Source IdentifierSource Value []byte } */ type Identifiers struct { DNSNames []string // stored as ASCII, with IDNs in Punycode IPAddrs []net.IP //Unknowns []UnknownIdentifier } func NewIdentifiers() *Identifiers { return &Identifiers{ DNSNames: []string{}, IPAddrs: []net.IP{}, //Unknowns: []UnknownIdentifier{}, } } func parseIPAddrString(str string) net.IP { return net.ParseIP(str) } func isASCIIString(value []byte) bool { for _, b := range value { if b > 127 { return false } } return true } func isUTF8String(value []byte) bool { return utf8.Valid(value) } func latin1ToUTF8(value []byte) string { runes := make([]rune, len(value)) for i, b := range value { runes[i] = rune(b) } return string(runes) } // Make sure the DNS label doesn't have any weird characters that // could cause trouble during later processing. func isSaneDNSLabelChar(ch rune) bool { return ch == '\t' || (ch >= 32 && ch <= 126) } func isSaneDNSLabel(label string) bool { for _, ch := range label { if !isSaneDNSLabelChar(ch) { return false } } return true } func trimHttpPrefixString(value string) string { if strings.HasPrefix(value, "http://") { return value[7:] } else if strings.HasPrefix(value, "https://") { return value[8:] } else { return value } } func trimHttpPrefixBytes(value []byte) []byte { if bytes.HasPrefix(value, []byte("http://")) { return value[7:] } else if bytes.HasPrefix(value, []byte("https://")) { return value[8:] } else { return value } } func trimTrailingDots(value string) string { length := len(value) for length > 0 && value[length-1] == '.' { length-- } return value[0:length] } // Try to canonicalize/sanitize the DNS name: // 1. Trim leading and trailing whitespace // 2. Trim trailing dots // 3. Convert to lower case // 4. Replace totally nonsensical labels (e.g. having non-printable characters) with a placeholder func sanitizeDNSName(value string) string { value = strings.ToLower(trimTrailingDots(strings.TrimSpace(value))) labels := strings.Split(value, ".") for i, label := range labels { if !isSaneDNSLabel(label) { labels[i] = UnparsableDNSLabelPlaceholder } } return strings.Join(labels, ".") } // Like sanitizeDNSName, but labels that are Unicode are converted to Punycode. func sanitizeUnicodeDNSName(value string) string { value = strings.ToLower(trimTrailingDots(strings.TrimSpace(value))) labels := strings.Split(value, ".") for i, label := range labels { if asciiLabel, err := idna.ToASCII(label); err == nil && isSaneDNSLabel(asciiLabel) { labels[i] = asciiLabel } else { labels[i] = UnparsableDNSLabelPlaceholder } } return strings.Join(labels, ".") } func (ids *Identifiers) appendDNSName(dnsName string) { if dnsName != "" && !ids.hasDNSName(dnsName) { ids.DNSNames = append(ids.DNSNames, dnsName) } } func (ids *Identifiers) appendIPAddress(ipaddr net.IP) { if !ids.hasIPAddress(ipaddr) { ids.IPAddrs = append(ids.IPAddrs, ipaddr) } } func (ids *Identifiers) hasDNSName(target string) bool { for _, value := range ids.DNSNames { if value == target { return true } } return false } func (ids *Identifiers) hasIPAddress(target net.IP) bool { for _, value := range ids.IPAddrs { if value.Equal(target) { return true } } return false } func (ids *Identifiers) addDnsSANfinal(value []byte) { if ipaddr := parseIPAddrString(string(value)); ipaddr != nil { // Stupid CAs put IP addresses in DNS SANs because stupid Microsoft // used to not support IP address SANs. Since there's no way for an IP // address to also be a valid DNS name, just treat it like an IP address // and not try to process it as a DNS name. ids.appendIPAddress(ipaddr) } else if isASCIIString(value) { ids.appendDNSName(sanitizeDNSName(string(value))) } else { // DNS SANs are supposed to be IA5Strings (i.e. ASCII) but CAs can't follow // simple rules. Unfortunately, we have no idea what the encoding really is // in this case, so interpret it as both UTF-8 (if it's valid UTF-8) // and Latin-1. if isUTF8String(value) { ids.appendDNSName(sanitizeUnicodeDNSName(string(value))) } ids.appendDNSName(sanitizeUnicodeDNSName(latin1ToUTF8(value))) } } func (ids *Identifiers) addDnsSANnonull(value []byte) { if slashIndex := bytes.IndexByte(value, '/'); slashIndex != -1 { // If the value contains a slash, then this might be a URL, // so process the part of the value up to the first slash, // which should be the domain. Even though no client should // ever successfully validate such a DNS name, the domain owner // might still want to know about it. ids.addDnsSANfinal(value[0:slashIndex]) } ids.addDnsSANfinal(value) } func (ids *Identifiers) AddDnsSAN(value []byte) { // Trim http:// and https:// prefixes, which are all too common in the wild, // so http://example.com becomes just example.com. Even though clients // should never successfully validate a DNS name like http://example.com, // the owner of example.com might still want to know about it. value = trimHttpPrefixBytes(value) if nullIndex := bytes.IndexByte(value, 0); nullIndex != -1 { // If the value contains a null byte, process the part of // the value up to the first null byte in addition to the // complete value, in case this certificate is an attempt to // fake out validators that only compare up to the first null. ids.addDnsSANnonull(value[0:nullIndex]) } ids.addDnsSANnonull(value) } func (ids *Identifiers) addCNfinal(value string) { if ipaddr := parseIPAddrString(value); ipaddr != nil { ids.appendIPAddress(ipaddr) } else if !strings.ContainsRune(value, ' ') { // If the CN contains a space it's clearly not a DNS name, so ignore it. ids.appendDNSName(sanitizeUnicodeDNSName(value)) } } func (ids *Identifiers) addCNnonull(value string) { if slashIndex := strings.IndexRune(value, '/'); slashIndex != -1 { // If the value contains a slash, then this might be a URL, // so process the part of the value up to the first slash, // which should be the domain. Even though no client should // ever successfully validate such a DNS name, the domain owner // might still want to know about it. ids.addCNfinal(value[0:slashIndex]) } ids.addCNfinal(value) } func (ids *Identifiers) AddCN(value string) { // Trim http:// and https:// prefixes, which are all too common in the wild, // so http://example.com becomes just example.com. Even though clients // should never successfully validate a DNS name like http://example.com, // the owner of example.com might still want to know about it. value = trimHttpPrefixString(value) if nullIndex := strings.IndexRune(value, 0); nullIndex != -1 { // If the value contains a null byte, process the part of // the value up to the first null byte in addition to the // complete value, in case this certificate is an attempt to // fake out validators that only compare up to the first null. ids.addCNnonull(value[0:nullIndex]) } ids.addCNnonull(value) } func (ids *Identifiers) AddIPAddress(value net.IP) { ids.appendIPAddress(value) } func (ids *Identifiers) dnsNamesString(sep string) string { return strings.Join(ids.DNSNames, sep) } func (ids *Identifiers) ipAddrsString(sep string) string { str := "" for _, ipAddr := range ids.IPAddrs { if str != "" { str += sep } str += ipAddr.String() } return str } func (cert *CertInfo) ParseIdentifiers() (*Identifiers, error) { ids := NewIdentifiers() if cert.SubjectParseError != nil { return nil, cert.SubjectParseError } cns, err := cert.Subject.ParseCNs() if err != nil { return nil, err } for _, cn := range cns { ids.AddCN(cn) } if cert.SANsParseError != nil { return nil, cert.SANsParseError } for _, san := range cert.SANs { switch san.Type { case sanDNSName: ids.AddDnsSAN(san.Value) case sanIPAddress: if len(san.Value) == 4 || len(san.Value) == 16 { ids.AddIPAddress(net.IP(san.Value)) } // TODO: decide what to do with IP addresses with an invalid length. // The two encoding errors I've observed in CT logs are: // 1. encoding the IP address as a string // 2. a value of 0x00000000FFFFFF00 (WTF?) // IP addresses aren't a high priority so just ignore invalid ones for now. // Hopefully no clients out there are dumb enough to process IP address // SANs encoded as strings... } } return ids, nil } certspotter-0.18.0/loglist/000077500000000000000000000000001452451401200156315ustar00rootroot00000000000000certspotter-0.18.0/loglist/helpers.go000066400000000000000000000020441452451401200176220ustar00rootroot00000000000000// Copyright (C) 2020 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package loglist import ( "time" ) func (list *List) AllLogs() []*Log { logs := []*Log{} for operator := range list.Operators { for log := range list.Operators[operator].Logs { logs = append(logs, &list.Operators[operator].Logs[log]) } } return logs } func (log *Log) LogIDString() string { return log.LogID.Base64String() } func (log *Log) AcceptsExpiration(expiration time.Time) bool { return log.TemporalInterval == nil || withinInterval(expiration, log.TemporalInterval.StartInclusive, log.TemporalInterval.EndExclusive) } func withinInterval(expiration, startInclusive, endExclusive time.Time) bool { return !expiration.Before(startInclusive) && expiration.Before(endExclusive) } certspotter-0.18.0/loglist/load.go000066400000000000000000000060241452451401200171010ustar00rootroot00000000000000// Copyright (C) 2020, 2023 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package loglist import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "os" "strings" "time" ) var UserAgent = "certspotter" type ModificationToken struct { etag string modified time.Time } var ErrNotModified = errors.New("loglist has not been modified") func newModificationToken(response *http.Response) *ModificationToken { token := &ModificationToken{ etag: response.Header.Get("ETag"), } if t, err := time.Parse(http.TimeFormat, response.Header.Get("Last-Modified")); err == nil { token.modified = t } return token } func (token *ModificationToken) setRequestHeaders(request *http.Request) { if token.etag != "" { request.Header.Set("If-None-Match", token.etag) } else if !token.modified.IsZero() { request.Header.Set("If-Modified-Since", token.modified.Format(http.TimeFormat)) } } func Load(ctx context.Context, urlOrFile string) (*List, error) { list, _, err := LoadIfModified(ctx, urlOrFile, nil) return list, err } func LoadIfModified(ctx context.Context, urlOrFile string, token *ModificationToken) (*List, *ModificationToken, error) { if strings.HasPrefix(urlOrFile, "https://") { return FetchIfModified(ctx, urlOrFile, token) } else { list, err := ReadFile(urlOrFile) return list, nil, err } } func Fetch(ctx context.Context, url string) (*List, error) { list, _, err := FetchIfModified(ctx, url, nil) return list, err } func FetchIfModified(ctx context.Context, url string, token *ModificationToken) (*List, *ModificationToken, error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, nil, err } request.Header.Set("User-Agent", UserAgent) if token != nil { token.setRequestHeaders(request) } response, err := http.DefaultClient.Do(request) if err != nil { return nil, nil, err } content, err := io.ReadAll(response.Body) response.Body.Close() if err != nil { return nil, nil, err } if token != nil && response.StatusCode == http.StatusNotModified { return nil, nil, ErrNotModified } if response.StatusCode != 200 { return nil, nil, fmt.Errorf("%s: %s", url, response.Status) } list, err := Unmarshal(content) if err != nil { return nil, nil, fmt.Errorf("error parsing %s: %w", url, err) } return list, newModificationToken(response), err } func ReadFile(filename string) (*List, error) { content, err := os.ReadFile(filename) if err != nil { return nil, err } return Unmarshal(content) } func Unmarshal(jsonBytes []byte) (*List, error) { list := new(List) if err := json.Unmarshal(jsonBytes, list); err != nil { return nil, err } if err := list.Validate(); err != nil { return nil, fmt.Errorf("Invalid log list: %s", err) } return list, nil } certspotter-0.18.0/loglist/schema.go000066400000000000000000000044431452451401200174250ustar00rootroot00000000000000// Copyright (C) 2020 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package loglist import ( "time" "software.sslmate.com/src/certspotter/ct" ) type List struct { Version string `json:"version"` LogListTimestamp time.Time `json:"log_list_timestamp"` // Only present in v3 of schema Operators []Operator `json:"operators"` } type Operator struct { Name string `json:"name"` Email []string `json:"email"` Logs []Log `json:"logs"` } type Log struct { Key []byte `json:"key"` LogID ct.SHA256Hash `json:"log_id"` MMD int `json:"mmd"` URL string `json:"url"` Description string `json:"description"` State State `json:"state"` DNS string `json:"dns"` LogType LogType `json:"log_type"` TemporalInterval *struct { StartInclusive time.Time `json:"start_inclusive"` EndExclusive time.Time `json:"end_exclusive"` } `json:"temporal_interval"` // TODO: add previous_operators } type State struct { Pending *struct { Timestamp time.Time `json:"timestamp"` } `json:"pending"` Qualified *struct { Timestamp time.Time `json:"timestamp"` } `json:"qualified"` Usable *struct { Timestamp time.Time `json:"timestamp"` } `json:"usable"` Readonly *struct { Timestamp time.Time `json:"timestamp"` FinalTreeHead struct { TreeSize int64 `json:"tree_size"` SHA256RootHash []byte `json:"sha256_root_hash"` } `json:"final_tree_head"` } `json:"readonly"` Retired *struct { Timestamp time.Time `json:"timestamp"` } `json:"retired"` Rejected *struct { Timestamp time.Time `json:"timestamp"` } `json:"rejected"` } func (state *State) IsApproved() bool { return state.Qualified != nil || state.Usable != nil || state.Readonly != nil } func (state *State) WasApprovedAt(t time.Time) bool { return state.Retired != nil && t.Before(state.Retired.Timestamp) } type LogType string const ( LogTypeProd = "prod" LogTypeTest = "test" ) certspotter-0.18.0/loglist/validate.go000066400000000000000000000020531452451401200177510ustar00rootroot00000000000000// Copyright (C) 2020 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package loglist import ( "crypto/sha256" "fmt" ) func (list *List) Validate() error { for i := range list.Operators { if err := list.Operators[i].Validate(); err != nil { return fmt.Errorf("problem with %dth operator (%s): %w", i, list.Operators[i].Name, err) } } return nil } func (operator *Operator) Validate() error { for i := range operator.Logs { if err := operator.Logs[i].Validate(); err != nil { return fmt.Errorf("problem with %dth log (%s): %w", i, operator.Logs[i].LogIDString(), err) } } return nil } func (log *Log) Validate() error { realLogID := sha256.Sum256(log.Key) if log.LogID != realLogID { return fmt.Errorf("log ID does not match log key") } return nil } certspotter-0.18.0/man/000077500000000000000000000000001452451401200147275ustar00rootroot00000000000000certspotter-0.18.0/man/.gitignore000066400000000000000000000000041452451401200167110ustar00rootroot00000000000000*.8 certspotter-0.18.0/man/Makefile000066400000000000000000000004131452451401200163650ustar00rootroot00000000000000all: certspotter-script.8 certspotter.8 %.8: %.md lowdown -s -Tman \ -M title:$(basename $(notdir $@)) \ -M section:$(subst .,,$(suffix $@)) \ -M date:$(if $(SOURCE_DATE_EPOCH),$(shell date -I -u -d "@$(SOURCE_DATE_EPOCH)"),$(shell date -I -u)) \ -o $@ $< certspotter-0.18.0/man/certspotter-script.md000066400000000000000000000214331452451401200211340ustar00rootroot00000000000000# NAME **certspotter-script** - Certificate Transparency Log Monitor (hook script) # DESCRIPTION **certspotter-script** is *any* program that is executed by **certspotter(8)** when it needs to notify you about an event, such as detecting a certificate for a domain on your watch list. Scripts are placed in the `$CERTSPOTTER_CONFIG_DIR/hooks.d` directory (`~/.certspotter/hooks.d` by default), or specified on the command line using the `-script` argument. # ENVIRONMENT ## Event information The following environment variables are set for all types of events: `EVENT` : One of the following values, indicating the type of event: * `discovered_cert` - certspotter has discovered a certificate for a domain on your watch list. * `malformed_cert` - certspotter can't determine if a certificate matches your watch list because the certificate or the log entry is malformed. * `error` - a problem is preventing certspotter from monitoring all logs. Additional event types may be defined in the future, so your script should be able to handle unknown values. `SUMMARY` : A short human-readable string describing the event. This is the same string used in the subject line of emails sent by certspotter. ## Discovered certificate information The following environment variables are set for `discovered_cert` events: `WATCH_ITEM` : The item from your watch list which matches this certificate. (If more than one item matches, the first one is used.) `LOG_URI` : The URI of the log containing the certificate. `ENTRY_INDEX` : The index of the log entry containing the certificate. `TBS_SHA256` : The hex-encoded SHA-256 digest of the TBSCertificate, as defined in RFC 6962 Section 3.2. Certificates and their corresponding precertificates have the same `TBS_SHA256` value. `CERT_SHA256` : The hex-encoded SHA-256 digest (sometimes called fingerprint) of the certificate. The digest is computed over the ASN.1 DER encoding. `PUBKEY_SHA256` : The hex-encoded SHA-256 digest of the certificate's Subject Public Key Info. `CERT_FILENAME` : Path to a file containing the PEM-encoded certificate chain. Not set if `-no_save` was used. `JSON_FILENAME` : Path to a JSON file containing additional information about the certificate. See below for the format of the JSON file. Not set if `-no_save` was used. `TEXT_FILENAME` : Path to a text file containing information about the certificate. This file contains the same text that certspotter uses in emails. You should not attempt to parse this file because its format may change in the future. Not set if `-no_save` was used. `NOT_BEFORE`, `NOT_BEFORE_UNIXTIME`, `NOT_BEFORE_RFC3339` : The not before time of the certificate, in a human-readable format, seconds since the UNIX epoch, and RFC3339, respectively. These variables may be unset if there was a parse error, in which case `VALIDITY_PARSE_ERROR` is set. `NOT_AFTER`, `NOT_AFTER_UNIXTIME`, `NOT_AFTER_RFC3339` : The not after (expiration) time of the certificate, in a human-readable format, seconds since the UNIX epoch, and RFC3339, respectively. These variables may be unset if there was a parse error, in which case `VALIDITY_PARSE_ERROR` is set. `VALIDITY_PARSE_ERROR` : Error parsing not before and not after, if any. If this variable is set, then the `NOT_BEFORE` and `NOT_AFTER` family of variables are unset. `SUBJECT_DN` : The distinguished name of the certificate's subject. This variable may be unset if there was a parse error, in which case `SUBJECT_PARSE_ERROR` is set. `SUBJECT_PARSE_ERROR` : Error parsing the subject, if any. If this variable is set, then `SUBJECT_DN` is unset. `ISSUER_DN` : The distinguished name of the certificate's issuer. This variable may be unset if there was a parse error, in which case `ISSUER_PARSE_ERROR` is set. `ISSUER_PARSE_ERROR` : Error parsing the issuer, if any. If this variable is set, then `ISSUER_DN` is unset. `SERIAL` : The hex-encoded serial number of the certificate. Prefixed with a minus (-) sign if negative. This variable may be unset if there was a parse error, in which case `SERIAL_PARSE_ERROR` is set. `SERIAL_PARSE_ERROR` : Error parsing the serial number, if any. If this variable is set, then `SERIAL` is unset. ## Malformed certificate information The following environment variables are set for `malformed_cert` events: `LOG_URI` : The URI of the log containing the malformed certificate. `ENTRY_INDEX` : The index of the log entry containing the malformed certificate. `LEAF_HASH` : The base64-encoded Merkle hash of the leaf containing the malformed certificate. `PARSE_ERROR` : A human-readable string describing why the certificate is malformed. `ENTRY_FILENAME` : Path to a file containing the JSON log entry. The file contains a JSON object with two fields, `leaf_input` and `extra_data`, as described in RFC 6962 Section 4.6. `TEXT_FILENAME` : Path to a text file containing a description of the malformed certificate. This file contains the same text that certspotter uses in emails. ## Error information The following environment variables are set for `error` events: `TEXT_FILENAME` : Path to a text file containing a description of the error. This file contains the same text that certspotter uses in emails. # JSON FILE FORMAT Unless `-no_save` is used, certspotter saves a JSON file for every discovered certificate under `$CERTSPOTTER_STATE_DIR`, and puts the path to the file in `$JSON_FILENAME`. Your script can read the JSON file, such as with the jq(1) command, to get additional information about the certificate which isn't appropriate for environment variables. The JSON file contains an object with the following fields: `tbs_sha256` : A string containing the hex-encoded SHA-256 digest of the TBSCertificate, as defined in RFC 6962 Section 3.2. Certificates and their corresponding precertificates have the same `tbs_sha256` value. `pubkey_sha256` : A string containing the hex-encoded SHA-256 digest of the certificate's Subject Public Key Info. `dns_names` : An array of strings containing the DNS names for which the certificate is valid, taken from both the DNS subject alternative names (SANs) and the subject common name (CN). Internationalized domain names are encoded in Punycode. `ip_addresses` : An array of strings containing the IP addresses for which the certificate is valid, taken from both the IP subject alternative names (SANs) and the subject common name (CN). `not_before` : A string containing the not before time of the certificate in RFC3339 format. Null if there was an error parsing the certificate's validity. `not_after` : A string containing the not after (expiration) time of the certificate in RFC3339 format. Null if there was an error parsing the certificate's validity. Additional fields will be added in the future based on user feedback. Please open an issue at if you have a use case for another field. # EXAMPLES Example environment variables for a `discovered_cert` event: ``` CERT_FILENAME=/home/andrew/.certspotter/certs/3c/3cdc83b3932c194fcdf17aa2bf1abc34e8438b293c3d5c70693e175b38ff128a.pem CERT_SHA256=3cdc83b3932c194fcdf17aa2bf1abc34e8438b293c3d5c70693e175b38ff128a ENTRY_INDEX=6464843 EVENT=discovered_cert ISSUER_DN=C=GB, ST=Greater Manchester, L=Salford, O=Sectigo Limited, CN=Sectigo RSA Domain Validation Secure Server CA JSON_FILENAME=/usr2/andrew/.certspotter/certs/3c/3cdc83b3932c194fcdf17aa2bf1abc34e8438b293c3d5c70693e175b38ff128a.v1.json LOG_URI=https://ct.cloudflare.com/logs/nimbus2024/ NOT_AFTER='2024-01-26 03:47:26 +0000 UTC' NOT_AFTER_RFC3339=2024-01-26T03:47:26Z NOT_AFTER_UNIXTIME=1706240846 NOT_BEFORE='2023-01-31 03:47:26 +0000 UTC' NOT_BEFORE_RFC3339=2023-01-31T03:47:26Z NOT_BEFORE_UNIXTIME=1675136846 PUBKEY_SHA256=33ac1d9b9e56005ccac045eac2398b3e9dd6b3f5b66ae6260f2d478c7c0d82c8 SERIAL=c170fbf3bf27481e5c351a4db6f2dc5f SUBJECT_DN=CN=sslmate.com SUMMARY='certificate discovered for .sslmate.com' TBS_SHA256=2388ee81c6f45cffc73e68a35fa8921e839e20acc9a98e8e6dcaea07cbfbdef8 TEXT_FILENAME=/usr2/andrew/.certspotter/certs/3c/3cdc83b3932c194fcdf17aa2bf1abc34e8438b293c3d5c70693e175b38ff128a.txt WATCH_ITEM=.sslmate.com ``` Example JSON file for a discovered certificate: ``` { "dns_names": [ "sslmate.com", "www.sslmate.com" ], "ip_addresses": [], "not_after": "2024-01-26T03:47:26Z", "not_before": "2023-01-31T03:47:26Z", "pubkey_sha256": "33ac1d9b9e56005ccac045eac2398b3e9dd6b3f5b66ae6260f2d478c7c0d82c8", "tbs_sha256": "2388ee81c6f45cffc73e68a35fa8921e839e20acc9a98e8e6dcaea07cbfbdef8" } ``` # SEE ALSO certspotter(8) # COPYRIGHT Copyright (c) 2016-2023 Opsmate, Inc. # BUGS Report bugs to . certspotter-0.18.0/man/certspotter.md000066400000000000000000000211331452451401200176270ustar00rootroot00000000000000# NAME **certspotter** - Certificate Transparency Log Monitor # SYNOPSIS **certspotter** [`-start_at_end`] [`-watchlist` *FILENAME*] [`-email` *ADDRESS*] `...` # DESCRIPTION **Cert Spotter** is a Certificate Transparency log monitor from SSLMate that alerts you when a SSL/TLS certificate is issued for one of your domains. Cert Spotter is easier to use than other open source CT monitors, since it does not require a database. It's also more robust, since it uses a special certificate parser that ensures it won't miss certificates. Cert Spotter is also available as a hosted service by SSLMate, . You can use Cert Spotter to detect: * Certificates issued to attackers who have compromised your DNS and are redirecting your visitors to their malicious site. * Certificates issued to attackers who have taken over an abandoned sub-domain in order to serve malware under your name. * Certificates issued to attackers who have compromised a certificate authority and want to impersonate your site. * Certificates issued in violation of your corporate policy or outside of your centralized certificate procurement process. # OPTIONS -batch_size *NUMBER* : Maximum number of entries to request per call to get-entries. You should not generally need to change this. Defaults to 1000. -email *ADDRESS* : Email address to contact when a matching certificate is discovered, or an error occurs. You can specify this option more than once to email multiple addresses. Your system must have a working sendmail(1) command. Regardless of the `-email` option, certspotter also emails any address listed in `$CERTSPOTTER_CONFIG_DIR/email_recipients` file (`~/.certspotter/email_recipients` by default). (One address per line, blank lines are ignored.) This file is read only at startup, so you must restart certspotter if you change it. -healthcheck *INTERVAL* : Perform a health check at the given interval (default: "24h") as described below. *INTERVAL* must be a decimal number followed by "h" for hours or "m" for minutes. -logs *ADDRESS* : Filename or HTTPS URL of a v2 or v3 JSON log list containing logs to monitor. The schema for this file can be found at . Defaults to , which includes the union of active logs recognized by Chrome and Apple. certspotter periodically reloads the log list in case it has changed. -no\_save : Do not save a copy of matching certificates. -script *COMMAND* : Command to execute when a matching certificate is found or an error occurs. See certspotter-script(8) for information about the interface to scripts. Regardless of the `-script` option, certspotter also executes any executable file in the `$CERTSPOTTER_CONFIG_DIR/hooks.d` directory (`~/.certspotter/hooks.d` by default). -start\_at\_end : Start monitoring logs from the end rather than the beginning. **WARNING**: monitoring from the beginning guarantees detection of all certificates, but requires downloading hundreds of millions of certificates, which takes days. -state\_dir *PATH* : Directory for storing state. Defaults to `$CERTSPOTTER_STATE_DIR`, which is "~/.certspotter" by default. -stdout : Write matching certificates and errors to stdout. -verbose : Be verbose. -version : Print version and exit. -watchlist *PATH* : File containing DNS names to monitor, one per line. To monitor an entire domain namespace (including the domain itself and all sub-domains) prefix the domain name with a dot (e.g. ".example.com"). To monitor a single DNS name only, do not prefix the name with a dot. Defaults to `$CERTSPOTTER_CONFIG_DIR/watchlist`, which is "~/.certspotter/watchlist" by default. Specify `-` to read the watch list from stdin. certspotter reads the watch list only when starting up, so you must restart certspotter if you change it. # NOTIFICATIONS When certspotter detects a certificate matching your watchlist, or encounters an error that is preventing it from discovering certificates, it notifies you as follows: * Emails any address specified by the `-email` command line flag. * Emails any address listed in the `$CERTSPOTTER_CONFIG_DIR/email_recipients` file (`~/.certspotter/email_recipients` by default). (One address per line, blank lines are ignored.) This file is read only at startup, so you must restart certspotter if you change it. * Executes the script specified by the `-script` command line flag. * Executes every executable file in the `$CERTSPOTTER_CONFIG_DIR/hooks.d` directory (`~/.certspotter/hooks.d` by default). * Writes the notification to standard out if the `-stdout` flag was specified. Sending email requires a working sendmail(1) command. For details about the script interface, see certspotter-script(8). # OPERATION certspotter continuously monitors all browser-recognized Certificate Transparency logs looking for certificates which are valid for any domain on your watch list. When certspotter detects a matching certificate, it emails you, executes a script, and/or writes a report to standard out, as described above. certspotter also saves a copy of matching certificates in `$CERTSPOTTER_STATE_DIR/certs` ("~/.certspotter/certs" by default) unless you specify the `-no_save` option. When certspotter has not previously monitored a log, it can either start monitoring the log from the beginning, or seek to the end of the log and start monitoring from there. Monitoring from the beginning guarantees detection of all certificates, but requires downloading hundreds of millions of certificates, which takes days. The default behavior is to monitor from the beginning. To start monitoring new logs from the end, specify the `-start_at_end` option. If certspotter has previously monitored a log, it resumes monitoring the log from the previous position. This means that if you add a domain to your watch list, certspotter will not detect any certificates that were logged prior to the addition. To detect such certificates, you must delete `$CERTSPOTTER_STATE_DIR/logs`, which will cause certspotter to restart monitoring from the very beginning of each log (provided `-start_at_end` is not specified). This will cause certspotter to download hundreds of millions of certificates, which takes days. To find preexisting certificates, it's faster to use the Cert Spotter service , SSLMate's Certificate Transparency Search API , or a CT search engine such as . # ERROR HANDLING When certspotter encounters a problem with the local system (e.g. failure to write a file or execute a script), it prints a message to stderr and exits with a non-zero status. When certspotter encounters a problem monitoring a log, it prints a message to stderr and continues running. It will try monitoring the log again later; most log errors are transient. Every 24 hours (unless overridden by `-healthcheck`), certspotter performs the following health checks: * Ensure that the log list has been successfully retrieved at least once since the previous health check. * Ensure that every log has been successfully contacted at least once since the previous health check. * Ensure that certspotter is not falling behind monitoring any logs. If any health check fails, certspotter notifies you by email, script, and/or standard out, as described above. Health check failures should be rare, and you should take them seriously because it means certspotter might not detect all certificates. It might also be an indication of CT log misbehavior. Consult certspotter's stderr output for details, and if you need help, file an issue at . # EXIT STATUS certspotter exits 0 when it receives `SIGTERM` or `SIGINT`, and non-zero when a serious error occurs. # ENVIRONMENT `CERTSPOTTER_STATE_DIR` : Directory for storing state. Overridden by `-state_dir`. Defaults to `~/.certspotter`. `CERTSPOTTER_CONFIG_DIR` : Directory from which any configuration, such as the watch list, is read. Defaults to `~/.certspotter`. `HTTPS_PROXY` : URL of proxy server for making HTTPS requests. `http://`, `https://`, and `socks5://` URLs are supported. By default, no proxy server is used. `SENDMAIL_PATH` : Path to the sendmail binary used for sending emails. Defaults to `/usr/sbin/sendmail`. # SEE ALSO certspotter-script(8) # COPYRIGHT Copyright (c) 2016-2023 Opsmate, Inc. # BUGS Report bugs to . certspotter-0.18.0/merkletree/000077500000000000000000000000001452451401200163135ustar00rootroot00000000000000certspotter-0.18.0/merkletree/collapsed_tree.go000066400000000000000000000060641452451401200216350ustar00rootroot00000000000000// Copyright (C) 2022 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package merkletree import ( "encoding/json" "fmt" "math/bits" "slices" ) type CollapsedTree struct { nodes []Hash size uint64 } func calculateNumNodes(size uint64) int { return bits.OnesCount64(size) } func EmptyCollapsedTree() *CollapsedTree { return &CollapsedTree{nodes: []Hash{}, size: 0} } func NewCollapsedTree(nodes []Hash, size uint64) (*CollapsedTree, error) { if len(nodes) != calculateNumNodes(size) { return nil, fmt.Errorf("nodes has wrong length (should be %d, not %d)", calculateNumNodes(size), len(nodes)) } return &CollapsedTree{nodes: nodes, size: size}, nil } func CloneCollapsedTree(source *CollapsedTree) *CollapsedTree { nodes := make([]Hash, len(source.nodes)) copy(nodes, source.nodes) return &CollapsedTree{nodes: nodes, size: source.size} } func (tree CollapsedTree) Equal(other CollapsedTree) bool { return tree.size == other.size && slices.Equal(tree.nodes, other.nodes) } func (tree *CollapsedTree) Add(hash Hash) { tree.nodes = append(tree.nodes, hash) tree.size++ tree.collapse() } func (tree *CollapsedTree) Append(other *CollapsedTree) error { maxSize := uint64(1) << bits.TrailingZeros64(tree.size) if other.size > maxSize { return fmt.Errorf("tree of size %d is too large to append to a tree of size %d (maximum size is %d)", other.size, tree.size, maxSize) } tree.nodes = append(tree.nodes, other.nodes...) tree.size += other.size tree.collapse() return nil } func (tree *CollapsedTree) collapse() { numNodes := calculateNumNodes(tree.size) for len(tree.nodes) > numNodes { left, right := tree.nodes[len(tree.nodes)-2], tree.nodes[len(tree.nodes)-1] tree.nodes = tree.nodes[:len(tree.nodes)-2] tree.nodes = append(tree.nodes, HashChildren(left, right)) } } func (tree *CollapsedTree) CalculateRoot() Hash { if len(tree.nodes) == 0 { return HashNothing() } i := len(tree.nodes) - 1 hash := tree.nodes[i] for i > 0 { i -= 1 hash = HashChildren(tree.nodes[i], hash) } return hash } func (tree *CollapsedTree) Size() uint64 { return tree.size } func (tree CollapsedTree) MarshalJSON() ([]byte, error) { return json.Marshal(map[string]interface{}{ "nodes": tree.nodes, "size": tree.size, }) } func (tree *CollapsedTree) UnmarshalJSON(b []byte) error { var rawTree struct { Nodes []Hash `json:"nodes"` Size uint64 `json:"size"` } if err := json.Unmarshal(b, &rawTree); err != nil { return fmt.Errorf("error unmarshalling Collapsed Merkle Tree: %w", err) } if len(rawTree.Nodes) != calculateNumNodes(rawTree.Size) { return fmt.Errorf("error unmarshalling Collapsed Merkle Tree: nodes has wrong length (should be %d, not %d)", calculateNumNodes(rawTree.Size), len(rawTree.Nodes)) } tree.size = rawTree.Size tree.nodes = rawTree.Nodes return nil } certspotter-0.18.0/merkletree/hash.go000066400000000000000000000030511452451401200175640ustar00rootroot00000000000000// Copyright (C) 2022 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package merkletree import ( "crypto/sha256" "encoding/base64" "encoding/json" "fmt" ) const HashLen = 32 type Hash [HashLen]byte func (h Hash) Base64String() string { return base64.StdEncoding.EncodeToString(h[:]) } func (h Hash) MarshalJSON() ([]byte, error) { return json.Marshal(h[:]) } func (h Hash) MarshalBinary() ([]byte, error) { return h[:], nil } func (h *Hash) UnmarshalJSON(b []byte) error { var hashBytes []byte if err := json.Unmarshal(b, &hashBytes); err != nil { return err } return h.UnmarshalBinary(hashBytes) } func (h *Hash) UnmarshalBinary(hashBytes []byte) error { if len(hashBytes) != HashLen { return fmt.Errorf("Merkle Tree hash has wrong length (should be %d bytes long, not %d)", HashLen, len(hashBytes)) } copy(h[:], hashBytes) return nil } func HashNothing() Hash { return sha256.Sum256(nil) } func HashLeaf(leafBytes []byte) Hash { var hash Hash hasher := sha256.New() hasher.Write([]byte{0x00}) hasher.Write(leafBytes) hasher.Sum(hash[:0]) return hash } func HashChildren(left Hash, right Hash) Hash { var hash Hash hasher := sha256.New() hasher.Write([]byte{0x01}) hasher.Write(left[:]) hasher.Write(right[:]) hasher.Sum(hash[:0]) return hash } certspotter-0.18.0/monitor/000077500000000000000000000000001452451401200156435ustar00rootroot00000000000000certspotter-0.18.0/monitor/config.go000066400000000000000000000013301452451401200174340ustar00rootroot00000000000000// Copyright (C) 2023 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package monitor import ( "time" ) type Config struct { LogListSource string StateDir string StartAtEnd bool WatchList WatchList Verbose bool SaveCerts bool Script string ScriptDir string Email []string Stdout bool HealthCheckInterval time.Duration } certspotter-0.18.0/monitor/daemon.go000066400000000000000000000114551452451401200174430ustar00rootroot00000000000000// Copyright (C) 2023 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package monitor import ( "context" "errors" "fmt" "golang.org/x/sync/errgroup" "log" insecurerand "math/rand" "path/filepath" "software.sslmate.com/src/certspotter/loglist" "time" ) const ( reloadLogListIntervalMin = 30 * time.Minute reloadLogListIntervalMax = 90 * time.Minute ) func randomDuration(min, max time.Duration) time.Duration { return min + time.Duration(insecurerand.Int63n(int64(max-min+1))) } func reloadLogListInterval() time.Duration { return randomDuration(reloadLogListIntervalMin, reloadLogListIntervalMax) } type task struct { log *loglist.Log stop context.CancelFunc } type daemon struct { config *Config taskgroup *errgroup.Group tasks map[LogID]task logsLoadedAt time.Time logListToken *loglist.ModificationToken logListError string logListErrorAt time.Time } func (daemon *daemon) healthCheck(ctx context.Context) error { if time.Since(daemon.logsLoadedAt) >= daemon.config.HealthCheckInterval { textPath := filepath.Join(daemon.config.StateDir, "healthchecks", healthCheckFilename()) event := &staleLogListEvent{ Source: daemon.config.LogListSource, LastSuccess: daemon.logsLoadedAt, LastError: daemon.logListError, LastErrorTime: daemon.logListErrorAt, TextPath: textPath, } if err := event.save(); err != nil { return fmt.Errorf("error saving stale log list event: %w", err) } if err := notify(ctx, daemon.config, event); err != nil { return fmt.Errorf("error notifying about stale log list: %w", err) } } for _, task := range daemon.tasks { if err := healthCheckLog(ctx, daemon.config, task.log); err != nil { return fmt.Errorf("error checking health of log %q: %w", task.log.URL, err) } } return nil } func (daemon *daemon) startTask(ctx context.Context, ctlog *loglist.Log) task { ctx, cancel := context.WithCancel(ctx) daemon.taskgroup.Go(func() error { defer cancel() err := monitorLogContinously(ctx, daemon.config, ctlog) if daemon.config.Verbose { log.Printf("task for log %s stopped with error %s", ctlog.URL, err) } if ctx.Err() == context.Canceled && errors.Is(err, context.Canceled) { return nil } else { return fmt.Errorf("error while monitoring %s: %w", ctlog.URL, err) } }) return task{log: ctlog, stop: cancel} } func (daemon *daemon) loadLogList(ctx context.Context) error { newLogList, newToken, err := getLogList(ctx, daemon.config.LogListSource, daemon.logListToken) if errors.Is(err, loglist.ErrNotModified) { return nil } else if err != nil { return err } if daemon.config.Verbose { log.Printf("fetched %d logs from %q", len(newLogList), daemon.config.LogListSource) } for logID, task := range daemon.tasks { if _, exists := newLogList[logID]; exists { continue } if daemon.config.Verbose { log.Printf("stopping task for log %s", logID.Base64String()) } task.stop() delete(daemon.tasks, logID) } for logID, ctlog := range newLogList { if _, isRunning := daemon.tasks[logID]; isRunning { continue } if daemon.config.Verbose { log.Printf("starting task for log %s (%s)", logID.Base64String(), ctlog.URL) } daemon.tasks[logID] = daemon.startTask(ctx, ctlog) } daemon.logsLoadedAt = time.Now() daemon.logListToken = newToken return nil } func (daemon *daemon) run(ctx context.Context) error { if err := prepareStateDir(daemon.config.StateDir); err != nil { return fmt.Errorf("error preparing state directory: %w", err) } if err := daemon.loadLogList(ctx); err != nil { return fmt.Errorf("error loading log list: %w", err) } reloadLogListTicker := time.NewTicker(reloadLogListInterval()) defer reloadLogListTicker.Stop() healthCheckTicker := time.NewTicker(daemon.config.HealthCheckInterval) defer healthCheckTicker.Stop() for ctx.Err() == nil { select { case <-ctx.Done(): case <-reloadLogListTicker.C: if err := daemon.loadLogList(ctx); err != nil { daemon.logListError = err.Error() daemon.logListErrorAt = time.Now() recordError(fmt.Errorf("error reloading log list (will try again later): %w", err)) } reloadLogListTicker.Reset(reloadLogListInterval()) case <-healthCheckTicker.C: if err := daemon.healthCheck(ctx); err != nil { return err } } } return ctx.Err() } func Run(ctx context.Context, config *Config) error { group, ctx := errgroup.WithContext(ctx) daemon := &daemon{ config: config, taskgroup: group, tasks: make(map[LogID]task), } group.Go(func() error { return daemon.run(ctx) }) return group.Wait() } certspotter-0.18.0/monitor/discoveredcert.go000066400000000000000000000135121452451401200212010ustar00rootroot00000000000000// Copyright (C) 2023 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package monitor import ( "bytes" "encoding/hex" "encoding/pem" "fmt" "strings" "time" "software.sslmate.com/src/certspotter" "software.sslmate.com/src/certspotter/ct" ) type discoveredCert struct { WatchItem WatchItem LogEntry *logEntry Info *certspotter.CertInfo Chain []ct.ASN1Cert // first entry is the leaf certificate or precertificate TBSSHA256 [32]byte // computed over Info.TBS.Raw SHA256 [32]byte // computed over Chain[0] PubkeySHA256 [32]byte // computed over Info.TBS.PublicKey.FullBytes Identifiers *certspotter.Identifiers CertPath string // empty if not saved on the filesystem JSONPath string // empty if not saved on the filesystem TextPath string // empty if not saved on the filesystem } func (cert *discoveredCert) pemChain() []byte { var buffer bytes.Buffer for _, certBytes := range cert.Chain { if err := pem.Encode(&buffer, &pem.Block{ Type: "CERTIFICATE", Bytes: certBytes, }); err != nil { panic(fmt.Errorf("encoding certificate as PEM failed unexpectedly: %w", err)) } } return buffer.Bytes() } func (cert *discoveredCert) json() any { object := map[string]any{ "tbs_sha256": hex.EncodeToString(cert.TBSSHA256[:]), "pubkey_sha256": hex.EncodeToString(cert.PubkeySHA256[:]), "dns_names": cert.Identifiers.DNSNames, "ip_addresses": cert.Identifiers.IPAddrs, } if cert.Info.ValidityParseError == nil { object["not_before"] = cert.Info.Validity.NotBefore object["not_after"] = cert.Info.Validity.NotAfter } else { object["not_before"] = nil object["not_after"] = nil } return object } func (cert *discoveredCert) save() error { if err := writeFile(cert.CertPath, cert.pemChain(), 0666); err != nil { return err } if err := writeJSONFile(cert.JSONPath, cert.json(), 0666); err != nil { return err } if err := writeTextFile(cert.TextPath, cert.Text(), 0666); err != nil { return err } return nil } func (cert *discoveredCert) Environ() []string { env := []string{ "EVENT=discovered_cert", "SUMMARY=" + cert.Summary(), "CERT_PARSEABLE=yes", // backwards compat with pre-0.15.0; not documented "LOG_URI=" + cert.LogEntry.Log.URL, "ENTRY_INDEX=" + fmt.Sprint(cert.LogEntry.Index), "WATCH_ITEM=" + cert.WatchItem.String(), "TBS_SHA256=" + hex.EncodeToString(cert.TBSSHA256[:]), "CERT_SHA256=" + hex.EncodeToString(cert.SHA256[:]), "FINGERPRINT=" + hex.EncodeToString(cert.SHA256[:]), // backwards compat with pre-0.15.0; not documented "PUBKEY_SHA256=" + hex.EncodeToString(cert.PubkeySHA256[:]), "PUBKEY_HASH=" + hex.EncodeToString(cert.PubkeySHA256[:]), // backwards compat with pre-0.15.0; not documented "CERT_FILENAME=" + cert.CertPath, "JSON_FILENAME=" + cert.JSONPath, "TEXT_FILENAME=" + cert.TextPath, } if cert.Info.ValidityParseError == nil { env = append(env, "NOT_BEFORE="+cert.Info.Validity.NotBefore.String()) env = append(env, "NOT_BEFORE_UNIXTIME="+fmt.Sprint(cert.Info.Validity.NotBefore.Unix())) env = append(env, "NOT_BEFORE_RFC3339="+cert.Info.Validity.NotBefore.Format(time.RFC3339)) env = append(env, "NOT_AFTER="+cert.Info.Validity.NotAfter.String()) env = append(env, "NOT_AFTER_UNIXTIME="+fmt.Sprint(cert.Info.Validity.NotAfter.Unix())) env = append(env, "NOT_AFTER_RFC3339="+cert.Info.Validity.NotAfter.Format(time.RFC3339)) } else { env = append(env, "VALIDITY_PARSE_ERROR="+cert.Info.ValidityParseError.Error()) } if cert.Info.SubjectParseError == nil { env = append(env, "SUBJECT_DN="+cert.Info.Subject.String()) } else { env = append(env, "SUBJECT_PARSE_ERROR="+cert.Info.SubjectParseError.Error()) } if cert.Info.IssuerParseError == nil { env = append(env, "ISSUER_DN="+cert.Info.Issuer.String()) } else { env = append(env, "ISSUER_PARSE_ERROR="+cert.Info.IssuerParseError.Error()) } if cert.Info.SerialNumberParseError == nil { env = append(env, "SERIAL="+fmt.Sprintf("%x", cert.Info.SerialNumber)) } else { env = append(env, "SERIAL_PARSE_ERROR="+cert.Info.SerialNumberParseError.Error()) } return env } func (cert *discoveredCert) Text() string { // TODO-4: improve the output: include WatchItem, indicate hash algorithm used for fingerprints, ... (look at SSLMate email for inspiration) text := new(strings.Builder) writeField := func(name string, value any) { fmt.Fprintf(text, "\t%13s = %s\n", name, value) } fmt.Fprintf(text, "%x:\n", cert.SHA256) for _, dnsName := range cert.Identifiers.DNSNames { writeField("DNS Name", dnsName) } for _, ipaddr := range cert.Identifiers.IPAddrs { writeField("IP Address", ipaddr) } writeField("Pubkey", hex.EncodeToString(cert.PubkeySHA256[:])) if cert.Info.IssuerParseError == nil { writeField("Issuer", cert.Info.Issuer) } else { writeField("Issuer", fmt.Sprintf("[unable to parse: %s]", cert.Info.IssuerParseError)) } if cert.Info.ValidityParseError == nil { writeField("Not Before", cert.Info.Validity.NotBefore) writeField("Not After", cert.Info.Validity.NotAfter) } else { writeField("Not Before", fmt.Sprintf("[unable to parse: %s]", cert.Info.ValidityParseError)) writeField("Not After", fmt.Sprintf("[unable to parse: %s]", cert.Info.ValidityParseError)) } writeField("Log Entry", fmt.Sprintf("%d @ %s", cert.LogEntry.Index, cert.LogEntry.Log.URL)) writeField("crt.sh", "https://crt.sh/?sha256="+hex.EncodeToString(cert.SHA256[:])) if cert.CertPath != "" { writeField("Filename", cert.CertPath) } return text.String() } func (cert *discoveredCert) Summary() string { return fmt.Sprintf("Certificate Discovered for %s", cert.WatchItem) } certspotter-0.18.0/monitor/errors.go000066400000000000000000000006701452451401200175110ustar00rootroot00000000000000// Copyright (C) 2023 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package monitor import ( "log" ) func recordError(err error) { log.Print(err) } certspotter-0.18.0/monitor/fileutils.go000066400000000000000000000027101452451401200201720ustar00rootroot00000000000000// Copyright (C) 2017, 2023 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package monitor import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "os" ) func randomFileSuffix() string { var randomBytes [12]byte if _, err := rand.Read(randomBytes[:]); err != nil { panic(err) } return hex.EncodeToString(randomBytes[:]) } func writeFile(filename string, data []byte, perm os.FileMode) error { tempname := filename + ".tmp." + randomFileSuffix() if err := os.WriteFile(tempname, data, perm); err != nil { return fmt.Errorf("error writing %s: %w", filename, err) } if err := os.Rename(tempname, filename); err != nil { os.Remove(tempname) return fmt.Errorf("error writing %s: %w", filename, err) } return nil } func writeTextFile(filename string, fileText string, perm os.FileMode) error { return writeFile(filename, []byte(fileText), perm) } func writeJSONFile(filename string, data any, perm os.FileMode) error { fileBytes, err := json.Marshal(data) if err != nil { return err } fileBytes = append(fileBytes, '\n') return writeFile(filename, fileBytes, perm) } func fileExists(filename string) bool { _, err := os.Lstat(filename) return err == nil } certspotter-0.18.0/monitor/healthcheck.go000066400000000000000000000127061452451401200204430ustar00rootroot00000000000000// Copyright (C) 2023 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package monitor import ( "context" "errors" "fmt" "io/fs" "path/filepath" "strings" "time" "software.sslmate.com/src/certspotter/ct" "software.sslmate.com/src/certspotter/loglist" ) func healthCheckFilename() string { return time.Now().UTC().Format(time.RFC3339) + ".txt" } func healthCheckLog(ctx context.Context, config *Config, ctlog *loglist.Log) error { var ( stateDirPath = filepath.Join(config.StateDir, "logs", ctlog.LogID.Base64URLString()) stateFilePath = filepath.Join(stateDirPath, "state.json") sthsDirPath = filepath.Join(stateDirPath, "unverified_sths") textPath = filepath.Join(stateDirPath, "healthchecks", healthCheckFilename()) ) state, err := loadStateFile(stateFilePath) if errors.Is(err, fs.ErrNotExist) { return nil } else if err != nil { return fmt.Errorf("error loading state file: %w", err) } if time.Since(state.LastSuccess) < config.HealthCheckInterval { return nil } sths, err := loadSTHsFromDir(sthsDirPath) if err != nil { return fmt.Errorf("error loading STHs directory: %w", err) } if len(sths) == 0 { event := &staleSTHEvent{ Log: ctlog, LastSuccess: state.LastSuccess, LatestSTH: state.VerifiedSTH, TextPath: textPath, } if err := event.save(); err != nil { return fmt.Errorf("error saving stale STH event: %w", err) } if err := notify(ctx, config, event); err != nil { return fmt.Errorf("error notifying about stale STH: %w", err) } } else { event := &backlogEvent{ Log: ctlog, LatestSTH: sths[len(sths)-1], Position: state.DownloadPosition.Size(), TextPath: textPath, } if err := event.save(); err != nil { return fmt.Errorf("error saving backlog event: %w", err) } if err := notify(ctx, config, event); err != nil { return fmt.Errorf("error notifying about backlog: %w", err) } } return nil } type staleSTHEvent struct { Log *loglist.Log LastSuccess time.Time LatestSTH *ct.SignedTreeHead // may be nil TextPath string } type backlogEvent struct { Log *loglist.Log LatestSTH *ct.SignedTreeHead Position uint64 TextPath string } type staleLogListEvent struct { Source string LastSuccess time.Time LastError string LastErrorTime time.Time TextPath string } func (e *backlogEvent) Backlog() uint64 { return e.LatestSTH.TreeSize - e.Position } func (e *staleSTHEvent) Environ() []string { return []string{ "EVENT=error", "SUMMARY=" + e.Summary(), "TEXT_FILENAME=" + e.TextPath, } } func (e *backlogEvent) Environ() []string { return []string{ "EVENT=error", "SUMMARY=" + e.Summary(), "TEXT_FILENAME=" + e.TextPath, } } func (e *staleLogListEvent) Environ() []string { return []string{ "EVENT=error", "SUMMARY=" + e.Summary(), "TEXT_FILENAME=" + e.TextPath, } } func (e *staleSTHEvent) Summary() string { return fmt.Sprintf("Unable to contact %s since %s", e.Log.URL, e.LastSuccess) } func (e *backlogEvent) Summary() string { return fmt.Sprintf("Backlog of size %d from %s", e.Backlog(), e.Log.URL) } func (e *staleLogListEvent) Summary() string { return fmt.Sprintf("Unable to retrieve log list since %s", e.LastSuccess) } func (e *staleSTHEvent) Text() string { text := new(strings.Builder) fmt.Fprintf(text, "certspotter has been unable to contact %s since %s. Consequentially, certspotter may fail to notify you about certificates in this log.\n", e.Log.URL, e.LastSuccess) fmt.Fprintf(text, "\n") fmt.Fprintf(text, "For details, see certspotter's stderr output.\n") fmt.Fprintf(text, "\n") if e.LatestSTH != nil { fmt.Fprintf(text, "Latest known log size = %d (as of %s)\n", e.LatestSTH.TreeSize, e.LatestSTH.TimestampTime()) } else { fmt.Fprintf(text, "Latest known log size = none\n") } return text.String() } func (e *backlogEvent) Text() string { text := new(strings.Builder) fmt.Fprintf(text, "certspotter has been unable to download entries from %s in a timely manner. Consequentially, certspotter may be slow to notify you about certificates in this log.\n", e.Log.URL) fmt.Fprintf(text, "\n") fmt.Fprintf(text, "For more details, see certspotter's stderr output.\n") fmt.Fprintf(text, "\n") fmt.Fprintf(text, "Current log size = %d (as of %s)\n", e.LatestSTH.TreeSize, e.LatestSTH.TimestampTime()) fmt.Fprintf(text, "Current position = %d\n", e.Position) fmt.Fprintf(text, " Backlog = %d\n", e.Backlog()) return text.String() } func (e *staleLogListEvent) Text() string { text := new(strings.Builder) fmt.Fprintf(text, "certspotter has been unable to retrieve the log list from %s since %s.\n", e.Source, e.LastSuccess) fmt.Fprintf(text, "\n") fmt.Fprintf(text, "Last error (at %s): %s\n", e.LastErrorTime, e.LastError) fmt.Fprintf(text, "\n") fmt.Fprintf(text, "Consequentially, certspotter may not be monitoring all logs, and might fail to detect certificates.\n") return text.String() } func (e *staleSTHEvent) save() error { return writeTextFile(e.TextPath, e.Text(), 0666) } func (e *backlogEvent) save() error { return writeTextFile(e.TextPath, e.Text(), 0666) } func (e *staleLogListEvent) save() error { return writeTextFile(e.TextPath, e.Text(), 0666) } // TODO-3: make the errors more actionable certspotter-0.18.0/monitor/loglist.go000066400000000000000000000022631452451401200176520ustar00rootroot00000000000000// Copyright (C) 2023 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package monitor import ( "context" "fmt" "software.sslmate.com/src/certspotter/ct" "software.sslmate.com/src/certspotter/loglist" ) type LogID = ct.SHA256Hash func getLogList(ctx context.Context, source string, token *loglist.ModificationToken) (map[LogID]*loglist.Log, *loglist.ModificationToken, error) { list, newToken, err := loglist.LoadIfModified(ctx, source, token) if err != nil { return nil, nil, err } logs := make(map[LogID]*loglist.Log) for operatorIndex := range list.Operators { for logIndex := range list.Operators[operatorIndex].Logs { log := &list.Operators[operatorIndex].Logs[logIndex] if _, exists := logs[log.LogID]; exists { return nil, nil, fmt.Errorf("log list contains more than one entry with ID %s", log.LogID.Base64String()) } logs[log.LogID] = log } } return logs, newToken, nil } certspotter-0.18.0/monitor/mailutils.go000066400000000000000000000015021452451401200201730ustar00rootroot00000000000000// Copyright (C) 2023 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package monitor import ( "crypto/rand" "encoding/hex" "os" ) const mailDateFormat = "Mon, 2 Jan 2006 15:04:05 -0700" func generateMessageID() string { var randomBytes [16]byte if _, err := rand.Read(randomBytes[:]); err != nil { panic(err) } return hex.EncodeToString(randomBytes[:]) + "@selfhosted.certspotter.org" } func sendmailPath() string { if envVar := os.Getenv("SENDMAIL_PATH"); envVar != "" { return envVar } else { return "/usr/sbin/sendmail" } } certspotter-0.18.0/monitor/malformed.go000066400000000000000000000043301452451401200201400ustar00rootroot00000000000000// Copyright (C) 2023 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package monitor import ( "fmt" "strings" ) type malformedLogEntry struct { Entry *logEntry Error string EntryPath string TextPath string } func (malformed *malformedLogEntry) entryJSON() any { return struct { LeafInput []byte `json:"leaf_input"` ExtraData []byte `json:"extra_data"` }{ LeafInput: malformed.Entry.LeafInput, ExtraData: malformed.Entry.ExtraData, } } func (malformed *malformedLogEntry) save() error { if err := writeJSONFile(malformed.EntryPath, malformed.entryJSON(), 0666); err != nil { return err } if err := writeTextFile(malformed.TextPath, malformed.Text(), 0666); err != nil { return err } return nil } func (malformed *malformedLogEntry) Environ() []string { return []string{ "EVENT=malformed_cert", "SUMMARY=" + malformed.Summary(), "LOG_URI=" + malformed.Entry.Log.URL, "ENTRY_INDEX=" + fmt.Sprint(malformed.Entry.Index), "LEAF_HASH=" + malformed.Entry.LeafHash.Base64String(), "PARSE_ERROR=" + malformed.Error, "ENTRY_FILENAME=" + malformed.EntryPath, "TEXT_FILENAME=" + malformed.TextPath, "CERT_PARSEABLE=no", // backwards compat with pre-0.15.0; not documented } } func (malformed *malformedLogEntry) Text() string { text := new(strings.Builder) writeField := func(name string, value any) { fmt.Fprintf(text, "\t%13s = %s\n", name, value) } fmt.Fprintf(text, "Unable to determine if log entry matches your watchlist. Please file a bug report at https://github.com/SSLMate/certspotter/issues/new with the following details:\n") writeField("Log Entry", fmt.Sprintf("%d @ %s", malformed.Entry.Index, malformed.Entry.Log.URL)) writeField("Leaf Hash", malformed.Entry.LeafHash.Base64String()) writeField("Error", malformed.Error) return text.String() } func (malformed *malformedLogEntry) Summary() string { return fmt.Sprintf("Unable to Parse Entry %d in %s", malformed.Entry.Index, malformed.Entry.Log.URL) } certspotter-0.18.0/monitor/monitor.go000066400000000000000000000213521452451401200176640ustar00rootroot00000000000000// Copyright (C) 2023 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package monitor import ( "context" "crypto/x509" "errors" "fmt" "io/fs" "log" "os" "path/filepath" "strings" "time" "software.sslmate.com/src/certspotter/ct" "software.sslmate.com/src/certspotter/ct/client" "software.sslmate.com/src/certspotter/loglist" "software.sslmate.com/src/certspotter/merkletree" ) const ( maxGetEntriesSize = 1000 monitorLogInterval = 5 * time.Minute ) func isFatalLogError(err error) bool { return errors.Is(err, context.Canceled) } func newLogClient(ctlog *loglist.Log) (*client.LogClient, error) { logKey, err := x509.ParsePKIXPublicKey(ctlog.Key) if err != nil { return nil, fmt.Errorf("error parsing log key: %w", err) } verifier, err := ct.NewSignatureVerifier(logKey) if err != nil { return nil, fmt.Errorf("error with log key: %w", err) } return client.NewWithVerifier(strings.TrimRight(ctlog.URL, "/"), verifier), nil } func monitorLogContinously(ctx context.Context, config *Config, ctlog *loglist.Log) error { logClient, err := newLogClient(ctlog) if err != nil { return err } ticker := time.NewTicker(monitorLogInterval) defer ticker.Stop() for ctx.Err() == nil { if err := monitorLog(ctx, config, ctlog, logClient); err != nil { return err } select { case <-ctx.Done(): case <-ticker.C: } } return ctx.Err() } func monitorLog(ctx context.Context, config *Config, ctlog *loglist.Log, logClient *client.LogClient) (returnedErr error) { ctx, cancel := context.WithCancel(ctx) defer cancel() var ( stateDirPath = filepath.Join(config.StateDir, "logs", ctlog.LogID.Base64URLString()) stateFilePath = filepath.Join(stateDirPath, "state.json") sthsDirPath = filepath.Join(stateDirPath, "unverified_sths") malformedDirPath = filepath.Join(stateDirPath, "malformed_entries") healthchecksDirPath = filepath.Join(stateDirPath, "healthchecks") ) for _, dirPath := range []string{stateDirPath, sthsDirPath, malformedDirPath, healthchecksDirPath} { if err := os.Mkdir(dirPath, 0777); err != nil && !errors.Is(err, fs.ErrExist) { return fmt.Errorf("error creating state directory: %w", err) } } startTime := time.Now() latestSTH, err := logClient.GetSTH(ctx) if isFatalLogError(err) { return err } else if err != nil { recordError(fmt.Errorf("error fetching latest STH for %s: %w", ctlog.URL, err)) return nil } latestSTH.LogID = ctlog.LogID if err := storeSTHInDir(sthsDirPath, latestSTH); err != nil { return fmt.Errorf("error storing latest STH: %w", err) } state, err := loadStateFile(stateFilePath) if errors.Is(err, fs.ErrNotExist) { if config.StartAtEnd { tree, err := reconstructTree(ctx, logClient, latestSTH) if isFatalLogError(err) { return err } else if err != nil { recordError(fmt.Errorf("error reconstructing tree of size %d for %s: %w", latestSTH.TreeSize, ctlog.URL, err)) return nil } state = &stateFile{ DownloadPosition: tree, VerifiedPosition: tree, VerifiedSTH: latestSTH, LastSuccess: startTime.UTC(), } } else { state = &stateFile{ DownloadPosition: merkletree.EmptyCollapsedTree(), VerifiedPosition: merkletree.EmptyCollapsedTree(), VerifiedSTH: nil, LastSuccess: startTime.UTC(), } } if config.Verbose { log.Printf("brand new log %s (starting from %d)", ctlog.URL, state.DownloadPosition.Size()) } if err := state.store(stateFilePath); err != nil { return fmt.Errorf("error storing state file: %w", err) } } else if err != nil { return fmt.Errorf("error loading state file: %w", err) } sths, err := loadSTHsFromDir(sthsDirPath) if err != nil { return fmt.Errorf("error loading STHs directory: %w", err) } for len(sths) > 0 && sths[0].TreeSize <= state.DownloadPosition.Size() { // TODO-4: audit sths[0] against state.VerifiedSTH if err := removeSTHFromDir(sthsDirPath, sths[0]); err != nil { return fmt.Errorf("error removing STH: %w", err) } sths = sths[1:] } defer func() { if config.Verbose { log.Printf("saving state in defer for %s", ctlog.URL) } if err := state.store(stateFilePath); err != nil && returnedErr == nil { returnedErr = fmt.Errorf("error storing state file: %w", err) } }() if len(sths) == 0 { state.LastSuccess = startTime.UTC() return nil } var ( downloadBegin = state.DownloadPosition.Size() downloadEnd = sths[len(sths)-1].TreeSize entries = make(chan client.GetEntriesItem, maxGetEntriesSize) downloadErr error ) if config.Verbose { log.Printf("downloading entries from %s in range [%d, %d)", ctlog.URL, downloadBegin, downloadEnd) } go func() { defer close(entries) downloadErr = downloadEntries(ctx, logClient, entries, downloadBegin, downloadEnd) }() for rawEntry := range entries { entry := &logEntry{ Log: ctlog, Index: state.DownloadPosition.Size(), LeafInput: rawEntry.LeafInput, ExtraData: rawEntry.ExtraData, LeafHash: merkletree.HashLeaf(rawEntry.LeafInput), } if err := processLogEntry(ctx, config, entry); err != nil { return fmt.Errorf("error processing entry %d: %w", entry.Index, err) } state.DownloadPosition.Add(entry.LeafHash) rootHash := state.DownloadPosition.CalculateRoot() shouldSaveState := state.DownloadPosition.Size()%10000 == 0 for len(sths) > 0 && state.DownloadPosition.Size() == sths[0].TreeSize { if merkletree.Hash(sths[0].SHA256RootHash) != rootHash { recordError(fmt.Errorf("error verifying %s at tree size %d: the STH root hash (%x) does not match the entries returned by the log (%x)", ctlog.URL, sths[0].TreeSize, sths[0].SHA256RootHash, rootHash)) state.DownloadPosition = state.VerifiedPosition if err := state.store(stateFilePath); err != nil { return fmt.Errorf("error storing state file: %w", err) } return nil } state.VerifiedPosition = state.DownloadPosition state.VerifiedSTH = sths[0] shouldSaveState = true if err := removeSTHFromDir(sthsDirPath, sths[0]); err != nil { return fmt.Errorf("error removing verified STH: %w", err) } sths = sths[1:] } if shouldSaveState { if err := state.store(stateFilePath); err != nil { return fmt.Errorf("error storing state file: %w", err) } } } if isFatalLogError(downloadErr) { return downloadErr } else if downloadErr != nil { recordError(fmt.Errorf("error downloading entries from %s: %w", ctlog.URL, downloadErr)) return nil } if config.Verbose { log.Printf("finished downloading entries from %s", ctlog.URL) } state.LastSuccess = startTime.UTC() return nil } func downloadEntries(ctx context.Context, logClient *client.LogClient, entriesChan chan<- client.GetEntriesItem, begin, end uint64) error { for begin < end && ctx.Err() == nil { size := end - begin if size > maxGetEntriesSize { size = maxGetEntriesSize } entries, err := logClient.GetRawEntries(ctx, begin, begin+size-1) if err != nil { return err } for _, entry := range entries { if ctx.Err() != nil { return ctx.Err() } select { case <-ctx.Done(): return ctx.Err() case entriesChan <- entry: } } begin += uint64(len(entries)) } return ctx.Err() } func reconstructTree(ctx context.Context, logClient *client.LogClient, sth *ct.SignedTreeHead) (*merkletree.CollapsedTree, error) { if sth.TreeSize == 0 { return merkletree.EmptyCollapsedTree(), nil } entries, err := logClient.GetRawEntries(ctx, sth.TreeSize-1, sth.TreeSize-1) if err != nil { return nil, err } leafHash := merkletree.HashLeaf(entries[0].LeafInput) var tree *merkletree.CollapsedTree if sth.TreeSize > 1 { // XXX: if leafHash is in the tree in more than one place, this might not return the proof that we need ... get-entry-and-proof avoids this problem but not all logs support it auditPath, _, err := logClient.GetAuditProof(ctx, leafHash[:], sth.TreeSize) if err != nil { return nil, err } hashes := make([]merkletree.Hash, len(auditPath)) for i := range hashes { copy(hashes[i][:], auditPath[len(auditPath)-i-1]) } tree, err = merkletree.NewCollapsedTree(hashes, sth.TreeSize-1) if err != nil { return nil, fmt.Errorf("log returned invalid audit proof for %x to %d: %w", leafHash, sth.TreeSize, err) } } else { tree = merkletree.EmptyCollapsedTree() } tree.Add(leafHash) rootHash := tree.CalculateRoot() if rootHash != merkletree.Hash(sth.SHA256RootHash) { return nil, fmt.Errorf("calculated root hash (%x) does not match signed tree head (%x) at size %d", rootHash, sth.SHA256RootHash, sth.TreeSize) } return tree, nil } certspotter-0.18.0/monitor/notify.go000066400000000000000000000077641452451401200175200ustar00rootroot00000000000000// Copyright (C) 2023 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package monitor import ( "bytes" "context" "errors" "fmt" "io/fs" "os" "os/exec" "path/filepath" "strings" "sync" "time" ) var stdoutMu sync.Mutex type notification interface { Environ() []string Summary() string Text() string } func notify(ctx context.Context, config *Config, notif notification) error { if config.Stdout { writeToStdout(notif) } if len(config.Email) > 0 { if err := sendEmail(ctx, config.Email, notif); err != nil { return err } } if config.Script != "" { if err := execScript(ctx, config.Script, notif); err != nil { return err } } if config.ScriptDir != "" { if err := execScriptDir(ctx, config.ScriptDir, notif); err != nil { return err } } return nil } func writeToStdout(notif notification) { stdoutMu.Lock() defer stdoutMu.Unlock() os.Stdout.WriteString(notif.Text() + "\n") } func sendEmail(ctx context.Context, to []string, notif notification) error { stdin := new(bytes.Buffer) stderr := new(bytes.Buffer) fmt.Fprintf(stdin, "To: %s\n", strings.Join(to, ", ")) fmt.Fprintf(stdin, "Subject: [certspotter] %s\n", notif.Summary()) fmt.Fprintf(stdin, "Date: %s\n", time.Now().Format(mailDateFormat)) fmt.Fprintf(stdin, "Message-ID: <%s>\n", generateMessageID()) fmt.Fprintf(stdin, "Mime-Version: 1.0\n") fmt.Fprintf(stdin, "Content-Type: text/plain; charset=US-ASCII\n") fmt.Fprintf(stdin, "X-Mailer: certspotter\n") fmt.Fprintf(stdin, "\n") fmt.Fprint(stdin, notif.Text()) args := []string{"-i", "--"} args = append(args, to...) sendmail := exec.CommandContext(ctx, sendmailPath(), args...) sendmail.Stdin = stdin sendmail.Stderr = stderr if err := sendmail.Run(); err == nil { return nil } else if ctx.Err() != nil { return ctx.Err() } else if exitErr, isExitError := err.(*exec.ExitError); isExitError && exitErr.Exited() { return fmt.Errorf("error sending email to %v: sendmail failed with exit code %d and error %q", to, exitErr.ExitCode(), strings.TrimSpace(stderr.String())) } else { return fmt.Errorf("error sending email to %v: %w", to, err) } } func execScript(ctx context.Context, scriptName string, notif notification) error { stderr := new(bytes.Buffer) cmd := exec.CommandContext(ctx, scriptName) cmd.Env = os.Environ() cmd.Env = append(cmd.Env, notif.Environ()...) cmd.Stderr = stderr if err := cmd.Run(); err == nil { return nil } else if ctx.Err() != nil { return ctx.Err() } else if exitErr, isExitError := err.(*exec.ExitError); isExitError && exitErr.Exited() { return fmt.Errorf("script %q exited with code %d and error %q", scriptName, exitErr.ExitCode(), strings.TrimSpace(stderr.String())) } else if isExitError { return fmt.Errorf("script %q terminated by signal with error %q", scriptName, strings.TrimSpace(stderr.String())) } else { return fmt.Errorf("error executing script: %w", err) } } func execScriptDir(ctx context.Context, dirPath string, notif notification) error { dirents, err := os.ReadDir(dirPath) if errors.Is(err, fs.ErrNotExist) { return nil } else if err != nil { return fmt.Errorf("error executing scripts in directory %q: %w", dirPath, err) } for _, dirent := range dirents { if strings.HasPrefix(dirent.Name(), ".") { continue } scriptPath := filepath.Join(dirPath, dirent.Name()) info, err := os.Stat(scriptPath) if errors.Is(err, fs.ErrNotExist) { continue } else if err != nil { return fmt.Errorf("error executing %q in directory %q: %w", dirent.Name(), dirPath, err) } else if info.Mode().IsRegular() && isExecutable(info.Mode()) { if err := execScript(ctx, scriptPath, notif); err != nil { return err } } } return nil } func isExecutable(mode os.FileMode) bool { return mode&0111 != 0 } certspotter-0.18.0/monitor/process.go000066400000000000000000000143631452451401200176570ustar00rootroot00000000000000// Copyright (C) 2023 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package monitor import ( "bytes" "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "io/fs" "os" "path/filepath" "software.sslmate.com/src/certspotter" "software.sslmate.com/src/certspotter/ct" "software.sslmate.com/src/certspotter/loglist" "software.sslmate.com/src/certspotter/merkletree" ) type logEntry struct { Log *loglist.Log Index uint64 LeafInput []byte ExtraData []byte LeafHash merkletree.Hash } func processLogEntry(ctx context.Context, config *Config, entry *logEntry) error { leaf, err := ct.ReadMerkleTreeLeaf(bytes.NewReader(entry.LeafInput)) if err != nil { return processMalformedLogEntry(ctx, config, entry, fmt.Errorf("error parsing Merkle Tree Leaf: %w", err)) } switch leaf.TimestampedEntry.EntryType { case ct.X509LogEntryType: return processX509LogEntry(ctx, config, entry, leaf.TimestampedEntry.X509Entry) case ct.PrecertLogEntryType: return processPrecertLogEntry(ctx, config, entry, leaf.TimestampedEntry.PrecertEntry) default: return processMalformedLogEntry(ctx, config, entry, fmt.Errorf("unknown log entry type %d", leaf.TimestampedEntry.EntryType)) } } func processX509LogEntry(ctx context.Context, config *Config, entry *logEntry, cert ct.ASN1Cert) error { certInfo, err := certspotter.MakeCertInfoFromRawCert(cert) if err != nil { return processMalformedLogEntry(ctx, config, entry, fmt.Errorf("error parsing X.509 certificate: %w", err)) } chain, err := ct.UnmarshalX509ChainArray(entry.ExtraData) if err != nil { return processMalformedLogEntry(ctx, config, entry, fmt.Errorf("error parsing extra_data for X.509 entry: %w", err)) } chain = append([]ct.ASN1Cert{cert}, chain...) if precertTBS, err := certspotter.ReconstructPrecertTBS(certInfo.TBS); err == nil { certInfo.TBS = precertTBS } else { return processMalformedLogEntry(ctx, config, entry, fmt.Errorf("error reconstructing precertificate TBSCertificate: %w", err)) } return processCertificate(ctx, config, entry, certInfo, chain) } func processPrecertLogEntry(ctx context.Context, config *Config, entry *logEntry, precert ct.PreCert) error { certInfo, err := certspotter.MakeCertInfoFromRawTBS(precert.TBSCertificate) if err != nil { return processMalformedLogEntry(ctx, config, entry, fmt.Errorf("error parsing precert TBSCertificate: %w", err)) } chain, err := ct.UnmarshalPrecertChainArray(entry.ExtraData) if err != nil { return processMalformedLogEntry(ctx, config, entry, fmt.Errorf("error parsing extra_data for precert entry: %w", err)) } if _, err := certspotter.ValidatePrecert(chain[0], precert.TBSCertificate); err != nil { return processMalformedLogEntry(ctx, config, entry, fmt.Errorf("precertificate in extra_data does not match TBSCertificate in leaf_input: %w", err)) } return processCertificate(ctx, config, entry, certInfo, chain) } func processCertificate(ctx context.Context, config *Config, entry *logEntry, certInfo *certspotter.CertInfo, chain []ct.ASN1Cert) error { identifiers, err := certInfo.ParseIdentifiers() if err != nil { return processMalformedLogEntry(ctx, config, entry, err) } matched, watchItem := config.WatchList.Matches(identifiers) if !matched { return nil } cert := &discoveredCert{ WatchItem: watchItem, LogEntry: entry, Info: certInfo, Chain: chain, TBSSHA256: sha256.Sum256(certInfo.TBS.Raw), SHA256: sha256.Sum256(chain[0]), PubkeySHA256: sha256.Sum256(certInfo.TBS.PublicKey.FullBytes), Identifiers: identifiers, } var notifiedPath string if config.SaveCerts { hexFingerprint := hex.EncodeToString(cert.SHA256[:]) prefixPath := filepath.Join(config.StateDir, "certs", hexFingerprint[0:2]) var ( notifiedFilename = "." + hexFingerprint + ".notified" certFilename = hexFingerprint + ".pem" jsonFilename = hexFingerprint + ".v1.json" textFilename = hexFingerprint + ".txt" legacyCertFilename = hexFingerprint + ".cert.pem" legacyPrecertFilename = hexFingerprint + ".precert.pem" ) for _, filename := range []string{notifiedFilename, legacyCertFilename, legacyPrecertFilename} { if fileExists(filepath.Join(prefixPath, filename)) { return nil } } if err := os.Mkdir(prefixPath, 0777); err != nil && !errors.Is(err, fs.ErrExist) { return fmt.Errorf("error creating directory in which to save certificate %x: %w", cert.SHA256, err) } notifiedPath = filepath.Join(prefixPath, notifiedFilename) cert.CertPath = filepath.Join(prefixPath, certFilename) cert.JSONPath = filepath.Join(prefixPath, jsonFilename) cert.TextPath = filepath.Join(prefixPath, textFilename) if err := cert.save(); err != nil { return fmt.Errorf("error saving certificate %x: %w", cert.SHA256, err) } } else { // TODO-4: save cert to temporary files, and defer their unlinking } if err := notify(ctx, config, cert); err != nil { return fmt.Errorf("error notifying about discovered certificate for %s (%x): %w", cert.WatchItem, cert.SHA256, err) } if notifiedPath != "" { if err := os.WriteFile(notifiedPath, nil, 0666); err != nil { return fmt.Errorf("error saving certificate %x: %w", cert.SHA256, err) } } return nil } func processMalformedLogEntry(ctx context.Context, config *Config, entry *logEntry, parseError error) error { dirPath := filepath.Join(config.StateDir, "logs", entry.Log.LogID.Base64URLString(), "malformed_entries") malformed := &malformedLogEntry{ Entry: entry, Error: parseError.Error(), EntryPath: filepath.Join(dirPath, fmt.Sprintf("%d.json", entry.Index)), TextPath: filepath.Join(dirPath, fmt.Sprintf("%d.txt", entry.Index)), } if err := malformed.save(); err != nil { return fmt.Errorf("error saving malformed log entry %d in %s (%q): %w", entry.Index, entry.Log.URL, parseError, err) } if err := notify(ctx, config, malformed); err != nil { return fmt.Errorf("error notifying about malformed log entry %d in %s (%q): %w", entry.Index, entry.Log.URL, parseError, err) } return nil } certspotter-0.18.0/monitor/statedir.go000066400000000000000000000101431452451401200200100ustar00rootroot00000000000000// Copyright (C) 2023 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package monitor import ( "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "software.sslmate.com/src/certspotter/ct" "software.sslmate.com/src/certspotter/merkletree" "strconv" "strings" "time" ) func readVersion(stateDir string) (int, error) { path := filepath.Join(stateDir, "version") fileBytes, err := os.ReadFile(path) if errors.Is(err, fs.ErrNotExist) { if fileExists(filepath.Join(stateDir, "evidence")) { return 0, nil } else { return -1, nil } } else if err != nil { return -1, err } version, err := strconv.Atoi(strings.TrimSpace(string(fileBytes))) if err != nil { return -1, fmt.Errorf("version file %q is malformed: %w", path, err) } return version, nil } func writeVersion(stateDir string) error { return writeFile(filepath.Join(stateDir, "version"), []byte{'2', '\n'}, 0666) } func migrateLogStateDirV1(dir string) error { var sth ct.SignedTreeHead var tree merkletree.CollapsedTree sthPath := filepath.Join(dir, "sth.json") sthData, err := os.ReadFile(sthPath) if errors.Is(err, fs.ErrNotExist) { return nil } else if err != nil { return err } treePath := filepath.Join(dir, "tree.json") treeData, err := os.ReadFile(treePath) if errors.Is(err, fs.ErrNotExist) { return nil } else if err != nil { return err } if err := json.Unmarshal(sthData, &sth); err != nil { return fmt.Errorf("error unmarshaling %s: %w", sthPath, err) } if err := json.Unmarshal(treeData, &tree); err != nil { return fmt.Errorf("error unmarshaling %s: %w", treePath, err) } stateFile := stateFile{ DownloadPosition: &tree, VerifiedPosition: &tree, VerifiedSTH: &sth, LastSuccess: time.Now().UTC(), } if stateFile.store(filepath.Join(dir, "state.json")); err != nil { return err } if err := os.Remove(sthPath); err != nil { return err } if err := os.Remove(treePath); err != nil { return err } return nil } func migrateStateDirV1(stateDir string) error { if lockfile := filepath.Join(stateDir, "lock"); fileExists(lockfile) { return fmt.Errorf("directory is locked by another instance of certspotter; remove %s if this is not the case", lockfile) } if logDirs, err := os.ReadDir(filepath.Join(stateDir, "logs")); err == nil { for _, logDir := range logDirs { if strings.HasPrefix(logDir.Name(), ".") || !logDir.IsDir() { continue } if err := migrateLogStateDirV1(filepath.Join(stateDir, "logs", logDir.Name())); err != nil { return fmt.Errorf("error migrating log state: %w", err) } } } else if !errors.Is(err, fs.ErrNotExist) { return err } if err := writeVersion(stateDir); err != nil { return err } if err := os.Remove(filepath.Join(stateDir, "once")); err != nil && !errors.Is(err, fs.ErrNotExist) { return err } return nil } func prepareStateDir(stateDir string) error { if err := os.Mkdir(stateDir, 0777); err != nil && !errors.Is(err, fs.ErrExist) { return err } if version, err := readVersion(stateDir); err != nil { return err } else if version == -1 { if err := writeVersion(stateDir); err != nil { return err } } else if version == 0 { return fmt.Errorf("%s was created by a very old version of certspotter; run any version of certspotter after 0.2 and before 0.15.0 to upgrade this directory, or remove it to start from scratch", stateDir) } else if version == 1 { if err := migrateStateDirV1(stateDir); err != nil { return err } } else if version > 2 { return fmt.Errorf("%s was created by a newer version of certspotter; upgrade to the latest version of certspotter or remove this directory to start from scratch", stateDir) } for _, subdir := range []string{"certs", "logs", "healthchecks"} { if err := os.Mkdir(filepath.Join(stateDir, subdir), 0777); err != nil && !errors.Is(err, fs.ErrExist) { return err } } return nil } certspotter-0.18.0/monitor/statefile.go000066400000000000000000000023101452451401200201460ustar00rootroot00000000000000// Copyright (C) 2023 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package monitor import ( "encoding/json" "fmt" "os" "software.sslmate.com/src/certspotter/ct" "software.sslmate.com/src/certspotter/merkletree" "time" ) type stateFile struct { DownloadPosition *merkletree.CollapsedTree `json:"download_position"` VerifiedPosition *merkletree.CollapsedTree `json:"verified_position"` VerifiedSTH *ct.SignedTreeHead `json:"verified_sth"` LastSuccess time.Time `json:"last_success"` } func loadStateFile(filePath string) (*stateFile, error) { fileBytes, err := os.ReadFile(filePath) if err != nil { return nil, err } file := new(stateFile) if err := json.Unmarshal(fileBytes, file); err != nil { return nil, fmt.Errorf("error parsing %s: %w", filePath, err) } return file, nil } func (file *stateFile) store(filePath string) error { return writeJSONFile(filePath, file, 0666) } certspotter-0.18.0/monitor/sthdir.go000066400000000000000000000052631452451401200174750ustar00rootroot00000000000000// Copyright (C) 2017, 2023 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package monitor import ( "cmp" "crypto/sha256" "encoding/base64" "encoding/binary" "encoding/json" "errors" "fmt" "slices" "io/fs" "os" "path/filepath" "software.sslmate.com/src/certspotter/ct" "strconv" "strings" ) func loadSTHsFromDir(dirPath string) ([]*ct.SignedTreeHead, error) { entries, err := os.ReadDir(dirPath) if errors.Is(err, fs.ErrNotExist) { return []*ct.SignedTreeHead{}, nil } else if err != nil { return nil, err } sths := make([]*ct.SignedTreeHead, 0, len(entries)) for _, entry := range entries { filename := entry.Name() if strings.HasPrefix(filename, ".") || !strings.HasSuffix(filename, ".json") { continue } sth, err := readSTHFile(filepath.Join(dirPath, filename)) if err != nil { return nil, err } sths = append(sths, sth) } slices.SortFunc(sths, func(a, b *ct.SignedTreeHead) int { return cmp.Compare(a.TreeSize, b.TreeSize) }) return sths, nil } func readSTHFile(filePath string) (*ct.SignedTreeHead, error) { fileBytes, err := os.ReadFile(filePath) if err != nil { return nil, err } sth := new(ct.SignedTreeHead) if err := json.Unmarshal(fileBytes, sth); err != nil { return nil, fmt.Errorf("error parsing %s: %w", filePath, err) } return sth, nil } func storeSTHInDir(dirPath string, sth *ct.SignedTreeHead) error { filePath := filepath.Join(dirPath, sthFilename(sth)) if fileExists(filePath) { return nil } return writeJSONFile(filePath, sth, 0666) } func removeSTHFromDir(dirPath string, sth *ct.SignedTreeHead) error { filePath := filepath.Join(dirPath, sthFilename(sth)) err := os.Remove(filePath) if err != nil && !errors.Is(err, fs.ErrNotExist) { return err } return nil } // generate a filename that uniquely identifies the STH (within the context of a particular log) func sthFilename(sth *ct.SignedTreeHead) string { hasher := sha256.New() switch sth.Version { case ct.V1: binary.Write(hasher, binary.LittleEndian, sth.Timestamp) binary.Write(hasher, binary.LittleEndian, sth.SHA256RootHash) default: panic(fmt.Errorf("sthFilename: invalid STH version %d", sth.Version)) } // For 6962-bis, we will need to handle a variable-length root hash, and include the signature in the filename hash (since signatures must be deterministic) return strconv.FormatUint(sth.TreeSize, 10) + "-" + base64.RawURLEncoding.EncodeToString(hasher.Sum(nil)) + ".json" } certspotter-0.18.0/monitor/watchlist.go000066400000000000000000000065431452451401200202040ustar00rootroot00000000000000// Copyright (C) 2016, 2023 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package monitor import ( "bufio" "fmt" "golang.org/x/net/idna" "io" "software.sslmate.com/src/certspotter" "strings" ) type WatchItem struct { domain []string acceptSuffix bool } type WatchList []WatchItem func ParseWatchItem(str string) (WatchItem, error) { fields := strings.Fields(str) if len(fields) == 0 { return WatchItem{}, fmt.Errorf("empty domain") } domain := fields[0] for _, field := range fields[1:] { switch { case strings.HasPrefix(field, "valid_at:"): // Ignore for backwards compatibility default: return WatchItem{}, fmt.Errorf("unknown parameter %q", field) } } if domain == "." { // "." as in root zone -> matches everything return WatchItem{ domain: []string{}, acceptSuffix: true, }, nil } acceptSuffix := false if strings.HasPrefix(domain, ".") { acceptSuffix = true domain = domain[1:] } asciiDomain, err := idna.ToASCII(strings.ToLower(strings.TrimRight(domain, "."))) if err != nil { return WatchItem{}, fmt.Errorf("invalid domain %q (%w)", domain, err) } return WatchItem{ domain: strings.Split(asciiDomain, "."), acceptSuffix: acceptSuffix, }, nil } func ReadWatchList(reader io.Reader) (WatchList, error) { items := make(WatchList, 0, 50) scanner := bufio.NewScanner(reader) lineNo := 0 for scanner.Scan() { line := scanner.Text() lineNo++ if line == "" || strings.HasPrefix(line, "#") { continue } item, err := ParseWatchItem(line) if err != nil { return nil, fmt.Errorf("%w on line %d", err, lineNo) } items = append(items, item) } return items, scanner.Err() } func (item WatchItem) String() string { if item.acceptSuffix { return "." + strings.Join(item.domain, ".") } else { return strings.Join(item.domain, ".") } } func (item WatchItem) matchesDNSName(dnsName []string) bool { watchDomain := item.domain for len(dnsName) > 0 && len(watchDomain) > 0 { certLabel := dnsName[len(dnsName)-1] watchLabel := watchDomain[len(watchDomain)-1] if !dnsLabelMatches(certLabel, watchLabel) { return false } dnsName = dnsName[:len(dnsName)-1] watchDomain = watchDomain[:len(watchDomain)-1] } return len(watchDomain) == 0 && (item.acceptSuffix || len(dnsName) == 0) } func dnsLabelMatches(certLabel string, watchLabel string) bool { // For fail-safe behavior, if a label was unparsable, it matches everything. // Similarly, redacted labels match everything, since the label _might_ be // for a name we're interested in. return certLabel == "*" || certLabel == "?" || certLabel == certspotter.UnparsableDNSLabelPlaceholder || certspotter.MatchesWildcard(watchLabel, certLabel) } func (list WatchList) Matches(identifiers *certspotter.Identifiers) (bool, WatchItem) { dnsNames := make([][]string, len(identifiers.DNSNames)) for i, dnsName := range identifiers.DNSNames { dnsNames[i] = strings.Split(dnsName, ".") } for _, item := range list { for _, dnsName := range dnsNames { if item.matchesDNSName(dnsName) { return true, item } } } return false, WatchItem{} } certspotter-0.18.0/precerts.go000066400000000000000000000122211452451401200163300ustar00rootroot00000000000000// Copyright (C) 2016 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package certspotter import ( "bytes" "encoding/asn1" "errors" "fmt" ) func bitStringEqual(a, b *asn1.BitString) bool { return a.BitLength == b.BitLength && bytes.Equal(a.Bytes, b.Bytes) } var ( oidExtensionAuthorityKeyId = []int{2, 5, 29, 35} oidExtensionSCT = []int{1, 3, 6, 1, 4, 1, 11129, 2, 4, 2} oidExtensionCTPoison = []int{1, 3, 6, 1, 4, 1, 11129, 2, 4, 3} ) type PrecertInfo struct { SameIssuer bool // The pre-certificate was issued from the same CA as the final certificate Issuer []byte // The pre-certificate's issuer, if different from the final certificate AKI []byte // The pre-certificate's AKI, if present and different from the final certificate } func ValidatePrecert(precertBytes []byte, tbsBytes []byte) (*PrecertInfo, error) { precert, err := ParseCertificate(precertBytes) if err != nil { return nil, errors.New("failed to parse pre-certificate: " + err.Error()) } precertTBS, err := precert.ParseTBSCertificate() if err != nil { return nil, errors.New("failed to parse pre-certificate TBS: " + err.Error()) } tbs, err := ParseTBSCertificate(tbsBytes) if err != nil { return nil, errors.New("failed to parse TBS: " + err.Error()) } // Everything must be equal except: // issuer // Authority Key Identifier extension (both must have it OR neither can have it) // CT poison extension (precert must have it, TBS must not have it) if precertTBS.Version != tbs.Version { return nil, errors.New("version not equal") } if !bytes.Equal(precertTBS.SerialNumber.FullBytes, tbs.SerialNumber.FullBytes) { return nil, errors.New("serial number not equal") } sameIssuer := bytes.Equal(precertTBS.Issuer.FullBytes, tbs.Issuer.FullBytes) if !bytes.Equal(precertTBS.SignatureAlgorithm.FullBytes, tbs.SignatureAlgorithm.FullBytes) { return nil, errors.New("SignatureAlgorithm not equal") } if !bytes.Equal(precertTBS.Validity.FullBytes, tbs.Validity.FullBytes) { return nil, errors.New("Validity not equal") } if !bytes.Equal(precertTBS.Subject.FullBytes, tbs.Subject.FullBytes) { return nil, errors.New("Subject not equal") } if !bytes.Equal(precertTBS.PublicKey.FullBytes, tbs.PublicKey.FullBytes) { return nil, errors.New("PublicKey not equal") } if !bitStringEqual(&precertTBS.UniqueId, &tbs.UniqueId) { return nil, errors.New("UniqueId not equal") } if !bitStringEqual(&precertTBS.SubjectUniqueId, &tbs.SubjectUniqueId) { return nil, errors.New("SubjectUniqueId not equal") } precertHasPoison := false tbsIndex := 0 var aki []byte for precertIndex := range precertTBS.Extensions { precertExt := &precertTBS.Extensions[precertIndex] if precertExt.Id.Equal(oidExtensionCTPoison) { if !precertExt.Critical { return nil, errors.New("pre-cert poison extension is not critical") } /* CAs can't even get this right, and Google's logs don't check. Fortunately, it's not that important. if !bytes.Equal(precertExt.Value, []byte{0x05, 0x00}) { return errors.New("pre-cert poison extension contains incorrect value") } */ precertHasPoison = true continue } if tbsIndex >= len(tbs.Extensions) { return nil, errors.New("pre-cert contains extension not in TBS") } tbsExt := &tbs.Extensions[tbsIndex] if !precertExt.Id.Equal(tbsExt.Id) { return nil, fmt.Errorf("pre-cert and TBS contain different extensions (%v vs %v)", precertExt.Id, tbsExt.Id) } if precertExt.Critical != tbsExt.Critical { return nil, fmt.Errorf("pre-cert and TBS %v extension differs in criticality", precertExt.Id) } if !sameIssuer && precertExt.Id.Equal(oidExtensionAuthorityKeyId) { aki = precertExt.Value } else { if !bytes.Equal(precertExt.Value, tbsExt.Value) { return nil, fmt.Errorf("pre-cert and TBS %v extension differs in value", precertExt.Id) } } tbsIndex++ } if tbsIndex < len(tbs.Extensions) { return nil, errors.New("TBS contains extension not in pre-cert") } if !precertHasPoison { return nil, errors.New("pre-cert does not have poison extension") } return &PrecertInfo{SameIssuer: sameIssuer, Issuer: precertTBS.Issuer.FullBytes, AKI: aki}, nil } func ReconstructPrecertTBS(tbs *TBSCertificate) (*TBSCertificate, error) { precertTBS := TBSCertificate{ Version: tbs.Version, SerialNumber: tbs.SerialNumber, SignatureAlgorithm: tbs.SignatureAlgorithm, Issuer: tbs.Issuer, Validity: tbs.Validity, Subject: tbs.Subject, PublicKey: tbs.PublicKey, UniqueId: tbs.UniqueId, SubjectUniqueId: tbs.SubjectUniqueId, Extensions: make([]Extension, 0, len(tbs.Extensions)), } for _, ext := range tbs.Extensions { switch { case ext.Id.Equal(oidExtensionSCT): default: precertTBS.Extensions = append(precertTBS.Extensions, ext) } } var err error precertTBS.Raw, err = asn1.Marshal(precertTBS) return &precertTBS, err } certspotter-0.18.0/sct.go000066400000000000000000000024661452451401200153040ustar00rootroot00000000000000// Copyright (C) 2017 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package certspotter import ( "software.sslmate.com/src/certspotter/ct" ) func VerifyX509SCT(sct *ct.SignedCertificateTimestamp, cert []byte, verify *ct.SignatureVerifier) error { entry := ct.LogEntry{ Leaf: ct.MerkleTreeLeaf{ Version: 0, LeafType: ct.TimestampedEntryLeafType, TimestampedEntry: ct.TimestampedEntry{ Timestamp: sct.Timestamp, EntryType: ct.X509LogEntryType, X509Entry: cert, Extensions: sct.Extensions, }, }, } return verify.VerifySCTSignature(*sct, entry) } func VerifyPrecertSCT(sct *ct.SignedCertificateTimestamp, precert ct.PreCert, verify *ct.SignatureVerifier) error { entry := ct.LogEntry{ Leaf: ct.MerkleTreeLeaf{ Version: 0, LeafType: ct.TimestampedEntryLeafType, TimestampedEntry: ct.TimestampedEntry{ Timestamp: sct.Timestamp, EntryType: ct.PrecertLogEntryType, PrecertEntry: precert, Extensions: sct.Extensions, }, }, } return verify.VerifySCTSignature(*sct, entry) } certspotter-0.18.0/x509.go000066400000000000000000000266371452451401200152260ustar00rootroot00000000000000// Copyright (C) 2016 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package certspotter import ( "bytes" "crypto/x509/pkix" "encoding/asn1" "errors" "fmt" "math/big" "net" "time" ) var ( oidExtensionSubjectAltName = asn1.ObjectIdentifier{2, 5, 29, 17} oidExtensionBasicConstraints = asn1.ObjectIdentifier{2, 5, 29, 19} oidCountry = asn1.ObjectIdentifier{2, 5, 4, 6} oidOrganization = asn1.ObjectIdentifier{2, 5, 4, 10} oidOrganizationalUnit = asn1.ObjectIdentifier{2, 5, 4, 11} oidCommonName = asn1.ObjectIdentifier{2, 5, 4, 3} oidSerialNumber = asn1.ObjectIdentifier{2, 5, 4, 5} oidLocality = asn1.ObjectIdentifier{2, 5, 4, 7} oidProvince = asn1.ObjectIdentifier{2, 5, 4, 8} oidStreetAddress = asn1.ObjectIdentifier{2, 5, 4, 9} oidPostalCode = asn1.ObjectIdentifier{2, 5, 4, 17} ) type CertValidity struct { NotBefore time.Time NotAfter time.Time } type basicConstraints struct { IsCA bool `asn1:"optional"` MaxPathLen int `asn1:"optional,default:-1"` } type Extension struct { Id asn1.ObjectIdentifier Critical bool `asn1:"optional"` Value []byte } const ( sanOtherName = 0 sanRfc822Name = 1 sanDNSName = 2 sanX400Address = 3 sanDirectoryName = 4 sanEdiPartyName = 5 sanURI = 6 sanIPAddress = 7 sanRegisteredID = 8 ) type SubjectAltName struct { Type int Value []byte } type RDNSequence []RelativeDistinguishedNameSET type RelativeDistinguishedNameSET []AttributeTypeAndValue type AttributeTypeAndValue struct { Type asn1.ObjectIdentifier Value asn1.RawValue } func ParseRDNSequence(rdnsBytes []byte) (RDNSequence, error) { var rdns RDNSequence if rest, err := asn1.Unmarshal(rdnsBytes, &rdns); err != nil { return nil, errors.New("failed to parse RDNSequence: " + err.Error()) } else if len(rest) != 0 { return nil, fmt.Errorf("trailing data after RDNSequence: %v", rest) // XXX: too strict? } return rdns, nil } func MarshalRDNSequence(rdns RDNSequence) ([]byte, error) { return asn1.Marshal(rdns) } type TBSCertificate struct { Raw asn1.RawContent Version int `asn1:"optional,explicit,default:1,tag:0"` SerialNumber asn1.RawValue SignatureAlgorithm asn1.RawValue Issuer asn1.RawValue Validity asn1.RawValue Subject asn1.RawValue PublicKey asn1.RawValue UniqueId asn1.BitString `asn1:"optional,tag:1"` SubjectUniqueId asn1.BitString `asn1:"optional,tag:2"` Extensions []Extension `asn1:"optional,explicit,tag:3"` } type Certificate struct { Raw asn1.RawContent TBSCertificate asn1.RawValue SignatureAlgorithm asn1.RawValue SignatureValue asn1.RawValue } func (rdns RDNSequence) ParseCNs() ([]string, error) { var cns []string for _, rdn := range rdns { if len(rdn) == 0 { continue } atv := rdn[0] if atv.Type.Equal(oidCommonName) { cnString, err := decodeASN1String(&atv.Value) if err != nil { return nil, errors.New("Error decoding CN: " + err.Error()) } cns = append(cns, cnString) } } return cns, nil } func rdnLabel(oid asn1.ObjectIdentifier) string { switch { case oid.Equal(oidCountry): return "C" case oid.Equal(oidOrganization): return "O" case oid.Equal(oidOrganizationalUnit): return "OU" case oid.Equal(oidCommonName): return "CN" case oid.Equal(oidSerialNumber): return "serialNumber" case oid.Equal(oidLocality): return "L" case oid.Equal(oidProvince): return "ST" case oid.Equal(oidStreetAddress): return "street" case oid.Equal(oidPostalCode): return "postalCode" } return oid.String() } func (rdns RDNSequence) String() string { var buf bytes.Buffer for _, rdn := range rdns { if len(rdn) == 0 { continue } atv := rdn[0] if buf.Len() != 0 { buf.WriteString(", ") } buf.WriteString(rdnLabel(atv.Type)) buf.WriteString("=") valueString, err := decodeASN1String(&atv.Value) if err == nil { buf.WriteString(valueString) // TODO: escape non-printable characters, '\', and ',' } else { fmt.Fprintf(&buf, "%v", atv.Value.FullBytes) } } return buf.String() } func (san SubjectAltName) String() string { switch san.Type { case sanDNSName: return "DNS:" + string(san.Value) // TODO: escape non-printable characters, '\', and ',' case sanIPAddress: if len(san.Value) == 4 || len(san.Value) == 16 { return "IP:" + net.IP(san.Value).String() } else { return fmt.Sprintf("IP:%v", san.Value) } default: // TODO: support other types of SANs return fmt.Sprintf("%d:%v", san.Type, san.Value) } } func ParseTBSCertificate(tbsBytes []byte) (*TBSCertificate, error) { var tbs TBSCertificate if rest, err := asn1.Unmarshal(tbsBytes, &tbs); err != nil { return nil, errors.New("failed to parse TBS: " + err.Error()) } else if len(rest) > 0 { return nil, fmt.Errorf("trailing data after TBS: %v", rest) // XXX: too strict? } return &tbs, nil } func (tbs *TBSCertificate) ParseValidity() (*CertValidity, error) { var rawValidity struct { NotBefore asn1.RawValue NotAfter asn1.RawValue } if rest, err := asn1.Unmarshal(tbs.Validity.FullBytes, &rawValidity); err != nil { return nil, errors.New("failed to parse validity: " + err.Error()) } else if len(rest) > 0 { return nil, fmt.Errorf("trailing data after validity: %v", rest) } var validity CertValidity var err error if validity.NotBefore, err = decodeASN1Time(&rawValidity.NotBefore); err != nil { return nil, errors.New("failed to decode notBefore time: " + err.Error()) } if validity.NotAfter, err = decodeASN1Time(&rawValidity.NotAfter); err != nil { return nil, errors.New("failed to decode notAfter time: " + err.Error()) } return &validity, nil } func (tbs *TBSCertificate) ParseBasicConstraints() (*bool, error) { isCA := false isNotCA := false // Some certs in the wild have multiple BasicConstraints extensions (is there anything // that CAs haven't screwed up???), so we process all of them and only choke if they // are contradictory (which has not been observed...yet). for _, ext := range tbs.GetExtension(oidExtensionBasicConstraints) { var constraints basicConstraints if rest, err := asn1.Unmarshal(ext.Value, &constraints); err != nil { return nil, errors.New("failed to parse Basic Constraints: " + err.Error()) } else if len(rest) > 0 { return nil, fmt.Errorf("trailing data after Basic Constraints: %v", rest) } if constraints.IsCA { isCA = true } else { isNotCA = true } } if !isCA && !isNotCA { return nil, nil } else if isCA && !isNotCA { trueValue := true return &trueValue, nil } else if !isCA && isNotCA { falseValue := false return &falseValue, nil } else { return nil, fmt.Errorf("Certificate has more than one Basic Constraints extension and they are contradictory") } } func (tbs *TBSCertificate) ParseSerialNumber() (*big.Int, error) { serialNumber := big.NewInt(0) if rest, err := asn1.Unmarshal(tbs.SerialNumber.FullBytes, &serialNumber); err != nil { return nil, errors.New("failed to parse serial number: " + err.Error()) } else if len(rest) > 0 { return nil, fmt.Errorf("trailing data after serial number: %v", rest) } return serialNumber, nil } func (tbs *TBSCertificate) GetRawPublicKey() []byte { return tbs.PublicKey.FullBytes } func (tbs *TBSCertificate) GetRawSubject() []byte { return tbs.Subject.FullBytes } func (tbs *TBSCertificate) GetRawIssuer() []byte { return tbs.Issuer.FullBytes } func (tbs *TBSCertificate) ParseSubject() (RDNSequence, error) { subject, err := ParseRDNSequence(tbs.GetRawSubject()) if err != nil { return nil, errors.New("failed to parse certificate subject: " + err.Error()) } return subject, nil } func (tbs *TBSCertificate) ParseIssuer() (RDNSequence, error) { issuer, err := ParseRDNSequence(tbs.GetRawIssuer()) if err != nil { return nil, errors.New("failed to parse certificate issuer: " + err.Error()) } return issuer, nil } func (tbs *TBSCertificate) ParseSubjectCommonNames() ([]string, error) { subject, err := tbs.ParseSubject() if err != nil { return nil, err } cns, err := subject.ParseCNs() if err != nil { return nil, errors.New("failed to process certificate subject: " + err.Error()) } return cns, nil } func (tbs *TBSCertificate) ParseSubjectAltNames() ([]SubjectAltName, error) { sans := []SubjectAltName{} for _, sanExt := range tbs.GetExtension(oidExtensionSubjectAltName) { var err error sans, err = ParseSANExtension(sans, sanExt.Value) if err != nil { return nil, err } } return sans, nil } func (tbs *TBSCertificate) GetExtension(id asn1.ObjectIdentifier) []Extension { var exts []Extension for _, ext := range tbs.Extensions { if ext.Id.Equal(id) { exts = append(exts, ext) } } return exts } func ParseCertificate(certBytes []byte) (*Certificate, error) { var cert Certificate if rest, err := asn1.Unmarshal(certBytes, &cert); err != nil { return nil, errors.New("failed to parse certificate: " + err.Error()) } else if len(rest) > 0 { return nil, fmt.Errorf("trailing data after certificate: %v", rest) // XXX: too strict? } return &cert, nil } func (cert *Certificate) GetRawTBSCertificate() []byte { return cert.TBSCertificate.FullBytes } func (cert *Certificate) ParseTBSCertificate() (*TBSCertificate, error) { return ParseTBSCertificate(cert.GetRawTBSCertificate()) } func (cert *Certificate) ParseSignatureAlgorithm() (*pkix.AlgorithmIdentifier, error) { signatureAlgorithm := new(pkix.AlgorithmIdentifier) if rest, err := asn1.Unmarshal(cert.SignatureAlgorithm.FullBytes, signatureAlgorithm); err != nil { return nil, errors.New("failed to parse signature algorithm: " + err.Error()) } else if len(rest) > 0 { return nil, fmt.Errorf("trailing data after signature algorithm: %v", rest) } return signatureAlgorithm, nil } func (cert *Certificate) ParseSignatureValue() ([]byte, error) { var signatureValue asn1.BitString if rest, err := asn1.Unmarshal(cert.SignatureValue.FullBytes, &signatureValue); err != nil { return nil, errors.New("failed to parse signature value: " + err.Error()) } else if len(rest) > 0 { return nil, fmt.Errorf("trailing data after signature value: %v", rest) } return signatureValue.RightAlign(), nil } func ParseSANExtension(sans []SubjectAltName, value []byte) ([]SubjectAltName, error) { var seq asn1.RawValue if rest, err := asn1.Unmarshal(value, &seq); err != nil { return nil, errors.New("failed to parse subjectAltName extension: " + err.Error()) } else if len(rest) != 0 { // Don't complain if the SAN is followed by exactly one zero byte, // which is a common error. if !(len(rest) == 1 && rest[0] == 0) { return nil, fmt.Errorf("trailing data in subjectAltName extension: %v", rest) // XXX: too strict? } } if !seq.IsCompound || seq.Tag != 16 || seq.Class != 0 { return nil, errors.New("failed to parse subjectAltName extension: bad SAN sequence") // XXX: too strict? } rest := seq.Bytes for len(rest) > 0 { var val asn1.RawValue var err error rest, err = asn1.Unmarshal(rest, &val) if err != nil { return nil, errors.New("failed to parse subjectAltName extension item: " + err.Error()) } sans = append(sans, SubjectAltName{Type: val.Tag, Value: val.Bytes}) } return sans, nil }