Commit e30786fd authored by jan.koester's avatar jan.koester
Browse files

pw change possible

parent 2e572695
Loading
Loading
Loading
Loading
Loading
+281 −8
Changes for plugin/krb5/krb5.cpp: 281 added lines, 8 removed lines.
Original line number Diff line number Diff line
@@ -26,6 +26,9 @@
 *******************************************************************************/

#include <atomic>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <iostream>
#include <cstring>
#include <cstdlib>
@@ -2646,6 +2649,11 @@ Krb5KDC::Krb5KDC(AuthBackend &adminBackend,
    /* UDP socket on the same port (Kerberos primary transport) */
    _Udp = new netplus::udp(bindAddr.c_str(), port, maxconn, -1);
    std::cerr << "KDC listening on " << bindAddr << ":" << port << " (tcp+udp)" << std::endl;

    /* kpasswd (464): same framing over TCP, own UDP socket */
    _ServerSockets.push_back(new netplus::tcp(bindAddr.c_str(), 464, maxconn, 0));
    _UdpKpasswd = new netplus::udp(bindAddr.c_str(), 464, maxconn, -1);
    std::cerr << "kpasswd listening on " << bindAddr << ":464 (tcp+udp)" << std::endl;
}

void Krb5KDC::init() {
@@ -2656,23 +2664,28 @@ void Krb5KDC::init() {
    }
    if (_Udp)
        _Udp->bind();
    if (_UdpKpasswd)
        _UdpKpasswd->bind();
}

/* UDP responder: each datagram is a bare ASN.1 DER Kerberos message (no TCP
 * 4-byte length prefix). Process it and send the bare response back. */
void Krb5KDC::udpLoop() {
void Krb5KDC::udpLoop(netplus::udp *sock) {
    while (_Running) {
        if (!_Udp->waitReadable(500))
        if (!sock->waitReadable(500))
            continue;
        std::vector<std::vector<uint8_t>> dgrams;
        std::vector<sockaddr_storage> addrs;
        size_t n = 0;
        try {
            n = _Udp->recvBatchAddr(dgrams, addrs, 16);
            n = sock->recvBatchAddr(dgrams, addrs, 16);
        } catch (...) { continue; }
        for (size_t i = 0; i < n; ++i) {
            std::vector<uint8_t> response;
            try {
                if (isKpasswd(dgrams[i].data(), dgrams[i].size()))
                    response = handleKpasswd(dgrams[i].data(), dgrams[i].size(), _LocalIp);
                else
                    response = handleStandardRequest(dgrams[i].data(), dgrams[i].size());
            } catch (const std::exception &e) {
                std::string errRealm = _Realms.empty() ? "UNKNOWN" : _Realms.begin()->first;
@@ -2682,7 +2695,7 @@ void Krb5KDC::udpLoop() {
                continue;
            socklen_t sl = addrs[i].ss_family == AF_INET6 ? sizeof(sockaddr_in6) : sizeof(sockaddr_in);
            std::vector<std::pair<const uint8_t*, size_t>> out = {{response.data(), response.size()}};
            try { _Udp->sendBatch(out, &addrs[i], sl); } catch (...) {}
            try { sock->sendBatch(out, &addrs[i], sl); } catch (...) {}
        }
    }
}
@@ -2691,7 +2704,10 @@ Krb5KDC::~Krb5KDC() {
    _Running = false;
    if (_UdpThread.joinable())
        _UdpThread.join();
    if (_KpwThread.joinable())
        _KpwThread.join();
    delete _Udp;
    delete _UdpKpasswd;
    delete static_cast<KdcEvent*>(_Event);
    for (auto *s : _ServerSockets)
        delete s;
@@ -2856,7 +2872,9 @@ bool Krb5KDC::getCrossRealmKey(const std::string &localRealm,
void Krb5KDC::runKdc(ULONG_PTR args) {
    _Running = true;
    if (_Udp)
        _UdpThread = std::thread([this]() { udpLoop(); });
        _UdpThread = std::thread([this]() { udpLoop(_Udp); });
        if (_UdpKpasswd)
            _KpwThread = std::thread([this]() { udpLoop(_UdpKpasswd); });
    auto *ev = new KdcEvent(_ServerSockets, this);
    _Event = ev;
    ev->runEventloop(args);
@@ -2913,7 +2931,18 @@ void Krb5KDC::RequestEvent(netplus::con &curcon, const int /*tid*/, ULONG_PTR /*
    std::vector<uint8_t> response;

    try {
        if (isKpasswd(asn1Data, asn1Len)) {
            /* our address for the KRB-PRIV s-address */
            sockaddr_in la{}; socklen_t ll = sizeof(la);
            uint32_t ip = _LocalIp;
            if (!curcon.slots.empty() && curcon.slots[0].csock &&
                getsockname(curcon.slots[0].csock->fd(), reinterpret_cast<sockaddr*>(&la), &ll) == 0 &&
                la.sin_family == AF_INET)
                ip = ntohl(la.sin_addr.s_addr);
            response = handleKpasswd(asn1Data, asn1Len, ip);
        } else {
            response = handleStandardRequest(asn1Data, asn1Len);
        }
    } catch (const std::exception &e) {
        std::string errRealm = _Realms.empty() ? "UNKNOWN" : _Realms.begin()->first;
        response = buildStandardError(KRB_ERR_GENERIC, e.what(), errRealm, "krbtgt");
@@ -2934,6 +2963,229 @@ void Krb5KDC::RequestEvent(netplus::con &curcon, const int /*tid*/, ULONG_PTR /*
                          curcon.RecvData.begin() + 4 + msg_len);
}

/*****************************************************************************
 * kpasswd (RFC 3244 / MS-KILE 3.1.5.12): change or set a password
 *
 * request: len(2) | version(2: 0x0001 change, 0xff80 set) | ap-req-len(2)
 *          | AP-REQ (ticket for kadmin/changepw) | KRB-PRIV
 * reply:   len(2) | version 1 | ap-rep-len(2) | AP-REP | KRB-PRIV(result)
 *****************************************************************************/
namespace {
    uint16_t be16(const uint8_t *p) { return uint16_t(p[0] << 8 | p[1]); }

    /* the DER content of context tag [n] inside a SEQUENCE content */
    bool ctxField(const uint8_t *seq, size_t len, uint8_t n, const uint8_t *&c, size_t &cl) {
        size_t off = 0; uint8_t tag; const uint8_t *v; size_t vl;
        while (off < len && Asn1Der::parseTLV(seq, len, off, tag, v, vl)) {
            if (tag == uint8_t(0xA0 | n)) { c = v; cl = vl; return true; }
        }
        return false;
    }
    /* unwrap one TLV with the expected tag */
    bool unTag(const uint8_t *d, size_t len, uint8_t want, const uint8_t *&c, size_t &cl) {
        size_t off = 0; uint8_t tag;
        return Asn1Der::parseTLV(d, len, off, tag, c, cl) && tag == want;
    }

    enum : uint16_t {
        KPW_SUCCESS = 0, KPW_MALFORMED = 1, KPW_HARDERROR = 2, KPW_AUTHERROR = 3,
        KPW_SOFTERROR = 4, KPW_ACCESSDENIED = 5, KPW_BAD_VERSION = 6, KPW_INITIAL_FLAG_NEEDED = 7,
    };

    std::vector<uint8_t> frameKpasswd(const std::vector<uint8_t> &apRep, const std::vector<uint8_t> &body) {
        std::vector<uint8_t> o;
        size_t total = 6 + apRep.size() + body.size();
        o.push_back(uint8_t(total >> 8)); o.push_back(uint8_t(total));
        o.push_back(0); o.push_back(1);                     /* version 1 */
        o.push_back(uint8_t(apRep.size() >> 8)); o.push_back(uint8_t(apRep.size()));
        o.insert(o.end(), apRep.begin(), apRep.end());
        o.insert(o.end(), body.begin(), body.end());
        return o;
    }
}

void Krb5KDC::setLocalAddress(const std::string &ipv4) {
    in_addr a{};
    if (inet_pton(AF_INET, ipv4.c_str(), &a) == 1)
        _LocalIp = ntohl(a.s_addr);
}

bool Krb5KDC::isKpasswd(const uint8_t *data, size_t len) {
    if (len < 6 || data[0] == Asn1Der::APP_AS_REQ || data[0] == Asn1Der::APP_TGS_REQ)
        return false;
    uint16_t ver = be16(data + 2);
    return be16(data) == len && (ver == 0x0001 || ver == 0xff80);
}

std::vector<uint8_t> Krb5KDC::handleKpasswd(const uint8_t *data, size_t len, uint32_t localIp) {
    std::lock_guard<std::mutex> guard(_Lock);
    std::string anyRealm = _Realms.empty() ? "UNKNOWN" : _Realms.begin()->first;
    auto fail = [&](uint16_t code, const std::string &text) {
        std::cerr << "[KPASSWD] error " << code << ": " << text << std::endl;
        Krb5Error err;
        err.pvno = 5; err.msg_type = KRB_ERROR; err.stime = time(nullptr); err.susec = 0;
        err.error_code = KRB_ERR_GENERIC;
        err.sname = Krb5Principal(KRB5_NT_SRV_INST, anyRealm, "kadmin", "changepw");
        err.e_text = text;
        err.e_data = {uint8_t(code >> 8), uint8_t(code)};
        err.e_data.insert(err.e_data.end(), text.begin(), text.end());
        return frameKpasswd({}, err.serialize());
    };

    uint16_t version = be16(data + 2), apLen = be16(data + 4);
    if (6u + apLen > len) return fail(KPW_MALFORMED, "truncated request");
    Krb5ApReq ap;
    size_t used = 0;
    if (!ap.deserialize(data + 6, apLen, used)) return fail(KPW_MALFORMED, "bad AP-REQ");
    RealmInfo *ri = resolveRealm(ap.ticket.sname.realm);
    if (!ri || !ri->dir) return fail(KPW_HARDERROR, "unknown realm");
    bool changepwTkt = ap.ticket.sname.name == "kadmin" && ap.ticket.sname.instance == "changepw";
    bool tgtTkt = ap.ticket.sname.name == "krbtgt";
    if (!changepwTkt && !tgtTkt) return fail(KPW_AUTHERROR, "ticket is not for kadmin/changepw");

    /* kadmin/changepw shares the krbtgt key, as in AD */
    Krb5EncTicketPart et;
    try {
        auto p = Krb5Crypto::decrypt(realmTgsKey(*ri), ap.ticket.enc_part, KRB5_KU_TICKET);
        size_t cc = 0;
        if (!et.deserialize(p.data(), p.size(), cc)) return fail(KPW_AUTHERROR, "bad ticket");
    } catch (const std::exception &) { return fail(KPW_AUTHERROR, "ticket does not decrypt"); }
    time_t now = time(nullptr);
    if (now > et.endtime) return fail(KPW_AUTHERROR, "ticket expired");
    Krb5Authenticator auth;
    try {
        auto a = Krb5Crypto::decrypt(et.session_key.keydata, ap.authenticator, KRB5_KU_AP_REQ_AUTH);
        size_t cc = 0;
        if (!auth.deserialize(a.data(), a.size(), cc)) return fail(KPW_AUTHERROR, "bad authenticator");
    } catch (const std::exception &) { return fail(KPW_AUTHERROR, "authenticator does not decrypt"); }
    if (std::abs(long(now - auth.ctime)) > _MaxClockSkew) return fail(KPW_AUTHERROR, "clock skew");
    /* KRB-PRIV in both directions: the authenticator subkey, else the session key */
    Krb5SessionKey privKey = (auth.has_subkey && !auth.subkey.keydata.empty()) ? auth.subkey : et.session_key;

    /* KRB-PRIV ::= [APPLICATION 21] SEQUENCE { pvno[0], msg-type[1], enc-part[3] } */
    const uint8_t *seq, *f; size_t seqLen, fl;
    const uint8_t *pd = data + 6 + apLen; size_t pdl = len - 6 - apLen;
    if (!unTag(pd, pdl, 0x75, seq, seqLen) || !unTag(seq, seqLen, 0x30, seq, seqLen) ||
        !ctxField(seq, seqLen, 3, f, fl))
        return fail(KPW_MALFORMED, "bad KRB-PRIV");
    Krb5EncType et2; uint32_t kv; std::vector<uint8_t> cipher;
    if (!Asn1Der::parseEncryptedData(f, fl, et2, kv, cipher)) return fail(KPW_MALFORMED, "bad KRB-PRIV enc-part");
    std::vector<uint8_t> plain;
    try { plain = Krb5Crypto::decrypt(privKey.keydata, cipher, KRB5_KU_KRB_PRIV_ENC_PART); }
    catch (const std::exception &) { return fail(KPW_AUTHERROR, "KRB-PRIV does not decrypt"); }
    /* EncKrbPrivPart ::= [APPLICATION 28] SEQUENCE { user-data[0] OCTET STRING, ... } */
    if (!unTag(plain.data(), plain.size(), 0x7C, seq, seqLen) || !unTag(seq, seqLen, 0x30, seq, seqLen) ||
        !ctxField(seq, seqLen, 0, f, fl))
        return fail(KPW_MALFORMED, "bad EncKrbPrivPart");
    std::vector<uint8_t> userData;
    if (!unTag(f, fl, 0x04, f, fl)) return fail(KPW_MALFORMED, "bad user-data");
    userData.assign(f, f + fl);

    /* the new password, and for set-password an optional target */
    std::string newPw;
    std::string targetName;
    if (version == 0x0001) {
        newPw.assign(userData.begin(), userData.end());
    } else {
        /* ChangePasswdData ::= SEQUENCE { newpasswd[0] OCTET STRING,
         *   targname[1] PrincipalName OPTIONAL, targrealm[2] Realm OPTIONAL } */
        const uint8_t *cs; size_t csl;
        if (!unTag(userData.data(), userData.size(), 0x30, cs, csl) || !ctxField(cs, csl, 0, f, fl) ||
            !unTag(f, fl, 0x04, f, fl))
            return fail(KPW_MALFORMED, "bad ChangePasswdData");
        newPw.assign(f, f + fl);
        if (ctxField(cs, csl, 1, f, fl)) {
            Krb5PrincipalType pt; std::vector<std::string> comps;
            if (Asn1Der::parsePrincipalName(f, fl, pt, comps) && !comps.empty()) {
                for (auto &c : comps) { if (!targetName.empty()) targetName += "/"; targetName += c; }
            }
        }
    }

    /* who is asking, and for whom */
    auto findAcct = [&](const std::string &name) -> std::optional<dsdb::Entry> {
        auto e = ri->dir->bySamAccountName(name);
        if (!e) e = ri->dir->byUpn(name);
        if (!e && name.find('@') != std::string::npos) e = ri->dir->bySamAccountName(name.substr(0, name.find('@')));
        if (!e && !name.empty() && name.back() != '$') e = ri->dir->bySamAccountName(name + "$");
        return e;
    };
    auto caller = findAcct(et.cname.name);
    if (!caller) return fail(KPW_AUTHERROR, "unknown client");
    std::optional<dsdb::Entry> target = caller;
    if (!targetName.empty()) {
        target = findAcct(targetName);
        if (!target) return fail(KPW_HARDERROR, "unknown target principal");
    }
    bool self = target->objectGuid == caller->objectGuid;
    uint16_t code = KPW_SUCCESS;
    std::string text = "Password changed";
    if (self && !(et.flags & KRB5_TF_INITIAL)) {
        code = KPW_INITIAL_FLAG_NEEDED; text = "Initial ticket required";
    } else if (!self) {
        /* setting another account's password: Domain Admins / Administrator */
        bool admin = !caller->objectSid.empty() && caller->objectSid.rid() == 500;
        dsdb::Dn da = ri->meta.baseDn.child("CN", "Users").child("CN", "Domain Admins");
        if (auto *mo = caller->get("memberOf"))
            for (auto &g : *mo) { dsdb::Dn gd; if (dsdb::Dn::parse(g, gd) && gd == da) admin = true; }
        if (!admin) { code = KPW_ACCESSDENIED; text = "Not allowed to set this password"; }
    }
    if (code == KPW_SUCCESS) {
        /* the domain's minimum length (minPwdLength on the domain head, AD default 7) */
        int64_t minLen = 7;
        if (auto head = ri->dir->byDn(ri->meta.baseDn)) minLen = dsdb::intValue(head->attrs, "minPwdLength", 7);
        size_t chars = crypto::utf16le(newPw).size() / 2;
        if (newPw.empty() || int64_t(chars) < minLen) {
            code = KPW_SOFTERROR;
            text = "Password must be at least " + std::to_string(minLen) + " characters";
        }
    }
    if (code == KPW_SUCCESS) {
        std::string sam = target->str("sAMAccountName");
        bool computer = target->isClass("computer");
        std::string salt = dsdb::kerberosSalt(computer ? dsdb::PrincipalKind::Computer : dsdb::PrincipalKind::User,
                                              ri->meta.realm, sam, ri->meta.dnsName);
        dsdb::Secrets old = ri->dir->secretsOf(target->objectGuid);
        dsdb::Secrets sec = dsdb::deriveSecrets(newPw, salt, old.kvno);
        dsdb::AttrMap a;
        dsdb::setInt(a, "pwdLastSet", sec.pwdLastSet);
        dsdb::setInt(a, "msDS-KeyVersionNumber", sec.kvno);
        try { ri->dir->modify(target->objectGuid, a, &sec, true); }
        catch (const std::exception &e) { code = KPW_HARDERROR; text = e.what(); }
    }
    std::cerr << "[KPASSWD] " << et.cname.name << " -> " << target->str("sAMAccountName")
              << " version=0x" << std::hex << version << std::dec << " result=" << code << " (" << text << ")" << std::endl;

    /* AP-REP (no subkey: the replies stay under the client's key) */
    uint32_t seqNo = Krb5Crypto::randomNonce() & 0x3fffffff;
    Krb5Service svc("", "", ri->meta.realm, realmTgsKey(*ri));
    std::vector<uint8_t> apRep = svc.buildApRep(auth, et.session_key, seqNo, nullptr);

    /* KRB-PRIV with result code + text */
    std::vector<uint8_t> result = {uint8_t(code >> 8), uint8_t(code)};
    result.insert(result.end(), text.begin(), text.end());
    std::vector<uint8_t> addr;
    Asn1Der::encodeContextTag(addr, 0, Asn1Der::encodeInteger(2));            /* IPv4 */
    Asn1Der::encodeContextTag(addr, 1, Asn1Der::encodeOctetString(
        {uint8_t(localIp >> 24), uint8_t(localIp >> 16), uint8_t(localIp >> 8), uint8_t(localIp)}));
    std::vector<uint8_t> encPart;
    Asn1Der::encodeContextTag(encPart, 0, Asn1Der::encodeOctetString(result));
    Asn1Der::encodeContextTag(encPart, 1, Asn1Der::encodeGeneralizedTime(now));
    Asn1Der::encodeContextTag(encPart, 2, Asn1Der::encodeUInteger(0));
    Asn1Der::encodeContextTag(encPart, 3, Asn1Der::encodeUInteger(seqNo));
    Asn1Der::encodeContextTag(encPart, 4, Asn1Der::encodeSequence(addr));
    std::vector<uint8_t> encPrivPart;
    Asn1Der::encodeTLV(encPrivPart, 0x7C, Asn1Der::encodeSequence(encPart));
    auto privCipher = Krb5Crypto::encrypt(privKey.keydata, encPrivPart, KRB5_KU_KRB_PRIV_ENC_PART);
    std::vector<uint8_t> priv;
    Asn1Der::encodeContextTag(priv, 0, Asn1Der::encodeInteger(5));
    Asn1Der::encodeContextTag(priv, 1, Asn1Der::encodeInteger(21));
    Asn1Der::encodeContextTag(priv, 3, Asn1Der::encodeEncryptedData(privKey.enctype, 0, privCipher));
    std::vector<uint8_t> krbPriv;
    Asn1Der::encodeTLV(krbPriv, 0x75, Asn1Der::encodeSequence(priv));
    return frameKpasswd(apRep, krbPriv);
}

/*
 * handleStandardRequest - Dispatch ASN.1 DER encoded Kerberos messages
 */
@@ -3171,6 +3423,22 @@ std::vector<uint8_t> Krb5KDC::handleStandardAsReq(const uint8_t *data, size_t le
        }
    }

    /* a ticket for the password-change service (kadmin/changepw, the
     * krbtgt key in AD) is what an expired password still gets */
    bool changepw = req.sname.name == "kadmin" && req.sname.instance == "changepw";
    /* pwdLastSet = 0: "must change password at next logon" */
    if (!changepw) {
        if (auto who = ri->dir ? ri->dir->byGuid(uid) : std::nullopt) {
            auto *pls = who->get("pwdLastSet");
            if (pls && !pls->empty() && pls->front() == "0") {
                std::cerr << "[KDC] AS-REQ: password of '" << who->str("sAMAccountName")
                          << "' must be changed (pwdLastSet=0)" << std::endl;
                return buildStandardError(KDC_ERR_KEY_EXPIRED, "Password has expired",
                                           req.cname.realm, "krbtgt");
            }
        }
    }

    /* Generate TGT session key */
    Krb5SessionKey tgt_session_key = Krb5Crypto::generateSessionKey(selectedEtype);

@@ -3251,7 +3519,8 @@ std::vector<uint8_t> Krb5KDC::handleStandardAsReq(const uint8_t *data, size_t le
    /* Build TGT */
    Krb5Ticket tgt;
    tgt.tkt_vno = 5;
    tgt.sname = Krb5Principal(KRB5_NT_SRV_INST, req.cname.realm, "krbtgt", req.cname.realm);
    tgt.sname = changepw ? Krb5Principal(KRB5_NT_SRV_INST, req.cname.realm, "kadmin", "changepw")
                         : Krb5Principal(KRB5_NT_SRV_INST, req.cname.realm, "krbtgt", req.cname.realm);
    tgt.enc_etype = selectedEtype;
    tgt.enc_kvno = 1;
    tgt.enc_part = enc_tkt_cipher;
@@ -4542,7 +4811,7 @@ extern "C" {

fenrirds::Plugin *fenrirds_plugin_create(fenrirds::AuthBackend &backend,
                                      const char *configJson) {
    std::string bindAddr = "0.0.0.0";
    std::string bindAddr = "0.0.0.0", address;
    int port = 88;
    int maxconn = 128;

@@ -4555,11 +4824,15 @@ fenrirds::Plugin *fenrirds_plugin_create(fenrirds::AuthBackend &backend,
            if (jbind) bindAddr = json_object_get_string(jbind);
            if (jport) port     = json_object_get_int(jport);
            if (jmax)  maxconn  = json_object_get_int(jmax);
            if (auto *ja = json_object_object_get(jobj, "dns_address")) address = json_object_get_string(ja);
            json_object_put(jobj);
        }
    }

    return new fenrirds::Krb5KDC(backend, bindAddr, port, maxconn);
    auto *kdc = new fenrirds::Krb5KDC(backend, bindAddr, port, maxconn);
    if (!address.empty())
        kdc->setLocalAddress(address);
    return kdc;
}

void fenrirds_plugin_destroy(fenrirds::Plugin *p) {
+14 −1
Changes for plugin/krb5/krb5.h: 14 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -89,6 +89,7 @@ namespace fenrirds {
        KDC_ERR_NEVER_VALID         = 11,
        KDC_ERR_POLICY              = 12,
        KDC_ERR_BADOPTION           = 13,
        KDC_ERR_KEY_EXPIRED         = 23,
        KDC_ERR_PREAUTH_FAILED      = 24,
        KDC_ERR_PREAUTH_REQUIRED    = 25,
        KDC_ERR_WRONG_REALM         = 68,
@@ -144,6 +145,7 @@ namespace fenrirds {
        KRB5_KU_TGS_REP_ENC_PART       = 8,   /* TGS-REP encrypted part      */
        KRB5_KU_TGS_REP_ENC_PART_SUB   = 9,   /* TGS-REP enc part (subkey)   */
        KRB5_KU_AP_REP_ENC_PART        = 12,  /* AP-REP encrypted part       */
        KRB5_KU_KRB_PRIV_ENC_PART       = 13,  /* KRB-PRIV encrypted part (kpasswd) */
        KRB5_KU_TICKET                  = 2    /* Ticket encrypted part       */
    };

@@ -770,6 +772,14 @@ namespace fenrirds {
    private:
        /* Resolve the realm from a request and return the RealmInfo, or nullptr */
        RealmInfo *resolveRealm(const std::string &realm);
        /* kpasswd (RFC 3244, port 464): change/set password; localIp
         * (host order) is our address for the KRB-PRIV s-address */
        std::vector<uint8_t> handleKpasswd(const uint8_t *data, size_t len, uint32_t localIp);
        static bool isKpasswd(const uint8_t *data, size_t len);
    public:
        /* the DC's IPv4 address (DNS/ADDRESS) for UDP kpasswd replies */
        void setLocalAddress(const std::string &ipv4);
    private:
        void canonicalizeRealms(Krb5KdcReq &req);

        /* Standard Kerberos protocol (ASN.1 DER / RFC 4120) handlers */
@@ -829,8 +839,11 @@ namespace fenrirds {
         * Windows sends AS-REQ over UDP first. Served in its own thread. */
        netplus::udp                     *_Udp = nullptr;
        std::thread                       _UdpThread;
        std::thread                       _KpwThread;
        netplus::udp                     *_UdpKpasswd = nullptr;
        uint32_t                          _LocalIp = 0;   /* DC address (host order), UDP kpasswd replies */
        std::atomic<bool>                 _Running{false};
        void                              udpLoop();
        void                              udpLoop(netplus::udp *sock);
    };

    /***************************************************************************