Add MQTT enrollment demo with Gitea deploy pipeline
Go backend plus C (libsodium) and C++ (OpenSSL) simulated boards enroll through a Mosquitto 2.1 broker with Dynamic Security using X25519/HKDF/ Ed25519; a web view shows the run live. The Gitea workflow validates and builds the images, redeploys the stack on the server with the viewer on port 8099 and fails unless both boards pass all checks. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+189
@@ -0,0 +1,189 @@
|
||||
// Interop spike: X25519 + HKDF-SHA256 + Ed25519 with fixed inputs (C++17, OpenSSL 3 EVP, RAII).
|
||||
// Prints the same key=value lines as the Go and C programs so the outputs can be diffed.
|
||||
#include <openssl/core_names.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/kdf.h>
|
||||
#include <openssl/params.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using Bytes = std::vector<uint8_t>;
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kBoardPriv = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
|
||||
constexpr const char *kBackendEph = "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f";
|
||||
constexpr const char *kSigningSeed = "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f";
|
||||
constexpr const char *kNonce = "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf";
|
||||
constexpr const char *kClientId = "e1c9f0a2b3d4c5b6a79881726354453f";
|
||||
constexpr const char *kFn = "FN-000042";
|
||||
constexpr const char *kSn = "SN000123";
|
||||
|
||||
struct PkeyDel { void operator()(EVP_PKEY *p) const { EVP_PKEY_free(p); } };
|
||||
struct PctxDel { void operator()(EVP_PKEY_CTX *p) const { EVP_PKEY_CTX_free(p); } };
|
||||
struct MdDel { void operator()(EVP_MD_CTX *p) const { EVP_MD_CTX_free(p); } };
|
||||
struct KdfDel { void operator()(EVP_KDF *p) const { EVP_KDF_free(p); } };
|
||||
struct KdfCtxDel { void operator()(EVP_KDF_CTX *p) const { EVP_KDF_CTX_free(p); } };
|
||||
using Pkey = std::unique_ptr<EVP_PKEY, PkeyDel>;
|
||||
using Pctx = std::unique_ptr<EVP_PKEY_CTX, PctxDel>;
|
||||
using MdCtx = std::unique_ptr<EVP_MD_CTX, MdDel>;
|
||||
|
||||
[[noreturn]] void die(const std::string &m) { throw std::runtime_error(m); }
|
||||
|
||||
Bytes unhex(const std::string &h) {
|
||||
Bytes out;
|
||||
for (size_t i = 0; i + 1 < h.size(); i += 2)
|
||||
out.push_back(static_cast<uint8_t>(std::stoi(h.substr(i, 2), nullptr, 16)));
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string tohex(const Bytes &b) {
|
||||
std::string s;
|
||||
char buf[3];
|
||||
for (uint8_t c : b) { std::snprintf(buf, sizeof buf, "%02x", c); s += buf; }
|
||||
return s;
|
||||
}
|
||||
|
||||
void expect(const std::string &name, const Bytes &got, const std::string &want) {
|
||||
if (tohex(got) != want) die(name + "\n got " + tohex(got) + "\n want " + want);
|
||||
}
|
||||
|
||||
Bytes x25519Pub(const Bytes &priv) {
|
||||
Pkey k(EVP_PKEY_new_raw_private_key(EVP_PKEY_X25519, nullptr, priv.data(), priv.size()));
|
||||
Bytes pub(32);
|
||||
size_t l = pub.size();
|
||||
if (!k || EVP_PKEY_get_raw_public_key(k.get(), pub.data(), &l) != 1 || l != 32) die("x25519 pub");
|
||||
return pub;
|
||||
}
|
||||
|
||||
Bytes x25519Shared(const Bytes &priv, const Bytes &peer) {
|
||||
Pkey k(EVP_PKEY_new_raw_private_key(EVP_PKEY_X25519, nullptr, priv.data(), priv.size()));
|
||||
Pkey p(EVP_PKEY_new_raw_public_key(EVP_PKEY_X25519, nullptr, peer.data(), peer.size()));
|
||||
if (!k || !p) die("x25519 keys");
|
||||
Pctx c(EVP_PKEY_CTX_new(k.get(), nullptr));
|
||||
Bytes out(32);
|
||||
size_t l = out.size();
|
||||
if (!c || EVP_PKEY_derive_init(c.get()) != 1 || EVP_PKEY_derive_set_peer(c.get(), p.get()) != 1 ||
|
||||
EVP_PKEY_derive(c.get(), out.data(), &l) != 1 || l != 32)
|
||||
die("x25519 derive");
|
||||
return out;
|
||||
}
|
||||
|
||||
Bytes hkdf(const Bytes &ikm, const Bytes &salt, const Bytes &info, size_t n) {
|
||||
std::unique_ptr<EVP_KDF, KdfDel> kdf(EVP_KDF_fetch(nullptr, "HKDF", nullptr));
|
||||
if (!kdf) die("hkdf fetch");
|
||||
std::unique_ptr<EVP_KDF_CTX, KdfCtxDel> ctx(EVP_KDF_CTX_new(kdf.get()));
|
||||
std::string digest = "SHA256";
|
||||
OSSL_PARAM p[5];
|
||||
p[0] = OSSL_PARAM_construct_utf8_string(OSSL_KDF_PARAM_DIGEST, digest.data(), 0);
|
||||
p[1] = OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_KEY, const_cast<uint8_t *>(ikm.data()), ikm.size());
|
||||
p[2] = OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_SALT, const_cast<uint8_t *>(salt.data()), salt.size());
|
||||
p[3] = OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_INFO, const_cast<uint8_t *>(info.data()), info.size());
|
||||
p[4] = OSSL_PARAM_construct_end();
|
||||
Bytes out(n);
|
||||
if (!ctx || EVP_KDF_derive(ctx.get(), out.data(), out.size(), p) != 1) die("hkdf");
|
||||
return out;
|
||||
}
|
||||
|
||||
Bytes edPub(const Bytes &seed) {
|
||||
Pkey k(EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, nullptr, seed.data(), seed.size()));
|
||||
Bytes pub(32);
|
||||
size_t l = pub.size();
|
||||
if (!k || EVP_PKEY_get_raw_public_key(k.get(), pub.data(), &l) != 1 || l != 32) die("ed25519 pub");
|
||||
return pub;
|
||||
}
|
||||
|
||||
Bytes edSign(const Bytes &seed, const Bytes &msg) {
|
||||
Pkey k(EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, nullptr, seed.data(), seed.size()));
|
||||
MdCtx c(EVP_MD_CTX_new());
|
||||
Bytes sig(64);
|
||||
size_t sl = sig.size();
|
||||
static const uint8_t empty = 0;
|
||||
const uint8_t *m = msg.empty() ? &empty : msg.data();
|
||||
if (!k || !c || EVP_DigestSignInit(c.get(), nullptr, nullptr, nullptr, k.get()) != 1 ||
|
||||
EVP_DigestSign(c.get(), sig.data(), &sl, m, msg.size()) != 1 || sl != 64)
|
||||
die("ed25519 sign");
|
||||
return sig;
|
||||
}
|
||||
|
||||
bool edVerify(const Bytes &pub, const Bytes &msg, const Bytes &sig) {
|
||||
Pkey k(EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519, nullptr, pub.data(), pub.size()));
|
||||
MdCtx c(EVP_MD_CTX_new());
|
||||
return k && c && EVP_DigestVerifyInit(c.get(), nullptr, nullptr, nullptr, k.get()) == 1 &&
|
||||
EVP_DigestVerify(c.get(), sig.data(), sig.size(), msg.data(), msg.size()) == 1;
|
||||
}
|
||||
|
||||
void kat() {
|
||||
// RFC 7748 section 6.1
|
||||
Bytes ap = unhex("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a");
|
||||
Bytes bp = unhex("5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb");
|
||||
Bytes apub = x25519Pub(ap), bpub = x25519Pub(bp);
|
||||
expect("rfc7748 alice pub", apub, "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a");
|
||||
expect("rfc7748 bob pub", bpub, "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f");
|
||||
expect("rfc7748 shared a", x25519Shared(ap, bpub), "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742");
|
||||
expect("rfc7748 shared b", x25519Shared(bp, apub), "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742");
|
||||
std::cout << "kat.x25519=ok\n";
|
||||
|
||||
// RFC 5869 test case 1
|
||||
expect("rfc5869 tc1", hkdf(Bytes(22, 0x0b), unhex("000102030405060708090a0b0c"), unhex("f0f1f2f3f4f5f6f7f8f9"), 42),
|
||||
"3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865");
|
||||
std::cout << "kat.hkdf=ok\n";
|
||||
|
||||
// RFC 8032 section 7.1, test 1 (empty message)
|
||||
Bytes seed = unhex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60");
|
||||
expect("rfc8032 pub", edPub(seed), "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a");
|
||||
expect("rfc8032 sig", edSign(seed, {}),
|
||||
"e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b");
|
||||
std::cout << "kat.ed25519=ok\n";
|
||||
}
|
||||
|
||||
std::string password(const Bytes &shared, const Bytes &nonce, const std::string &id) {
|
||||
std::string info = "robot-market/mqtt-pw/v1|" + id;
|
||||
return tohex(hkdf(shared, nonce, Bytes(info.begin(), info.end()), 32));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() try {
|
||||
kat();
|
||||
|
||||
Bytes b = unhex(kBoardPriv), s = unhex(kBackendEph), nonce = unhex(kNonce), seed = unhex(kSigningSeed);
|
||||
Bytes bpub = x25519Pub(b), spub = x25519Pub(s);
|
||||
std::cout << "board_pub=" << tohex(bpub) << "\n";
|
||||
std::cout << "backend_eph_pub=" << tohex(spub) << "\n";
|
||||
|
||||
Bytes shared = x25519Shared(s, bpub); // backend side
|
||||
Bytes shared2 = x25519Shared(b, spub); // board side
|
||||
std::cout << "shared_secret=" << tohex(shared) << "\n";
|
||||
if (shared != shared2) die("shared secret mismatch");
|
||||
std::cout << "shared_secret_peer_match=ok\n";
|
||||
|
||||
std::cout << "password_fn=" << password(shared, nonce, kFn) << "\n";
|
||||
std::cout << "password_sn=" << password(shared, nonce, kSn) << "\n";
|
||||
|
||||
// Backend signs S || B || nonce || clientID.
|
||||
Bytes msg;
|
||||
msg.insert(msg.end(), spub.begin(), spub.end());
|
||||
msg.insert(msg.end(), bpub.begin(), bpub.end());
|
||||
msg.insert(msg.end(), nonce.begin(), nonce.end());
|
||||
std::string cid = kClientId;
|
||||
msg.insert(msg.end(), cid.begin(), cid.end());
|
||||
|
||||
Bytes pub = edPub(seed), sig = edSign(seed, msg);
|
||||
std::cout << "signing_pub=" << tohex(pub) << "\n";
|
||||
std::cout << "signed_message=" << tohex(msg) << "\n";
|
||||
std::cout << "signature=" << tohex(sig) << "\n";
|
||||
if (!edVerify(pub, msg, sig)) die("signature verify");
|
||||
std::cout << "signature_verify=ok\n";
|
||||
return 0;
|
||||
} catch (const std::exception &e) {
|
||||
std::cerr << "FAIL: " << e.what() << "\n";
|
||||
return 1;
|
||||
}
|
||||
Reference in New Issue
Block a user