libdpf/include/dpf/net/identity.hpp

379 lines
13 KiB
C++
Raw Normal View History

/// @file dpf/net/identity.hpp
/// @brief Party identity keys (Ed25519) and their text and file forms.
/// @details A party is identified by a raw 32-byte Ed25519 public key, written
/// as 44 characters of base64 (the same shape as a WireGuard key). A
/// key file holds the 32-byte private seed as one base64 line and is
/// created mode 0600. TLS needs a certificate, so `identity` also
/// carries a self-signed certificate generated in memory from the
/// key; peers check the key inside it, never the certificate fields.
/// `development()` is a fixed, publicly known identity: it keeps the
/// client path working with no configuration and provides no security.
#ifndef LIBDPF_INCLUDE_DPF_NET_IDENTITY_HPP__
#define LIBDPF_INCLUDE_DPF_NET_IDENTITY_HPP__
#include <algorithm>
#include <array>
#include <cerrno>
#include <cstdint>
#include <cstring>
#include <fstream>
#include <memory>
#include <stdexcept>
#include <string>
#include <utility>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#ifndef DPF_HAS_OPENSSL
#if defined(__has_include)
#if __has_include(<openssl/ssl.h>)
#define DPF_HAS_OPENSSL 1
#endif
#endif
#endif
#ifndef DPF_HAS_OPENSSL
#define DPF_HAS_OPENSSL 0
#endif
#if DPF_HAS_OPENSSL
#include <openssl/err.h>
#include <openssl/evp.h>
#include <openssl/rand.h>
#include <openssl/x509.h>
#endif
#include "dpf/log.hpp"
namespace dpf
{
namespace net
{
namespace detail
{
inline std::string base64_encode(const std::uint8_t * p, std::size_t n)
{
static const char tab[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
out.reserve((n + 2) / 3 * 4);
for (std::size_t i = 0; i < n; i += 3)
{
const std::uint32_t a = p[i];
const std::uint32_t b = i + 1 < n ? p[i + 1] : 0;
const std::uint32_t c = i + 2 < n ? p[i + 2] : 0;
const std::uint32_t v = (a << 16) | (b << 8) | c;
out += tab[(v >> 18) & 63];
out += tab[(v >> 12) & 63];
out += i + 1 < n ? tab[(v >> 6) & 63] : '=';
out += i + 2 < n ? tab[v & 63] : '=';
}
return out;
}
/// @brief Strict base64 (standard alphabet, padded). Returns false on any
/// malformed input.
inline bool base64_decode(const std::string & s, std::string & out)
{
auto val = [](char ch) -> int {
if (ch >= 'A' && ch <= 'Z')
return ch - 'A';
if (ch >= 'a' && ch <= 'z')
return ch - 'a' + 26;
if (ch >= '0' && ch <= '9')
return ch - '0' + 52;
if (ch == '+')
return 62;
if (ch == '/')
return 63;
return -1;
};
out.clear();
if (s.size() % 4 != 0)
return false;
for (std::size_t i = 0; i < s.size(); i += 4)
{
const bool last = i + 4 == s.size();
int v[4];
for (int k = 0; k < 4; ++k)
{
const char ch = s[i + k];
if (ch == '=' && last && k >= 2)
v[k] = -2;
else
v[k] = val(ch);
if (v[k] == -1)
return false;
}
if (v[0] < 0 || v[1] < 0 || (v[2] == -2 && v[3] != -2))
return false;
const std::uint32_t x = (static_cast<std::uint32_t>(v[0]) << 18)
| (static_cast<std::uint32_t>(v[1]) << 12)
| (static_cast<std::uint32_t>(v[2] < 0 ? 0 : v[2]) << 6)
| static_cast<std::uint32_t>(v[3] < 0 ? 0 : v[3]);
out += static_cast<char>((x >> 16) & 0xff);
if (v[2] >= 0)
out += static_cast<char>((x >> 8) & 0xff);
if (v[3] >= 0)
out += static_cast<char>(x & 0xff);
}
return true;
}
/// @brief First line of `path` that is neither blank nor a `#` comment.
inline std::string first_key_line(const std::string & path, const char * what)
{
std::ifstream in(path);
if (!in)
throw std::runtime_error(std::string(what) + ": cannot read '" + path + "'");
std::string line;
while (std::getline(in, line))
{
while (!line.empty()
&& (line.back() == '\r' || line.back() == ' ' || line.back() == '\t'))
line.pop_back();
std::size_t b = 0;
while (b < line.size() && (line[b] == ' ' || line[b] == '\t'))
++b;
line.erase(0, b);
if (!line.empty() && line[0] != '#')
return line;
}
throw std::runtime_error(std::string(what) + ": '" + path + "' holds no key");
}
#if DPF_HAS_OPENSSL
inline std::string openssl_error(const char * what)
{
std::string out = what;
unsigned long e = 0;
bool first = true;
while ((e = ERR_get_error()) != 0)
{
char buf[256];
ERR_error_string_n(e, buf, sizeof(buf));
out += first ? ": " : "; ";
out += buf;
first = false;
}
return out;
}
struct pkey_free
{
void operator()(EVP_PKEY * k) const noexcept { EVP_PKEY_free(k); }
};
struct x509_free
{
void operator()(X509 * x) const noexcept { X509_free(x); }
};
#endif
} // namespace detail
/// @brief A party's raw Ed25519 public key.
struct public_key
{
static constexpr std::size_t size = 32;
std::array<std::uint8_t, size> bytes{};
/// @brief 44 characters of base64.
std::string base64() const { return detail::base64_encode(bytes.data(), size); }
/// @brief Parse base64, or read a public-key file given as `file:PATH`.
static public_key parse(const std::string & text)
{
std::string s = text;
if (s.rfind("file:", 0) == 0)
s = detail::first_key_line(s.substr(5), "public key");
std::string raw;
if (!detail::base64_decode(s, raw) || raw.size() != size)
throw std::invalid_argument("public key must be 32 bytes of base64 "
"(44 characters), got '" + s + "'");
public_key k;
std::memcpy(k.bytes.data(), raw.data(), size);
return k;
}
friend bool operator==(const public_key & a, const public_key & b) noexcept
{
return a.bytes == b.bytes;
}
friend bool operator!=(const public_key & a, const public_key & b) noexcept
{
return !(a == b);
}
};
/// @brief A party's private key plus the self-signed certificate TLS presents.
/// @details Copies share the key. Without OpenSSL every constructor throws.
class identity
{
public:
/// @brief A fresh random key.
static identity generate()
{
#if DPF_HAS_OPENSSL
std::uint8_t seed[32];
if (RAND_bytes(seed, sizeof(seed)) != 1)
throw std::runtime_error(detail::openssl_error("identity: RAND_bytes"));
auto id = from_seed(seed);
OPENSSL_cleanse(seed, sizeof(seed));
return id;
#else
throw std::logic_error("identity: built without OpenSSL");
#endif
}
/// @brief The key whose private seed is `seed`.
static identity from_seed(const std::uint8_t * seed, const char * cn = "libdpf party")
{
#if DPF_HAS_OPENSSL
identity id;
EVP_PKEY * k = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, nullptr, seed, 32);
if (k == nullptr)
throw std::runtime_error(detail::openssl_error("identity: bad Ed25519 seed"));
id.key_.reset(k, detail::pkey_free{});
std::size_t n = public_key::size;
if (EVP_PKEY_get_raw_public_key(k, id.pub_.bytes.data(), &n) != 1
|| n != public_key::size)
throw std::runtime_error(detail::openssl_error("identity: public key"));
id.cert_ = self_signed(k, cn);
return id;
#else
(void)seed;
(void)cn;
throw std::logic_error("identity: built without OpenSSL");
#endif
}
/// @brief Read a key file (one base64 line holding the 32-byte seed).
/// @details Warns when the file is readable by group or others.
static identity load(const std::string & path)
{
std::string raw;
const auto line = detail::first_key_line(path, "identity");
if (!detail::base64_decode(line, raw) || raw.size() != 32)
throw std::invalid_argument("identity: '" + path
+ "' is not a key file (expected 32 bytes of base64)");
struct stat st{};
if (::stat(path.c_str(), &st) == 0 && (st.st_mode & 077) != 0)
DPF_LOG(warning, "security.key_file_mode").kv("path", path)
.kv("detail", "identity key file is readable by group or others; "
"chmod 600 it");
auto id = from_seed(reinterpret_cast<const std::uint8_t *>(raw.data()));
std::fill(raw.begin(), raw.end(), '\0');
return id;
}
/// @brief The fixed development identity. Its private key is in this
/// source file, so it authenticates nothing.
static const identity & development()
{
static const identity dev = [] {
#if DPF_HAS_OPENSSL
static const char phrase[] =
"libdpf development identity (public; provides no security)";
std::uint8_t seed[32];
unsigned int n = sizeof(seed);
if (EVP_Digest(phrase, sizeof(phrase) - 1, seed, &n, EVP_sha256(), nullptr)
!= 1)
throw std::runtime_error(detail::openssl_error("identity: digest"));
auto id = from_seed(seed, "libdpf development certificate (no security)");
id.development_ = true;
return id;
#else
return identity();
#endif
}();
if (!dev.key_)
throw std::logic_error("identity: built without OpenSSL");
return dev;
}
/// @brief Write the private seed to a new file with mode 0600. Refuses to
/// replace an existing file unless `overwrite`.
void save(const std::string & path, bool overwrite = false) const
{
#if DPF_HAS_OPENSSL
std::uint8_t seed[32];
std::size_t n = sizeof(seed);
if (!key_ || EVP_PKEY_get_raw_private_key(key_.get(), seed, &n) != 1 || n != 32)
throw std::runtime_error(detail::openssl_error("identity: private key"));
const std::string text = "# libdpf identity key (private; keep mode 600)\n"
+ detail::base64_encode(seed, sizeof(seed)) + "\n";
OPENSSL_cleanse(seed, sizeof(seed));
const int flags = O_WRONLY | O_CREAT | O_CLOEXEC | (overwrite ? O_TRUNC : O_EXCL);
const int fd = ::open(path.c_str(), flags, 0600);
if (fd < 0)
throw std::runtime_error("identity: cannot create '" + path + "': "
+ std::strerror(errno));
const bool ok = ::fchmod(fd, 0600) == 0
&& ::write(fd, text.data(), text.size()) == static_cast<ssize_t>(text.size());
::close(fd);
if (!ok)
throw std::runtime_error("identity: cannot write '" + path + "'");
#else
(void)path;
(void)overwrite;
throw std::logic_error("identity: built without OpenSSL");
#endif
}
const public_key & key() const noexcept { return pub_; }
bool is_development() const noexcept { return development_; }
#if DPF_HAS_OPENSSL
EVP_PKEY * pkey() const noexcept { return key_.get(); }
X509 * cert() const noexcept { return cert_.get(); }
#endif
private:
identity() = default;
#if DPF_HAS_OPENSSL
static std::shared_ptr<X509> self_signed(EVP_PKEY * k, const char * cn)
{
std::shared_ptr<X509> x(X509_new(), detail::x509_free{});
if (!x)
throw std::runtime_error(detail::openssl_error("identity: X509_new"));
std::uint8_t serial[8];
if (RAND_bytes(serial, sizeof(serial)) != 1)
throw std::runtime_error(detail::openssl_error("identity: serial"));
std::uint64_t s = 0;
for (auto b : serial)
s = (s << 8) | b;
s &= 0x7fffffffffffffffull;
bool ok = X509_set_version(x.get(), 2) == 1
&& ASN1_INTEGER_set_uint64(X509_get_serialNumber(x.get()), s) == 1
&& X509_gmtime_adj(X509_getm_notBefore(x.get()), -86400) != nullptr
&& X509_time_adj_ex(X509_getm_notAfter(x.get()), 36500, 0, nullptr) != nullptr
&& X509_set_pubkey(x.get(), k) == 1;
X509_NAME * name = X509_get_subject_name(x.get());
ok = ok && name != nullptr
&& X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC,
reinterpret_cast<const unsigned char *>(cn), -1, -1, 0)
== 1
&& X509_set_issuer_name(x.get(), name) == 1
&& X509_sign(x.get(), k, nullptr) > 0;
if (!ok)
throw std::runtime_error(detail::openssl_error("identity: certificate"));
return x;
}
std::shared_ptr<EVP_PKEY> key_;
std::shared_ptr<X509> cert_;
#else
std::shared_ptr<void> key_;
#endif
public_key pub_{};
bool development_ = false;
};
} // namespace net
} // namespace dpf
#endif // LIBDPF_INCLUDE_DPF_NET_IDENTITY_HPP__