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