Re: RFC: PROTOCOL.authrec, a structured authentication record from sshd

Avinash Duduskar <[email protected]>
Newsgroups gmane.network.openssh.devel
Message-ID <[email protected]>
Here is the revised spec and a prototype patch implementing it.

The spec changes are the ones from djm's review. One correction to
the July draft: it said method_info carries the sk signature
counter and the hostbased client user and host. Tested on stock
10.4 with a FIDO key, neither reaches the file; the GSSAPI
display name is the only case populated today. The spec now claims
only that.

The patch adds the marshaller, ExposeAuthInfo=structured, the
sshd_config.5 text, a unit test for the outer framing, and the
spec as PROTOCOL.authrec. ExposeAuthInfo=yes is untouched. Two
changes to review: kex->name is added to the keystate blob,
otherwise NULL in the process that writes the file, and Authctxt
gains the method sequence, filled from auth2_update_session_info().
Blob skew during an upgrade fails closed both ways.

The section 6 example is a live record from this build.
Tested on Arch Linux (7.1.6-arch1-1), OpenBSD 7.8 and FreeBSD 14.2.
ASan shows no leaks over a 200-step accumulator run.

The AKC record and the gates stay separate follow-ups.

Avi

-- >8 --
From: Avinash Duduskar <[email protected]>
Subject: [PATCH] Add a structured authentication record (PROTOCOL.authrec)

ExposeAuthInfo gains a third value, "structured", which writes a
binary record of the completed userauth to the $SSH_USER_AUTH file
in place of the legacy text. The format is documented in
PROTOCOL.authrec: an sshbuf wire format with a magic preamble and a
version, carrying a session block (user, uid, gid, home, auth time,
one entry per satisfied authentication step with the step's key)
and a transport block (negotiated algorithms, strict KEX, host key
fingerprint, endpoints, version strings, rdomain).

The method sequence accumulates on the Authctxt next to
session_info, filled from auth2_update_session_info(), so only
steps that succeeded outright or satisfied a step of
AuthenticationMethods are recorded.

kex->name now rides the keystate blob sshd-auth hands sshd-session
at userauth completion; it is otherwise NULL in the process that
writes the file. packet.c also gains an accessor for the active
algorithm names.

ExposeAuthInfo=yes and the legacy text format are unchanged. A new
unit test covers the record's outer framing: magic and version
checks, truncation at every byte, and trailing data.
---
 Makefile.in                        |  17 +-
 PROTOCOL.authrec                   | 295 +++++++++++++++++++++++++++++
 auth.h                             |   1 +
 auth2.c                            |   4 +
 authrec.c                          | 222 ++++++++++++++++++++++
 authrec.h                          |  57 ++++++
 packet.c                           |  22 ++-
 packet.h                           |   2 +
 regress/Makefile                   |   1 +
 regress/unittests/Makefile         |   2 +-
 regress/unittests/authrec/Makefile |  44 +++++
 regress/unittests/authrec/tests.c  | 123 ++++++++++++
 servconf.c                         |  11 +-
 servconf.h                         |   6 +-
 session.c                          |  22 ++-
 sshd_config.5                      |   6 +
 16 files changed, 825 insertions(+), 10 deletions(-)

diff --git a/Makefile.in b/Makefile.in
index da59bcc10..e405ea30a 100644
--- a/Makefile.in
+++ b/Makefile.in
@@ -123,7 +123,7 @@ SSHDOBJS=sshd.o \
 	dns.o fatal.o compat.o utf8.o authfd.o canohost.o \
 	$(P11OBJS) $(SKOBJS)
 
-SSHD_SESSION_OBJS=sshd-session.o auth-rhosts.o auth-passwd.o \
+SSHD_SESSION_OBJS=sshd-session.o authrec.o auth-rhosts.o auth-passwd.o \
 	audit.o audit-bsm.o audit-linux.o platform.o \
 	sshpty.o sshlogin.o servconf.o serverloop.o \
 	auth.o auth2.o auth2-methods.o auth-options.o session.o \
@@ -137,6 +137,7 @@ SSHD_SESSION_OBJS=sshd-session.o auth-rhosts.o auth-passwd.o \
 	uidswap.o platform-listen.o $(P11OBJS) $(SKOBJS)
 
 SSHD_AUTH_OBJS=sshd-auth.o \
+	authrec.o \
 	auth2-methods.o \
 	auth-rhosts.o auth-passwd.o sshpty.o sshlogin.o servconf.o \
 	serverloop.o auth.o auth2.o auth-options.o session.o auth2-chall.o \
@@ -306,6 +307,8 @@ clean:	regressclean
 	rm -f regress/unittests/test_helper/*.o
 	rm -f regress/unittests/authopt/*.o
 	rm -f regress/unittests/authopt/test_authopt$(EXEEXT)
+	rm -f regress/unittests/authrec/*.o
+	rm -f regress/unittests/authrec/test_authrec$(EXEEXT)
 	rm -f regress/unittests/bitmap/*.o
 	rm -f regress/unittests/bitmap/test_bitmap$(EXEEXT)
 	rm -f regress/unittests/conversion/*.o
@@ -644,6 +647,17 @@ regress/unittests/bitmap/test_bitmap$(EXEEXT): ${UNITTESTS_TEST_BITMAP_OBJS} \
 	    regress/unittests/test_helper/libtest_helper.a \
 	    -lssh -lopenbsd-compat -lssh -lopenbsd-compat $(TESTLIBS)
 
+UNITTESTS_TEST_AUTHREC_OBJS=\
+	regress/unittests/authrec/tests.o \
+	authrec.o \
+	$(P11OBJS) $(SKOBJS)
+
+regress/unittests/authrec/test_authrec$(EXEEXT): ${UNITTESTS_TEST_AUTHREC_OBJS} \
+    regress/unittests/test_helper/libtest_helper.a libssh.a
+	$(LD) -o $@ $(LDFLAGS) $(UNITTESTS_TEST_AUTHREC_OBJS) \
+	    regress/unittests/test_helper/libtest_helper.a \
+	    -lssh -lopenbsd-compat -lssh -lopenbsd-compat $(TESTLIBS)
+
 UNITTESTS_TEST_AUTHOPT_OBJS=\
 	regress/unittests/authopt/tests.o \
 	auth-options.o \
@@ -793,6 +807,7 @@ regress-binaries: regress-prep $(LIBCOMPAT) \
 
 regress-unit-binaries: regress-prep $(REGRESSLIBS) \
 	regress/unittests/authopt/test_authopt$(EXEEXT) \
+	regress/unittests/authrec/test_authrec$(EXEEXT) \
 	regress/unittests/bitmap/test_bitmap$(EXEEXT) \
 	regress/unittests/conversion/test_conversion$(EXEEXT) \
 	regress/unittests/hostkeys/test_hostkeys$(EXEEXT) \
diff --git a/PROTOCOL.authrec b/PROTOCOL.authrec
new file mode 100644
index 000000000..6a5b12643
--- /dev/null
+++ b/PROTOCOL.authrec
@@ -0,0 +1,295 @@
+This document describes a structured authentication record produced
+by sshd at userauth completion, for tooling that needs the facts sshd
+determined during userauth in a machine-readable form: audit
+pipelines, session policy hooks, identity-provider liveness checks.
+
+Version 1 defines the record and one delivery channel, an extension
+to the ExposeAuthInfo file mechanism shipped in OpenSSH 7.6. The wire
+format is delivery-agnostic and further channels may be added without
+changing the grammar. The encoding is sshbuf for OpenSSH-internal
+handling; a rendering tool for consumers that do not parse sshbuf is
+a property of that tool, not of this format.
+
+1. Wire format
+
+#define MAGIC_PREAMBLE	"SSHAUTHR"
+#define FORMAT_VERSION	0x00000001
+
+	byte[8]	MAGIC_PREAMBLE
+	uint32	FORMAT_VERSION
+	string	contents
+
+Where "contents" carries:
+
+	string	session_block
+	string	transport_block
+
+Every element that encloses others is an SSH string and so carries its
+own length. There are no counts and no reserved fields; section 2
+describes how the format grows. Blocks that do not apply to the
+authentication event are present but empty, and a producer emits every
+field and block this document defines.
+
+The MAGIC_PREAMBLE ensures records cannot be confused with any other
+SSH wire artefact, or with the legacy text format written at the same
+path.
+
+1.1 Session block
+
+	string	session_id
+	string	user
+	uint32	uid
+	uint32	gid
+	string	home
+	uint64	auth_time
+	string	methods
+
+Where "session_id" is the canonical SSH session identifier from the
+first key exchange, "user" is the username the client requested,
+"home" is the matched account's home directory, "auth_time" is the
+time in seconds since 1970-01-01 00:00:00 UTC at userauth
+completion, and "methods" is the authentication method sequence
+below.
+
+"uid" and "gid" have no absent encoding, since every uint32 is a legal
+uid including zero. A version-1 producer must have resolved a passwd
+entry before emitting a record; sshd always has at the write site.
+
+"methods" holds one entry per satisfied authentication step, in the
+order satisfied, and is read until exhausted:
+
+	repeat until methods is exhausted:
+	    string	method_entry
+
+Each "method_entry" carries:
+
+	string	method
+	string	submethod
+	string	method_info
+	string	key_blob
+
+Only steps that succeeded appear: an entry is appended when an attempt
+succeeds outright or satisfies one step of AuthenticationMethods.
+Failed attempts are not recorded.
+
+Where "method" is the authentication method name, "publickey",
+"password", "hostbased", "keyboard-interactive" or "gssapi-with-mic"
+in current OpenSSH. "submethod" is the kbd-int device name for
+keyboard-interactive and empty for every other method; "bsdauth" and
+"pam" are the current device names. "method_info" mirrors the
+free-form per-method field ExposeAuthInfo=yes captures, which in
+current OpenSSH is the client principal's display name for GSSAPI and
+empty otherwise. "key_blob" is the step's public key in the usual SSH
+serialisation, or the full certificate for a certificate key, for the
+key-bearing methods and empty otherwise.
+
+None of these name sets is closed; see section 2.
+
+key_blob is the record's only carrier of key identity. A consumer
+needing a fingerprint, or a certificate's serial, key id, validity
+window or principals, parses them from the blob. For multi-step
+authentication the sequence carries one entry per step, mirroring the
+per-method lines the legacy file accumulates, so the record stays a
+superset of the text it replaces. Each entry is individually
+length-prefixed, so fields may be appended to an entry later.
+
+1.2 Transport block
+
+	string	cipher_c2s
+	string	cipher_s2c
+	string	mac_c2s
+	string	mac_s2c
+	string	comp_c2s
+	string	comp_s2c
+	string	kex_algorithm
+	uint32	kex_options
+	string	hostkey_algorithm
+	string	hostkey_fingerprint
+	string	client_addr
+	uint32	client_port
+	string	server_addr
+	uint32	server_port
+	string	client_version
+	string	server_version
+	string	rdomain
+
+Where the cipher, MAC and compression fields are the names
+negotiated in each direction and in effect at userauth completion,
+"mac_c2s" and "mac_s2c" being empty for AEAD
+ciphers that authenticate internally, and "comp_c2s" and "comp_s2c"
+being "none" when compression is disabled. "client_version" and
+"server_version" are the identification strings without CRLF, the V_C
+and V_S inputs to the KEX hash. "rdomain" is the routing domain the
+connection arrived on, as exposed to AuthorizedKeysCommand via %D, and
+empty if none.
+
+"kex_options" is a bitfield. Bit 0 is set if strict KEX
+(kex-strict-*[email protected], the Terrapin mitigation) was
+negotiated; no other bit is assigned. Producers write zero to
+unassigned bits and consumers ignore bits they do not recognise. A bit
+may only be given a meaning for which zero is the correct reading of
+records that predate its assignment.
+
+"hostkey_fingerprint" is the SHA-256 fingerprint, in OpenSSH
+"SHA256:..." form, of the host key sshd presented. It identifies which
+key was in use where several are in rotation. It is derived rather
+than carried whole because the host key blob is not in the record.
+
+The address and port fields carry what sshd holds. When the connection
+is not on a socket, as under sshd -i, sshd reports "UNKNOWN" for both
+addresses and 65535 for both ports. 65535 is a legal port, so a
+consumer distinguishing that case tests the address.
+
+2. Versioning and extensibility
+
+FORMAT_VERSION numbers begin at 1. Parsers MUST reject version 0
+and records with FORMAT_VERSION greater than they support.
+FORMAT_VERSION is incremented only for a change an older
+parser would misread: removing or repurposing a field or block, or
+changing the meaning of an assigned kex_options bit.
+
+Within a version the enclosing length prefix is what allows growth,
+and two rules apply at every enclosure:
+
+  - A parser that has read the fields it knows and finds bytes
+    remaining MUST ignore them.
+
+  - A parser that reaches the end of an enclosure before reading every
+    field it knows MUST treat the remainder as absent.
+
+The first lets an old parser read a new record, the second a new
+parser an old one. Neither applies to a malformed record: any field
+that cannot be read in full, whether a truncated scalar, a truncated
+length prefix, or a prefix claiming more bytes than remain in its
+enclosure, means the record MUST be rejected. A record ends at the
+end of contents; whether anything may follow it is a property of the
+delivery channel.
+
+These rules do not carry across a version bump, which exists precisely
+because tolerance could not absorb the change. A parser selects the
+grammar for the FORMAT_VERSION it reads; implementing versions below
+its maximum is optional.
+
+Appending fields to a method entry or a block, appending a block
+inside contents, and assigning an unused kex_options bit do not bump
+FORMAT_VERSION. Consumers may refuse a record whose contents exceed an
+implementation limit.
+
+The method, submethod and algorithm names are not closed sets; they
+gain members as OpenSSH gains methods, kbd-int devices and algorithms.
+A consumer that does not recognise a value MUST NOT treat it as
+absent, and MUST NOT treat it as acceptable: an unfamiliar value is
+one the consumer cannot evaluate, not one that has passed.
+
+3. Delivery channels
+
+3.1 File: ExposeAuthInfo=structured
+
+A new value for the sshd_config(5) ExposeAuthInfo keyword. When set,
+sshd writes the record to the path exported in $SSH_USER_AUTH, using
+the existing ExposeAuthInfo lifecycle. ExposeAuthInfo=yes is unchanged
+and continues to write the legacy text format. The file carries
+exactly one record, and a reader MUST reject a file carrying data
+after it.
+
+3.2 Additional channels
+
+Further channels may be added without changing the grammar. Each is a
+separate proposal:
+
+  - a partial record for AuthorizedKeysCommand and
+    AuthorizedPrincipalsCommand, which run before the record is
+    complete: the same grammar with only the blocks known at that
+    point populated. The satisfied steps and their keys are already
+    present when those commands run.
+
+  - a post-auth subprocess gate whose exit code decides session
+    establishment.
+
+  - a per-attempt gate at the start of userauth_finish(), able to
+    reject an otherwise-successful authentication.
+
+  - a PAM channel. This needs a binary-safe encoding, since the record
+    contains NUL bytes the existing C-string environment channel
+    cannot carry.
+
+  - a trusted channel for consumers outside sshd's own handoff, for
+    the case section 4 describes as unserved.
+
+4. Security considerations
+
+Whether a consumer may rely on a record depends on how it was
+delivered.
+
+Consumers sshd hands the record to directly, such as the gates in
+section 3.2, receive it with nothing under the authenticated user's
+control in between.
+
+The section 3.1 file is different. sshd creates it under the
+authenticated user's own uid, and the path arrives in an environment
+variable the user can repoint, so anything in-session can forge it. No
+ownership or mode check helps, because the adversary is the file's
+legitimate owner. A sudo policy, a PAM module, or any other component
+deciding what the user may do next MUST NOT treat the file as evidence
+of how the session authenticated. This is a property of the channel,
+unchanged since ExposeAuthInfo shipped in 7.6, and a trusted channel
+for such consumers is listed as a candidate in section 3.2.
+
+Parts of a record originate with the peer: client_version, the
+contents of method_info, and the key id, principal names and
+application string carried inside a key blob. A consumer rendering a
+record into a log, a terminal or a document MUST escape them for that
+destination.
+
+5. Fields not in version 1
+
+Deferred, and addable later without a version bump: the signature
+algorithm actually used and the sk-* signature counter and flags,
+which sshd does not retain to the write site; the authorized_keys
+options that applied to the matched key; a FIDO attestation block, for
+which no upstream mechanism exists yet; and authorization provenance,
+the matched principal, failed attempts, service name and GSSAPI ticket
+detail.
+
+Declined rather than deferred: parsed views of key and certificate
+material. All are recoverable from key_blob, and carrying both invites
+a record whose parsed fields disagree with its own blob.
+
+6. Example
+
+The record below was produced from a live loopback authentication: a
+single publickey step with an ed25519 user key, no certificate,
+mlkem768x25519-sha256 key exchange with strict KEX negotiated, and
+chacha20-poly1305 with no separate MAC. It is 462 bytes.
+
+	U1NIQVVUSFIAAAABAAABvgAAAKQAAAAgtUfpa/AltzIVZHuRsPk6tA8bLzc88xbT4G
+	oMovLORnUAAAAHc3RyeWthcgAAA+gAAAPoAAAADS9ob21lL3N0cnlrYXIAAAAAancs
+	ogAAAFAAAABMAAAACXB1YmxpY2tleQAAAAAAAAAAAAAAMwAAAAtzc2gtZWQyNTUxOQ
+	AAACCynm5B6MQrl2iQsUiWVbOWJVlO3qK2HdUdyK75bwMh3QAAARIAAAAdY2hhY2hh
+	MjAtcG9seTEzMDVAb3BlbnNzaC5jb20AAAAdY2hhY2hhMjAtcG9seTEzMDVAb3Blbn
+	NzaC5jb20AAAAAAAAAAAAAAARub25lAAAABG5vbmUAAAAVbWxrZW03Njh4MjU1MTkt
+	c2hhMjU2AAAAAQAAAAtzc2gtZWQyNTUxOQAAADJTSEEyNTY6Y2dRRDJUdjNoMEZsWF
+	ZIK2hBTmh6Si96cVJIUXBWSU9UZXNodzRkQ2x6WQAAAAkxMjcuMC4wLjEAAIUCAAAA
+	CTEyNy4wLjAuMQAACPwAAAAUU1NILTIuMC1PcGVuU1NIXzEwLjQAAAAUU1NILTIuMC
+	1PcGVuU1NIXzEwLjQAAAAA
+
+Reading the framing: the first eight bytes are the preamble, then
+FORMAT_VERSION 1, then contents as a 446-byte string holding a
+164-byte session block and a 274-byte transport block. Inside the
+session block, methods is 80 bytes holding one 76-byte entry, whose
+own fields are method "publickey", an empty submethod and
+method_info, and a 51-byte key blob. There is no count before the
+entry; the sequence ends when methods is exhausted. In the transport
+block kex_options reads 0x00000001, bit 0 being strict KEX.
+
+7. References
+
+This format follows the conventions of PROTOCOL.sshsig and
+PROTOCOL.krl: an sshbuf wire format with a magic preamble and a uint32
+version, and strict refusal of versions above the maximum supported.
+
+draft-ietf-sshm-cert defines the certificate format carried in
+key_blob when a step presented a certificate. The sk-* key type names
+that can appear in a method entry follow PROTOCOL.u2f. RFC 4251
+section 5 defines the wire encoding primitives used throughout, and
+RFC 4253 section 6.6 the public key serialisation used for key_blob.
diff --git a/auth.h b/auth.h
index 0f11458ca..90c92da9e 100644
--- a/auth.h
+++ b/auth.h
@@ -95,6 +95,7 @@ struct Authctxt {
 
 	/* Information exposed to session */
 	struct sshbuf	*session_info;	/* Auth info for environment */
+	struct sshbuf	*authrec_methods; /* PROTOCOL.authrec method sequence */
 };
 
 /*
diff --git a/auth2.c b/auth2.c
index 3f353a719..9fe5f9e9f 100644
--- a/auth2.c
+++ b/auth2.c
@@ -49,6 +49,7 @@
 #include "sshkey.h"
 #include "hostfile.h"
 #include "auth.h"
+#include "authrec.h"
 #include "dispatch.h"
 #include "pathnames.h"
 #include "ssherr.h"
@@ -810,5 +811,8 @@ auth2_update_session_info(Authctxt *authctxt, const char *method,
 	}
 	if ((r = sshbuf_put_u8(authctxt->session_info, '\n')) != 0)
 		fatal_fr(r, "append");
+
+	if ((r = authrec_record_step(authctxt, method, submethod)) != 0)
+		fatal_fr(r, "authrec_record_step");
 }
 
diff --git a/authrec.c b/authrec.c
new file mode 100644
index 000000000..2773c117e
--- /dev/null
+++ b/authrec.c
@@ -0,0 +1,222 @@
+/*
+ * Copyright (c) 2026 Avinash Duduskar
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include "includes.h"
+
+#include <sys/types.h>
+
+#include <pwd.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#include "ssherr.h"
+#include "sshbuf.h"
+#include "sshkey.h"
+#include "packet.h"
+#include "kex.h"
+#include "digest.h"
+#include "hostfile.h"
+#include "auth.h"
+#include "authrec.h"
+
+static int
+put_cstring_or_empty(struct sshbuf *b, const char *s)
+{
+	return sshbuf_put_cstring(b, s == NULL ? "" : s);
+}
+
+int
+authrec_record_step(Authctxt *authctxt, const char *method,
+    const char *submethod)
+{
+	struct sshbuf *entry = NULL, *key = NULL;
+	int r;
+
+	if (authctxt->authrec_methods == NULL &&
+	    (authctxt->authrec_methods = sshbuf_new()) == NULL)
+		return SSH_ERR_ALLOC_FAIL;
+	if ((entry = sshbuf_new()) == NULL)
+		return SSH_ERR_ALLOC_FAIL;
+
+	if ((r = put_cstring_or_empty(entry, method)) != 0 ||
+	    (r = put_cstring_or_empty(entry, submethod)) != 0 ||
+	    (r = put_cstring_or_empty(entry, authctxt->auth_method_info)) != 0)
+		goto out;
+
+	if (authctxt->auth_method_key == NULL) {
+		if ((r = sshbuf_put_string(entry, NULL, 0)) != 0)
+			goto out;
+	} else {
+		if ((key = sshbuf_new()) == NULL) {
+			r = SSH_ERR_ALLOC_FAIL;
+			goto out;
+		}
+		if ((r = sshkey_putb(authctxt->auth_method_key, key)) != 0 ||
+		    (r = sshbuf_put_stringb(entry, key)) != 0)
+			goto out;
+	}
+
+	/* Each entry carries its own length; the sequence has no count. */
+	r = sshbuf_put_stringb(authctxt->authrec_methods, entry);
+ out:
+	sshbuf_free(key);
+	sshbuf_free(entry);
+	return r;
+}
+
+static int
+session_block(struct ssh *ssh, Authctxt *authctxt, struct sshbuf *b)
+{
+	int r;
+
+	/* The spec requires a resolved passwd entry; refuse to guess. */
+	if (authctxt->pw == NULL)
+		return SSH_ERR_INTERNAL_ERROR;
+
+	if ((r = sshbuf_put_stringb(b, ssh->kex->session_id)) != 0 ||
+	    (r = put_cstring_or_empty(b, authctxt->user)) != 0 ||
+	    (r = sshbuf_put_u32(b, (u_int)authctxt->pw->pw_uid)) != 0 ||
+	    (r = sshbuf_put_u32(b, (u_int)authctxt->pw->pw_gid)) != 0 ||
+	    (r = put_cstring_or_empty(b, authctxt->pw->pw_dir)) != 0 ||
+	    (r = sshbuf_put_u64(b, (u_int64_t)time(NULL))) != 0)
+		return r;
+
+	if (authctxt->authrec_methods == NULL)
+		return sshbuf_put_string(b, NULL, 0);
+	return sshbuf_put_stringb(b, authctxt->authrec_methods);
+}
+
+static int
+transport_block(struct ssh *ssh, struct sshbuf *b)
+{
+	const char *enc_in = NULL, *mac_in = NULL, *comp_in = NULL;
+	const char *enc_out = NULL, *mac_out = NULL, *comp_out = NULL;
+	struct sshkey *hostkey;
+	char *hostkey_fp = NULL;
+	u_int32_t kex_options = 0;
+	int r;
+
+	/* Server side: MODE_IN is client to server, MODE_OUT the reverse. */
+	if ((r = ssh_packet_get_active_alg_names(ssh, MODE_IN,
+	    &enc_in, &mac_in, &comp_in)) != 0 ||
+	    (r = ssh_packet_get_active_alg_names(ssh, MODE_OUT,
+	    &enc_out, &mac_out, &comp_out)) != 0)
+		return r;
+
+	if (ssh->kex->kex_strict)
+		kex_options |= AUTHREC_KEX_STRICT;
+
+	/* The spec defines no absent case for the fingerprint. */
+	hostkey = get_hostkey_public_by_type(ssh->kex->hostkey_type,
+	    ssh->kex->hostkey_nid, ssh);
+	if (hostkey == NULL)
+		return SSH_ERR_INTERNAL_ERROR;
+	if ((hostkey_fp = sshkey_fingerprint(hostkey, SSH_DIGEST_SHA256,
+	    SSH_FP_DEFAULT)) == NULL)
+		return SSH_ERR_ALLOC_FAIL;
+
+	if ((r = put_cstring_or_empty(b, enc_in)) != 0 ||
+	    (r = put_cstring_or_empty(b, enc_out)) != 0 ||
+	    (r = put_cstring_or_empty(b, mac_in)) != 0 ||
+	    (r = put_cstring_or_empty(b, mac_out)) != 0 ||
+	    (r = put_cstring_or_empty(b, comp_in)) != 0 ||
+	    (r = put_cstring_or_empty(b, comp_out)) != 0 ||
+	    (r = put_cstring_or_empty(b, ssh->kex->name)) != 0 ||
+	    (r = sshbuf_put_u32(b, kex_options)) != 0 ||
+	    (r = put_cstring_or_empty(b, ssh->kex->hostkey_alg)) != 0 ||
+	    (r = put_cstring_or_empty(b, hostkey_fp)) != 0 ||
+	    (r = put_cstring_or_empty(b, ssh_remote_ipaddr(ssh))) != 0 ||
+	    (r = sshbuf_put_u32(b, (u_int)ssh_remote_port(ssh))) != 0 ||
+	    (r = put_cstring_or_empty(b, ssh_local_ipaddr(ssh))) != 0 ||
+	    (r = sshbuf_put_u32(b, (u_int)ssh_local_port(ssh))) != 0 ||
+	    (r = sshbuf_put_stringb(b, ssh->kex->client_version)) != 0 ||
+	    (r = sshbuf_put_stringb(b, ssh->kex->server_version)) != 0 ||
+	    (r = put_cstring_or_empty(b, ssh_packet_rdomain_in(ssh))) != 0)
+		goto out;
+	r = 0;
+ out:
+	free(hostkey_fp);
+	return r;
+}
+
+int
+authrec_build(struct ssh *ssh, Authctxt *authctxt, struct sshbuf **out)
+{
+	struct sshbuf *rec = NULL, *contents = NULL;
+	struct sshbuf *session = NULL, *transport = NULL;
+	int r = SSH_ERR_ALLOC_FAIL;
+
+	*out = NULL;
+	if ((rec = sshbuf_new()) == NULL ||
+	    (contents = sshbuf_new()) == NULL ||
+	    (session = sshbuf_new()) == NULL ||
+	    (transport = sshbuf_new()) == NULL)
+		goto out;
+
+	if ((r = session_block(ssh, authctxt, session)) != 0 ||
+	    (r = transport_block(ssh, transport)) != 0)
+		goto out;
+
+	if ((r = sshbuf_put_stringb(contents, session)) != 0 ||
+	    (r = sshbuf_put_stringb(contents, transport)) != 0)
+		goto out;
+
+	if ((r = sshbuf_put(rec, AUTHREC_MAGIC, AUTHREC_MAGIC_LEN)) != 0 ||
+	    (r = sshbuf_put_u32(rec, AUTHREC_FORMAT_V1)) != 0 ||
+	    (r = sshbuf_put_stringb(rec, contents)) != 0)
+		goto out;
+
+	*out = rec;
+	rec = NULL;
+	r = 0;
+ out:
+	sshbuf_free(rec);
+	sshbuf_free(contents);
+	sshbuf_free(session);
+	sshbuf_free(transport);
+	return r;
+}
+
+int
+authrec_parse(struct sshbuf *in, u_int *version, struct sshbuf **contents)
+{
+	u_char magic[AUTHREC_MAGIC_LEN];
+	struct sshbuf *body = NULL;
+	u_int ver;
+	int r;
+
+	*version = 0;
+	*contents = NULL;
+
+	if ((r = sshbuf_get(in, magic, sizeof(magic))) != 0 ||
+	    (r = sshbuf_get_u32(in, &ver)) != 0)
+		return r;
+	if (memcmp(magic, AUTHREC_MAGIC, AUTHREC_MAGIC_LEN) != 0)
+		return SSH_ERR_INVALID_FORMAT;
+	if (ver == 0 || ver > AUTHREC_FORMAT_V1)
+		return SSH_ERR_INVALID_FORMAT;
+	if ((r = sshbuf_froms(in, &body)) != 0)
+		return r;
+	if (sshbuf_len(in) != 0) {
+		sshbuf_free(body);
+		return SSH_ERR_INVALID_FORMAT;
+	}
+
+	*version = ver;
+	*contents = body;
+	return 0;
+}
diff --git a/authrec.h b/authrec.h
new file mode 100644
index 000000000..38d8f2630
--- /dev/null
+++ b/authrec.h
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2026 Avinash Duduskar
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/* Structured authentication record; see PROTOCOL.authrec */
+
+#ifndef AUTHREC_H
+#define AUTHREC_H
+
+#define AUTHREC_MAGIC		"SSHAUTHR"
+#define AUTHREC_MAGIC_LEN	8
+#define AUTHREC_FORMAT_V1	1
+
+/* kex_options bits, transport block */
+#define AUTHREC_KEX_STRICT	(1U << 0)
+
+struct ssh;
+struct sshbuf;
+struct Authctxt;
+
+/*
+ * Append one satisfied authentication step to the record's method
+ * sequence. Called alongside the legacy text accumulation in
+ * auth2_update_session_info(), so only steps that succeeded outright
+ * or satisfied a step of AuthenticationMethods are recorded.
+ */
+int	 authrec_record_step(struct Authctxt *, const char *,
+	    const char *);
+
+/*
+ * Build a version-1 record from state available at the site that
+ * writes the legacy ExposeAuthInfo file. On success returns 0 and
+ * sets *out to a freshly allocated sshbuf owned by the caller.
+ */
+int	 authrec_build(struct ssh *, struct Authctxt *, struct sshbuf **);
+
+/*
+ * Parse the outer framing of a version-1 record. On success returns 0
+ * with *version set and *contents pointing at a fresh sshbuf holding
+ * the blocks. Rejects version 0, versions above AUTHREC_FORMAT_V1,
+ * and data after contents.
+ */
+int	 authrec_parse(struct sshbuf *, u_int *, struct sshbuf **);
+
+#endif /* AUTHREC_H */
diff --git a/packet.c b/packet.c
index 4c89e60c5..d7d091c28 100644
--- a/packet.c
+++ b/packet.c
@@ -678,6 +678,22 @@ ssh_packet_rdomain_in(struct ssh *ssh)
 	return ssh->rdomain_in;
 }
 
+/* Active algorithm names for the given mode; see PROTOCOL.authrec */
+int
+ssh_packet_get_active_alg_names(struct ssh *ssh, int mode,
+    const char **enc, const char **mac, const char **comp)
+{
+	struct newkeys *nk;
+
+	if (ssh == NULL || ssh->state == NULL || mode < 0 ||
+	    mode >= MODE_MAX || (nk = ssh->state->newkeys[mode]) == NULL)
+		return SSH_ERR_INTERNAL_ERROR;
+	*enc = nk->enc.name;
+	*mac = nk->mac.name;
+	*comp = nk->comp.name;
+	return 0;
+}
+
 /* Closes the connection and clears and frees internal data structures. */
 
 static void
@@ -2431,7 +2447,8 @@ kex_to_blob(struct sshbuf *m, struct kex *kex)
 	    (r = sshbuf_put_stringb(m, kex->client_version)) != 0 ||
 	    (r = sshbuf_put_stringb(m, kex->server_version)) != 0 ||
 	    (r = sshbuf_put_stringb(m, kex->session_id)) != 0 ||
-	    (r = sshbuf_put_u32(m, kex->flags)) != 0)
+	    (r = sshbuf_put_u32(m, kex->flags)) != 0 ||
+	    (r = sshbuf_put_cstring(m, kex->name == NULL ? "" : kex->name)) != 0)
 		return r;
 	return 0;
 }
@@ -2605,7 +2622,8 @@ kex_from_blob(struct sshbuf *m, struct kex **kexp)
 	    (r = sshbuf_get_stringb(m, kex->client_version)) != 0 ||
 	    (r = sshbuf_get_stringb(m, kex->server_version)) != 0 ||
 	    (r = sshbuf_get_stringb(m, kex->session_id)) != 0 ||
-	    (r = sshbuf_get_u32(m, &kex->flags)) != 0)
+	    (r = sshbuf_get_u32(m, &kex->flags)) != 0 ||
+	    (r = sshbuf_get_cstring(m, &kex->name, NULL)) != 0)
 		goto out;
 	if (kex->we_need > 1024) {
 		r = SSH_ERR_INVALID_FORMAT;
diff --git a/packet.h b/packet.h
index 3e8acb2cd..980d9ca11 100644
--- a/packet.h
+++ b/packet.h
@@ -166,6 +166,8 @@ int	 ssh_remote_port(struct ssh *);
 const char *ssh_local_ipaddr(struct ssh *);
 int	 ssh_local_port(struct ssh *);
 const char *ssh_packet_rdomain_in(struct ssh *);
+int	 ssh_packet_get_active_alg_names(struct ssh *, int,
+    const char **, const char **, const char **);
 char	*ssh_remote_hostname(struct ssh *);
 
 void	 ssh_packet_set_rekey_limits(struct ssh *, uint64_t, uint32_t);
diff --git a/regress/Makefile b/regress/Makefile
index a6f81bef4..ef8c6a724 100644
--- a/regress/Makefile
+++ b/regress/Makefile
@@ -301,6 +301,7 @@ unit unit-bench:
 			-d ${.CURDIR}/unittests/sshsig/testdata $${ARGS}; \
 		$$V ${.OBJDIR}/unittests/authopt/test_authopt \
 			-d ${.CURDIR}/unittests/authopt/testdata $${ARGS}; \
+		$$V ${.OBJDIR}/unittests/authrec/test_authrec $${ARGS}; \
 		$$V ${.OBJDIR}/unittests/bitmap/test_bitmap $${ARGS}; \
 		$$V ${.OBJDIR}/unittests/conversion/test_conversion $${ARGS}; \
 		$$V ${.OBJDIR}/unittests/kex/test_kex $${ARGS}; \
diff --git a/regress/unittests/Makefile b/regress/unittests/Makefile
index 5d482eab4..f30f85214 100644
--- a/regress/unittests/Makefile
+++ b/regress/unittests/Makefile
@@ -1,6 +1,6 @@
 #	$OpenBSD: Makefile,v 1.15 2026/06/14 04:08:05 djm Exp $
 
 SUBDIR=	test_helper sshbuf sshkey bitmap kex hostkeys utf8 match conversion
-SUBDIR+=authopt misc sshsig servconf crypto
+SUBDIR+=authopt authrec misc sshsig servconf crypto
 
 .include <bsd.subdir.mk>
diff --git a/regress/unittests/authrec/Makefile b/regress/unittests/authrec/Makefile
new file mode 100644
index 000000000..45231ad7d
--- /dev/null
+++ b/regress/unittests/authrec/Makefile
@@ -0,0 +1,44 @@
+#	$OpenBSD$
+
+PROG=test_authrec
+SRCS=tests.c
+
+SRCS+=authrec.c
+
+# From usr.bin/ssh; authrec.c pulls packet.c, and packet.c the rest.
+SRCS+=sshbuf-getput-basic.c sshbuf-getput-crypto.c sshbuf-misc.c sshbuf.c
+SRCS+=sshbuf-io.c atomicio.c sshkey.c authfile.c cipher.c log.c ssh-rsa.c
+SRCS+=ssh-ecdsa.c ssh-ed25519.c mac.c umac.c umac128.c hmac.c misc.c
+SRCS+=ssherr.c uidswap.c cleanup.c xmalloc.c match.c krl.c fatal.c
+SRCS+=addr.c addrmatch.c bitmap.c packet.c dispatch.c canohost.c ssh_api.c
+SRCS+=compat.c
+SRCS+=cipher-chachapoly.c chacha.c poly1305.c ssh-ecdsa-sk.c ssh-sk.c
+SRCS+=ssh-ed25519-sk.c sk-usbhid.c ssh-pkcs11-client.c ssherr-libcrypto.c
+SRCS+=libcrux-mlkem-mldsa.c ssh-mldsa-eddsa.c
+SRCS+=	kex.c
+SRCS+=	kex-names.c
+SRCS+=	dh.c
+SRCS+=	kexdh.c
+SRCS+=	kexecdh.c
+SRCS+=	kexgex.c
+SRCS+=	kexgexc.c
+SRCS+=	kexgexs.c
+SRCS+=	kexc25519.c
+SRCS+=	smult_curve25519_ref.c
+SRCS+=	kexgen.c
+SRCS+=	kexsntrup761x25519.c
+SRCS+=	kexmlkem768x25519.c
+SRCS+=	sntrup761.c
+SRCS+=	utf8.c
+
+SRCS+=digest-openssl.c ed25519-openssl.c
+#SRCS+=digest-libc.c ed25519.c
+
+REGRESS_TARGETS=run-regress-${PROG}
+
+run-regress-${PROG}: ${PROG}
+	env ${TEST_ENV} ./${PROG} ${UNITTEST_ARGS}
+
+.include <bsd.regress.mk>
+
+LDADD+=-lz
diff --git a/regress/unittests/authrec/tests.c b/regress/unittests/authrec/tests.c
new file mode 100644
index 000000000..a74bac211
--- /dev/null
+++ b/regress/unittests/authrec/tests.c
@@ -0,0 +1,123 @@
+/*
+ * Regress test for the PROTOCOL.authrec outer framing parser.
+ *
+ * Placed in the public domain
+ */
+
+#include "includes.h"
+
+#include <sys/types.h>
+#include <stdio.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "../test_helper/test_helper.h"
+
+#include "ssherr.h"
+#include "sshbuf.h"
+#include "authrec.h"
+
+/* authrec.o links server-only symbols; the parser never calls this. */
+struct ssh;
+struct sshkey *get_hostkey_public_by_type(int, int, struct ssh *);
+struct sshkey *
+get_hostkey_public_by_type(int type, int nid, struct ssh *ssh)
+{
+	return NULL;
+}
+
+static struct sshbuf *
+build_record(u_int version, const u_char *magic)
+{
+	struct sshbuf *rec, *contents;
+
+	rec = sshbuf_new();
+	ASSERT_PTR_NE(rec, NULL);
+	contents = sshbuf_new();
+	ASSERT_PTR_NE(contents, NULL);
+	/* two enclosed blocks, contents opaque to the outer parser */
+	ASSERT_INT_EQ(sshbuf_put_cstring(contents, "session"), 0);
+	ASSERT_INT_EQ(sshbuf_put_cstring(contents, "transport"), 0);
+	ASSERT_INT_EQ(sshbuf_put(rec, magic, AUTHREC_MAGIC_LEN), 0);
+	ASSERT_INT_EQ(sshbuf_put_u32(rec, version), 0);
+	ASSERT_INT_EQ(sshbuf_put_stringb(rec, contents), 0);
+	sshbuf_free(contents);
+	return rec;
+}
+
+void
+tests(void)
+{
+	struct sshbuf *rec, *cut, *contents;
+	u_int version;
+	size_t i, len;
+	int r;
+
+	TEST_START("parse valid record");
+	rec = build_record(AUTHREC_FORMAT_V1, AUTHREC_MAGIC);
+	contents = NULL;
+	version = 0;
+	ASSERT_INT_EQ(authrec_parse(rec, &version, &contents), 0);
+	ASSERT_U_INT_EQ(version, AUTHREC_FORMAT_V1);
+	ASSERT_PTR_NE(contents, NULL);
+	ASSERT_SIZE_T_EQ(sshbuf_len(rec), 0);
+	sshbuf_free(contents);
+	sshbuf_free(rec);
+	TEST_DONE();
+
+	TEST_START("reject version 0");
+	rec = build_record(0, AUTHREC_MAGIC);
+	r = authrec_parse(rec, &version, &contents);
+	ASSERT_INT_EQ(r, SSH_ERR_INVALID_FORMAT);
+	ASSERT_PTR_EQ(contents, NULL);
+	sshbuf_free(rec);
+	TEST_DONE();
+
+	TEST_START("reject version above maximum");
+	rec = build_record(AUTHREC_FORMAT_V1 + 1, AUTHREC_MAGIC);
+	r = authrec_parse(rec, &version, &contents);
+	ASSERT_INT_EQ(r, SSH_ERR_INVALID_FORMAT);
+	ASSERT_PTR_EQ(contents, NULL);
+	sshbuf_free(rec);
+	TEST_DONE();
+
+	TEST_START("reject bad magic");
+	rec = build_record(AUTHREC_FORMAT_V1, "SSHAUTHX");
+	r = authrec_parse(rec, &version, &contents);
+	ASSERT_INT_EQ(r, SSH_ERR_INVALID_FORMAT);
+	ASSERT_PTR_EQ(contents, NULL);
+	sshbuf_free(rec);
+	TEST_DONE();
+
+	TEST_START("reject every truncation");
+	rec = build_record(AUTHREC_FORMAT_V1, AUTHREC_MAGIC);
+	len = sshbuf_len(rec);
+	for (i = 0; i < len; i++) {
+		cut = sshbuf_new();
+		ASSERT_PTR_NE(cut, NULL);
+		ASSERT_INT_EQ(sshbuf_put(cut, sshbuf_ptr(rec), i), 0);
+		test_subtest_info("truncated to %zu of %zu", i, len);
+		r = authrec_parse(cut, &version, &contents);
+		ASSERT_INT_NE(r, 0);
+		ASSERT_PTR_EQ(contents, NULL);
+		sshbuf_free(cut);
+	}
+	sshbuf_free(rec);
+	TEST_DONE();
+
+	TEST_START("reject trailing data after contents");
+	rec = build_record(AUTHREC_FORMAT_V1, AUTHREC_MAGIC);
+	ASSERT_INT_EQ(sshbuf_put_u8(rec, 0), 0);
+	r = authrec_parse(rec, &version, &contents);
+	ASSERT_INT_EQ(r, SSH_ERR_INVALID_FORMAT);
+	ASSERT_PTR_EQ(contents, NULL);
+	sshbuf_free(rec);
+	TEST_DONE();
+}
+
+void
+benchmarks(void)
+{
+	printf("no benchmarks\n");
+}
diff --git a/servconf.c b/servconf.c
index 9b443bea0..1e0d3e53f 100644
--- a/servconf.c
+++ b/servconf.c
@@ -1084,6 +1084,12 @@ static const struct multistate multistate_gatewayports[] = {
 	{ "no",				0 },
 	{ NULL, -1 }
 };
+static const struct multistate multistate_exposeauthinfo[] = {
+	{ "yes",			EXPOSE_AUTHINFO_YES },
+	{ "structured",			EXPOSE_AUTHINFO_STRUCTURED },
+	{ "no",				EXPOSE_AUTHINFO_NO },
+	{ NULL, -1 }
+};
 static const struct multistate multistate_tcpfwd[] = {
 	{ "yes",			FORWARD_ALLOW },
 	{ "all",			FORWARD_ALLOW },
@@ -2475,7 +2481,8 @@ process_server_config_line_depth(ServerOptions *options, char *line,
 
 	case sExposeAuthInfo:
 		intptr = &options->expose_userauth_info;
-		goto parse_flag;
+		multistate_ptr = multistate_exposeauthinfo;
+		goto parse_multistate;
 
 	case sRDomain:
 #if !defined(__OpenBSD__) && !defined(HAVE_SYS_SET_PROCESS_RDOMAIN)
@@ -4044,6 +4051,8 @@ fmt_intarg(ServerOpCodes code, int val)
 		return fmt_multistate_int(val, multistate_gatewayports);
 	case sCompression:
 		return fmt_multistate_int(val, multistate_compression);
+	case sExposeAuthInfo:
+		return fmt_multistate_int(val, multistate_exposeauthinfo);
 	case sAllowTcpForwarding:
 		return fmt_multistate_int(val, multistate_tcpfwd);
 	case sAllowStreamLocalForwarding:
diff --git a/servconf.h b/servconf.h
index a2345e88a..6fc3410b2 100644
--- a/servconf.h
+++ b/servconf.h
@@ -38,6 +38,10 @@ struct sshbuf;
 #define IGNORE_RHOSTS_YES	1
 #define IGNORE_RHOSTS_SHOSTS	2
 
+#define EXPOSE_AUTHINFO_NO		0
+#define EXPOSE_AUTHINFO_YES		1
+#define EXPOSE_AUTHINFO_STRUCTURED	2
+
 #define DEFAULT_AUTH_FAIL_MAX	6	/* Default for MaxAuthTries */
 #define DEFAULT_SESSIONS_MAX	10	/* Default for MaxSessions */
 
@@ -228,7 +232,7 @@ SSHCONF_STRING(authorized_principals_command_user, AuthorizedPrincipalsCommandUs
 SSHCONF_STRING(version_addendum, VersionAddendum, SSHCFG_GLOBAL, SSHCFG_COPY_NONE) \
 SSHCONF_STRARRAY(auth_methods, num_auth_methods, AuthenticationMethods, SSHCFG_ALL, SSHCFG_COPY_MATCH) \
 SSHCONF_INT(fingerprint_hash, FingerprintHash, SSHCFG_GLOBAL, NULL, SSH_FP_HASH_DEFAULT, SSHCFG_COPY_NONE) \
-SSHCONF_INTFLAG(expose_userauth_info, ExposeAuthInfo, SSHCFG_ALL, 0, SSHCFG_COPY_MATCH) \
+SSHCONF_INT(expose_userauth_info, ExposeAuthInfo, SSHCFG_ALL, multistate_exposeauthinfo, 0, SSHCFG_COPY_MATCH) \
 SSHCONF_STRING(sk_provider, SecurityKeyProvider, SSHCFG_GLOBAL, SSHCFG_COPY_NONE) \
 SSHCONF_INT(required_rsa_size, RequiredRSASize, SSHCFG_ALL, NULL, SSH_RSA_MINIMUM_MODULUS_SIZE, SSHCFG_COPY_MATCH) \
 SSHCONF_STRARRAY(channel_timeouts, num_channel_timeouts, ChannelTimeout, SSHCFG_ALL, SSHCFG_COPY_MATCH) \
diff --git a/session.c b/session.c
index fc9c9d6f8..b48c2e6a6 100644
--- a/session.c
+++ b/session.c
@@ -74,6 +74,7 @@
 #include "kex.h"
 #include "hostfile.h"
 #include "auth.h"
+#include "authrec.h"
 #include "auth-options.h"
 #include "authfd.h"
 #include "pathnames.h"
@@ -236,11 +237,23 @@ display_loginmsg(void)
 }
 
 static void
-prepare_auth_info_file(struct passwd *pw, struct sshbuf *info)
+prepare_auth_info_file(struct ssh *ssh, Authctxt *authctxt)
 {
-	int fd = -1, success = 0;
+	struct passwd *pw = authctxt->pw;
+	struct sshbuf *info = authctxt->session_info;
+	struct sshbuf *rec = NULL;
+	int fd = -1, success = 0, r;
 
-	if (!options.expose_userauth_info || info == NULL)
+	if (options.expose_userauth_info == EXPOSE_AUTHINFO_NO)
+		return;
+	if (options.expose_userauth_info == EXPOSE_AUTHINFO_STRUCTURED) {
+		if ((r = authrec_build(ssh, authctxt, &rec)) != 0) {
+			error_fr(r, "authrec_build");
+			return;
+		}
+		info = rec;
+	}
+	if (info == NULL)
 		return;
 
 	temporarily_use_uid(pw);
@@ -260,6 +273,7 @@ prepare_auth_info_file(struct passwd *pw, struct sshbuf *info)
 	}
 	success = 1;
  out:
+	sshbuf_free(rec);
 	if (!success) {
 		if (fd != -1)
 			close(fd);
@@ -335,7 +349,7 @@ do_authenticated(struct ssh *ssh, Authctxt *authctxt)
 	}
 	auth_debug_send(ssh);
 
-	prepare_auth_info_file(authctxt->pw, authctxt->session_info);
+	prepare_auth_info_file(ssh, authctxt);
 
 	do_authenticated2(ssh, authctxt);
 
diff --git a/sshd_config.5 b/sshd_config.5
index 39a864dee..5c808fd54 100644
--- a/sshd_config.5
+++ b/sshd_config.5
@@ -690,6 +690,12 @@ public credentials (e.g. keys) used to authenticate the user.
 The location of the file is exposed to the user session through the
 .Ev SSH_USER_AUTH
 environment variable.
+If set to
+.Cm structured ,
+the file instead contains a binary record of the completed
+authentication, described in the
+.Pa PROTOCOL.authrec
+file in the source distribution.
 The default is
 .Cm no .
 .It Cm FingerprintHash
-- 
2.55.0
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.