Bläddra i källkod

implement kerberos auth

kerberos wip + dockerized stunnel

kerberos: fix aim 6.5 login error

kerberos: AIM 6.5 login flow works

kerberos: finalize client request struct

kerberos: finalize server response

kerberos: invalid credentials response

kerberos: refactor & document structs

kerberos: fix clbuttic refactoring

kerberos: move auth logic from http handler to auth foodgroup

kerberos: unit test kerberos http server

kerberos: unit test kerberos auth in foodgroup svc

kerberos: fix remaining tests

kerberos: tooling for cert creation

kerberos: tooling for cert creation cleanup

so far

kerberos: cleanup and docs
Mike 1 år sedan
förälder
incheckning
6cfd7e3f43

+ 1 - 0
.gitignore

@@ -3,3 +3,4 @@
 *.sqlite
 dist/
 *.DS_Store
+certs/

+ 18 - 0
Dockerfile.certgen

@@ -0,0 +1,18 @@
+# use the last version of alpine that has a version of nss-tools with support
+# for the legacy dbm format compiled in.
+FROM alpine:3.16
+
+RUN apk add --no-cache \
+    openssl \
+    nss-tools \
+    bash \
+    ca-certificates \
+    curl
+
+WORKDIR /certs
+
+# Set default database type to legacy DBM (instead of sqlite), the only type
+# AIM 6 supports.
+ENV NSS_DEFAULT_DB_TYPE=dbm
+
+CMD ["/bin/bash"]

+ 66 - 0
Dockerfile.stunnel

@@ -0,0 +1,66 @@
+###############################################################################
+# Build stage – compile OpenSSL 1.0.2u and stunnel 5.75
+###############################################################################
+FROM debian:bookworm-slim AS build
+
+ARG OPENSSL_VERSION=1.0.2u
+ARG OPENSSL_TAG=OpenSSL_1_0_2u
+ARG STUNNEL_VERSION=5.75
+
+ARG OPENSSL_URL=https://github.com/openssl/openssl/releases/download/${OPENSSL_TAG}/openssl-${OPENSSL_VERSION}.tar.gz
+ARG STUNNEL_URL=https://www.stunnel.org/downloads/stunnel-${STUNNEL_VERSION}.tar.gz
+
+# Build prerequisites
+RUN apt-get update && \
+    apt-get install -y --no-install-recommends \
+        build-essential \
+        ca-certificates \
+        wget \
+        perl \
+        zlib1g-dev \
+        pkg-config && \
+    rm -rf /var/lib/apt/lists/*
+
+WORKDIR /usr/src
+
+# ---------- OpenSSL ----------------------------------------------------------
+RUN wget -qO openssl.tar.gz  "${OPENSSL_URL}" && \
+    tar xzf openssl.tar.gz && \
+    cd openssl-${OPENSSL_VERSION} && \
+    ./config --prefix=/usr/local/openssl --openssldir=/usr/local/openssl shared zlib && \
+    make -j"$(nproc)" && \
+    make install_sw
+
+# ---------- stunnel ----------------------------------------------------------
+RUN wget -qO stunnel.tar.gz "${STUNNEL_URL}" && \
+    tar xzf stunnel.tar.gz && \
+    cd stunnel-${STUNNEL_VERSION} && \
+    ./configure \
+        --with-ssl=/usr/local/openssl \
+        --prefix=/usr/local \
+        --sysconfdir=/etc \
+        --disable-libwrap && \
+    make -j"$(nproc)" && \
+    make install
+
+###############################################################################
+# Runtime stage – only what we need to run stunnel
+###############################################################################
+FROM debian:bookworm-slim AS runtime
+
+COPY --from=build /usr/local/openssl /usr/local/openssl
+COPY --from=build /usr/local/bin/stunnel   /usr/local/bin/
+COPY --from=build /usr/local/lib           /usr/local/lib
+
+# Make sure the custom OpenSSL is preferred at runtime
+ENV LD_LIBRARY_PATH="/usr/local/openssl/lib"
+
+# Directory to hold the user‑supplied stunnel.conf
+RUN mkdir -p /etc/stunnel
+
+WORKDIR /etc/stunnel
+EXPOSE 443 1088
+
+ENTRYPOINT ["stunnel"]
+# You can pass the config file name as CMD or at `docker run` time, e.g.:
+# CMD ["stunnel.conf"]

+ 54 - 8
Makefile

@@ -1,21 +1,67 @@
-DOCKER_IMAGE_TAG := goreleaser/goreleaser:v2.9.0
+################################################################################
+# Build & release helpers
+################################################################################
 
-DOCKER_RUN := @docker run \
+DOCKER_IMAGE_TAG_GO_RELEASER := goreleaser/goreleaser:v2.9.0
+DOCKER_RUN_GO_RELEASER := @docker run \
 	--env CGO_ENABLED=0 \
 	--env GITHUB_TOKEN=$(GITHUB_TOKEN) \
 	--rm \
 	--volume `pwd`:/go/src/retro-aim-server \
 	--workdir /go/src/retro-aim-server \
-	$(DOCKER_IMAGE_TAG)
+	$(DOCKER_IMAGE_TAG_GO_RELEASER)
 
 .PHONY: config
-config:
+config: ## Generate config file template from Config struct
 	go generate ./config
 
 .PHONY: release
-release:
-	$(DOCKER_RUN) --clean
+release: ## Run a clean, full GoReleaser run (publish + validate)
+	$(DOCKER_RUN_GO_RELEASER) --clean
 
 .PHONY: release-dry-run
-release-dry-run:
-	$(DOCKER_RUN) --clean --skip=validate --skip=publish
+release-dry-run: ## GoReleaser dry-run (skips validate & publish)
+	$(DOCKER_RUN_GO_RELEASER) --clean --skip=validate --skip=publish
+
+################################################################################
+# SSL Helpers
+################################################################################
+
+CERT_DIR       ?= certs
+CERT_GEN_IMAGE ?= cert-nss
+CERT_NAME      ?= ras.dev
+CERT_NSSDB_DIR := $(CERT_DIR)/nssdb
+CERT_PEM       := $(CERT_DIR)/$(CERT_NAME).pem
+
+.PHONY: stunnel-image
+stunnel-image: ## Build stunnel image pinned to v5.75 / OpenSSL 1.0.2u
+	docker build -t stunnel:5.75-openssl-1.0.2u -f Dockerfile.stunnel .
+
+.PHONY: cert-gen-image
+cert-gen-image: ## Build minimal helper image with openssl & nss tools
+	docker build -t $(CERT_GEN_IMAGE) -f Dockerfile.certgen .
+
+.PHONY: certs
+certs: clean-certs cert-gen-image ## Create SSL certificates for AIM 6.0+ clients
+	mkdir -p $(CERT_DIR)
+
+ 	# create SSL certificate
+	docker run --rm -v "$$PWD":/work -w /work/$(CERT_DIR) $(CERT_GEN_IMAGE) \
+		openssl req -x509 -newkey rsa:1024 \
+			-keyout "key.pem" \
+			-out "cert.pem" \
+			-sha256 -days 365 -nodes \
+			-subj "/CN=$(CERT_NAME)"
+	cat $(CERT_DIR)/cert.pem $(CERT_DIR)/key.pem > $(CERT_PEM)
+	rm $(CERT_DIR)/cert.pem $(CERT_DIR)/key.pem
+
+ 	# build NSS DB
+	mkdir -p $(CERT_NSSDB_DIR)
+	docker run -it --rm -v "$$PWD":/work -w /work $(CERT_GEN_IMAGE) \
+		sh -c "certutil -N -d $(CERT_NSSDB_DIR) --empty-password && \
+		       certutil -A -n 'RAS' -t 'CT,,C' -i $(CERT_PEM) -d $(CERT_NSSDB_DIR)"
+
+	@echo "Successfully created certificates in '$(CERT_DIR)/'"
+
+clean-certs: ## Remove all generated certificates & NSS DB
+	rm -rf $(CERT_DIR)

+ 16 - 0
cmd/server/factory.go

@@ -422,6 +422,22 @@ func ChatNav(deps Container) oscar.BOSServer {
 	}
 }
 
+// KerberosAPI creates an HTTP server for the Kerberos server.
+func KerberosAPI(deps Container) *oscar.KerberosServer {
+	authService := foodgroup.NewAuthService(
+		deps.cfg,
+		deps.inMemorySessionManager,
+		deps.chatSessionManager,
+		deps.sqLiteUserStore,
+		deps.hmacCookieBaker,
+		deps.chatSessionManager,
+		deps.sqLiteUserStore,
+		nil,
+		deps.rateLimitClasses,
+	)
+	return oscar.NewKerberosServer(deps.cfg, deps.logger, authService)
+}
+
 // MgmtAPI creates an HTTP server for the management API.
 func MgmtAPI(deps Container) *http.Server {
 	bld := config.Build{

+ 1 - 0
cmd/server/main.go

@@ -71,6 +71,7 @@ func main() {
 	start(BOS(deps))
 	start(Chat(deps))
 	start(ChatNav(deps))
+	start(KerberosAPI(deps))
 	start(MgmtAPI(deps))
 	start(ODir(deps))
 	start(TOC(deps))

+ 17 - 16
config/config.go

@@ -2,22 +2,23 @@ package config
 
 //go:generate go run github.com/mk6i/retro-aim-server/cmd/config_generator unix settings.env
 type Config struct {
-	ApiHost     string `envconfig:"API_HOST" require:"true" val:"127.0.0.1" description:"Specifies the IP address or hostname that the management API binds to for incoming connections (127.0.0.1 restricts to same machine only)."`
-	ApiPort     string `envconfig:"API_PORT" required:"true" val:"8080" description:"The port that the management API service binds to."`
-	AlertPort   string `envconfig:"ALERT_PORT" required:"true" val:"5194" description:"The port that the Alert service binds to."`
-	AuthPort    string `envconfig:"AUTH_PORT" required:"true" val:"5190" description:"The port that the auth service binds to."`
-	BARTPort    string `envconfig:"BART_PORT" required:"true" val:"5195" description:"The port that the BART service binds to."`
-	BOSPort     string `envconfig:"BOS_PORT" required:"true" val:"5191" description:"The port that the BOS service binds to."`
-	ChatNavPort string `envconfig:"CHAT_NAV_PORT" required:"true" val:"5193" description:"The port that the chat nav service binds to."`
-	ChatPort    string `envconfig:"CHAT_PORT" required:"true" val:"5192" description:"The port that the chat service binds to."`
-	AdminPort   string `envconfig:"ADMIN_PORT" required:"true" val:"5196" description:"The port that the admin service binds to."`
-	ODirPort    string `envconfig:"ODIR_PORT" required:"true" val:"5197" description:"The port that the ODir service binds to."`
-	DBPath      string `envconfig:"DB_PATH" required:"true" val:"oscar.sqlite" description:"The path to the SQLite database file. The file and DB schema are auto-created if they doesn't exist."`
-	DisableAuth bool   `envconfig:"DISABLE_AUTH" required:"true" val:"true" description:"Disable password check and auto-create new users at login time. Useful for quickly creating new accounts during development without having to register new users via the management API."`
-	LogLevel    string `envconfig:"LOG_LEVEL" required:"true" val:"info" description:"Set logging granularity. Possible values: 'trace', 'debug', 'info', 'warn', 'error'."`
-	OSCARHost   string `envconfig:"OSCAR_HOST" required:"true" val:"127.0.0.1" description:"The hostname that AIM clients connect to in order to reach OSCAR services (auth, BOS, BUCP, etc). Make sure the hostname is reachable by all clients. For local development, the default loopback address should work provided the server and AIM client(s) are running on the same machine. For LAN-only clients, a private IP address (e.g. 192.168..) or hostname should suffice. For clients connecting over the Internet, specify your public IP address and ensure that TCP ports 5190-5197 are open on your firewall."`
-	TOCHost     string `envconfig:"TOC_HOST" require:"true" val:"0.0.0.0" description:"Specifies the IP address or hostname that the TOC service binds to for incoming connections (0.0.0.0 listens on all interfaces)."`
-	TOCPort     string `envconfig:"TOC_PORT" required:"true" val:"9898" description:"The port that the TOC service binds to."`
+	ApiHost      string `envconfig:"API_HOST" require:"true" val:"127.0.0.1" description:"Specifies the IP address or hostname that the management API binds to for incoming connections (127.0.0.1 restricts to same machine only)."`
+	ApiPort      string `envconfig:"API_PORT" required:"true" val:"8080" description:"The port that the management API service binds to."`
+	KerberosPort string `envconfig:"KERBEROS_PORT" required:"true" val:"1088" description:"The port that the Kerberos server binds to."`
+	AlertPort    string `envconfig:"ALERT_PORT" required:"true" val:"5194" description:"The port that the Alert service binds to."`
+	AuthPort     string `envconfig:"AUTH_PORT" required:"true" val:"5190" description:"The port that the auth service binds to."`
+	BARTPort     string `envconfig:"BART_PORT" required:"true" val:"5195" description:"The port that the BART service binds to."`
+	BOSPort      string `envconfig:"BOS_PORT" required:"true" val:"5191" description:"The port that the BOS service binds to."`
+	ChatNavPort  string `envconfig:"CHAT_NAV_PORT" required:"true" val:"5193" description:"The port that the chat nav service binds to."`
+	ChatPort     string `envconfig:"CHAT_PORT" required:"true" val:"5192" description:"The port that the chat service binds to."`
+	AdminPort    string `envconfig:"ADMIN_PORT" required:"true" val:"5196" description:"The port that the admin service binds to."`
+	ODirPort     string `envconfig:"ODIR_PORT" required:"true" val:"5197" description:"The port that the ODir service binds to."`
+	DBPath       string `envconfig:"DB_PATH" required:"true" val:"oscar.sqlite" description:"The path to the SQLite database file. The file and DB schema are auto-created if they doesn't exist."`
+	DisableAuth  bool   `envconfig:"DISABLE_AUTH" required:"true" val:"true" description:"Disable password check and auto-create new users at login time. Useful for quickly creating new accounts during development without having to register new users via the management API."`
+	LogLevel     string `envconfig:"LOG_LEVEL" required:"true" val:"info" description:"Set logging granularity. Possible values: 'trace', 'debug', 'info', 'warn', 'error'."`
+	OSCARHost    string `envconfig:"OSCAR_HOST" required:"true" val:"127.0.0.1" description:"The hostname that AIM clients connect to in order to reach OSCAR services (auth, BOS, BUCP, etc). Make sure the hostname is reachable by all clients. For local development, the default loopback address should work provided the server and AIM client(s) are running on the same machine. For LAN-only clients, a private IP address (e.g. 192.168..) or hostname should suffice. For clients connecting over the Internet, specify your public IP address and ensure that TCP ports 5190-5197 are open on your firewall."`
+	TOCHost      string `envconfig:"TOC_HOST" require:"true" val:"0.0.0.0" description:"Specifies the IP address or hostname that the TOC service binds to for incoming connections (0.0.0.0 listens on all interfaces)."`
+	TOCPort      string `envconfig:"TOC_PORT" required:"true" val:"9898" description:"The port that the TOC service binds to."`
 }
 
 type Build struct {

+ 3 - 0
config/settings.env

@@ -5,6 +5,9 @@ export API_HOST=127.0.0.1
 # The port that the management API service binds to.
 export API_PORT=8080
 
+# The port that the Kerberos server binds to.
+export KERBEROS_PORT=1088
+
 # The port that the Alert service binds to.
 export ALERT_PORT=5194
 

+ 11 - 0
config/ssl/stunnel.conf

@@ -0,0 +1,11 @@
+foreground = yes
+debug = 7
+
+[ssl_proxy]
+options = NO_SSLv2
+options = NO_SSLv3
+options = NO_TLSv1_1
+ciphers = ALL
+accept = 443
+connect = host.docker.internal:1088
+cert = /etc/stunnel/certs/server.pem

+ 1 - 6
foodgroup/admin_test.go

@@ -565,12 +565,7 @@ func TestAdminService_InfoChangeRequest_ScreenName(t *testing.T) {
 									FoodGroup: wire.OService,
 									SubGroup:  wire.OServiceUserInfoUpdate,
 								},
-								Body: wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
-									UserInfo: []wire.TLVUserInfo{
-										newTestSession("Chatting Chuck").TLVUserInfo(),
-										newTestSession("Chatting Chuck").TLVUserInfo(),
-									},
-								},
+								Body: newMultiSessionInfoUpdate(newTestSession("Chatting Chuck")),
 							},
 						},
 					},

+ 109 - 8
foodgroup/auth.go

@@ -40,6 +40,7 @@ func NewAuthService(
 		// hack - adminServerSessionRetriever is just used for admin server
 		adminServerSessionRetriever: adminServerSessionRetriever,
 		rateLimitClasses:            classes,
+		timeNow:                     time.Now,
 	}
 }
 
@@ -56,6 +57,7 @@ type AuthService struct {
 	accountManager              AccountManager
 	adminServerSessionRetriever SessionRetriever
 	rateLimitClasses            wire.RateLimitClasses
+	timeNow                     func() time.Time
 }
 
 // RegisterChatSession adds a user to a chat room. The authCookie param is an
@@ -277,16 +279,110 @@ func (s AuthService) FLAPLogin(ctx context.Context, frame wire.FLAPSignonFrame,
 	return s.login(ctx, frame.TLVList, newUserFn)
 }
 
+// KerberosLogin handles AIM-style Kerberos authentication for AIM 6.0+.
+// Credit for understanding the SNAC structure and values goes to this mailing
+// list attachment from 2007:
+//
+//	https://web.archive.org/web/20100619063015/http://pidgin.im/pipermail/devel/attachments/20070906/e0069ff5/attachment-0001.txt
+//
+// Several values in the response are poorly understood but necessary for proper
+// processing on the client side.
+func (s AuthService) KerberosLogin(
+	ctx context.Context,
+	inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest,
+	newUserFn func(screenName state.DisplayScreenName) (state.User, error),
+) (wire.SNACMessage, error) {
+
+	b, ok := inBody.TicketRequestMetadata.Bytes(wire.KerberosTLVTicketRequest)
+	if !ok {
+		return wire.SNACMessage{}, fmt.Errorf("ticket request metadata bytes is missing")
+	}
+
+	var info wire.KerberosLoginRequestTicket
+	if err := wire.UnmarshalBE(&info, bytes.NewReader(b)); err != nil {
+		return wire.SNACMessage{}, fmt.Errorf("ticket request metadata unmarshal: %w", err)
+	}
+
+	list := wire.TLVList{
+		wire.NewTLVBE(wire.LoginTLVTagsScreenName, inBody.ClientPrincipal),
+		wire.NewTLVBE(wire.LoginTLVTagsPlaintextPassword, info.Password),
+	}
+
+	result, err := s.login(ctx, list, newUserFn)
+	if err != nil {
+		return wire.SNACMessage{}, fmt.Errorf("login: %w", err)
+	}
+
+	cookie, loginOK := result.Bytes(wire.LoginTLVTagsAuthorizationCookie)
+	if !loginOK {
+		return wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Kerberos,
+				SubGroup:  wire.KerberosKerberosLoginErrResponse,
+			},
+			Body: wire.SNAC_0x050C_0x0004_KerberosLoginErrResponse{
+				KerbRequestID: inBody.RequestID,
+				ScreenName:    inBody.ClientPrincipal,
+				ErrCode:       wire.KerberosErrAuthFailure,
+				Message:       "Auth failure",
+			},
+		}, nil
+	}
+
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Kerberos,
+			SubGroup:  wire.KerberosLoginSuccessResponse,
+		},
+		Body: wire.SNAC_0x050C_0x0003_KerberosLoginSuccessResponse{
+			RequestID:       inBody.RequestID,
+			Epoch:           uint32(s.timeNow().Unix()),
+			ClientPrincipal: inBody.ClientPrincipal,
+			ClientRealm:     "AOL",
+			Tickets: []wire.KerberosTicket{
+				{
+					PVNO:             5,
+					EncTicket:        []byte{},
+					TicketRealm:      "AOL",
+					ServicePrincipal: "im/boss",
+					ClientRealm:      "AOL",
+					ClientPrincipal:  inBody.ClientPrincipal,
+					AuthTime:         uint32(s.timeNow().Unix()),
+					StartTime:        uint32(s.timeNow().Unix()),
+					EndTime:          uint32(s.timeNow().Add(24 * time.Hour).Unix()),
+					Unknown4:         1610612736,
+					Unknown5:         1073741824,
+					ConnectionMetadata: wire.TLVBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.KerberosTLVBOSServerInfo, wire.KerberosBOSServerInfo{
+								Unknown: 1,
+								ConnectionInfo: wire.TLVBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.KerberosTLVHostname, net.JoinHostPort(s.config.OSCARHost, s.config.BOSPort)),
+										wire.NewTLVBE(wire.KerberosTLVCookie, cookie),
+									},
+								},
+							}),
+						},
+					},
+				},
+			},
+		},
+	}, nil
+}
+
 // loginProperties represents the properties sent by the client at login.
 type loginProperties struct {
-	clientID       string
-	isBUCPAuth     bool
-	isFLAPJavaAuth bool
-	isTOCAuth      bool
-	isFLAPAuth     bool
-	passwordHash   []byte
-	roastedPass    []byte
-	screenName     state.DisplayScreenName
+	clientID          string
+	isBUCPAuth        bool
+	isFLAPAuth        bool
+	isFLAPJavaAuth    bool
+	isKerberosAuth    bool
+	isTOCAuth         bool
+	passwordHash      []byte
+	plaintextPassword []byte
+	roastedPass       []byte
+	screenName        state.DisplayScreenName
 }
 
 // fromTLV creates an instance of loginProperties from a TLV list.
@@ -324,6 +420,9 @@ func (l *loginProperties) fromTLV(list wire.TLVList) error {
 		// extract roasted password for TOC FLAP login
 		l.roastedPass, _ = list.Bytes(wire.LoginTLVTagsRoastedTOCPassword)
 		l.isTOCAuth = true
+	case list.HasTag(wire.LoginTLVTagsPlaintextPassword):
+		l.plaintextPassword, _ = list.Bytes(wire.LoginTLVTagsPlaintextPassword)
+		l.isKerberosAuth = true
 	default:
 		l.isFLAPAuth = true
 	}
@@ -379,6 +478,8 @@ func (s AuthService) login(ctx context.Context, tlv wire.TLVList, newUserFn func
 		loginOK = user.ValidateRoastedJavaPass(props.roastedPass)
 	case props.isTOCAuth:
 		loginOK = user.ValidateRoastedTOCPass(props.roastedPass)
+	case props.isKerberosAuth:
+		loginOK = user.ValidatePlaintextPass(props.plaintextPassword)
 	}
 
 	if !loginOK {

+ 187 - 1
foodgroup/auth_test.go

@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"io"
 	"testing"
+	"time"
 
 	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/state"
@@ -664,7 +665,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 	}
 }
 
-func TestAuthService_FLAPLoginResponse(t *testing.T) {
+func TestAuthService_FLAPLogin(t *testing.T) {
 	user := state.User{
 		AuthKey:           "auth_key",
 		DisplayScreenName: "screenName",
@@ -1122,6 +1123,191 @@ func TestAuthService_FLAPLoginResponse(t *testing.T) {
 	}
 }
 
+func TestAuthService_KerberosLogin(t *testing.T) {
+	user := state.User{
+		AuthKey:           "auth_key",
+		DisplayScreenName: "screenName",
+		IdentScreenName:   state.NewIdentScreenName("screenName"),
+	}
+	assert.NoError(t, user.HashPassword("the_password"))
+
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// cfg is the app configuration
+		cfg config.Config
+		// inputSNAC is the kerberos SNAC sent from the client to the server
+		inputSNAC wire.SNAC_0x050C_0x0002_KerberosLoginRequest
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+		// newUserFn is the function that registers a new user account
+		newUserFn func(screenName state.DisplayScreenName) (state.User, error)
+		// expectOutput is the response sent from the server to client
+		expectOutput wire.SNACMessage
+		// wantErr is the error we expect from the method
+		wantErr error
+		// timeNow returns a canned time value
+		timeNow func() time.Time
+	}{
+		{
+			name: "AIM account exists, correct password, login OK",
+			cfg: config.Config{
+				OSCARHost: "127.0.0.1",
+				BOSPort:   "1234",
+			},
+			timeNow: func() time.Time {
+				return time.Unix(1000, 0)
+			},
+			inputSNAC: wire.SNAC_0x050C_0x0002_KerberosLoginRequest{
+				RequestID:       54321,
+				ClientPrincipal: user.DisplayScreenName.String(),
+				TicketRequestMetadata: wire.TLVBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.KerberosTLVTicketRequest, wire.KerberosLoginRequestTicket{
+							Password: "the_password",
+						}),
+					},
+				},
+			},
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: user.IdentScreenName,
+							result:     &user,
+						},
+					},
+				},
+				cookieBakerParams: cookieBakerParams{
+					cookieIssueParams: cookieIssueParams{
+						{
+							dataIn: func() []byte {
+								loginCookie := bosCookie{
+									ScreenName: user.DisplayScreenName,
+								}
+								buf := &bytes.Buffer{}
+								assert.NoError(t, wire.MarshalBE(loginCookie, buf))
+								return buf.Bytes()
+							}(),
+							cookieOut: []byte("the-cookie"),
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Kerberos,
+					SubGroup:  wire.KerberosLoginSuccessResponse,
+				},
+				Body: wire.SNAC_0x050C_0x0003_KerberosLoginSuccessResponse{
+					RequestID:       54321,
+					Epoch:           1000,
+					ClientPrincipal: user.DisplayScreenName.String(),
+					ClientRealm:     "AOL",
+					Tickets: []wire.KerberosTicket{
+						{
+							PVNO:             0x5,
+							EncTicket:        []uint8{},
+							TicketRealm:      "AOL",
+							ServicePrincipal: "im/boss",
+							ClientRealm:      "AOL",
+							ClientPrincipal:  user.DisplayScreenName.String(),
+							AuthTime:         1000,
+							StartTime:        1000,
+							EndTime:          87400,
+							Unknown4:         0x60000000,
+							Unknown5:         0x40000000,
+							ConnectionMetadata: wire.TLVBlock{
+								TLVList: wire.TLVList{
+									wire.NewTLVBE(wire.KerberosTLVBOSServerInfo, wire.KerberosBOSServerInfo{
+										Unknown: 1,
+										ConnectionInfo: wire.TLVBlock{
+											TLVList: wire.TLVList{
+												wire.NewTLVBE(wire.KerberosTLVHostname, "127.0.0.1:1234"),
+												wire.NewTLVBE(wire.KerberosTLVCookie, []byte("the-cookie")),
+											},
+										},
+									}),
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "AIM account exists, incorrect password, login failed",
+			cfg: config.Config{
+				OSCARHost: "127.0.0.1",
+				BOSPort:   "1234",
+			},
+			timeNow: func() time.Time {
+				return time.Unix(1000, 0)
+			},
+			inputSNAC: wire.SNAC_0x050C_0x0002_KerberosLoginRequest{
+				RequestID:       54321,
+				ClientPrincipal: user.DisplayScreenName.String(),
+				TicketRequestMetadata: wire.TLVBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.KerberosTLVTicketRequest, wire.KerberosLoginRequestTicket{
+							Password: "the_WRONG_password",
+						}),
+					},
+				},
+			},
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: user.IdentScreenName,
+							result:     nil,
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Kerberos,
+					SubGroup:  wire.KerberosKerberosLoginErrResponse,
+				},
+				Body: wire.SNAC_0x050C_0x0004_KerberosLoginErrResponse{
+					KerbRequestID: 54321,
+					ScreenName:    user.DisplayScreenName.String(),
+					ErrCode:       wire.KerberosErrAuthFailure,
+					Message:       "Auth failure",
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			userManager := newMockUserManager(t)
+			for _, params := range tc.mockParams.userManagerParams.getUserParams {
+				userManager.EXPECT().
+					User(matchContext(), params.screenName).
+					Return(params.result, params.err)
+			}
+			cookieBaker := newMockCookieBaker(t)
+			for _, params := range tc.mockParams.cookieIssueParams {
+				cookieBaker.EXPECT().
+					Issue(params.dataIn).
+					Return(params.cookieOut, params.err)
+			}
+			svc := AuthService{
+				config:      tc.cfg,
+				cookieBaker: cookieBaker,
+				userManager: userManager,
+				timeNow:     tc.timeNow,
+			}
+			outputSNAC, err := svc.KerberosLogin(context.Background(), tc.inputSNAC, tc.newUserFn)
+			assert.ErrorIs(t, err, tc.wantErr)
+			assert.Equal(t, tc.expectOutput, outputSNAC)
+		})
+	}
+}
+
 func TestAuthService_BUCPChallengeRequest(t *testing.T) {
 	sessUUID := uuid.UUID{1, 2, 3}
 	cases := []struct {

+ 19 - 0
foodgroup/helpers_test.go

@@ -828,3 +828,22 @@ func matchContext() interface{} {
 		return ok
 	})
 }
+
+// newMultiSessionInfoUpdate is a hack that returns a user info update SNAC with
+// primary and current instance user info blocks.
+func newMultiSessionInfoUpdate(session *state.Session) wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate {
+	return wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
+		UserInfo: []wire.TLVUserInfo{
+			func() wire.TLVUserInfo {
+				info := session.TLVUserInfo()
+				info.Append(wire.NewTLVBE(wire.OServiceUserInfoPrimaryInstance, []byte{0x01}))
+				return info
+			}(),
+			func() wire.TLVUserInfo {
+				info := session.TLVUserInfo()
+				info.Append(wire.NewTLVBE(wire.OServiceUserInfoMyInstanceNum, []byte{0x01}))
+				return info
+			}(),
+		},
+	}
+}

+ 4 - 0
foodgroup/oservice.go

@@ -868,6 +868,10 @@ func newOServiceUserInfoUpdate(sess *state.Session) wire.SNAC_0x01_0x0F_OService
 		// ideally, the second block should contain only instance-specific TLVs,
 		// but since the exact structure is unclear, we temporarily duplicate the first.
 		userInfo = append(userInfo, info)
+		// identify the primary session
+		userInfo[0].Append(wire.NewTLVBE(wire.OServiceUserInfoPrimaryInstance, []byte{0x01}))
+		// identify the first session (currently only 1x concurrent session supported)
+		userInfo[1].Append(wire.NewTLVBE(wire.OServiceUserInfoMyInstanceNum, []byte{0x01}))
 	}
 
 	return wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{

+ 3 - 18
foodgroup/oservice_test.go

@@ -460,12 +460,7 @@ func TestSetUserInfoFields(t *testing.T) {
 					SubGroup:  wire.OServiceUserInfoUpdate,
 					RequestID: 1234,
 				},
-				Body: wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
-					UserInfo: []wire.TLVUserInfo{
-						newTestSession("me").TLVUserInfo(),
-						newTestSession("me").TLVUserInfo(),
-					},
-				},
+				Body: newMultiSessionInfoUpdate(newTestSession("me")),
 			},
 			mockParams: mockParams{
 				buddyBroadcasterParams: buddyBroadcasterParams{
@@ -498,12 +493,7 @@ func TestSetUserInfoFields(t *testing.T) {
 					SubGroup:  wire.OServiceUserInfoUpdate,
 					RequestID: 1234,
 				},
-				Body: wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
-					UserInfo: []wire.TLVUserInfo{
-						newTestSession("me", sessOptInvisible).TLVUserInfo(),
-						newTestSession("me", sessOptInvisible).TLVUserInfo(),
-					},
-				},
+				Body: newMultiSessionInfoUpdate(newTestSession("me", sessOptInvisible)),
 			},
 			mockParams: mockParams{
 				buddyBroadcasterParams: buddyBroadcasterParams{
@@ -1196,12 +1186,7 @@ func TestOServiceService_UserInfoQuery(t *testing.T) {
 					SubGroup:  wire.OServiceUserInfoUpdate,
 					RequestID: 1234,
 				},
-				Body: wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
-					UserInfo: []wire.TLVUserInfo{
-						newTestSession("me").TLVUserInfo(),
-						newTestSession("me").TLVUserInfo(),
-					},
-				},
+				Body: newMultiSessionInfoUpdate(newTestSession("me")),
 			},
 		},
 	}

+ 16 - 0
scripts/run_stunnel.sh

@@ -0,0 +1,16 @@
+#!/bin/sh
+
+set -e
+
+if [ -z "$1" ]; then
+  echo "Usage: $0 /path/to/server.pem"
+  exit 1
+fi
+
+PEM_PATH="$1"
+
+docker run --rm -it \
+  -v "$PEM_PATH:/etc/stunnel/certs/server.pem:ro" \
+  -v "$(pwd)/config/ssl/stunnel.conf:/etc/stunnel/stunnel.conf:ro" \
+  -p 443:443 \
+  stunnel:5.75-openssl-1.0.2u stunnel.conf

+ 2 - 1
server/oscar/auth.go

@@ -24,9 +24,10 @@ type AuthService interface {
 	BUCPChallenge(ctx context.Context, bodyIn wire.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (wire.SNACMessage, error)
 	BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error)) (wire.SNACMessage, error)
 	FLAPLogin(ctx context.Context, frame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error)) (wire.TLVRestBlock, error)
+	KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error)) (wire.SNACMessage, error)
 	RegisterBOSSession(ctx context.Context, authCookie []byte) (*state.Session, error)
-	RetrieveBOSSession(ctx context.Context, authCookie []byte) (*state.Session, error)
 	RegisterChatSession(ctx context.Context, authCookie []byte) (*state.Session, error)
+	RetrieveBOSSession(ctx context.Context, authCookie []byte) (*state.Session, error)
 	Signout(ctx context.Context, sess *state.Session)
 	SignoutChat(ctx context.Context, sess *state.Session)
 }

+ 110 - 0
server/oscar/kerberos.go

@@ -0,0 +1,110 @@
+package oscar
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	"net"
+	"net/http"
+	"time"
+
+	"github.com/mk6i/retro-aim-server/config"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+func NewKerberosServer(
+	cfg config.Config,
+	logger *slog.Logger,
+	authService AuthService,
+) *KerberosServer {
+	mux := http.NewServeMux()
+
+	mux.HandleFunc("POST /", func(writer http.ResponseWriter, request *http.Request) {
+		postKerberosHandler(writer, request, authService, logger)
+	})
+
+	return &KerberosServer{
+		Server: http.Server{
+			Addr:    net.JoinHostPort("", cfg.KerberosPort),
+			Handler: mux,
+		},
+		Logger: logger,
+	}
+}
+
+// KerberosServer hosts an HTTP endpoint capable of handling AIM-style Kerberos
+// authentication. The messages are structured as SNACs transmitted over HTTP.
+type KerberosServer struct {
+	http.Server
+	Logger *slog.Logger
+}
+
+func (s *KerberosServer) Start(ctx context.Context) error {
+	ch := make(chan error)
+
+	go func() {
+		s.Logger.Info("starting kerberos server", "addr", s.Addr)
+		if err := s.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
+			ch <- fmt.Errorf("unable to start kerberos server: %w", err)
+		}
+	}()
+
+	select {
+	case <-ctx.Done():
+	case err := <-ch:
+		return err
+	}
+
+	shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	if err := s.Shutdown(shutdownCtx); err != nil {
+		s.Logger.Error("unable to shutdown kerberos server", "err", err.Error())
+	}
+	return nil
+}
+
+// postKerberosHandler handles AIM-style Kerberos authentication for AIM 6.0+.
+func postKerberosHandler(w http.ResponseWriter, r *http.Request, authService AuthService, logger *slog.Logger) {
+	b, err := io.ReadAll(r.Body)
+	if err != nil {
+		http.Error(w, "unable to read HTTP body", http.StatusBadRequest)
+		return
+	}
+	reader := bytes.NewReader(b)
+
+	var header wire.SNACFrame
+	if err := wire.UnmarshalBE(&header, reader); err != nil {
+		http.Error(w, "unable to read kerberos login SNAC header", http.StatusBadRequest)
+		return
+	}
+	if header.FoodGroup != wire.Kerberos && header.FoodGroup != wire.KerberosLoginRequest {
+		http.Error(w, "unexpected SNAC type", http.StatusBadRequest)
+		return
+	}
+
+	var body wire.SNAC_0x050C_0x0002_KerberosLoginRequest
+	if err := wire.UnmarshalBE(&body, reader); err != nil {
+		http.Error(w, "unable to read kerberos login SNAC body", http.StatusBadRequest)
+		return
+	}
+
+	response, err := authService.KerberosLogin(r.Context(), body, state.NewStubUser)
+	if err != nil {
+		logger.Error("authService.KerberosLogin", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/x-snac")
+
+	if err := wire.MarshalBE(response, w); err != nil {
+		logger.Error("unable to marshal SNAC response", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+}

+ 124 - 0
server/oscar/kerberos_test.go

@@ -0,0 +1,124 @@
+package oscar
+
+import (
+	"bytes"
+	"io"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/mk6i/retro-aim-server/config"
+	"github.com/mk6i/retro-aim-server/wire"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+)
+
+func TestKerberosLoginHandler(t *testing.T) {
+	tests := []struct {
+		name               string
+		request            wire.SNACMessage
+		response           wire.SNACMessage
+		responseErr        error
+		expectLogin        bool
+		expectSNACResponse bool
+		wantStatus         int
+	}{
+		{
+			name: "successful login",
+			request: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Kerberos,
+					SubGroup:  wire.KerberosLoginRequest,
+				},
+				Body: wire.SNAC_0x050C_0x0002_KerberosLoginRequest{
+					RequestID: 4321,
+				},
+			},
+			response: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Kerberos,
+					SubGroup:  wire.KerberosLoginSuccessResponse,
+				},
+				Body: wire.SNAC_0x050C_0x0003_KerberosLoginSuccessResponse{
+					RequestID: 4321,
+				},
+			},
+			expectLogin:        true,
+			expectSNACResponse: true,
+			wantStatus:         http.StatusOK,
+		},
+		{
+			name: "invalid request SNAC type",
+			request: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMChannelMsgToHost,
+				},
+				Body: wire.SNAC_0x050C_0x0002_KerberosLoginRequest{
+					RequestID: 4321,
+				},
+			},
+			expectLogin:        false,
+			expectSNACResponse: false,
+			wantStatus:         http.StatusBadRequest,
+		},
+		{
+			name: "login runtime error",
+			request: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Kerberos,
+					SubGroup:  wire.KerberosLoginRequest,
+				},
+				Body: wire.SNAC_0x050C_0x0002_KerberosLoginRequest{
+					RequestID: 4321,
+				},
+			},
+			response:           wire.SNACMessage{},
+			responseErr:        io.EOF,
+			expectLogin:        true,
+			expectSNACResponse: false,
+			wantStatus:         http.StatusInternalServerError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			mockAuth := newMockAuthService(t)
+			if tt.expectLogin {
+				mockAuth.EXPECT().
+					KerberosLogin(mock.Anything, tt.request.Body, mock.Anything).
+					Return(tt.response, tt.responseErr)
+			}
+
+			log := slog.New(slog.NewTextHandler(io.Discard, nil))
+			srv := NewKerberosServer(config.Config{KerberosPort: "0"}, log, mockAuth)
+
+			b := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(tt.request, b))
+
+			req := httptest.NewRequest(http.MethodPost, "/", b)
+			req.Header.Set("Content-Type", "application/x-snac")
+			w := httptest.NewRecorder()
+
+			srv.Handler.ServeHTTP(w, req)
+
+			assert.Equal(t, tt.wantStatus, w.Result().StatusCode)
+
+			if tt.expectSNACResponse {
+				respBytes, _ := io.ReadAll(w.Result().Body)
+				reader := bytes.NewReader(respBytes)
+				haveFrame := wire.SNACFrame{}
+				assert.NoError(t, wire.UnmarshalBE(&haveFrame, reader))
+				assert.Equal(t, tt.response.Frame, haveFrame)
+				haveBody := wire.SNAC_0x050C_0x0003_KerberosLoginSuccessResponse{}
+				assert.NoError(t, wire.UnmarshalBE(&haveBody, reader))
+				assert.Equal(t, tt.response.Body, haveBody)
+				assert.Equal(t, "application/x-snac", w.Result().Header.Get("Content-Type"))
+			} else {
+				assert.Equal(t, "text/plain; charset=utf-8", w.Result().Header.Get("Content-Type"))
+			}
+		})
+	}
+}

+ 58 - 0
server/oscar/mock_auth_test.go

@@ -200,6 +200,64 @@ func (_c *mockAuthService_FLAPLogin_Call) RunAndReturn(run func(context.Context,
 	return _c
 }
 
+// KerberosLogin provides a mock function with given fields: ctx, inBody, newUserFn
+func (_m *mockAuthService) KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(state.DisplayScreenName) (state.User, error)) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, inBody, newUserFn)
+
+	if len(ret) == 0 {
+		panic("no return value specified for KerberosLogin")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(state.DisplayScreenName) (state.User, error)) (wire.SNACMessage, error)); ok {
+		return rf(ctx, inBody, newUserFn)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(state.DisplayScreenName) (state.User, error)) wire.SNACMessage); ok {
+		r0 = rf(ctx, inBody, newUserFn)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(state.DisplayScreenName) (state.User, error)) error); ok {
+		r1 = rf(ctx, inBody, newUserFn)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockAuthService_KerberosLogin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'KerberosLogin'
+type mockAuthService_KerberosLogin_Call struct {
+	*mock.Call
+}
+
+// KerberosLogin is a helper method to define mock.On call
+//   - ctx context.Context
+//   - inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest
+//   - newUserFn func(state.DisplayScreenName)(state.User , error)
+func (_e *mockAuthService_Expecter) KerberosLogin(ctx interface{}, inBody interface{}, newUserFn interface{}) *mockAuthService_KerberosLogin_Call {
+	return &mockAuthService_KerberosLogin_Call{Call: _e.mock.On("KerberosLogin", ctx, inBody, newUserFn)}
+}
+
+func (_c *mockAuthService_KerberosLogin_Call) Run(run func(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(state.DisplayScreenName) (state.User, error))) *mockAuthService_KerberosLogin_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNAC_0x050C_0x0002_KerberosLoginRequest), args[2].(func(state.DisplayScreenName) (state.User, error)))
+	})
+	return _c
+}
+
+func (_c *mockAuthService_KerberosLogin_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockAuthService_KerberosLogin_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockAuthService_KerberosLogin_Call) RunAndReturn(run func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(state.DisplayScreenName) (state.User, error)) (wire.SNACMessage, error)) *mockAuthService_KerberosLogin_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // RegisterBOSSession provides a mock function with given fields: ctx, authCookie
 func (_m *mockAuthService) RegisterBOSSession(ctx context.Context, authCookie []byte) (*state.Session, error) {
 	ret := _m.Called(ctx, authCookie)

+ 5 - 0
state/user.go

@@ -426,6 +426,11 @@ func (u *User) ValidateRoastedTOCPass(roastedPass []byte) bool {
 	return bytes.Equal(u.WeakMD5Pass, md5Hash)
 }
 
+func (u *User) ValidatePlaintextPass(plaintextPass []byte) bool {
+	md5Hash := wire.WeakMD5PasswordHash(string(plaintextPass), u.AuthKey) // todo remove string conversion
+	return bytes.Equal(u.WeakMD5Pass, md5Hash)
+}
+
 // HashPassword computes MD5 hashes of the user's password. It computes both
 // weak and strong variants and stores them in the struct.
 func (u *User) HashPassword(passwd string) error {

+ 253 - 0
wire/snacs.go

@@ -37,6 +37,7 @@ const (
 	UnnamedFG24 uint16 = 0x0024
 	MDir        uint16 = 0x0025
 	ARS         uint16 = 0x044A
+	Kerberos    uint16 = 0x050C
 )
 
 //
@@ -91,6 +92,7 @@ const (
 	LoginTLVTagsErrorSubcode        uint16 = 0x08
 	LoginTLVTagsPasswordHash        uint16 = 0x25
 	LoginTLVTagsRoastedTOCPassword  uint16 = 0x1337
+	LoginTLVTagsPlaintextPassword   uint16 = 0x1338
 )
 
 const (
@@ -170,6 +172,8 @@ const (
 	OServiceUserInfoBARTInfo        uint16 = 0x1D
 	OServiceUserInfoMySubscriptions uint16 = 0x1E
 	OServiceUserInfoUserFlags2      uint16 = 0x1F
+	OServiceUserInfoMyInstanceNum   uint16 = 0x14
+	OServiceUserInfoPrimaryInstance uint16 = 0x28
 
 	OServiceUserStatusAvailable         uint32 = 0x00000000 // user is available
 	OServiceUserStatusAway              uint32 = 0x00000001 // user is away
@@ -2155,6 +2159,255 @@ const (
 	AlertUserOnline                uint16 = 0x0017
 )
 
+//
+// Kerberos Auth (AIM 6+)
+//
+
+const (
+	KerberosLoginRequest             uint16 = 0x0002
+	KerberosLoginSuccessResponse     uint16 = 0x0003
+	KerberosKerberosLoginErrResponse uint16 = 0x0004
+
+	KerberosTLVTicketRequest uint16 = 0x0002
+	KerberosTLVBOSServerInfo uint16 = 0x0003
+	KerberosTLVHostname      uint16 = 0x0005
+	KerberosTLVCookie        uint16 = 0x0006
+
+	KerberosErrAuthFailure uint16 = 0x0401
+)
+
+// SNAC_0x050C_0x0002_KerberosLoginRequest represents a Kerberos-like login request
+// sent by the AIM client as part of the OSCAR authentication handshake using SNAC(0x050C, 0x0002).
+type SNAC_0x050C_0x0002_KerberosLoginRequest struct {
+	// RequestID is a client-generated identifier that matches the one echoed in the server response.
+	RequestID uint32
+
+	// ClientIP is the client's IPv4 address in network byte order.
+	ClientIP uint32
+
+	// ClientCOOLVersionMajor is the major version of the AIM client "COOL" protocol.
+	ClientCOOLVersionMajor uint32
+
+	// ClientCOOLVersionMinor is the minor version of the AIM client "COOL" protocol.
+	ClientCOOLVersionMinor uint32
+
+	// PaddingOrZero is always observed as 0x00000000 and may be reserved/padding.
+	PaddingOrZero uint32
+
+	// KerberosPayload contains TLV-encoded data, typically including the AP-REQ (TLV 0x0005).
+	KerberosPayload TLVBlock
+
+	// LocaleFlags1 appears to contain locale or encoding hints, possibly bitflags.
+	LocaleFlags1 uint32
+
+	// LocaleFlags2 is another locale- or feature-related field, typically zero.
+	LocaleFlags2 uint32
+
+	// CountryCode is the user's ISO 3166-1 alpha-2 country code (e.g., "US").
+	CountryCode string `oscar:"len_prefix=uint16"`
+
+	// LanguageCode is the user's ISO 639-1 language code (e.g., "en").
+	LanguageCode string `oscar:"len_prefix=uint16"`
+
+	// ServiceContextBlock is a TLV block containing client realm and service info.
+	ServiceContextBlock TLVBlock
+
+	// VersionOrFlags is likely a protocol feature version or flag mask (exact meaning unknown).
+	VersionOrFlags uint32
+
+	// AuthType is a 1-byte value (usually 0x00 or 0x01), possibly denoting password type or encoding.
+	AuthType byte
+
+	// ClientPrincipal is the Kerberos principal name (username).
+	ClientPrincipal string `oscar:"len_prefix=uint16"`
+
+	// ServicePrincipal is the Kerberos service string (e.g., "im/boss").
+	ServicePrincipal string `oscar:"len_prefix=uint16"`
+
+	// Reserved1 is typically zero; possibly a padding or reserved field.
+	Reserved1 uint32
+
+	// RealmCount is typically 0x0002; may indicate how many realms or tickets are requested.
+	RealmCount uint16
+
+	// TicketRequestMetadata is a TLV block describing requested tickets (e.g., realm, lifetime, enctype).
+	TicketRequestMetadata TLVBlock
+}
+
+// SNAC_0x050C_0x0003_KerberosLoginSuccessResponse represents the server's response to a successful
+// Kerberos-like login request in AIM's OSCAR protocol. It includes the client identity and issued tickets.
+type SNAC_0x050C_0x0003_KerberosLoginSuccessResponse struct {
+	// RequestID matches the KerberosRequestID from the original client request (for correlation).
+	RequestID uint32
+
+	// Epoch is a server-issued timestamp indicating when the client was authenticated.
+	// This is often used as the start time for ticket validity.
+	Epoch uint32
+
+	// Reserved is a 4-byte field, usually zero. Possibly reserved for future use or alignment.
+	Reserved uint32
+
+	// ClientPrincipal is the Kerberos principal name of the authenticated client.
+	ClientPrincipal string `oscar:"len_prefix=uint16"`
+
+	// ClientRealm is the Kerberos realm in which the client was authenticated (e.g., "AOL").
+	ClientRealm string `oscar:"len_prefix=uint16"`
+
+	// Tickets contains one or more issued Kerberos tickets, including service tickets and/or a TGT.
+	// These are encoded in ASN.1 DER and include session keys, expiration, and encrypted blobs.
+	Tickets []KerberosTicket `oscar:"count_prefix=uint16"`
+
+	// Extensions is a TLVBlock containing optional metadata. May include:
+	// - Client capabilities
+	// - Locales or language tags
+	// - Echoed usernames
+	// - Additional authentication hints
+	Extensions TLVBlock
+}
+
+// SNAC_0x050C_0x0004_KerberosLoginErrResponse represents a login failure response
+// sent by the AIM server in reply to a Kerberos-style login attempt (SNAC 0x050C/0x0002).
+//
+// This SNAC is typically sent when authentication fails due to an invalid password,
+// unknown screen name, or protocol-level issues. It includes a human-readable message
+// and an error code, along with optional TLV metadata.
+type SNAC_0x050C_0x0004_KerberosLoginErrResponse struct {
+	// KerbRequestID matches the KerberosRequestID from the original login request,
+	// allowing the client to correlate the response to the appropriate attempt.
+	KerbRequestID uint32
+
+	// ScreenName is the screen name the client attempted to authenticate as.
+	// This is echoed back by the server for clarity/debugging.
+	ScreenName string `oscar:"len_prefix=uint16"`
+
+	// ErrCode is a 2-byte error code indicating the reason for login failure.
+	// The only supported value is 0x0401, which indicates an invalid username
+	// or password.
+	ErrCode uint16
+
+	// Message is a UTF-8 string providing a user-facing explanation of the error
+	// (e.g., "Invalid screen name or password").
+	Message string `oscar:"len_prefix=uint16"`
+
+	// Unknown1 is an unknown flag.
+	Unknown1 uint32
+
+	// Metadata is a TLVBlock that may contain additional metadata about the failure.
+	Metadata TLVBlock
+}
+
+// KerberosLoginRequestTicket appears inside TLV 0x0002 of the
+// Ticket-Request Metadata block that the AIM client bundles into
+// its "Kerberos Login Request" (SNAC 0x050C/0x0002).
+type KerberosLoginRequestTicket struct {
+	// Marker might indicate the payload type.
+	Marker uint32
+
+	// Version might indicate the internal structure version.
+	Version uint16
+
+	// Flags contains unknown flags.
+	Flags uint32
+
+	// PwdLen is the length (in bytes) of the following Password string.
+	// The field is kept explicit even though the struct tag also carries
+	// the length-prefix rule.
+	PwdLen uint16
+
+	// Password holds the user’s password.
+	Password string `oscar:"len_prefix=uint16"`
+
+	// PasswordMetadata may hold additional metadata about the password.
+	PasswordMetadata TLVBlock
+}
+
+// KerberosTicket represents one service ticket returned inside the
+// SNAC(0x050C, 0x0003) "Kerberos Login Success" response.
+//
+// TicketBlob follows the general layout of an RFC 4120 Ticket,
+// but is delivered in AIM’s TLV wrapper rather than a full KRB-TGS-REP.
+type KerberosTicket struct {
+	// PVNO is the Kerberos protocol-version number carried
+	// in the ticket header.  In Kerberos V5 this is always 0x0005.
+	PVNO uint16
+
+	// EncTicket is the raw ASN.1 DER-encoded Ticket structure
+	// (encrypted to the service key).  Length is given by the preceding
+	// uint16 in the OSCAR stream.
+	EncTicket []byte `oscar:"len_prefix=uint16"`
+
+	// TicketRealm is the realm to which the service principal belongs
+	// (e.g. "AOL").
+	TicketRealm string `oscar:"len_prefix=uint16"`
+
+	// ServicePrincipal is the complete service-principal name for which
+	// the ticket is valid (e.g. "im/boss").
+	ServicePrincipal string `oscar:"len_prefix=uint16"`
+
+	// ClientRealm is the Kerberos realm of the authenticated user.
+	ClientRealm string `oscar:"len_prefix=uint16"`
+
+	// ClientPrincipal is the principal name inside that realm.
+	ClientPrincipal string `oscar:"len_prefix=uint16"`
+
+	// KVNO (Key Version Number) tells the service which
+	// long-term key to use when decrypting EncTicket.
+	KVNO uint8
+
+	// SessionKey is the clear-text session key that the KDC also placed,
+	// encrypted, inside EncTicket.  Provided here for the client’s
+	// convenience so it doesn’t have to decrypt the ticket itself.
+	SessionKey []byte `oscar:"len_prefix=uint16"`
+
+	// Unknown1 is typically zero. Possibly reserved or unused.
+	Unknown1 uint32
+
+	// Unknown2 is a possible bitfield.
+	Unknown2 uint32
+
+	// AuthTime is the time when the initial authentication occurred (UNIX epoch).
+	AuthTime uint32
+
+	// StartTime is when the ticket becomes valid (UNIX epoch).
+	StartTime uint32
+
+	// EndTime is when the ticket expires (UNIX epoch).
+	EndTime uint32
+
+	// RenewTill is likely the latest time the ticket can be renewed (UNIX epoch).
+	RenewTill uint32
+
+	// Unknown5 is a possible bitfield.
+	Unknown4 uint32
+
+	// Unknown5 is a possible bitfield.
+	Unknown5 uint32
+
+	// Unknown6 is a possible bitfield.
+	Unknown6 uint32
+
+	// ConnectionMetadata holds metadata used for the next connection (IP address, cookie, etc).
+	ConnectionMetadata TLVBlock
+}
+
+// KerberosBOSServerInfo provides the client with connection parameters for contacting
+// the BOS (Basic OSCAR Service) server.
+//
+// This TLV is typically returned by the server inside a successful Kerberos login response
+// (SNAC 0x050C/0x0003).
+type KerberosBOSServerInfo struct {
+	// Unknown may signify a version or feature flag block.
+	Unknown uint16
+
+	// ConnectionInfo is a TLV block defining the next server to contact (BOS).
+	// Includes IP address, port, session cookie, and other required fields for login.
+	ConnectionInfo TLVBlock
+}
+
+//
+// Misc
+//
+
 type TLVUserInfo struct {
 	ScreenName   string `oscar:"len_prefix=uint8"`
 	WarningLevel uint16