net: Sanity check private keys received from SAM proxy #28695

pull dergoegge wants to merge 2 commits into bitcoin:master from dergoegge:2023-10-i2p-sanitize-priv changing 2 files +59 −0
  1. dergoegge commented at 3:44 pm on October 20, 2023: member

    Not sanity checking can lead to crashes or worse:

    0==1715589==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x6140000055c2 at pc 0x5622ed66e7ad bp 0x7ffee547a2c0 sp 0x7ffee547a2b8
    1READ of size 2 at 0x6140000055c2 thread T0 (b-test)
    2    [#0](/bitcoin-bitcoin/0/) 0x5622ed66e7ac in memcpy include/bits/string_fortified.h:29:10
    3    [#1](/bitcoin-bitcoin/1/) 0x5622ed66e7ac in i2p::sam::Session::MyDestination() const src/i2p.cpp:362:5
    4    [#2](/bitcoin-bitcoin/2/) 0x5622ed662e46 in i2p::sam::Session::CreateIfNotCreatedAlready() src/i2p.cpp:414:40
    5    [#3](/bitcoin-bitcoin/3/) 0x5622ed6619f2 in i2p::sam::Session::Listen(i2p::Connection&) src/i2p.cpp:143:9
    
  2. DrahtBot commented at 3:44 pm on October 20, 2023: contributor

    The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

    Code Coverage

    For detailed information about the code coverage, see the test coverage report.

    Reviews

    See the guideline for information on the review process.

    Type Reviewers
    ACK maflcko, stickies-v, vasild
    Concept ACK mzumsande, kristapsk

    If your review is incorrectly listed, please react with 👎 to this comment and the bot will ignore it on the next update.

  3. DrahtBot added the label P2P on Oct 20, 2023
  4. dergoegge commented at 3:46 pm on October 20, 2023: member
    I would greatly appreciate it if someone could write unit tests for this, @vasild @jonatack maybe?
  5. mzumsande commented at 3:53 pm on October 20, 2023: contributor

    Concept ACK

    I think that keys that are loaded from disk (ReadBinaryFile()) also should be sanitized. That’s what made the fuzzer crash in #28665.

  6. in src/i2p.cpp:83 in 1e6d1dbd4a outdated
    76@@ -77,6 +77,19 @@ static Binary DecodeI2PBase64(const std::string& i2p_b64)
    77     return std::move(*decoded);
    78 }
    79 
    80+static Binary SanitizePrivKey(Binary&& binary)
    81+{
    82+    if (binary.size() < 387) {
    83+        throw std::runtime_error("Private key to small (size < 387 bytes)");
    


    sipa commented at 5:15 pm on October 20, 2023:
    Nit: typo to -> too (here and below).
  7. DrahtBot added the label CI failed on Oct 20, 2023
  8. cacrowley approved
  9. cacrowley approved
  10. dergoegge force-pushed on Oct 22, 2023
  11. dergoegge commented at 8:49 am on October 23, 2023: member
    This should probably be in the 26.0 milestone?
  12. fanquake added this to the milestone 26.0 on Oct 23, 2023
  13. maflcko commented at 9:03 am on October 23, 2023: member
    To what extent is the proxy trusted, and to what extent is it required to validate inputs from it? I think it would be good to first document that and then design unit and fuzz tests based on that design assumption?
  14. maflcko commented at 9:06 am on October 23, 2023: member
    typo-nit: received
  15. fanquake renamed this:
    net: Sanitize private keys recevied from SAM proxy
    net: Sanitize private keys received from SAM proxy
    on Oct 23, 2023
  16. fanquake commented at 9:10 am on October 23, 2023: member

    To what extent is the proxy trusted, and to what extent is it required to validate inputs from it? @jonatack @vasild @zzzi2p

  17. zzzi2p commented at 12:16 pm on October 23, 2023: none

    The i2pd crash in OP should be reported to that project and/or @orignal

    Java I2P probably won’t crash on bad input and we do some validation of public and private keys but we can’t promise to catch everything.

    The format is a little elaborate, I don’t know how far down you want to go. Would you validate the individual key fields? Would you check for pubkey/privkey mismatches? Just basic length checks?

    From the SAM spec:

     0The $privkey is the base 64 of the concatenation of the [Destination](https://geti2p.net/en/docs/spec/common-structures#type_Destination) 
     1followed by the [Private Key](https://geti2p.net/en/docs/spec/common-structures#type_PrivateKey) 
     2followed by the [Signing Private Key](https://geti2p.net/en/docs/spec/common-structures#type_SigningPrivateKey),
     3 optionally followed by the [Offline Signature](https://geti2p.net/en/docs/spec/common-structures#struct_OfflineSignature), 
     4which is 663 or more bytes in binary and 884 or more bytes in base 64, depending on signature type. 
     5The binary format is specified in [Private Key File](https://github.com/i2p/i2p.i2p/blob/master/core/java/src/net/i2p/data/PrivateKeyFile.java). 
     6See additional notes about the [Private Key](https://geti2p.net/en/docs/spec/common-structures#type_PrivateKey)
     7 in the Destination Key Generation section below.
     8
     9If the signing private key is all zeros, 
    10the [Offline Signature](https://geti2p.net/en/docs/spec/common-structures#struct_OfflineSignature) 
    11section follows....
    

    And there’s more near the bottom in the ‘Destination Key Generation’ section of https://geti2p.net/en/docs/api/samv3

    You’re not using offline signatures so you can skip that part.

    I can’t really answer your question about “trust” in SAM. It probably won’t send you bad input, and in most cases you can treat the private key as an opaque string without “validation”. So as long as you don’t corrupt it when you store and retrieve it, you should be fine. But it’s up to you.

  18. zzzi2p commented at 12:23 pm on October 23, 2023: none
    Ah, correction, OP is a bitcoin crash not a i2pd crash, apologies to @orignal
  19. in src/i2p.cpp:90 in 9ff405e9bb outdated
    85+
    86+    if (binary.size() > (387 + std::numeric_limits<uint16_t>::max())) {
    87+        throw std::runtime_error("Private key too large");
    88+    }
    89+
    90+    return binary;
    


    stickies-v commented at 1:03 pm on October 23, 2023:
    0    return std::move(binary);
    

    (requires #include <utility>)

  20. in src/i2p.cpp:80 in 9ff405e9bb outdated
    76@@ -77,6 +77,19 @@ static Binary DecodeI2PBase64(const std::string& i2p_b64)
    77     return std::move(*decoded);
    78 }
    79 
    80+static Binary SanitizePrivKey(Binary&& binary)
    


    stickies-v commented at 1:06 pm on October 23, 2023:

    nit: I find the name a bit misleading as we’re not modifying the private key, I’d prefer ValidatePrivKey{Size} instead

    0static Binary ValidatePrivKeySize(Binary&& binary)
    

    stickies-v commented at 1:12 pm on October 23, 2023:
    Could also use static const Binary& ValidatePrivKeySize(const Binary& binary LIFETIMEBOUND) (requires #include <attributes.h>) to highlight the constness of binary and make the fn accept both lvalues and rvalues? I think this approach would have my preference, but I’m okay with either.
  21. in src/i2p.cpp:86 in 9ff405e9bb outdated
    81+{
    82+    if (binary.size() < 387) {
    83+        throw std::runtime_error("Private key too small (size < 387 bytes)");
    84+    }
    85+
    86+    if (binary.size() > (387 + std::numeric_limits<uint16_t>::max())) {
    


    stickies-v commented at 1:07 pm on October 23, 2023:

    nit: since they’re mutually exclusive, I think else if would make that more clear

    0    } else if (binary.size() > (387 + std::numeric_limits<uint16_t>::max())) {
    
  22. mzumsande commented at 3:13 pm on October 23, 2023: contributor

    To what extent is the proxy trusted, and to what extent is it required to validate inputs from it? I think it would be good to first document that and then design unit and fuzz tests based on that design assumption?

    Wouldn’t it be best to write error handling code / tests on the assumption that no external input source should be able to cause memory corruption within bitcoind - including trusted ones, like the datadir?

  23. stickies-v commented at 3:47 pm on October 23, 2023: contributor

    Concept ACK

    I think I would prefer the constructor of Binary to do size sanity checks so we don’t need to call a helper function any time we use a Binary, but it seems this doesn’t pair very elegantly with the lazy construction approach of sam::Session. Below a diff of one such example implementation that passes unit tests by making m_private_key a std::optional<Binary> (using not-super-safe dereferencing on m_private_key for now just to PoC it) edit: I think vasild’s approach is better

      0diff --git a/src/i2p.cpp b/src/i2p.cpp
      1index 1b8345059e..fc5d23e8fb 100644
      2--- a/src/i2p.cpp
      3+++ b/src/i2p.cpp
      4@@ -77,19 +77,6 @@ static Binary DecodeI2PBase64(const std::string& i2p_b64)
      5     return std::move(*decoded);
      6 }
      7 
      8-static Binary SanitizePrivKey(Binary&& binary)
      9-{
     10-    if (binary.size() < 387) {
     11-        throw std::runtime_error("Private key too small (size < 387 bytes)");
     12-    }
     13-
     14-    if (binary.size() > (387 + std::numeric_limits<uint16_t>::max())) {
     15-        throw std::runtime_error("Private key too large");
     16-    }
     17-
     18-    return binary;
     19-}
     20-
     21 /**
     22  * Derive the .b32.i2p address of an I2P destination (binary).
     23  * [@param](/bitcoin-bitcoin/contributor/param/)[in] dest I2P destination.
     24@@ -340,7 +327,7 @@ void Session::DestGenerate(const Sock& sock)
     25     // If SIGNATURE_TYPE is not specified, then the default one is DSA_SHA1.
     26     const Reply& reply = SendRequestAndGetReply(sock, "DEST GENERATE SIGNATURE_TYPE=7", false);
     27 
     28-    m_private_key = SanitizePrivKey(DecodeI2PBase64(reply.Get("PRIV")));
     29+    m_private_key = DecodeI2PBase64(reply.Get("PRIV"));
     30 }
     31 
     32 void Session::GenerateAndSavePrivateKey(const Sock& sock)
     33@@ -349,7 +336,7 @@ void Session::GenerateAndSavePrivateKey(const Sock& sock)
     34 
     35     // umask is set to 0077 in common/system.cpp, which is ok.
     36     if (!WriteBinaryFile(m_private_key_file,
     37-                         std::string(m_private_key.begin(), m_private_key.end()))) {
     38+                         std::string(m_private_key->begin(), m_private_key->end()))) {
     39         throw std::runtime_error(
     40             strprintf("Cannot save I2P private key to %s", fs::quoted(fs::PathToString(m_private_key_file))));
     41     }
     42@@ -364,16 +351,16 @@ Binary Session::MyDestination() const
     43     static constexpr size_t CERT_LEN_POS = 385;
     44 
     45     uint16_t cert_len;
     46-    memcpy(&cert_len, &m_private_key.at(CERT_LEN_POS), sizeof(cert_len));
     47+    memcpy(&cert_len, &m_private_key->at(CERT_LEN_POS), sizeof(cert_len));
     48     cert_len = be16toh(cert_len);
     49 
     50     const size_t dest_len = DEST_LEN_BASE + cert_len;
     51 
     52-    if (dest_len > m_private_key.size()) {
     53+    if (dest_len > m_private_key->size()) {
     54         throw std::runtime_error("Certificate length did not match the actual private key length");
     55     }
     56 
     57-    return Binary{m_private_key.begin(), m_private_key.begin() + dest_len};
     58+    return Binary{m_private_key->begin(), m_private_key->begin() + dest_len};
     59 }
     60 
     61 void Session::CreateIfNotCreatedAlready()
     62@@ -399,18 +386,18 @@ void Session::CreateIfNotCreatedAlready()
     63                       "inbound.quantity=1 outbound.quantity=1",
     64                       session_id));
     65 
     66-        m_private_key = SanitizePrivKey(DecodeI2PBase64(reply.Get("DESTINATION")));
     67+        m_private_key = DecodeI2PBase64(reply.Get("DESTINATION"));
     68     } else {
     69         // Read our persistent destination (private key) from disk or generate
     70         // one and save it to disk. Then use it when creating the session.
     71         const auto& [read_ok, data] = ReadBinaryFile(m_private_key_file);
     72         if (read_ok) {
     73-            m_private_key = SanitizePrivKey(Binary{data.begin(), data.end()});
     74+            m_private_key = Binary{data.begin(), data.end()};
     75         } else {
     76             GenerateAndSavePrivateKey(*sock);
     77         }
     78 
     79-        const std::string& private_key_b64 = SwapBase64(EncodeBase64(m_private_key));
     80+        const std::string& private_key_b64 = SwapBase64(EncodeBase64(m_private_key.value()));
     81 
     82         SendRequestAndGetReply(*sock,
     83                                strprintf("SESSION CREATE STYLE=STREAM ID=%s DESTINATION=%s "
     84diff --git a/src/i2p.h b/src/i2p.h
     85index cb9da64816..e5bcef53e5 100644
     86--- a/src/i2p.h
     87+++ b/src/i2p.h
     88@@ -23,7 +23,31 @@ namespace i2p {
     89 /**
     90  * Binary data.
     91  */
     92-using Binary = std::vector<uint8_t>;
     93+class Binary {
     94+private:
     95+    std::vector<uint8_t> m_data;
     96+    void Validate() const {
     97+        if (m_data.size() < 387) {
     98+            throw std::runtime_error("Private key too small (size < 387 bytes)");
     99+        } else if (m_data.size() > (387 + std::numeric_limits<uint16_t>::max())) {
    100+            throw std::runtime_error("Private key too large");
    101+        }
    102+    }
    103+
    104+public:
    105+    template<typename... Args>
    106+    Binary(Args&&... args) : m_data(std::forward<Args>(args)...) { Validate(); }
    107+
    108+    operator Span<const uint8_t>() const { return m_data; }
    109+
    110+    // forward to std::vector methods
    111+    template<typename... Args>
    112+    auto& at(Args&&... args) const { return m_data.at(std::forward<Args>(args)...); }
    113+    auto begin() const { return m_data.begin(); }
    114+    auto data() const { return m_data.data(); }
    115+    auto end() const { return m_data.end(); }
    116+    auto size() const { return m_data.size(); }
    117+};
    118 
    119 /**
    120  * An established connection with another peer.
    121@@ -253,7 +277,7 @@ private:
    122      * The private key of this peer.
    123      * [@see](/bitcoin-bitcoin/contributor/see/) The reply to the "DEST GENERATE" command in https://geti2p.net/en/docs/api/samv3
    124      */
    125-    Binary m_private_key GUARDED_BY(m_mutex);
    126+    std::optional<Binary> m_private_key GUARDED_BY(m_mutex);
    127 
    128     /**
    129      * SAM control socket.
    
  24. maflcko commented at 8:06 am on October 24, 2023: member
    In any case, I don’t think this is a blocker for rc1. It is not a regression and this diff can be added to rc2 trivially.
  25. in src/i2p.cpp:82 in 9ff405e9bb outdated
    76@@ -77,6 +77,19 @@ static Binary DecodeI2PBase64(const std::string& i2p_b64)
    77     return std::move(*decoded);
    78 }
    79 
    80+static Binary SanitizePrivKey(Binary&& binary)
    81+{
    82+    if (binary.size() < 387) {
    


    vasild commented at 12:34 pm on October 24, 2023:

    I think it would be better to do the check just before doing the offensive operation. Consider:

    0void f()
    1{
    2    if (x >= a.size()) {
    3        return or throw (dont continue below)
    4    }
    5    a[x] = 0;
    

    vs

     0void f()
     1{
     2    a[x] = 0; // it is not immediately obvious if x has been checked
     3
     4...
     5
     6// somewhere else in the code
     7if (x >= a.size()) {
     8    // make sure f() is not called
     9}
    10// but new code or a refactor could end up calling f() nevertheless
    

    The following is shorter and I find it safer and it also avoids using magic numbers (+ unit test):

      0diff --git i/src/i2p.cpp w/src/i2p.cpp
      1index 05a5dde396..685b43ba18 100644
      2--- i/src/i2p.cpp
      3+++ w/src/i2p.cpp
      4@@ -381,17 +381,32 @@ Binary Session::MyDestination() const
      5     // "They are 387 bytes plus the certificate length specified at bytes 385-386, which may be
      6     // non-zero"
      7     static constexpr size_t DEST_LEN_BASE = 387;
      8     static constexpr size_t CERT_LEN_POS = 385;
      9 
     10     uint16_t cert_len;
     11+
     12+    if (m_private_key.size() < CERT_LEN_POS + sizeof(cert_len)) {
     13+        throw std::runtime_error(strprintf("The private key is too short (%d < %d)",
     14+                                           m_private_key.size(),
     15+                                           CERT_LEN_POS + sizeof(cert_len)));
     16+    }
     17+
     18     memcpy(&cert_len, &m_private_key.at(CERT_LEN_POS), sizeof(cert_len));
     19     cert_len = be16toh(cert_len);
     20 
     21     const size_t dest_len = DEST_LEN_BASE + cert_len;
     22 
     23+    if (dest_len > m_private_key.size()) {
     24+        throw std::runtime_error(strprintf("Certificate length (%d) designates that the private key should "
     25+                                           "be %d bytes, but it is only %d bytes",
     26+                                           cert_len,
     27+                                           dest_len,
     28+                                           m_private_key.size()));
     29+    }
     30+
     31     return Binary{m_private_key.begin(), m_private_key.begin() + dest_len};
     32 }
     33 
     34 void Session::CreateIfNotCreatedAlready()
     35 {
     36     std::string errmsg;
     37diff --git i/src/test/i2p_tests.cpp w/src/test/i2p_tests.cpp
     38index 5b8b0e9215..d6bbe4fa8f 100644
     39--- i/src/test/i2p_tests.cpp
     40+++ w/src/test/i2p_tests.cpp
     41@@ -6,12 +6,13 @@
     42 #include <i2p.h>
     43 #include <logging.h>
     44 #include <netaddress.h>
     45 #include <test/util/logging.h>
     46 #include <test/util/net.h>
     47 #include <test/util/setup_common.h>
     48+#include <util/readwritefile.h>
     49 #include <util/threadinterrupt.h>
     50 
     51 #include <boost/test/unit_test.hpp>
     52 
     53 #include <memory>
     54 #include <string>
     55@@ -122,7 +123,51 @@ BOOST_AUTO_TEST_CASE(listen_ok_accept_fail)
     56         ASSERT_DEBUG_LOG("Destroying SAM session");
     57         BOOST_REQUIRE(session.Listen(conn));
     58         BOOST_REQUIRE(!session.Accept(conn));
     59     }
     60 }
     61 
     62+BOOST_AUTO_TEST_CASE(damaged_private_key)
     63+{
     64+    const auto CreateSockOrig = CreateSock;
     65+
     66+    CreateSock = [](const CService&) {
     67+        return std::make_unique<StaticContentsSock>("HELLO REPLY RESULT=OK VERSION=3.1\n"
     68+                                                    "SESSION STATUS RESULT=OK DESTINATION=\n");
     69+    };
     70+
     71+    const auto i2p_private_key_file = gArgs.GetDataDirNet() / "test_i2p_private_key_damaged";
     72+
     73+    for (const auto& [file_contents, expected_error] : std::vector<std::tuple<std::string, std::string>>{
     74+             {"", "The private key is too short (0 < 387)"},
     75+
     76+             {"abcd", "The private key is too short (4 < 387)"},
     77+
     78+             {std::string(386, '\0'), "The private key is too short (386 < 387)"},
     79+
     80+             {std::string(385, '\0') + '\0' + '\1',
     81+              "Certificate length (1) designates that the private key should be 388 bytes, but it is only "
     82+              "387 bytes"},
     83+
     84+             {std::string(385, '\0') + '\0' + '\5' + "abcd",
     85+              "Certificate length (5) designates that the private key should be 392 bytes, but it is only "
     86+              "391 bytes"}}) {
     87+
     88+        BOOST_REQUIRE(WriteBinaryFile(i2p_private_key_file, file_contents));
     89+
     90+        CThreadInterrupt interrupt;
     91+        i2p::sam::Session session(i2p_private_key_file, CService{}, &interrupt);
     92+
     93+        {
     94+            ASSERT_DEBUG_LOG("Creating persistent SAM session");
     95+            ASSERT_DEBUG_LOG(expected_error);
     96+
     97+            i2p::Connection conn;
     98+            bool proxy_error;
     99+            BOOST_CHECK(!session.Connect(CService{}, conn, proxy_error));
    100+        }
    101+    }
    102+
    103+    CreateSock = CreateSockOrig;
    104+}
    105+
    106 BOOST_AUTO_TEST_SUITE_END()
    

    jonatack commented at 5:17 am on October 25, 2023:

    The following is shorter and I find it safer and it also avoids using magic numbers (+ unit test)

    Nice! (and unit test 👍)


    dergoegge commented at 3:55 pm on October 26, 2023:
    Very nice, thank you!
  26. vasild commented at 12:35 pm on October 24, 2023: contributor
    Concept ACK
  27. DrahtBot removed the label CI failed on Oct 25, 2023
  28. [net] Check i2p private key constraints
    Co-authored-by: Vasil Dimov <vd@FreeBSD.org>
    cf70a8d565
  29. dergoegge force-pushed on Oct 26, 2023
  30. dergoegge renamed this:
    net: Sanitize private keys received from SAM proxy
    net: Sanity check private keys received from SAM proxy
    on Oct 26, 2023
  31. stickies-v approved
  32. stickies-v commented at 6:41 pm on October 26, 2023: contributor
    ACK adb5d6b8df575cacfdaf4223289bff7093d15639
  33. DrahtBot requested review from vasild on Oct 26, 2023
  34. DrahtBot requested review from mzumsande on Oct 26, 2023
  35. in src/test/i2p_tests.cpp:58 in adb5d6b8df outdated
    53+    CreateSock = [](const CService&) {
    54+        return std::make_unique<StaticContentsSock>("HELLO REPLY RESULT=OK VERSION=3.1\n"
    55+                                                    "SESSION STATUS RESULT=OK DESTINATION=\n");
    56+    };
    57+
    58+    const auto i2p_private_key_file = gArgs.GetDataDirNet() / "test_i2p_private_key_damaged";
    


    maflcko commented at 8:43 am on October 27, 2023:
    0    const auto i2p_private_key_file{m_args.GetDataDirNet() / "test_i2p_private_key_damaged"};
    

    (if it compiles and passes tests)

    nit (feel free to ignore): Any reason to use the global, when the member is likely to work as well?


    dergoegge commented at 11:41 am on October 30, 2023:
    Done
  36. vasild approved
  37. vasild commented at 12:18 pm on October 27, 2023: contributor
    ACK adb5d6b8df575cacfdaf4223289bff7093d15639
  38. kristapsk commented at 2:23 pm on October 27, 2023: contributor
    Concept ACK
  39. luke-jr referenced this in commit 272785aaea on Oct 28, 2023
  40. luke-jr referenced this in commit 14881cb180 on Oct 28, 2023
  41. [test] Test i2p private key constraints 5cf4d266d9
  42. dergoegge force-pushed on Oct 30, 2023
  43. maflcko commented at 12:20 pm on October 30, 2023: member
    code lgtm ACK 5cf4d266d9b1e7bd9394e7581398de5bc540ae99
  44. DrahtBot requested review from stickies-v on Oct 30, 2023
  45. DrahtBot requested review from vasild on Oct 30, 2023
  46. stickies-v approved
  47. stickies-v commented at 12:53 pm on October 30, 2023: contributor
    re-ACK 5cf4d266d9b1e7bd9394e7581398de5bc540ae99
  48. fanquake added the label Needs backport (26.x) on Oct 30, 2023
  49. vasild approved
  50. vasild commented at 1:24 pm on October 30, 2023: contributor
    ACK 5cf4d266d9b1e7bd9394e7581398de5bc540ae99
  51. fanquake merged this on Oct 30, 2023
  52. fanquake closed this on Oct 30, 2023

  53. fanquake referenced this in commit 762c61078e on Oct 30, 2023
  54. fanquake referenced this in commit ca962a08b2 on Oct 30, 2023
  55. fanquake removed the label Needs backport (26.x) on Oct 30, 2023
  56. fanquake commented at 1:53 pm on October 30, 2023: member
    Backported to 26.x in #28754.
  57. fanquake referenced this in commit 1f11784aac on Oct 31, 2023
  58. fanquake referenced this in commit d3ebf6e9fc on Oct 31, 2023
  59. fanquake referenced this in commit 67b2512560 on Nov 1, 2023
  60. luke-jr referenced this in commit 3e8eb74809 on Nov 7, 2023
  61. bitcoin locked this on Oct 29, 2024

github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2024-12-21 15:12 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me