• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mendersoftware / mender / 1022753986

02 Oct 2023 10:37AM UTC coverage: 78.168% (-2.0%) from 80.127%
1022753986

push

gitlab-ci

oleorhagen
feat: Run the authentication loop once upon bootstrap

Ticket: MEN-6658
Changelog: None

Signed-off-by: Ole Petter <ole.orhagen@northern.tech>

32 of 32 new or added lines in 1 file covered. (100.0%)

6996 of 8950 relevant lines covered (78.17%)

10353.4 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

77.21
/common/crypto/platform/openssl/crypto.cpp
1
// Copyright 2023 Northern.tech AS
2
//
3
//    Licensed under the Apache License, Version 2.0 (the "License");
4
//    you may not use this file except in compliance with the License.
5
//    You may obtain a copy of the License at
6
//
7
//        http://www.apache.org/licenses/LICENSE-2.0
8
//
9
//    Unless required by applicable law or agreed to in writing, software
10
//    distributed under the License is distributed on an "AS IS" BASIS,
11
//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
//    See the License for the specific language governing permissions and
13
//    limitations under the License.
14

15
#include <common/crypto.hpp>
16

17
#include <common/crypto/platform/openssl/openssl_config.h>
18

19
#include <cstdint>
20
#include <string>
21
#include <vector>
22
#include <memory>
23

24
#include <openssl/evp.h>
25
#include <openssl/pem.h>
26
#include <openssl/err.h>
27
#include <openssl/rsa.h>
28
#include <openssl/bn.h>
29

30
#include <common/io.hpp>
31
#include <common/error.hpp>
32
#include <common/expected.hpp>
33
#include <common/common.hpp>
34

35
#include <artifact/sha/sha.hpp>
36

37

38
namespace mender {
39
namespace common {
40
namespace crypto {
41

42
const size_t MENDER_DIGEST_SHA256_LENGTH = 32;
43

44
const size_t OPENSSL_SUCCESS = 1;
45

46
using namespace std;
47

48
namespace error = mender::common::error;
49
namespace io = mender::common::io;
50

51
auto pkey_ctx_free_func = [](EVP_PKEY_CTX *ctx) {
32✔
52
        if (ctx) {
32✔
53
                EVP_PKEY_CTX_free(ctx);
32✔
54
        }
55
};
32✔
56
auto pkey_free_func = [](EVP_PKEY *key) {
54✔
57
        if (key) {
54✔
58
                EVP_PKEY_free(key);
54✔
59
        }
60
};
54✔
61
auto bio_free_func = [](BIO *bio) {
57✔
62
        if (bio) {
57✔
63
                BIO_free(bio);
57✔
64
        }
65
};
57✔
66
auto bio_free_all_func = [](BIO *bio) {
15✔
67
        if (bio) {
15✔
68
                BIO_free_all(bio);
15✔
69
        }
70
};
15✔
71
#ifdef MENDER_CRYPTO_OPENSSL_LEGACY
72
auto bn_free = [](BIGNUM *bn) {
×
73
        if (bn) {
×
74
                BN_free(bn);
×
75
        }
76
};
×
77
#endif
78

79
// NOTE: GetOpenSSLErrorMessage should be called upon all OpenSSL errors, as
80
// the errors are queued, and if not harvested, the FIFO structure of the
81
// queue will mean that if you just get one, you might actually get the wrong
82
// one.
83
string GetOpenSSLErrorMessage() {
16✔
84
        const auto sysErrorCode = errno;
16✔
85
        auto sslErrorCode = ERR_get_error();
16✔
86

87
        std::string errorDescription {};
16✔
88
        while (sslErrorCode != 0) {
51✔
89
                if (!errorDescription.empty()) {
35✔
90
                        errorDescription += '\n';
19✔
91
                }
92
                errorDescription += ERR_error_string(sslErrorCode, nullptr);
35✔
93
                sslErrorCode = ERR_get_error();
35✔
94
        }
95
        if (sysErrorCode != 0) {
16✔
96
                if (!errorDescription.empty()) {
16✔
97
                        errorDescription += '\n';
16✔
98
                }
99
                errorDescription += "System error, code=" + std::to_string(sysErrorCode);
16✔
100
                errorDescription += ", ";
16✔
101
                errorDescription += strerror(sysErrorCode);
16✔
102
        }
103
        return errorDescription;
16✔
104
}
105

106
ExpectedPrivateKey PrivateKey::LoadFromPEM(
14✔
107
        const string &private_key_path, const string &passphrase) {
108
        auto private_bio_key = unique_ptr<BIO, void (*)(BIO *)>(
109
                BIO_new_file(private_key_path.c_str(), "r"), bio_free_func);
28✔
110
        if (private_bio_key == nullptr) {
14✔
111
                return expected::unexpected(MakeError(
3✔
112
                        SetupError, "Failed to open the private key file: " + GetOpenSSLErrorMessage()));
6✔
113
        }
114

115
        vector<char> chars(passphrase.begin(), passphrase.end());
22✔
116
        chars.push_back('\0');
11✔
117
        char *c_str = chars.data();
11✔
118

119
        // We need our own custom callback routine, as the default one will prompt
120
        // for a passphrase.
121
        auto callback = [](char *buf, int size, int rwflag, void *u) {
3✔
122
                // We'll only use this callback for reading passphrases, not for
123
                // writing them.
124
                assert(rwflag == 0);
3✔
125

126
                if (u == nullptr) {
3✔
127
                        return 0;
×
128
                }
129

130
                // NB: buf is not expected to be null terminated.
131
                char *const pass = static_cast<char *>(u);
3✔
132
                strncpy(buf, pass, size);
3✔
133

134
                const int len = static_cast<int>(strlen(pass));
3✔
135
                return (len < size) ? len : size;
3✔
136
        };
137

138
        auto private_key = unique_ptr<EVP_PKEY, void (*)(EVP_PKEY *)>(
139
                PEM_read_bio_PrivateKey(private_bio_key.get(), nullptr, callback, c_str), pkey_free_func);
22✔
140
        if (private_key == nullptr) {
11✔
141
                return expected::unexpected(
4✔
142
                        MakeError(SetupError, "Failed to load the key: " + GetOpenSSLErrorMessage()));
8✔
143
        }
144

145
        return unique_ptr<PrivateKey>(new PrivateKey(std::move(private_key)));
14✔
146
}
147

148
ExpectedPrivateKey PrivateKey::LoadFromPEM(const string &private_key_path) {
6✔
149
        return PrivateKey::LoadFromPEM(private_key_path, "");
12✔
150
}
151

152
ExpectedPrivateKey PrivateKey::Generate(const unsigned int bits, const unsigned int exponent) {
8✔
153
#ifdef MENDER_CRYPTO_OPENSSL_LEGACY
154
        auto pkey_gen_ctx = unique_ptr<EVP_PKEY_CTX, void (*)(EVP_PKEY_CTX *)>(
155
                EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, nullptr), pkey_ctx_free_func);
16✔
156

157
        int ret = EVP_PKEY_keygen_init(pkey_gen_ctx.get());
8✔
158
        if (ret != OPENSSL_SUCCESS) {
8✔
159
                return expected::unexpected(MakeError(
×
160
                        SetupError,
161
                        "Failed to generate a private key. Initialization failed: "
162
                                + GetOpenSSLErrorMessage()));
×
163
        }
164

165
        ret = EVP_PKEY_CTX_set_rsa_keygen_bits(pkey_gen_ctx.get(), bits);
8✔
166
        if (ret != OPENSSL_SUCCESS) {
8✔
167
                return expected::unexpected(MakeError(
×
168
                        SetupError,
169
                        "Failed to generate a private key. Parameters setting failed: "
170
                                + GetOpenSSLErrorMessage()));
×
171
        }
172

173
        auto exponent_bn = unique_ptr<BIGNUM, void (*)(BIGNUM *)>(BN_new(), bn_free);
16✔
174
        ret = BN_set_word(exponent_bn.get(), exponent);
8✔
175
        if (ret != OPENSSL_SUCCESS) {
8✔
176
                return expected::unexpected(MakeError(
×
177
                        SetupError,
178
                        "Failed to generate a private key. Parameters setting failed: "
179
                                + GetOpenSSLErrorMessage()));
×
180
        }
181

182
        ret = EVP_PKEY_CTX_set_rsa_keygen_pubexp(pkey_gen_ctx.get(), exponent_bn.get());
8✔
183
        if (ret != OPENSSL_SUCCESS) {
8✔
184
                return expected::unexpected(MakeError(
×
185
                        SetupError,
186
                        "Failed to generate a private key. Parameters setting failed: "
187
                                + GetOpenSSLErrorMessage()));
×
188
        }
189
        exponent_bn.release();
8✔
190

191
        EVP_PKEY *pkey = nullptr;
8✔
192
        ret = EVP_PKEY_keygen(pkey_gen_ctx.get(), &pkey);
8✔
193
        if (ret != OPENSSL_SUCCESS) {
8✔
194
                return expected::unexpected(MakeError(
×
195
                        SetupError,
196
                        "Failed to generate a private key. Generation failed: " + GetOpenSSLErrorMessage()));
×
197
        }
198
#else
199
        auto pkey_gen_ctx = unique_ptr<EVP_PKEY_CTX, void (*)(EVP_PKEY_CTX *)>(
200
                EVP_PKEY_CTX_new_from_name(nullptr, "RSA", nullptr), pkey_ctx_free_func);
201

202
        int ret = EVP_PKEY_keygen_init(pkey_gen_ctx.get());
203
        if (ret != OPENSSL_SUCCESS) {
204
                return expected::unexpected(MakeError(
205
                        SetupError,
206
                        "Failed to generate a private key. Initialization failed: "
207
                                + GetOpenSSLErrorMessage()));
208
        }
209

210
        OSSL_PARAM params[3];
211
        auto bits_buffer = bits;
212
        auto exponent_buffer = exponent;
213
        params[0] = OSSL_PARAM_construct_uint("bits", &bits_buffer);
214
        params[1] = OSSL_PARAM_construct_uint("e", &exponent_buffer);
215
        params[2] = OSSL_PARAM_construct_end();
216

217
        ret = EVP_PKEY_CTX_set_params(pkey_gen_ctx.get(), params);
218
        if (ret != OPENSSL_SUCCESS) {
219
                return expected::unexpected(MakeError(
220
                        SetupError,
221
                        "Failed to generate a private key. Parameters setting failed: "
222
                                + GetOpenSSLErrorMessage()));
223
        }
224

225
        EVP_PKEY *pkey = nullptr;
226
        ret = EVP_PKEY_generate(pkey_gen_ctx.get(), &pkey);
227
        if (ret != OPENSSL_SUCCESS) {
228
                return expected::unexpected(MakeError(
229
                        SetupError,
230
                        "Failed to generate a private key. Generation failed: " + GetOpenSSLErrorMessage()));
231
        }
232
#endif
233

234
        auto private_key = unique_ptr<EVP_PKEY, void (*)(EVP_PKEY *)>(pkey, pkey_free_func);
8✔
235
        return unique_ptr<PrivateKey>(new PrivateKey(std::move(private_key)));
16✔
236
}
237

238
expected::ExpectedString EncodeBase64(vector<uint8_t> to_encode) {
18✔
239
        // Predict the len of the decoded for later verification. From man page:
240
        // For every 3 bytes of input provided 4 bytes of output
241
        // data will be produced. If n is not divisible by 3 (...)
242
        // the output is padded such that it is always divisible by 4.
243
        const uint64_t predicted_len {4 * ((to_encode.size() + 2) / 3)};
18✔
244

245
        // Add space for a NUL terminator. From man page:
246
        // Additionally a NUL terminator character will be added
247
        auto buffer {vector<unsigned char>(predicted_len + 1)};
36✔
248

249
        const int64_t output_len {
250
                EVP_EncodeBlock(buffer.data(), to_encode.data(), static_cast<int>(to_encode.size()))};
18✔
251
        assert(output_len >= 0);
18✔
252

253
        if (predicted_len != static_cast<uint64_t>(output_len)) {
18✔
254
                return expected::unexpected(
×
255
                        MakeError(Base64Error, "The predicted and the actual length differ"));
×
256
        }
257

258
        return string(buffer.begin(), buffer.end() - 1); // Remove the last zero byte
36✔
259
}
260

261
expected::ExpectedBytes DecodeBase64(string to_decode) {
13✔
262
        // Predict the len of the decoded for later verification. From man page:
263
        // For every 4 input bytes exactly 3 output bytes will be
264
        // produced. The output will be padded with 0 bits if necessary
265
        // to ensure that the output is always 3 bytes.
266
        const uint64_t predicted_len {3 * ((to_decode.size() + 3) / 4)};
13✔
267

268
        auto buffer {vector<unsigned char>(predicted_len)};
26✔
269

270
        const int64_t output_len {EVP_DecodeBlock(
13✔
271
                buffer.data(),
272
                common::ByteVectorFromString(to_decode).data(),
13✔
273
                static_cast<int>(to_decode.size()))};
26✔
274
        assert(output_len >= 0);
13✔
275

276
        if (predicted_len != static_cast<uint64_t>(output_len)) {
13✔
277
                return expected::unexpected(MakeError(
×
278
                        Base64Error,
279
                        "The predicted (" + std::to_string(predicted_len) + ") and the actual ("
×
280
                                + std::to_string(output_len) + ") length differ"));
×
281
        }
282

283
        // Subtract padding bytes. Inspired by internal OpenSSL code from:
284
        // https://github.com/openssl/openssl/blob/ff88545e02ab48a52952350c52013cf765455dd3/crypto/ct/ct_b64.c#L46
285
        for (auto it = to_decode.crbegin(); *it == '='; it++) {
19✔
286
                buffer.pop_back();
6✔
287
        }
288

289
        return buffer;
13✔
290
}
291

292

293
expected::ExpectedString ExtractPublicKey(const string &private_key_path) {
17✔
294
        auto private_bio_key = unique_ptr<BIO, void (*)(BIO *)>(
295
                BIO_new_file(private_key_path.c_str(), "r"), bio_free_func);
34✔
296

297
        if (!private_bio_key.get()) {
17✔
298
                return expected::unexpected(MakeError(
2✔
299
                        SetupError, "Failed to open the private key file: " + GetOpenSSLErrorMessage()));
4✔
300
        }
301

302
        auto private_key = unique_ptr<EVP_PKEY, void (*)(EVP_PKEY *)>(
303
                PEM_read_bio_PrivateKey(private_bio_key.get(), nullptr, nullptr, nullptr), pkey_free_func);
30✔
304
        if (private_key == nullptr) {
15✔
305
                return expected::unexpected(
×
306
                        MakeError(SetupError, "Failed to load the key: " + GetOpenSSLErrorMessage()));
×
307
        }
308

309
        auto bio_public_key = unique_ptr<BIO, void (*)(BIO *)>(BIO_new(BIO_s_mem()), bio_free_all_func);
30✔
310

311
        if (!bio_public_key.get()) {
15✔
312
                return expected::unexpected(MakeError(
×
313
                        SetupError,
314
                        "Failed to extract the public key from the private key: " + GetOpenSSLErrorMessage()));
×
315
        }
316

317
        int ret = PEM_write_bio_PUBKEY(bio_public_key.get(), private_key.get());
15✔
318
        if (ret != OPENSSL_SUCCESS) {
15✔
319
                return expected::unexpected(MakeError(
×
320
                        SetupError,
321
                        "Failed to extract the public key. OpenSSL BIO write failed: "
322
                                + GetOpenSSLErrorMessage()));
×
323
        }
324

325
        int pending = BIO_ctrl_pending(bio_public_key.get());
15✔
326
        if (pending <= 0) {
15✔
327
                return expected::unexpected(MakeError(
×
328
                        SetupError,
329
                        "Failed to extract the public key. Zero byte key unexpected: "
330
                                + GetOpenSSLErrorMessage()));
×
331
        }
332

333
        vector<uint8_t> key_vector(pending);
30✔
334

335
        size_t read = BIO_read(bio_public_key.get(), key_vector.data(), pending);
15✔
336

337
        if (read == 0) {
15✔
338
                MakeError(
×
339
                        SetupError,
340
                        "Failed to extract the public key. Zero bytes read from BIO: "
341
                                + GetOpenSSLErrorMessage());
×
342
        }
343

344
        return string(key_vector.begin(), key_vector.end());
30✔
345
}
346

347
expected::ExpectedBytes SignData(const string &private_key_path, const vector<uint8_t> &digest) {
18✔
348
        auto bio_private_key = unique_ptr<BIO, void (*)(BIO *)>(
349
                BIO_new_file(private_key_path.c_str(), "r"), bio_free_func);
36✔
350
        if (bio_private_key == nullptr) {
18✔
351
                return expected::unexpected(MakeError(
1✔
352
                        SetupError, "Failed to open the private key file: " + GetOpenSSLErrorMessage()));
2✔
353
        }
354

355
        auto pkey = unique_ptr<EVP_PKEY, void (*)(EVP_PKEY *)>(
356
                PEM_read_bio_PrivateKey(bio_private_key.get(), nullptr, nullptr, nullptr), pkey_free_func);
34✔
357
        if (pkey == nullptr) {
17✔
358
                return expected::unexpected(
×
359
                        MakeError(SetupError, "Failed to load the key: " + GetOpenSSLErrorMessage()));
×
360
        }
361

362
        auto pkey_signer_ctx = unique_ptr<EVP_PKEY_CTX, void (*)(EVP_PKEY_CTX *)>(
363
                EVP_PKEY_CTX_new(pkey.get(), nullptr), pkey_ctx_free_func);
34✔
364

365
        if (EVP_PKEY_sign_init(pkey_signer_ctx.get()) <= 0) {
17✔
366
                return expected::unexpected(MakeError(
×
367
                        SetupError, "Failed to initialize the OpenSSL signer: " + GetOpenSSLErrorMessage()));
×
368
        }
369
        if (EVP_PKEY_CTX_set_signature_md(pkey_signer_ctx.get(), EVP_sha256()) <= 0) {
17✔
370
                return expected::unexpected(MakeError(
×
371
                        SetupError,
372
                        "Failed to set the OpenSSL signature to sha256: " + GetOpenSSLErrorMessage()));
×
373
        }
374

375
        vector<uint8_t> signature {};
34✔
376

377
        // Set the needed signature buffer length
378
        size_t digestlength = MENDER_DIGEST_SHA256_LENGTH, siglength;
17✔
379
        if (EVP_PKEY_sign(pkey_signer_ctx.get(), nullptr, &siglength, digest.data(), digestlength)
17✔
380
                <= 0) {
17✔
381
                return expected::unexpected(MakeError(
×
382
                        SetupError, "Failed to get the signature buffer length: " + GetOpenSSLErrorMessage()));
×
383
        }
384
        signature.resize(siglength);
17✔
385

386
        if (EVP_PKEY_sign(
17✔
387
                        pkey_signer_ctx.get(), signature.data(), &siglength, digest.data(), digestlength)
388
                <= 0) {
17✔
389
                return expected::unexpected(
×
390
                        MakeError(SetupError, "Failed to sign the digest: " + GetOpenSSLErrorMessage()));
×
391
        }
392

393
        // The signature may in some cases be shorter than the previously allocated
394
        // length (which is the max)
395
        signature.resize(siglength);
17✔
396

397
        return signature;
17✔
398
}
399

400
expected::ExpectedString Sign(const string &private_key_path, const mender::sha::SHA &shasum) {
18✔
401
        auto exp_signed_data = SignData(private_key_path, shasum);
36✔
402
        if (!exp_signed_data) {
18✔
403
                return expected::unexpected(exp_signed_data.error());
2✔
404
        }
405
        vector<uint8_t> signature = exp_signed_data.value();
17✔
406

407
        return EncodeBase64(signature);
17✔
408
}
409

410
expected::ExpectedString SignRawData(
17✔
411
        const string &private_key_path, const vector<uint8_t> &raw_data) {
412
        auto exp_shasum = mender::sha::Shasum(raw_data);
34✔
413

414
        if (!exp_shasum) {
17✔
415
                return expected::unexpected(exp_shasum.error());
×
416
        }
417
        auto shasum = exp_shasum.value();
34✔
418
        log::Debug("Shasum is: " + shasum.String());
17✔
419

420
        return Sign(private_key_path, shasum);
17✔
421
}
422

423
expected::ExpectedBool VerifySignData(
12✔
424
        const string &public_key_path,
425
        const mender::sha::SHA &shasum,
426
        const vector<uint8_t> &signature) {
427
        auto bio_key =
428
                unique_ptr<BIO, void (*)(BIO *)>(BIO_new_file(public_key_path.c_str(), "r"), bio_free_func);
24✔
429
        if (bio_key == nullptr) {
12✔
430
                return expected::unexpected(MakeError(
3✔
431
                        SetupError, "Failed to open the public key file: " + GetOpenSSLErrorMessage()));
6✔
432
        }
433

434
        auto pkey = unique_ptr<EVP_PKEY, void (*)(EVP_PKEY *)>(
435
                PEM_read_bio_PUBKEY(bio_key.get(), nullptr, nullptr, nullptr), pkey_free_func);
18✔
436
        if (pkey == nullptr) {
9✔
437
                return expected::unexpected(
2✔
438
                        MakeError(SetupError, "Failed to load the key: " + GetOpenSSLErrorMessage()));
4✔
439
        }
440

441
        // prepare context
442
        auto pkey_signer_ctx = unique_ptr<EVP_PKEY_CTX, void (*)(EVP_PKEY_CTX *)>(
443
                EVP_PKEY_CTX_new(pkey.get(), nullptr), pkey_ctx_free_func);
14✔
444

445
        auto ret = EVP_PKEY_verify_init(pkey_signer_ctx.get());
7✔
446
        if (ret <= 0) {
7✔
447
                return expected::unexpected(MakeError(
×
448
                        SetupError, "Failed to initialize the OpenSSL signer: " + GetOpenSSLErrorMessage()));
×
449
        }
450
        ret = EVP_PKEY_CTX_set_signature_md(pkey_signer_ctx.get(), EVP_sha256());
7✔
451
        if (ret <= 0) {
7✔
452
                return expected::unexpected(MakeError(
×
453
                        SetupError,
454
                        "Failed to set the OpenSSL signature to sha256: " + GetOpenSSLErrorMessage()));
×
455
        }
456

457
        // verify signature
458
        ret = EVP_PKEY_verify(
7✔
459
                pkey_signer_ctx.get(), signature.data(), signature.size(), shasum.data(), shasum.size());
460
        if (ret < 0) {
7✔
461
                return expected::unexpected(MakeError(
×
462
                        VerificationError,
463
                        "Failed to verify signature. OpenSSL PKEY verify failed: " + GetOpenSSLErrorMessage()));
×
464
        }
465

466
        return ret == OPENSSL_SUCCESS;
14✔
467
}
468

469
expected::ExpectedBool VerifySign(
12✔
470
        const string &public_key_path, const mender::sha::SHA &shasum, const string &signature) {
471
        // signature: decode base64
472
        auto exp_decoded_signature = DecodeBase64(signature);
24✔
473
        if (!exp_decoded_signature) {
12✔
474
                return expected::unexpected(exp_decoded_signature.error());
×
475
        }
476
        auto decoded_signature = exp_decoded_signature.value();
24✔
477

478
        return VerifySignData(public_key_path, shasum, decoded_signature);
12✔
479
}
480

481
error::Error PrivateKey::SaveToPEM(const string &private_key_path) {
6✔
482
        auto bio_key = unique_ptr<BIO, void (*)(BIO *)>(
483
                BIO_new_file(private_key_path.c_str(), "w"), bio_free_func);
12✔
484
        if (bio_key == nullptr) {
6✔
485
                return MakeError(
486
                        SetupError, "Failed to open the private key file: " + GetOpenSSLErrorMessage());
2✔
487
        }
488

489
        // PEM_write_bio_PrivateKey_traditional will use the key-specific PKCS1
490
        // format if one is available for that key type, otherwise it will encode
491
        // to a PKCS8 key.
492
        auto ret = PEM_write_bio_PrivateKey_traditional(
5✔
493
                bio_key.get(), key.get(), nullptr, nullptr, 0, nullptr, nullptr);
494
        if (ret != OPENSSL_SUCCESS) {
5✔
495
                return MakeError(
496
                        SetupError, "Failed to save the private key to file: " + GetOpenSSLErrorMessage());
×
497
        }
498

499
        return error::NoError;
5✔
500
}
501

502
} // namespace crypto
503
} // namespace common
504
} // namespace mender
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc