fix(a7-auth): address SCRAM auth review findings A-G

- tests: pass CREDENTIAL_KEY_LEN to unhex for the 32-byte KAT proof/sig
  (sizeof(expect) is 348, over-reading the 65-byte hex literal under ASan)
- credentials: close the username-enumeration oracle with a store-wide
  dummy_key and a deterministic per-username dummy salt; make the store's
  iteration count uniform (reject intra-file and layered disagreements) and
  answer a miss with the store-wide count; run the constant-time key compare
  even when found=false and fold the decision with bitwise AND
- credentials_compute_keys: enforce [CREDENTIAL_MIN_ITERS, CREDENTIAL_MAX_ITERS]
- tests: recompute the whole KAT independently at CREDENTIAL_DEFAULT_ITERS
  (600000) and pin the golden store line; add non-uniform-store rejection,
  bound and deterministic-dummy-salt assertions
- server: send exactly one generic STATUS_AUTH_FAILED on every failure path
  (including credentials_get_verifier failure); route all handshake exits
  through one burn path
- credentials/server: burn the base64 decoders' scratch on error, the
  hash_store_line base64/line buffers on failure, and all handshake key/proof
  material
- fuzz: guard the auth-offset scan against size_t underflow and use a found flag
- docs: drop stale digest wording, use CREDENTIAL_MIN_ITERS as the --iterations
  bound, document 0600 output for --hash-credentials (plus a stderr warning on
  a group/other-accessible stdout file), and describe the deterministic dummy
  salt in the no-oracle claims
This commit is contained in:
2026-09-12 17:56:52 +02:00
parent 8c94ec9886
commit eaf67f6257
10 changed files with 302 additions and 112 deletions
+69 -55
View File
@@ -73,65 +73,68 @@ typedef struct ModuleGateContext {
* 2.19.0). Sends STATUS_AUTH_CHALLENGE (iteration count, base64 salt, base64
* server nonce), expects STATUS_AUTH_RESPONSE (base64 client nonce, base64
* ClientProof), verifies the proof constant-time and answers STATUS_AUTH_OK
* with the base64 ServerSignature. On any failure it sends a single generic
* with the base64 ServerSignature. On any failure it sends exactly one generic
* STATUS_AUTH_FAILED and returns false. The verifier for an unknown/off-list
* user is a dummy (random salt, dummy keys, found=false) so the same math runs
* and no user-enumeration/timing oracle is exposed. */
* user is a dummy (deterministic per-username salt, store-wide iterations, dummy
* keys, found=false) so the same math runs and no user-enumeration/timing oracle
* is exposed. */
static bool server_auth_handshake(int fd, const Config* config, const DaemonModule* module) {
if (!config->auth_user) {
send_status(fd, STATUS_AUTH_FAILED);
return false;
}
bool result = false;
CredentialVerifier verifier;
memset(&verifier, 0, sizeof(verifier));
uint8_t snonce[CREDENTIAL_NONCE_LEN] = {0};
char salt_b64[25] = {0};
char snonce_b64[45] = {0};
char* cnonce_b64 = NULL;
char* proof_b64 = NULL;
uint8_t cnonce[CREDENTIAL_NONCE_LEN] = {0};
uint8_t proof[CREDENTIAL_KEY_LEN] = {0};
uint8_t server_sig[CREDENTIAL_KEY_LEN] = {0};
char sig_b64[45] = {0};
size_t cnonce_len = 0;
size_t proof_len = 0;
if (!config->auth_user)
goto fail; /* no username: generic failure, no challenge */
if (!credentials_get_verifier(g_credentials, config->auth_user,
(const char* const*)module->auth_users, module->auth_user_count,
&verifier))
return false;
uint8_t snonce[CREDENTIAL_NONCE_LEN];
char salt_b64[25];
char snonce_b64[45];
bool ok =
credentials_random_bytes(snonce, sizeof(snonce)) &&
credentials_b64_encode(verifier.salt, CREDENTIAL_SALT_LEN, salt_b64, sizeof(salt_b64)) &&
credentials_b64_encode(snonce, sizeof(snonce), snonce_b64, sizeof(snonce_b64));
if (!ok) {
send_status(fd, STATUS_AUTH_FAILED);
return false;
}
ok = send_status(fd, STATUS_AUTH_CHALLENGE) && send_int(fd, (int)verifier.iters) &&
send_str(fd, salt_b64) && send_str(fd, snonce_b64);
goto fail; /* a crypto failure still owes the gate a terminal frame */
if (!(credentials_random_bytes(snonce, sizeof(snonce)) &&
credentials_b64_encode(verifier.salt, CREDENTIAL_SALT_LEN, salt_b64, sizeof(salt_b64)) &&
credentials_b64_encode(snonce, sizeof(snonce), snonce_b64, sizeof(snonce_b64))))
goto fail;
if (!(send_status(fd, STATUS_AUTH_CHALLENGE) && send_int(fd, (int)verifier.iters) &&
send_str(fd, salt_b64) && send_str(fd, snonce_b64)))
goto fail;
Status status = STATUS_ERROR;
char* cnonce_b64 = NULL;
char* proof_b64 = NULL;
uint8_t cnonce[CREDENTIAL_NONCE_LEN];
uint8_t proof[CREDENTIAL_KEY_LEN];
uint8_t server_sig[CREDENTIAL_KEY_LEN];
size_t cnonce_len = 0;
size_t proof_len = 0;
bool verified = false;
if (ok) {
ok = receive_status(fd, &status) && status == STATUS_AUTH_RESPONSE;
if (ok) {
cnonce_b64 = receive_str_redacted(fd);
proof_b64 = receive_str_redacted(fd);
ok = cnonce_b64 && proof_b64 &&
credentials_b64_decode(cnonce_b64, cnonce, sizeof(cnonce), &cnonce_len) &&
cnonce_len == CREDENTIAL_NONCE_LEN &&
credentials_b64_decode(proof_b64, proof, sizeof(proof), &proof_len) &&
proof_len == CREDENTIAL_KEY_LEN;
}
verified = ok && credentials_verify_response(&verifier, config->auth_user, snonce, cnonce,
proof, server_sig);
}
if (verified) {
char sig_b64[45];
ok = credentials_b64_encode(server_sig, sizeof(server_sig), sig_b64, sizeof(sig_b64)) &&
send_status(fd, STATUS_AUTH_OK) && send_str_redacted(fd, sig_b64);
credentials_burn(sig_b64, sizeof(sig_b64));
} else {
send_status(fd, STATUS_AUTH_FAILED);
ok = false;
}
if (!(receive_status(fd, &status) && status == STATUS_AUTH_RESPONSE))
goto fail;
cnonce_b64 = receive_str_redacted(fd);
proof_b64 = receive_str_redacted(fd);
if (!(cnonce_b64 && proof_b64 &&
credentials_b64_decode(cnonce_b64, cnonce, sizeof(cnonce), &cnonce_len) &&
cnonce_len == CREDENTIAL_NONCE_LEN &&
credentials_b64_decode(proof_b64, proof, sizeof(proof), &proof_len) &&
proof_len == CREDENTIAL_KEY_LEN))
goto fail;
if (!credentials_verify_response(&verifier, config->auth_user, snonce, cnonce, proof, server_sig))
goto fail;
/* Success writes exactly one terminal frame (STATUS_AUTH_OK). A broken pipe
* while sending the signature just drops the connection; it must never emit a
* second terminal status. */
result = credentials_b64_encode(server_sig, sizeof(server_sig), sig_b64, sizeof(sig_b64)) &&
send_status(fd, STATUS_AUTH_OK) && send_str_redacted(fd, sig_b64);
goto cleanup;
fail:
/* Every failure path writes exactly one generic terminal status, satisfying
* the gate's CONFIG_VALIDATE_ALREADY_TERMINATED contract. */
send_status(fd, STATUS_AUTH_FAILED);
cleanup:
credentials_burn(cnonce_b64, cnonce_b64 ? strlen(cnonce_b64) : 0);
credentials_burn(proof_b64, proof_b64 ? strlen(proof_b64) : 0);
free(cnonce_b64);
@@ -142,10 +145,11 @@ static bool server_auth_handshake(int fd, const Config* config, const DaemonModu
credentials_burn((char*)cnonce, sizeof(cnonce));
credentials_burn((char*)proof, sizeof(proof));
credentials_burn((char*)server_sig, sizeof(server_sig));
credentials_burn(sig_b64, sizeof(sig_b64));
credentials_burn((char*)verifier.salt, sizeof(verifier.salt));
credentials_burn((char*)verifier.stored_key, sizeof(verifier.stored_key));
credentials_burn((char*)verifier.server_key, sizeof(verifier.server_key));
return verified && ok;
return result;
}
/* Aggregate payload bytes the multithreaded receiver may buffer ahead of the
@@ -364,7 +368,8 @@ static const char* server_module_gate(const Config* config, void* context) {
/* Auth-required module (A7, protocol 2.19.0): run the SCRAM challenge/
* response BEFORE the module root is installed and before any data moves.
* Fail closed: no store -> refuse (server misconfiguration, STATUS_ERROR);
* a failed handshake already sent STATUS_AUTH_FAILED. The username may be
* a failed handshake writes exactly one STATUS_AUTH_FAILED (on every
* failure path) before signalling ALREADY_TERMINATED. The username may be
* logged (never the password or any derived proof). */
if (g_credentials == NULL) {
log_message(LOG_LEVEL_ERROR,
@@ -751,7 +756,8 @@ static void print_server_usage(void) {
printf(" --allow-unauthenticated Allow plaintext/anonymous network clients\n");
printf(" --hash-credentials <file> Read <file>'s user:password lines and print\n");
printf(" PBKDF2 credential-store lines to stdout, then exit.\n");
printf(" Use the output as --password-file for --daemon\n");
printf(" Use the output as --password-file for --daemon;\n");
printf(" redirect it to an owner-only (0600) file\n");
printf(" --iterations N PBKDF2 iteration count for --hash-credentials\n");
printf(" (default %u, range %u-%u)\n", CREDENTIAL_DEFAULT_ITERS,
CREDENTIAL_MIN_ITERS, CREDENTIAL_MAX_ITERS);
@@ -828,6 +834,14 @@ int main(int argc, char* argv[]) {
* emit new-format credential-store lines, then exit. */
if (opts.hash_credentials_file) {
uint32_t iters = opts.hash_iterations_set ? opts.hash_iterations : CREDENTIAL_DEFAULT_ITERS;
/* The output is secret material: if it is redirected to a regular file,
* warn when that file is group/other-accessible (the store must be 0600). */
struct stat out_st;
if (fstat(STDOUT_FILENO, &out_st) == 0 && S_ISREG(out_st.st_mode) &&
(out_st.st_mode & (S_IRWXG | S_IRWXO)) != 0)
fprintf(stderr,
"Warning: credential-store output is a group/other-accessible file; restrict it to "
"mode 0600 (chmod 600)\n");
char hash_err[512];
if (credentials_hash_file(opts.hash_credentials_file, iters, stdout, hash_err,
sizeof(hash_err)) != 0) {
+5 -2
View File
@@ -1,5 +1,6 @@
#include "server_cli.h"
#include "charset.h"
#include "credentials.h"
#include "utils.h"
#include <limits.h>
#include <stdarg.h>
@@ -146,8 +147,10 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
}
char* end = NULL;
long n = strtol(inline_value, &end, 10);
if (!end || *end != '\0' || n < 0 || n > 10000000L) {
set_error(err, err_size, "invalid --iterations '%s'", inline_value);
if (!end || *end != '\0' || n < (long)CREDENTIAL_MIN_ITERS ||
n > (long)CREDENTIAL_MAX_ITERS) {
set_error(err, err_size, "--iterations must be in [%u,%u], got '%s'", CREDENTIAL_MIN_ITERS,
CREDENTIAL_MAX_ITERS, inline_value);
return -1;
}
opts->hash_iterations = (uint32_t)n;
+87 -19
View File
@@ -30,6 +30,16 @@ struct CredentialStore {
CredentialEntry* entries;
int count;
int capacity;
/* Store-wide uniform PBKDF2 iteration count. Every entry must agree on it
* (the parser refuses a store whose entries disagree), so a miss can be
* challenged with the same count as a hit and the count itself never leaks
* membership. Unused (0) for an empty store. */
uint32_t iters;
/* Random secret generated once at load. The dummy salt handed out for an
* unknown/off-list user is HMAC-SHA256(dummy_key, username)[:SALT_LEN], so
* repeated probes of the same username always see an identical challenge
* while different usernames differ -- with no fresh-random tell. */
uint8_t dummy_key[CREDENTIAL_KEY_LEN];
};
/* Exact marker prefix of the new store verifier field. */
@@ -179,15 +189,20 @@ bool credentials_b64_decode(const char* in, uint8_t* out, size_t out_sz, size_t*
if (decoded_len > out_sz)
return false;
/* EVP_DecodeBlock writes the full (padded) quantum, so decode into a scratch
* buffer sized for it and copy only the real bytes out. */
uint8_t scratch[192];
* buffer sized for it and copy only the real bytes out. The single `done`
* path burns the scratch on failure as well as success, so no partial secret
* survives an early return. */
uint8_t scratch[192] = {0};
bool ok = false;
int n = EVP_DecodeBlock(scratch, (const unsigned char*)in, (int)len);
if (n < 0 || (size_t)n != padded_len)
return false;
goto done;
memcpy(out, scratch, decoded_len);
credentials_burn((char*)scratch, sizeof(scratch));
*out_len = decoded_len;
return true;
ok = true;
done:
credentials_burn((char*)scratch, sizeof(scratch));
return ok;
}
bool credentials_random_bytes(uint8_t* out, size_t n) {
@@ -230,10 +245,9 @@ bool credentials_compute_keys(const char* password, const uint8_t salt[CREDENTIA
uint8_t server_key[CREDENTIAL_KEY_LEN]) {
if (!password || !salt)
return false;
/* The caller (store parser / client clamp) is responsible for the
* [MIN,MAX] policy; this primitive only refuses a zero/unbounded work
* factor. Tests exercise the known-answer vector at a smaller count. */
if (iters == 0 || iters > CREDENTIAL_MAX_ITERS)
/* Enforce the full [MIN,MAX] policy here so no caller can derive a verifier
* with a work factor outside the validated store range. */
if (iters < CREDENTIAL_MIN_ITERS || iters > CREDENTIAL_MAX_ITERS)
return false;
size_t password_len = strlen(password);
if (password_len > CREDENTIAL_MAX_PASSWORD_LEN || password_len > (size_t)INT_MAX)
@@ -338,11 +352,15 @@ bool credentials_verify_response(const CredentialVerifier* v, const char* user,
computed = hmac_sha256(v->server_key, CREDENTIAL_KEY_LEN, auth_msg, msg_len, server_sig);
if (computed)
memcpy(server_sig_out, server_sig, CREDENTIAL_KEY_LEN);
/* Constant-time compare over the fixed 32-byte keys; a tampered nonce
* changes the AuthMessage and so the recovered key. */
bool accept = computed && v->found &&
credentials_secure_equal((const char*)recovered, (const char*)v->stored_key,
/* Always run the constant-time key compare (even when `found` is false) and
* fold the accept decision with bitwise AND so no short-circuit reveals
* whether the user was found. A tampered nonce changes the AuthMessage and
* so the recovered key. */
bool key_match = false;
if (computed)
key_match = credentials_secure_equal((const char*)recovered, (const char*)v->stored_key,
CREDENTIAL_KEY_LEN);
bool accept = computed & v->found & key_match;
credentials_burn((char*)auth_msg, sizeof(auth_msg));
credentials_burn((char*)client_sig, sizeof(client_sig));
credentials_burn((char*)client_key, sizeof(client_key));
@@ -526,6 +544,18 @@ static CredentialStore* load_store_file(const char* path, char* err, size_t err_
ok = false;
break;
}
/* Every entry must agree on the iteration count, so a miss can be answered
* with the store-wide count without leaking membership. */
if (store->count == 0) {
store->iters = parsed.iters;
} else if (store->iters != parsed.iters) {
set_error(err, err_size,
"credential file '%s' line %d: iteration count %u disagrees with the store-wide %u "
"(the store must be uniform)",
path, line_no, parsed.iters, store->iters);
ok = false;
break;
}
if (find_user(store, user) >= 0) {
set_error(err, err_size, "credential file '%s' line %d: duplicate entry for user '%.*s'",
path, line_no, (int)strlen(user), user);
@@ -560,6 +590,15 @@ CredentialStore* credentials_load(const char* password_file, const char* early_i
CredentialStore* store = load_store_file(password_file, err, err_size);
if (!store)
return NULL;
/* Generate the store-wide dummy key once for the final (possibly merged)
* store. It makes an unknown-user challenge deterministic, so fail the load
* if the CSPRNG is unavailable rather than degrading the anti-enumeration
* property. */
if (!credentials_random_bytes(store->dummy_key, sizeof(store->dummy_key))) {
set_error(err, err_size, "failed to generate the credential store dummy key");
credentials_free(store);
return NULL;
}
if (!early_input_file)
return store;
@@ -568,6 +607,18 @@ CredentialStore* credentials_load(const char* password_file, const char* early_i
credentials_free(store);
return NULL;
}
/* A layered store must stay uniform too. */
if (store->count > 0 && early->count > 0 && store->iters != early->iters) {
set_error(err, err_size,
"credential file '%s' and early-input file '%s' disagree on the iteration count "
"(%u vs %u); the store must be uniform",
password_file, early_input_file, store->iters, early->iters);
credentials_free(early);
credentials_free(store);
return NULL;
}
if (store->count == 0 && early->count > 0)
store->iters = early->iters;
/* Layer early input over the password file: an identical verifier dedupes, a
* differing verifier for the same user is ambiguous and fails closed. */
for (int i = 0; i < early->count; i++) {
@@ -652,14 +703,23 @@ bool credentials_get_verifier(const CredentialStore* store, const char* user,
if (!out)
return false;
memset(out, 0, sizeof(*out));
/* Start from the dummy verifier: a fresh random salt and the default
* iteration count, so a miss is shaped exactly like a hit. */
if (!credentials_random_bytes(out->salt, CREDENTIAL_SALT_LEN))
return false;
out->iters = CREDENTIAL_DEFAULT_ITERS;
const char* uname = user ? user : "";
/* The dummy verifier is shaped exactly like a hit: the store-wide uniform
* iteration count (default for an empty store) and fixed dummy keys. */
out->iters = (store && store->count > 0) ? store->iters : CREDENTIAL_DEFAULT_ITERS;
memcpy(out->stored_key, k_dummy_stored_key, CREDENTIAL_KEY_LEN);
memcpy(out->server_key, k_dummy_server_key, CREDENTIAL_KEY_LEN);
out->found = false;
/* Deterministic per-username dummy salt: HMAC-SHA256(dummy_key, username)
* truncated to the salt length. Two probes of the same unknown username see
* an identical challenge; distinct usernames differ. A NULL store (never
* reached in production) falls back to the all-zero static key. */
const uint8_t* dummy_key = store ? store->dummy_key : k_dummy_stored_key;
uint8_t mac[CREDENTIAL_KEY_LEN];
if (!hmac_sha256(dummy_key, CREDENTIAL_KEY_LEN, (const uint8_t*)uname, strlen(uname), mac))
return false;
memcpy(out->salt, mac, CREDENTIAL_SALT_LEN);
credentials_burn((char*)mac, sizeof(mac));
if (!store || !user || n < 0)
return true;
/* Module-list membership: constant-time full scan, no early break, so the
@@ -731,10 +791,17 @@ bool credentials_hash_store_line(const char* user, const char* password, uint32_
credentials_burn((char*)stored_key, sizeof(stored_key));
credentials_burn((char*)server_key, sizeof(server_key));
credentials_burn((char*)salt, sizeof(salt));
if (!ok)
/* The base64 encodings of the salt/keys are secret material too (A7-4). */
credentials_burn(salt_b64, sizeof(salt_b64));
credentials_burn(stored_b64, sizeof(stored_b64));
credentials_burn(server_b64, sizeof(server_b64));
if (!ok) {
credentials_burn(out, out_sz);
return false;
}
if (written < 0 || (size_t)written >= out_sz) {
set_error(err, err_size, "output buffer too small for the credential line");
credentials_burn(out, out_sz);
return false;
}
return true;
@@ -797,6 +864,7 @@ int credentials_hash_file(const char* path, uint32_t iters, FILE* out, char* err
char store_line[CREDENTIAL_MAX_LINE];
if (!credentials_hash_store_line(user, password, iters, store_line, sizeof(store_line), err,
err_size)) {
credentials_burn(store_line, sizeof(store_line));
result = -1;
break;
}
+15 -9
View File
@@ -55,9 +55,11 @@
typedef struct CredentialStore CredentialStore;
/* One resolved verifier. `found` is false for an unknown user or a user not on
* a module's auth list; the remaining fields then hold a fresh random salt, the
* default iteration count and fixed dummy keys, so the server can run the same
* challenge/response math with no enumeration/timing oracle. */
* a module's auth list; the remaining fields then hold a deterministic dummy
* salt (HMAC of the store-wide dummy key over the username), the store-wide
* uniform iteration count (default for an empty store) and fixed dummy keys, so
* the server can run the same challenge/response math with no enumeration or
* timing oracle. */
typedef struct {
uint8_t salt[CREDENTIAL_SALT_LEN];
uint32_t iters;
@@ -73,8 +75,10 @@ typedef struct {
* opened or that fails the strict grammar is a hard error (err filled, NULL
* returned) -- the daemon fails CLOSED rather than serving an auth-required
* module with a partial store. Both files may be NULL, which yields an empty
* store (every auth-required module then refuses connections). When both are
* given, the --early-input file is layered over --password-file: a duplicate
* store (every auth-required module then refuses connections). Every entry in
* the resulting store must agree on the iteration count; entries that disagree
* (within one file or across the two layered sources) are rejected. When both
* are given, the --early-input file is layered over --password-file: a duplicate
* username whose verifier matches is deduplicated; one whose verifier differs
* is an error (the two sources disagree), never a silent pick.
*
@@ -101,9 +105,10 @@ bool credentials_random_bytes(uint8_t* out, size_t n);
/* Resolve `user` against the store AND the module's auth-user list. The list
* scan is a constant-time full-length comparison with no early break. On a
* miss, *out is filled with a dummy verifier (fresh random salt, default
* iterations, fixed dummy keys, found=false). Returns false only on invalid
* arguments/allocation failure. */
* miss, *out is filled with a dummy verifier (a deterministic per-username salt
* derived from the store's dummy key, the store-wide uniform iteration count,
* fixed dummy keys, found=false). Returns false on invalid arguments or an
* HMAC/crypto primitive failure. */
bool credentials_get_verifier(const CredentialStore* store, const char* user,
const char* const* module_users, int n, CredentialVerifier* out);
@@ -111,7 +116,8 @@ bool credentials_get_verifier(const CredentialStore* store, const char* user,
* K = PBKDF2-HMAC-SHA256(password, salt, iters, 32)
* ClientKey = HMAC-SHA256(K, "Client Key"); StoredKey = SHA256(ClientKey)
* ServerKey = HMAC-SHA256(K, "Server Key")
* Any of client_key/stored_key/server_key may be NULL when not needed. */
* Any of client_key/stored_key/server_key may be NULL when not needed.
* `iters` must lie in [CREDENTIAL_MIN_ITERS, CREDENTIAL_MAX_ITERS]. */
bool credentials_compute_keys(const char* password, const uint8_t salt[CREDENTIAL_SALT_LEN],
uint32_t iters, uint8_t client_key[CREDENTIAL_KEY_LEN],
uint8_t stored_key[CREDENTIAL_KEY_LEN],
+6 -4
View File
@@ -427,9 +427,10 @@ static const char* status_to_string(Status status) {
}
/* Shared string send/receive implementation. `redact` selects whether the
* payload body is written to the LOG_DEBUG_PROTO debug log: secrets (daemon
* auth username/digest) set it so a --verbose log never captures a replayable
* credential, while every other string keeps its normal debug trace. */
* payload body is written to the LOG_DEBUG_PROTO debug log: daemon auth material
* (the username and the proof/signature fields) sets it so a --verbose log never
* captures a replayable credential, while every other string keeps its normal
* debug trace. */
static bool protocol_send_str_impl(ProtocolSession* session, const char* data, bool redact) {
if (data == NULL)
return false;
@@ -602,7 +603,8 @@ char* receive_str(int fd) {
return protocol_receive_str(legacy_session(fd, -1));
}
/* Redacted variants: identical framing, but the string body is never written to
the debug protocol log. Used for the daemon auth username/digest. */
the debug protocol log. Used for daemon auth material (username, proof,
signature). */
bool send_str_redacted(int fd, const char* data) {
return protocol_send_str_redacted(legacy_session(-1, fd), data);
}
+3 -2
View File
@@ -151,8 +151,9 @@ bool protocol_send_str(ProtocolSession* session, const char* data);
char* protocol_receive_str(ProtocolSession* session);
/* Redacted string variants: identical wire framing to protocol_send_str /
* protocol_receive_str, but the payload body is replaced by `<redacted>` in the
* LOG_DEBUG_PROTO debug log. Used for secrets (daemon auth username/digest) so
* a --verbose log can never capture a replayable credential. */
* LOG_DEBUG_PROTO debug log. Used for daemon auth material (the username and
* the proof/signature fields) so a --verbose log can never capture a credential
* that could be replayed. */
bool protocol_send_str_redacted(ProtocolSession* session, const char* data);
char* protocol_receive_str_redacted(ProtocolSession* session);
bool protocol_send_data(ProtocolSession* session, const Data* data);