Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ curl -u user:pass -H "X-CSRF-Token: your_token_here" \
## POST /api/password
@copydoc confighttp::savePassword()

## GET /api/pin
@copydoc confighttp::getPendingPairings()

## POST /api/pin
@copydoc confighttp::savePin()

Expand Down
30 changes: 30 additions & 0 deletions src/confighttp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1315,6 +1315,20 @@ namespace confighttp {
* @brief Get the list of paired clients.
* @param response The HTTP response object.
* @param request The HTTP request object.
* The response is JSON serialized in the following format:
* @code{.json}
* {
* "named_certs": [
* {
* "name": "My Phone",
* "uuid": "<uuid>",
* "enabled": true,
* "fingerprint": "<SHA-256 of the client certificate, uppercase hex>"
* }
* ],
* "status": true
* }
* @endcode
*
* @api_examples{/api/clients/list|:| GET|:| null}
*/
Expand Down Expand Up @@ -1799,6 +1813,21 @@ namespace confighttp {

/**
* @brief List client pairing requests that are waiting for PIN approval.
* The response is JSON serialized in the following format:
* @code{.json}
* {
* "pairings": [
* {
* "id": "<pairing_id>",
* "name": "<device name reported by the client>",
* "address": "192.0.2.10",
* "fingerprint": "<SHA-256 of the client certificate, uppercase hex>"
* }
* ]
* }
* @endcode
* The fingerprint matches the one `/api/clients/list` reports once the request is paired, so a request
* can be attributed to a device that is already paired elsewhere.
*
* @api_examples{/api/pin|:| GET|:| null}
*/
Expand All @@ -1816,6 +1845,7 @@ namespace confighttp {
{"id", pairing.id},
{"name", pairing.name},
{"address", pairing.address},
{"fingerprint", pairing.fingerprint},
});
}
send_response(response, output_tree);
Expand Down
17 changes: 17 additions & 0 deletions src/crypto.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,23 @@ namespace crypto {
};
}

/**
* @brief Compute the SHA-256 fingerprint of a PEM certificate.
*/
std::string fingerprint256(const std::string_view &pem) {
const auto cert = x509(pem);
if (!cert) {
return {};
}

sha256_t digest;
if (unsigned int size = 0; X509_digest(cert.get(), EVP_sha256(), digest.data(), &size) != 1 || size != digest.size()) {
return {};
}

return util::hex_vec(digest, true);
}

/**
* @brief Generate cryptographically secure random bytes.
*/
Expand Down
10 changes: 10 additions & 0 deletions src/crypto.h
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,16 @@ namespace crypto {
*/
std::string_view signature(const x509_t &x);

/**
* @brief Compute the SHA-256 fingerprint of a PEM certificate.
*
* The digest covers the DER encoding, as `openssl x509 -fingerprint -sha256` does.
*
* @param pem PEM text containing an X.509 certificate.
* @return Uppercase hexadecimal digest without separators, or an empty string when the PEM is invalid.
*/
std::string fingerprint256(const std::string_view &pem);

/**
* @brief Generate cryptographically secure random bytes.
*
Expand Down
2 changes: 2 additions & 0 deletions src/nvhttp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,7 @@ namespace nvhttp {
.id = sess->async_insert_pin.id,
.name = sess->async_insert_pin.device_name,
.address = sess->async_insert_pin.address,
.fingerprint = crypto::fingerprint256(sess->client.cert),
});
}
return result;
Expand Down Expand Up @@ -1317,6 +1318,7 @@ namespace nvhttp {
named_cert_node["name"] = named_cert.name;
named_cert_node["uuid"] = named_cert.uuid;
named_cert_node["enabled"] = named_cert.enabled;
named_cert_node["fingerprint"] = crypto::fingerprint256(named_cert.cert);
named_cert_nodes.push_back(named_cert_node);
}

Expand Down
1 change: 1 addition & 0 deletions src/nvhttp.h
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ namespace nvhttp {
std::string id; ///< Unguessable approval identifier.
std::string name; ///< Untrusted device name reported by the pairing client.
std::string address; ///< Network address from which the request originated.
std::string fingerprint; ///< SHA-256 fingerprint of the client certificate, empty when it is invalid.
};

/**
Expand Down
22 changes: 22 additions & 0 deletions tests/unit/test_crypto.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,25 @@ TEST(CryptoTest, CertificateChainRequiresExactPairedIdentity) {
EXPECT_EQ(certificate_chain.verify(paired_certificate.get()), nullptr);
EXPECT_NE(certificate_chain.verify(derived_certificate.get()), nullptr);
}

TEST(CryptoTest, Fingerprint256HashesTheDerCertificate) {
const auto creds = crypto::gen_creds("Sunshine Fingerprint Client", 2048);
const auto cert = crypto::x509(creds.x509);
ASSERT_NE(cert.get(), nullptr);

unsigned char *der = nullptr;
const auto der_size = i2d_X509(cert.get(), &der);
ASSERT_GT(der_size, 0);
const std::string der_bytes {reinterpret_cast<const char *>(der), static_cast<std::size_t>(der_size)};
OPENSSL_free(der);

const auto fingerprint = crypto::fingerprint256(creds.x509);
EXPECT_EQ(fingerprint.size(), 64);
EXPECT_EQ(fingerprint, util::hex_vec(crypto::hash(der_bytes), true));
EXPECT_NE(fingerprint, crypto::fingerprint256(crypto::gen_creds("Sunshine Other Client", 2048).x509));
}

TEST(CryptoTest, Fingerprint256OfInvalidCertificateIsEmpty) {
EXPECT_TRUE(crypto::fingerprint256("").empty());
EXPECT_TRUE(crypto::fingerprint256("-----BEGIN CERTIFICATE-----\nnot a certificate\n-----END CERTIFICATE-----\n").empty());
}
21 changes: 21 additions & 0 deletions tests/unit/test_http_pairing.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include "../tests_common.h"

// standard includes
#include <algorithm>
#include <atomic>
#include <chrono>
#include <condition_variable>
Expand Down Expand Up @@ -694,6 +695,26 @@ TEST_F(PairingSessionRegistryTest, PinApprovalTargetsExplicitPairingId) {
EXPECT_EQ(pending.front().address, "192.0.2.20");
}

TEST_F(PairingSessionRegistryTest, PendingPairingReportsClientCertificateFingerprint) {
auto session = pending_session("fingerprinted", "Phone", "192.0.2.30");
session.client.cert = PUBLIC_CERT;
std::string pairing_id;
ASSERT_EQ(insert_pair_session(std::move(session), pairing_id), pair_session_insert_e::ADDED);
std::string invalid_pairing_id;
ASSERT_EQ(insert_pair_session(pending_session("no-cert", "Tablet", "192.0.2.31"), invalid_pairing_id), pair_session_insert_e::ADDED);

const auto pending = get_pending_pairings();
ASSERT_EQ(pending.size(), 2);
const auto find = [&](const std::string &id) {
return std::ranges::find(pending, id, &pending_pairing_t::id);
};
ASSERT_NE(find(pairing_id), pending.end());
EXPECT_FALSE(find(pairing_id)->fingerprint.empty());
EXPECT_EQ(find(pairing_id)->fingerprint, crypto::fingerprint256(PUBLIC_CERT));
ASSERT_NE(find(invalid_pairing_id), pending.end());
EXPECT_TRUE(find(invalid_pairing_id)->fingerprint.empty());
}

TEST_F(PairingSessionRegistryTest, InvalidApprovalCannotConsumePendingSession) {
std::string pairing_id;
ASSERT_EQ(
Expand Down
15 changes: 15 additions & 0 deletions tests/unit/test_nvhttp_client_auth.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,21 @@ TEST_F(ClientAuthorizationTest, MultipleClientsPersistAndUnpairIndependently) {
EXPECT_FALSE(nvhttp::test_support::authorize_client_certificate(expired_credentials.x509));
}

TEST_F(ClientAuthorizationTest, ClientListReportsCertificateFingerprints) {
const auto first_credentials = test_utils::certificates::generate_ca_credentials("Sunshine First Client");
const auto second_credentials = crypto::gen_creds("Sunshine Second Client", 2048);
ASSERT_FALSE(nvhttp::test_support::add_client("first", first_credentials.x509, true).empty());
ASSERT_FALSE(nvhttp::test_support::add_client("second", second_credentials.x509, false).empty());

const auto clients = nvhttp::get_all_clients();
ASSERT_EQ(clients.size(), 2);
for (const auto &client : clients) {
const auto &credentials = client.at("name") == "first" ? first_credentials : second_credentials;
EXPECT_EQ(client.at("fingerprint"), crypto::fingerprint256(credentials.x509));
}
EXPECT_NE(clients[0].at("fingerprint"), clients[1].at("fingerprint"));
}

TEST_F(ClientAuthorizationTest, DuplicateCertificateIdentityFailsClosed) {
const auto credentials = test_utils::certificates::generate_ca_credentials();
const auto uuid = nvhttp::test_support::add_client("first", credentials.x509, true);
Expand Down