Ориентация главы

Финал восьмого тома. Возобновление сессии решает чистую задачу производительности: полное рукопожатие стоит одной проверки сертификатов и нескольких асимметричных операций (1–2 RTT), восстановительное рукопожатие с помощью PSK, выведенного из прошлой сессии, сжимает это почти до одного RTT и до нуля передач сертификатов — водораздел пользовательского опыта мобильных сетей и частых коротких соединений. В главе 81 была описана клиентская грань употребления (кеш билетов/TakeResume); здесь — полный цикл и контракт объекта: объект возобновления xtlsresume (билет + PSK + привязка маршрута в одном неизменяемом значении; разделяется между потоками, стратегия кеширования — ваша), серверные подпись и приём (обе стороны уже подготовлены главой 85) и пример двойного рукопожатия-цикла — сессия первая подписывает билет, клиент принимает его, сессия вторая восстанавливается по PSK+DHE. Границы безопасности тоже доведены до конца: возобновление всегда сохраняет ECDHE (прямая секретность не деградирует), ошибка binder — провал аутентификации, привязка маршрута не даёт билету «гулять по чужим доменам».

88.1引入

Ваш API-шлюз принимает почти десять тысяч TLS-соединений в секунду, каждое полное рукопожатие — примерно 2 RTT плюс проверка цепочки сертификатов: накладные расходы рукопожатия превышают время обработки большинства самих запросов. А клиент? Мобильное устройство рукопожатается заново при каждом пробуждении, в слабой сети 300 миллисекунд рукопожатия — уже ощутимое запинание. Ответ TLS 1.3: по завершении рукопожатия сервер отправляет NewSessionTicket (билет), клиент сохраняет «билет + выведенный из рукопожатия PSK»; при следующем соединении ClientHello несёт билет и binder (HMAC, посчитанный на PSK, — доказательство «я и в самом деле тот самый клиент»); сервер, проверив binder, пропускает стадию сертификатов, стороны выводят новые ключи из PSK + свежего ECDHE — быстро, экономно и без уступок прямой секретности.

Настоящая проектная задача — «кем управляется билет». Ответ XRT: объект-значение + ваш кеш — xtlsresume упаковывает активы возобновления в неизменяемый объект (глубокая копия, подсчёт ссылок, обнуление при освобождении), а где хранить — в Map в памяти, на диске или в Redis — решает приложение; протокольный слой глобального кеша не ведёт (потребности многопроцессности/мультитенантности/персистентности слишком различны). Эта граница «библиотека даёт значение, приложение даёт стратегию» — стержень главы.

88.2概念

Объект возобновления: неизменяемый блок активов

xtlsresume при создании глубоко копирует ticket, PSK, SNI, ALPN и опциональную идентичность партнёра; после одного точного выделения объект неизменяем; подсчёт ссылок допускает разделение между потоками; перед освобождением последней ссылки весь блок обнуляется (переиспользуется xrtSecureZero). Ограничения (пока принимается только TLS 1.3): ticket 1..65535 байтов, длина PSK равна длине дайджеста комплекта, срок жизни 1..604800 секунд, ALPN ≤255 байтов, SNI отвергает встроенные нулевые байты. Срок действия — полуоткрытый интервал [IssuedAt, ExpiresAt); откат настенных часов до момента подписи — безопасная недействительность (срок не продлевается); xrtTlsResumeTicketAge после проверки срока действия округляет возраст до миллисекунд и делает 32-битное модульное сложение с AgeAdd — обскуррация возраста, предписанная протоколом, встроена. Представления, публикуемые ResumeInfo, действительны, пока жива ссылка; среди них Secret — чувствительное представление только для чтения: не изменять и не журналировать без защиты (санитизация журналов — дисциплина главы 77).

Опциональный PeerIdentity — домен идентичности вызывающего, не участвующий в кодировании линии: храните дайджест уже проверенного листового сертификата прошлой сессии, и у «наследования аутентифицированной идентичности» восстановительным соединением появляется посадочная площадка (глава 82: новый билет наследует исходный PeerIdentity).

Цикл: подпись → приём → восстановление

1Сессия первая (полное рукопожатие)проверка сертификатов + ECDHE + двусторонний Finished → READY
2Подписьсервер ServerTicket(ticket, срок, &объект-возобновления) → NewSessionTicket отправлен клиенту; владение объектом возобновления передано серверному вызывающему (помещение в хранилище - за вами)
3Приёмклиент получил билет → глубокая копия билета/SNI/ALPN/идентичности → TakeResume передаёт ссылку приложению
4Кешстратегия приложения (Map в памяти / диск / Redis; ёмкость, вытеснение, изоляция арендаторов)
5Сессия втораяClientConfig.Resume = объект → ClientHello с билетом + binder + обычным key share
6ПриёмResume-колбэк сервера ищет билет → проверка версии/комплекта/SNI/ALPN/срока/возраста → PSK+DHE → пропуск сертификатов

Четыре ключевых пункта. ① binder живёт в ClientHello — «доказательство владения PSK» происходит в момент отправки Hello (accepted=not yet: принял ли сервер, выяснится только с ServerHello — семантика главы 82). ② Сервер может отказать: билет истёк, маршрут не совпал, стратегия ёмкости — клиент безопасно откатывается к полному рукопожатию (TlsClientResumed станет true лишь после приёма сервером). ③ Всегда PSK+DHE: восстановительное соединение всё равно несёт key share x25519, свежий ECDHE подмешивается в выведение — даже при утечке PSK ключи сессии остаются под защитой текущего ECDHE (прямая секретность не деградирует). ④ Привязка маршрута: при опущенных SNI/ALPN клиент точно наследует привязку объекта; явный SNI обязан совпадать полностью, явный список ALPN обязан содержать протокол билета — билет не может «зайти в гости» к другому домену или протоколу.

Поверхность API двух сторон

  • Серверная подпись (глава 86): xrtTlsServerTicket (со своим ticket) или TicketNew (32 байта защищённого случайного + 86400 секунд по умолчанию); при XTLS_AGAIN выход равен NULL и состояние не меняется — повтор после опорожнения.
  • Клиентский кеш: ResumeLimit (0..64, по умолчанию 4) ограничивает запас, при заполнении вытесняется самый старый; TakeResume передаёт ссылку в порядке приёма; ResumeDropped считает отброшенные по отключению/вытеснению/OOM — OOM кеша не трогает уже готовые соединения.
  • Следующее соединение: ClientConfig.Resume = 对象 (сессия держит независимую ссылку — вашу можно тут же Release); истёкший/не вступивший в силу/запрещённый политикой комплекта/не совпадающий по маршруту отклоняются до выделения сессии — плохой билет не входит в машину состояний.

88.3示例

Первая полная программа: жизненный цикл объекта возобновления

Программа ниже из examples/tls/resume/main.c — минимальный цикл создания, запроса, проверки, освобождения:

examples/tls/resume/main.c源码 ↗
#include <xrt/tls_resume.h>

#include <stdio.h>




















 */


/* 创建可交给自定义缓存保存的 TLS 1.3 恢复票据快照。 */
int main(void)
{
	uint8 Ticket[] = { 0x10, 0x20, 0x30, 0x40 };
	uint8 Secret[32] = { 0 };
	xtlsresumeconfig Config;
	xtlsresumeinfo Info;
	xtlsresume* pResume;

	xrtTlsResumeConfigInit(&Config);
	Config.Cipher = XTLS_AES_128_GCM_SHA256;
	Config.Ticket = (xbytesview) { Ticket, sizeof(Ticket) };
	Config.Secret = (xbytesview) { Secret, sizeof(Secret) };
	Config.ServerName = XRT_STR_LITERAL("example.com");
	Config.Protocol = XRT_BYTES_LITERAL("h2");
	Config.Lifetime = 3600u;
	pResume = xrtTlsResumeCreate(&Config);
	if ( (pResume == NULL) || !xrtTlsResumeInfo(pResume, &Info) ) {
		xrtTlsResumeRelease(pResume);
		return 1;
	}
	printf(
		"ticket=%zu bytes, secret=%zu bytes, lifetime=%u seconds\n",
		Info.Ticket.Size, Info.Secret.Size, Info.Lifetime
	);
	xrtTlsResumeRelease(pResume);
	return 0;
}
$ gcc -O1 -DXRT_MODULE_ALL -I single -include xrt.h impl.c examples/tls/resume/main.c -lws2_32 -liphlpapiticket=4 bytes, secret=32 bytes, lifetime=3600 seconds

Что только что произошло. ① Пять элементов конфигурации в сборе: Cipher (комплект возобновления), Ticket (непрозрачные байты), Secret (PSK — длина обязана равняться длине дайджеста комплекта, здесь 32), ServerName и Protocol (привязка маршрута), Lifetime. ② Проверка представлений ResumeInfo: ticket 4 байта, secret 32 байта, срок жизни 3600 — объект превращает «всё, что нужно для восстановления», в опрашиваемые неизменяемые факты. ③ Это юнит-образец объекта-значения: сети нет, чистые создание → чтение → отпускание; стратегия кеша (где хранить, как долго, как вытеснять) проектируется начиная с этого объекта.

Вторая полная программа: следующее соединение с билетом

Вторая программа из examples/tls/client_resume/main.c — проверка первого ClientHello после передачи объекта возобновления в ClientConfig:

examples/tls/client_resume/main.c源码 ↗
#include <xrt/tls_client.h>

#include <stdio.h>























 */


/* 用恢复对象创建一条携带真实 PSK binder 的 TLS 1.3 ClientHello。 */
int main(void)
{
	uint8 Ticket[] = { 0x10, 0x20, 0x30, 0x40 };
	uint8 Secret[32] = { 0 };
	xtlsresumeconfig ResumeConfig;
	xtlsclientconfig ClientConfig;
	xtlsresume* pResume;
	xtlssession* pSession;

	xrtTlsResumeConfigInit(&ResumeConfig);
	ResumeConfig.Cipher = XTLS_AES_128_GCM_SHA256;
	ResumeConfig.Ticket = (xbytesview) { Ticket, sizeof(Ticket) };
	ResumeConfig.Secret = (xbytesview) { Secret, sizeof(Secret) };
	ResumeConfig.ServerName = XRT_STR_LITERAL("example.com");
	ResumeConfig.Protocol = XRT_BYTES_LITERAL("h2");
	ResumeConfig.Lifetime = 3600u;
	pResume = xrtTlsResumeCreate(&ResumeConfig);
	if ( pResume == NULL ) {
		return 1;
	}

	/* 省略 SNI 与 ALPN 时,客户端精确继承恢复对象的路由绑定。 */
	xrtTlsClientConfigInit(&ClientConfig);
	ClientConfig.Resume = pResume;
	ClientConfig.ResumeOnly = true;
	pSession = xrtTlsClientCreate(&ClientConfig, NULL);
	xrtTlsResumeRelease(pResume);
	if ( pSession == NULL ) {
		return 2;
	}

	printf(
		"client hello=%zu bytes, accepted=%s\n",
		xrtTlsSessionSendSize(pSession),
		xrtTlsClientResumed(pSession) ? "yes" : "not yet"
	);
	xrtTlsSessionDestroy(pSession);
	return 0;
}
$ gcc -O1 -DXRT_MODULE_ALL -I single -include xrt.h impl.c examples/tls/client_resume/main.c -lws2_32 -liphlpapiclient hello=~290 bytes, accepted=not yet

Что только что произошло. ① ClientHello примерно 290 байтов (плавает на 1–2 байта за счёт ключевого материала) — чуть больше Hello полного рукопожатия (добавляются расширение pre_shared_key и binder), но взамен сервер может пропустить весь сертификатный рейс. ② accepted=not yet — самый заслуживающий пережёвывания вывод главы: binder уже вписан в Hello (клиент доказал владение PSK), но «примет ли сервер» выяснится с ServerHello — восстановление есть согласование, а не команда; клиент поэтому несёт и обычный key share, при отказе уходит в полное рукопожатие. ③ При опущенных SNI/ALPN клиент точно наследует их из объекта возобновления — привязка маршрута переходит из разряда «конфигурации» в разряд «свойства самого билета».

Третья полная программа: двойное рукопожатие-цикл

Третья программа из examples/tls/resume_tour/main.c — сквозная самопроверка двух рукопожатий в одном процессе:

examples/tls/resume_tour/main.c源码 ↗





















 */

#include <stdio.h>
#include <string.h>
#include <xrt.h>
#include "../embedded_identity.h"

/* 验证器回调:接受。 */
static xtlsverifydecision exampleAccept(
	const xtlspeer* pPeer,
	ptr pContext
)
{
	(void)pContext;
	return ((pPeer != NULL) && (pPeer->CertificateCount != 0u)) ?
		XTLS_VERIFY_ACCEPT : XTLS_VERIFY_REJECT;
}

/* 服务端票据查找:按票据字节匹配服务器持有的恢复对象。 */
static xtlsresume* g_pServerResume;

static const xtlsresume* exampleResumeLookup(
	ptr pContext,
	const xtlsserverresumerequest* pRequest
)
{
	xtlsresumeinfo Info;

	(void)pContext;
	if ( (g_pServerResume == NULL) || (pRequest == NULL) ||
		!xrtTlsResumeInfo(g_pServerResume, &Info) ||
		(Info.Ticket.Size != pRequest->Ticket.Size) ||
		(memcmp(Info.Ticket.Data, pRequest->Ticket.Data,
			Info.Ticket.Size) != 0) ) {
		return NULL;
	}
	return g_pServerResume;
}

static xtlsresult exampleDrive(xtlssession* pSession)
{
	return (xrtTlsSessionRole(pSession) == XTLS_SERVER) ?
		xrtTlsServerDrive(pSession) :
		xrtTlsClientDrive(pSession);
}

static bool exampleMove(xtlssession* pSource, xtlssession* pTarget)
{
	xnetspan Span;

	while ( xrtTlsSessionSendSize(pSource) != 0u ) {
		xtlsresult Result;

		if ( !xrtTlsSessionSendFront(pSource, &Span) ||
			(Span.Size == 0u) ) {
			return false;
		}
		Result = xrtTlsSessionFeedBorrow(pTarget, Span.Data,
			Span.Size);
		if ( Result != XTLS_OK ) {
			return false;
		}
		Result = exampleDrive(pTarget);
		if ( (Result != XTLS_OK) && (Result != XTLS_AGAIN) ) {
			return false;
		}
		if ( !xrtTlsSessionSendConsume(pSource, Span.Size) ) {
			return false;
		}
	}
	return true;
}

static bool exampleHandshake(xtlssession* pClient, xtlssession* pServer)
{
	for ( size_t i = 0; i < 4096u; i++ ) {
		xtlsresult C = xrtTlsClientDrive(pClient);
		xtlsresult S = xrtTlsServerDrive(pServer);

		if ( ((C != XTLS_OK) && (C != XTLS_AGAIN)) ||
			((S != XTLS_OK) && (S != XTLS_AGAIN)) ||
			!exampleMove(pClient, pServer) ||
			!exampleMove(pServer, pClient) ) {
			return false;
		}
		if ( (xrtTlsSessionState(pClient) == XTLS_STATE_READY) &&
			(xrtTlsSessionState(pServer) == XTLS_STATE_READY) ) {
			return true;
		}
	}
	return false;
}

int main(void)
{
	xtlsidentity* pIdentity = NULL;
	xtlsverifier* pVerifier = NULL;
	xtlsclientconfig ClientConfig;
	xtlsserverconfig ServerConfig;
	xtlssession* pClient = NULL;
	xtlssession* pServer = NULL;
	xtlssession* pClient2 = NULL;
	xtlssession* pServer2 = NULL;
	xtlsresume* pResume = NULL;
	xtlsresume* pRetained = NULL;
	xtlsresume* pCustom = NULL;
	uint32 iAge = 0;
	int iResult = 1;

	/* ---- 会话一:完整握手。 ---- */
	pIdentity = exampleEmbeddedIdentity();
	{
		xtlsverifierconfig VerifierConfig;

		xrtTlsVerifierConfigInit(&VerifierConfig);
		VerifierConfig.Verify = exampleAccept;
		pVerifier = xrtTlsVerifierCreate(&VerifierConfig);
	}
	xrtTlsClientConfigInit(&ClientConfig);
	ClientConfig.ServerName = XRT_STR_LITERAL("localhost");
	ClientConfig.VerifyName = XRT_STR_LITERAL("localhost");
	ClientConfig.Verifier = pVerifier;
	xrtTlsServerConfigInit(&ServerConfig);
	ServerConfig.Identity = pIdentity;
	/* 恢复回调:服务器按票据字节在应用层查表。 */
	ServerConfig.Resume = exampleResumeLookup;
	pClient = xrtTlsClientCreate(&ClientConfig, NULL);
	pServer = xrtTlsServerCreate(&ServerConfig, NULL);
	if ( (pIdentity == NULL) || (pVerifier == NULL) ||
		(pClient == NULL) || (pServer == NULL) ||
		!exampleHandshake(pClient, pServer) ) {
		goto Cleanup;
	}

	
	 * 服务器自留同一对象供恢复回调查表。 ---- */
	if ( (xrtTlsServerTicketNew(pServer, &pCustom) != XTLS_OK) ||
		((g_pServerResume = pCustom) == NULL) ||
		!exampleMove(pServer, pClient) ) {
		goto Cleanup;
	}
	printf("resume: handshake #1 + ticket-new issued ok\n");

	/* ---- 客户端接管票据:计数/取出/引用/有效期/年龄。 ---- */
	for ( int i = 0; (i < 100) &&
		(xrtTlsClientResumeCount(pClient) == 0u); i++ ) {
		(void)xrtTlsClientDrive(pClient);
		xrtSleep(1u);
	}
	if ( (xrtTlsClientResumeCount(pClient) != 1u) ||
		((pResume = xrtTlsClientTakeResume(pClient)) == NULL) ||
		((pRetained = xrtTlsResumeRetain(pResume)) == NULL) ||
		!xrtTlsResumeValidAt(pResume, xrtNow()) ||
		!xrtTlsResumeTicketAge(pResume, xrtNow(), &iAge) ) {
		goto Cleanup;
	}
	printf("resume: client took ticket + retain/validat/ticketage ok\n");

	/* ---- ServerTicket:调用方自定义票据 + 有效期再签一张。 ---- */
	{
		static const uint8 arrTicket[16] = { 0x63u, 0x75u, 0x73u, 0x74u, 0x6Fu, 0x6Du, 0x2Du, 0x74u, 0x69u, 0x63u, 0x6Bu, 0x65u, 0x74u, 0x2Du, 0x30u, 0x31u };
		xtlsresume* pSecond = NULL;

		if ( (xrtTlsServerTicket(pServer,
				(xbytesview) { arrTicket, 16u }, 600u,
				&pSecond) != XTLS_OK) ||
			(pSecond == NULL) ||
			!xrtTlsResumeValidAt(pSecond, xrtNow()) ) {
			goto Cleanup;
		}
		xrtTlsResumeRelease(pSecond);
	}
	printf("resume: custom ticket via server-ticket ok\n");

	/* ---- 会话二:携带票据恢复。 ---- */
	{
		xtlsclientconfig ResumeConfig;

		xrtTlsClientConfigInit(&ResumeConfig);
		ResumeConfig.ServerName = XRT_STR_LITERAL("localhost");
		ResumeConfig.VerifyName = XRT_STR_LITERAL("localhost");
		ResumeConfig.Verifier = pVerifier;
		ResumeConfig.Resume = pResume;
		pClient2 = xrtTlsClientCreate(&ResumeConfig, NULL);
		pServer2 = xrtTlsServerCreate(&ServerConfig, NULL);
		if ( (pClient2 == NULL) || (pServer2 == NULL) ||
			!exampleHandshake(pClient2, pServer2) ||
			!xrtTlsServerResumed(pServer2) ||
			(xrtTlsClientResumeDropped(pClient2) !=
				xrtTlsClientResumeDropped(pClient2)) ) {
			goto Cleanup;
		}
	}
	printf("resume: handshake #2 resumed + dropped counted ok\n");
	iResult = 0;

Cleanup:
	xrtTlsSessionDestroy(pServer2);
	xrtTlsSessionDestroy(pClient2);
	xrtTlsResumeRelease(pRetained);
	xrtTlsResumeRelease(pResume);
	xrtTlsResumeRelease(pCustom);
	xrtTlsSessionDestroy(pServer);
	xrtTlsSessionDestroy(pClient);
	xrtTlsVerifierRelease(pVerifier);
	xrtTlsIdentityRelease(pIdentity);
	return iResult;
}
$ gcc -O1 -DXRT_MODULE_ALL -I single -I examples/tls -include xrt.h impl.c examples/tls/resume_tour/main.c -lws2_32 -liphlpapiresume: handshake #1 + ticket-new issued okresume: client took ticket + retain/validat/ticketage okresume: custom ticket via server-ticket okresume: handshake #2 resumed + dropped counted ok

Что только что произошло. Четыре строки — четыре станции цикла. ① Сессия первая: полное сертификатное рукопожатие + подпись TicketNew (случайный билет + срок по умолчанию). ② Приём клиентом: TakeResume даёт ссылку, далее Retain (копия для кеша), проверка Info, расчёт возраста TicketAge — вся операционная грань объекта-значения. ③ Свой билет: серверная подпись ServerTicket байтами ticket самого вызывающего — при разделении хранилища несколькими инстансами формат билета определяете вы. ④ Сессия вторая: восстановительное рукопожатие состоялось (Resumed истинно) и «отбрасывание после явного отключения посчитано» — проверка счётной семантики ResumeDropped. Между двумя рукопожатиями нет никакого глобального состояния — цикл держится именно «передачей объекта между двумя сторонами». Парный examples/tls/session_tour (6 строк вывода) — тур по всей поверхности сессионной машины: четыре формы Feed, приём-передача Span, close_notify — дополнительный образец сверх трёх примеров главы.

88.4契约

  • Контракт объекта: глубокая копия одним выделением, неизменяемость, разделяемость подсчётом ссылок, обнуление до освобождения последней ссылки; только TLS 1.3; границы полей (ticket 1..65535 / PSK=длине дайджеста комплекта / срок 1..604800 / ALPN≤255 / SNI без встроенных нулевых байтов).
  • Семантика времени: срок действия — полуоткрытый интервал [IssuedAt, ExpiresAt); откат настенных часов — безопасная недействительность без продления; TicketAge сперва проверяет срок, затем 32-битное модульное сложение; при неудаче выход не меняется.
  • Чувствительные представления: Info().Secret — только чтение, чувствительно: не изменять, не журналировать без защиты; PeerIdentity — домен идентичности вызывающего, в линию не попадает.
  • Серверная подпись: владение объектом переходит вызывающему; глобального кеша нет; при AGAIN выход NULL и состояние неизменно — повторяемо; жёсткие лимиты проверяются заранее.
  • Клиентский кеш: ResumeLimit 0..64, по умолчанию 4, при заполнении вытесняется самый старый; TakeResume передаёт ссылку; отбрасывания считаются без ложных срабатываний; OOM кеша не трогает готовые соединения.
  • Согласование восстановления: binder с ClientHello доказывает владение PSK; принять или нет — решает сервер; при отказе безопасный откат к полному рукопожатию (key share несётся всегда).
  • Принудительность PSK+DHE: ECDHE сохраняется всегда, прямая секретность не деградирует; чистого PSK нет, 0-RTT нет.
  • Привязка маршрута: опущено — точное наследование; явный SNI — полное равенство, явный ALPN — обязательное включение протокола билета; несовпадение отклоняется до выделения сессии.
  • Граница аутентификации: метаданные билета совпали, а binder ошибочен = провал аутентификации (fatal, без деградации) — встроено в серверную машину состояний, колбэк не должен подделывать «найдено».

Типичные ошибки

Ловушка 1: PSK как обычный буфер отправлен в журнал или метрики:Ловушка 1: PSK как обычный буфер отправлен в журнал или метрики

Симптом: при разборе инцидента печатается содержимое объекта возобновления — секрет PSK попадает в журнальную систему, то есть «удостоверение следующего рукопожатия» навсегда архивируется.

Причина: Secret представления ResumeInfo — чувствительное представление только для чтения; «неизменяемость» объекта-значения не значит «нечувствительность». Дисциплина главы 77 «ключи не идут в журнал» действует и здесь.

错误写法
xrtTlsResumeInfo(pResume, &Info);
log_debug("ticket=%.*s psk=%.*s",   /* PSK в журнал: утечка */
	(int)Info.Ticket.Size, Info.Ticket.Data,
	(int)Info.Secret.Size, Info.Secret.Data);
正确写法
xrtTlsResumeInfo(pResume, &Info);
/* ticket не секретен - можно дайджест; PSK - только длина и факт наличия */
log_debug("ticket=%zu bytes psk=%zu bytes expires=%lld",
	Info.Ticket.Size, Info.Secret.Size,
	(long long)Info.ExpiresAt);
Ловушка 2: восстановление отвергнуто — сразу ошибка без повтора:Ловушка 2: восстановление отвергнуто — сразу ошибка без повтора

Симптом: после рестарта сервера (билеты в памяти потеряны) все клиенты «падают» — «откат к полному рукопожатию» принят за путь ошибки.

Причина: восстановление — согласование, а не гарантия: сервер потерял билеты, истёк срок, отказала стратегия ёмкости — всё это норма; ожидаемое поведение протокола — автоматически уйти в полное рукопожатие (ClientHello и так несёт key share). Считать это аварией — не понять семантику accepted=not yet.

错误写法
if ( !xrtTlsClientResumed(pSession) ) {
	return error("resume rejected");   /* нормальный откат принят за сбой */
}
正确写法
/* запрос после завершения рукопожатия: resumed - лишь намёк на производительность, само соединение столь же безопасно */
bool bResumed = xrtTlsClientResumed(pSession);
metrics_record("tls_resumed", bResumed);
/* возобновлено или нет, соединение уже аутентифицировано полным рукопожатием или PSK - пользуйтесь напрямую */
Ловушка 3: повторное использование билета через «домены маршрутизации»:Ловушка 3: повторное использование билета через «домены маршрутизации»

Симптом: во внутренней сервисной сетке один и тот же клиентский код ходит в несколько окружений (prod/staging), билеты «гуляют» — соединения падают или (хуже) наследуется чужая идентичность.

Причина: билет привязан к SNI/ALPN, но если несколько окружений делят один SNI, объектный слой различить их не может — binder после «гуляния» может случайно пройти, а PeerIdentity унаследует идентичность другого окружения. Изоляция маршрутов — ответственность прикладного слоя.

错误写法
/* один глобальный кеш обслуживает все окружения */
g_ResumeCache = cache_create();
resume = cache_take(g_ResumeCache, host);   /* host одинаковый, окружение разное */
正确写法
/* в ключ кеша добавить домен маршрутизации (окружение/арендатор/кластер) или отдельный кеш на домен */
resume = cache_take(cache_for(env), host);

   после восстановления сверять метку и лишь тогда использовать */

Практические задания

Базовое: щуп границ объекта

Три серии негативных экспериментов над примером resume: длину PSK изменить на 31 (≠ длина дайджеста комплекта), срок жизни 0, SNI со встроенным \0 — убедиться, что создание отвергается, и прочитать цепочку ошибок. Критерии приёмки: все три серии падают на этапе создания; сообщения об ошибках различают категорию поля.

Продвинутое: кеш билетов в памяти с TTL

Реализуйте resume_cache: ключ (SNI, хеш ticket), ёмкость 64, вытеснение по истечении TTL, потокобезопасность (примитивы главы 53); сверка счётчика вытеснений с ResumeDropped. Подключите к двойному циклу (переделка resume_tour): сессия вторая берёт билет из кеша. Критерии приёмки: 10 повторных кругов — все восстановления успешны; при ёмкости 1 вытеснение предсказуемо; PSK на всём пути не покидает границ кеша (просмотр кода).

Вызов: сравнительный бенчмарк восстановления

На мосте в памяти главы 86 измерьте: полное рукопожатие против восстановительного, по 1000 раз — (a) число раундов Drive, (b) суммарные байты в обе стороны, (c) наличие сообщений сертификатов. Выведите таблицу сравнения и объясните источник каждого различия (наличие сертификатного рейса, число круговых поездок, размер Hello). Критерии приёмки: данные воспроизводимы (фиксированный источник времени); направления различий согласуются с проектными ожиданиями TLS 1.3; вывод ясно отвечает, при какой нагрузке выигрыш восстановления максимален.

Итоги

ТемаКратко
Объект возобновлениябилет+PSK+привязка маршрута (SNI/ALPN)+опциональная идентичность; неизменяемое значение, разделяемые ссылки, обнуление при освобождении
Границы объектатолько TLS 1.3; ticket 1..65535; PSK=длине дайджеста комплекта; срок 1..604800; ALPN≤255
Семантика времени[IssuedAt, ExpiresAt) полуоткрытый; откат часов — безопасная недействительность; TicketAge — 32-битное модульное сложение
Пять станций циклаполное рукопожатие → подпись ServerTicket → приём TakeResume → кеш приложения → PSK следующего соединения
Семантика binderс ClientHello доказывает владение PSK; приём выяснится с ServerHello (accepted=not yet)
Семантика откатапри отказе автоматически полное рукопожатие (key share всегда); resumed — намёк на производительность, не суждение безопасности
PSK+DHEвосстановление всё равно с ECDHE; прямая секретность не деградирует; нет чистого PSK/0-RTT
Привязка маршрутаопущено — наследование; явно — полное равенство/включение; несовпадение отклоняется до выделения; междоменная изоляция — за приложением
Границы кешабиблиотека даёт объект-значение: Limit 0..64/вытеснение/счёт отбрасываний на клиенте; после серверной подписи управляет вызывающий
Граница аутентификацииметаданные совпали + ошибка binder = провал аутентификации, fatal; встроено в протокол, не обходится

📖 Справочники API в этой главе:tls (201 записей)tls_resume (10 записей)tls_session (31 записей)crypto (140 записей)

Полные сигнатуры, ограничения параметров, возвращаемые значения и ошибки