feat(tls): TLS 1.3 record layer (rv2 9 phase F1)

- new tls.c/tls.h on the crypto ladder: wo_tls_record_seal/open
  (RFC 8446 §5.2) — TLSInnerPlaintext (content||type, no padding),
  5-byte header as AEAD additional-data, per-record nonce = iv XOR
  seq big-endian (§5.3)
- suite dispatch: TLS_AES_128_GCM_SHA256 (mandatory) +
  TLS_CHACHA20_POLY1305_SHA256 (AES-NI-less fallback), over phase-A AEAD
- open() strips trailing zero padding to recover the inner content type;
  rejects a length-field lie before the AEAD, and auth failure after
- KAT vs python AEAD oracle (test/gen_tls_record.py): sealed record
  byte-for-byte both suites, open() recovers it, 5-seq round-trip,
  tamper + wrong-seq + bad-suite rejected. test_tls 51 pass, ASan/UBSan

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 5021a99f8359f78a642bb0cc2b28ab66f6b624e2)
This commit is contained in:
shoney.arickathil 2026-09-08 17:51:49 +02:00
parent bb64278aa9
commit 7e71c1a171
5 changed files with 311 additions and 0 deletions

111
runtime/src/tls.c Normal file
View file

@ -0,0 +1,111 @@
/* tls.c — hand-rolled TLS 1.3 (runtime-v2 9 phase F).
*
* Phase F1: the record layer (RFC 8446 §5.2). A TLS 1.3 record protects
*
* TLSInnerPlaintext = content || content_type(1) || zero padding
*
* with an AEAD whose additional data is the 5-byte record header and whose
* nonce is the static write IV XOR'd with the 64-bit record sequence number,
* big-endian, left-padded to 12 bytes (§5.3). The outer opaque_type is always
* application_data (23) once traffic is protected; the real type is the last
* non-zero byte of the decrypted inner plaintext.
*
* The AEAD itself is phase A (crypto.h): AES-128-GCM or ChaCha20-Poly1305,
* selected by the negotiated cipher suite. This file adds only the framing. */
#include <stdlib.h>
#include <string.h>
#include "tls.h"
#include "crypto.h"
/* Build the per-record nonce: iv XOR (seq as a big-endian 64-bit value in the
* low 8 bytes). RFC 8446 §5.3. */
static void record_nonce(const uint8_t iv[12], uint64_t seq, uint8_t nonce[12]) {
memcpy(nonce, iv, 12);
for (int i = 0; i < 8; i++)
nonce[4 + i] ^= (uint8_t)(seq >> (8 * (7 - i)));
}
/* AEAD seal/open dispatch by suite. aad is the 5-byte header. seal writes
* ct||tag into out (inner_len + 16 bytes); open reads ct||tag from in. */
static int aead_seal(int suite, const uint8_t *key, size_t keylen,
const uint8_t nonce[12], const uint8_t *aad, size_t aadlen,
const uint8_t *pt, size_t ptlen, uint8_t *out) {
if (suite == WO_TLS_AES_128_GCM_SHA256)
return wo_aes_gcm_seal(key, keylen, nonce, aad, aadlen, pt, ptlen, out);
if (suite == WO_TLS_CHACHA20_POLY1305_SHA256)
return wo_chacha20poly1305_seal(key, nonce, aad, aadlen, pt, ptlen, out);
return -1;
}
static int aead_open(int suite, const uint8_t *key, size_t keylen,
const uint8_t nonce[12], const uint8_t *aad, size_t aadlen,
const uint8_t *ct, size_t ctlen, const uint8_t tag[16],
uint8_t *out) {
if (suite == WO_TLS_AES_128_GCM_SHA256)
return wo_aes_gcm_open(key, keylen, nonce, aad, aadlen, ct, ctlen, tag, out);
if (suite == WO_TLS_CHACHA20_POLY1305_SHA256)
return wo_chacha20poly1305_open(key, nonce, aad, aadlen, ct, ctlen, tag, out);
return -1;
}
int wo_tls_record_seal(int suite, const uint8_t *key, size_t keylen,
const uint8_t iv[12], uint64_t seq, uint8_t content_type,
const uint8_t *pt, size_t ptlen, uint8_t *out) {
if (suite != WO_TLS_AES_128_GCM_SHA256 &&
suite != WO_TLS_CHACHA20_POLY1305_SHA256)
return -1;
size_t inner_len = ptlen + 1; /* content || content_type */
size_t payload_len = inner_len + 16; /* + AEAD tag */
/* 5-byte header: application_data, legacy 0x0303, payload length. */
out[0] = WO_TLS_CT_APPLICATION_DATA;
out[1] = 0x03; out[2] = 0x03;
out[3] = (uint8_t)(payload_len >> 8);
out[4] = (uint8_t)payload_len;
/* Assemble the inner plaintext (no padding) in a scratch buffer. */
uint8_t stackbuf[512];
uint8_t *inner = inner_len <= sizeof stackbuf ? stackbuf
: (uint8_t *)malloc(inner_len);
if (!inner) return -1;
memcpy(inner, pt, ptlen);
inner[ptlen] = content_type;
uint8_t nonce[12];
record_nonce(iv, seq, nonce);
/* AEAD writes ct(inner_len) || tag(16) straight after the header. */
int rc = aead_seal(suite, key, keylen, nonce, out, 5, inner, inner_len,
out + 5);
if (inner != stackbuf) free(inner);
if (rc != 0) return -1;
return (int)(5 + payload_len);
}
int wo_tls_record_open(int suite, const uint8_t *key, size_t keylen,
const uint8_t iv[12], uint64_t seq, const uint8_t *rec,
size_t reclen, uint8_t *out, uint8_t *content_type) {
if (suite != WO_TLS_AES_128_GCM_SHA256 &&
suite != WO_TLS_CHACHA20_POLY1305_SHA256)
return -1;
if (reclen < 5 + 16) return -1; /* header + at least a tag */
size_t payload_len = ((size_t)rec[3] << 8) | rec[4];
if (payload_len + 5 != reclen || payload_len < 16) return -1;
size_t inner_len = payload_len - 16;
const uint8_t *ct = rec + 5;
const uint8_t *tag = rec + 5 + inner_len;
uint8_t nonce[12];
record_nonce(iv, seq, nonce);
/* additional data is the 5-byte header, verbatim. */
if (aead_open(suite, key, keylen, nonce, rec, 5, ct, inner_len, tag, out) != 0)
return -1;
/* Strip trailing zero padding; the last non-zero byte is the content type. */
size_t n = inner_len;
while (n > 0 && out[n - 1] == 0) n--;
if (n == 0) return -1; /* all-zero: no content type */
*content_type = out[n - 1];
return (int)(n - 1);
}

50
runtime/src/tls.h Normal file
View file

@ -0,0 +1,50 @@
/* tls.h — hand-rolled TLS 1.3 (runtime-v2 9 phase F). Sits on the crypto
* ladder (crypto.h): AEAD (A), HKDF (B), X25519 (C), signatures (D), X.509
* (E). This header is phase F: the record layer first, the handshake FSM and
* net.connect_tls on top. Internal C; the VM enters through net builtins. */
#ifndef WO_TLS_H
#define WO_TLS_H
#include <stddef.h>
#include <stdint.h>
/* The two SHA-256 TLS 1.3 cipher suites we implement. AES-128-GCM is
* mandatory-to-implement (RFC 8446 §9.1); ChaCha20-Poly1305 is the portable
* fallback when the CPU has no AES-NI. AES-256-GCM uses SHA-384 and is a later
* add (the HKDF is SHA-256 today). */
enum {
WO_TLS_AES_128_GCM_SHA256 = 1,
WO_TLS_CHACHA20_POLY1305_SHA256 = 2,
};
/* TLS 1.3 record content types (RFC 8446 §5.1). */
enum {
WO_TLS_CT_CHANGE_CIPHER_SPEC = 20,
WO_TLS_CT_ALERT = 21,
WO_TLS_CT_HANDSHAKE = 22,
WO_TLS_CT_APPLICATION_DATA = 23,
};
/* Overhead a sealed record adds over its plaintext: 5-byte header + 1-byte
* inner content-type + 16-byte AEAD tag. */
#define WO_TLS_RECORD_OVERHEAD 22
/* Seal one TLS 1.3 record (RFC 8446 §5.2). Writes the full wire record —
* 5-byte header || encrypted (TLSInnerPlaintext) || 16-byte tag — into out,
* which must hold at least ptlen + WO_TLS_RECORD_OVERHEAD bytes. `seq` is the
* record sequence number; the per-record nonce is iv XOR seq (big-endian, §5.3).
* No padding. Returns the record length, or -1 on a bad suite. */
int wo_tls_record_seal(int suite, const uint8_t *key, size_t keylen,
const uint8_t iv[12], uint64_t seq, uint8_t content_type,
const uint8_t *pt, size_t ptlen, uint8_t *out);
/* Open one TLS 1.3 record. `rec` is the full wire record (header included),
* reclen its length. Writes the recovered content into out (must hold
* reclen bytes) and the recovered inner content-type into *content_type,
* after stripping trailing zero padding (§5.2/§5.4). Returns the content
* length, or -1 on a malformed record or AEAD authentication failure. */
int wo_tls_record_open(int suite, const uint8_t *key, size_t keylen,
const uint8_t iv[12], uint64_t seq, const uint8_t *rec,
size_t reclen, uint8_t *out, uint8_t *content_type);
#endif

View file

@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""TLS 1.3 record-layer KAT vectors (RFC 8446 §5.2). For a fixed key/iv/seq/
plaintext/content-type, compute the full wire record with python's AEAD as the
oracle, for both AES-128-GCM and ChaCha20-Poly1305. Emits C literals."""
from cryptography.hazmat.primitives.ciphers.aead import AESGCM, ChaCha20Poly1305
def nonce(iv, seq):
n = bytearray(iv)
for i in range(8):
n[4 + i] ^= (seq >> (8 * (7 - i))) & 0xff
return bytes(n)
def record(aead, key, iv, seq, ct, pt):
inner = pt + bytes([ct]) # no padding
payload_len = len(inner) + 16
hdr = bytes([23, 3, 3, payload_len >> 8, payload_len & 0xff])
enc = aead(key).encrypt(nonce(iv, seq), inner, hdr)
return hdr + enc
def hexlit(b):
return '"' + "".join("\\x%02x" % x for x in b) + '"'
# AES-128-GCM: 16-byte key, ChaCha: 32-byte key. Shared iv/seq/pt/type.
aes_key = bytes.fromhex("000102030405060708090a0b0c0d0e0f")
cha_key = bytes.fromhex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
iv = bytes.fromhex("cafebabecafebabecafebabe")
seq = 0x0102030405060708
pt = b"hello writeonce tls"
ct = 22 # handshake
r_aes = record(AESGCM, aes_key, iv, seq, ct, pt)
r_cha = record(ChaCha20Poly1305, cha_key, iv, seq, ct, pt)
print("/* Generated by scratchpad/gen_tls_record.py (python cryptography). */")
print("#define TLSREC_IV %s" % hexlit(iv))
print("#define TLSREC_AESKEY %s" % hexlit(aes_key))
print("#define TLSREC_CHAKEY %s" % hexlit(cha_key))
print("#define TLSREC_SEQ 0x%016xULL" % seq)
print("#define TLSREC_CT %d" % ct)
print("#define TLSREC_PT %s" % hexlit(pt))
print("#define TLSREC_PTLEN %d" % len(pt))
print("#define TLSREC_AES %s" % hexlit(r_aes))
print("#define TLSREC_AESLEN %d" % len(r_aes))
print("#define TLSREC_CHA %s" % hexlit(r_cha))
print("#define TLSREC_CHALEN %d" % len(r_cha))

93
runtime/test/test_tls.c Normal file
View file

@ -0,0 +1,93 @@
/* test_tls.c — TLS 1.3 record layer (runtime-v2 9 phase F1). KAT against
* python's AEAD as oracle (tls_record_vectors.h), plus seal/open round-trip,
* a tamper-rejection, and the sequence-number nonce advancing. ASan/UBSan. */
#include <stdint.h>
#include <string.h>
#include "tls.h"
#include "t.h"
#include "tls_record_vectors.h"
int main(void) {
uint8_t iv[12], aeskey[16], chakey[32], pt[TLSREC_PTLEN];
memcpy(iv, TLSREC_IV, 12);
memcpy(aeskey, TLSREC_AESKEY, 16);
memcpy(chakey, TLSREC_CHAKEY, 32);
memcpy(pt, TLSREC_PT, TLSREC_PTLEN);
/* AES-128-GCM: sealed record must equal python's byte-for-byte. */
{
uint8_t out[TLSREC_PTLEN + WO_TLS_RECORD_OVERHEAD];
int n = wo_tls_record_seal(WO_TLS_AES_128_GCM_SHA256, aeskey, 16, iv,
TLSREC_SEQ, TLSREC_CT, pt, TLSREC_PTLEN, out);
T_CHECK(n == TLSREC_AESLEN);
T_CHECK(memcmp(out, TLSREC_AES, TLSREC_AESLEN) == 0);
}
/* ChaCha20-Poly1305: same. */
{
uint8_t out[TLSREC_PTLEN + WO_TLS_RECORD_OVERHEAD];
int n = wo_tls_record_seal(WO_TLS_CHACHA20_POLY1305_SHA256, chakey, 32,
iv, TLSREC_SEQ, TLSREC_CT, pt, TLSREC_PTLEN,
out);
T_CHECK(n == TLSREC_CHALEN);
T_CHECK(memcmp(out, TLSREC_CHA, TLSREC_CHALEN) == 0);
}
/* open() recovers the record python sealed: content, type, length. */
{
uint8_t rec[TLSREC_AESLEN], out[TLSREC_AESLEN]; uint8_t ct = 0;
memcpy(rec, TLSREC_AES, TLSREC_AESLEN);
int n = wo_tls_record_open(WO_TLS_AES_128_GCM_SHA256, aeskey, 16, iv,
TLSREC_SEQ, rec, TLSREC_AESLEN, out, &ct);
T_CHECK(n == TLSREC_PTLEN);
T_CHECK(ct == TLSREC_CT);
T_CHECK(memcmp(out, pt, TLSREC_PTLEN) == 0);
}
/* Round-trip both suites over several sequence numbers (nonce advances). */
for (int suite = 1; suite <= 2; suite++) {
const uint8_t *k = suite == WO_TLS_AES_128_GCM_SHA256 ? aeskey : chakey;
size_t kl = suite == WO_TLS_AES_128_GCM_SHA256 ? 16 : 32;
for (uint64_t seq = 0; seq < 5; seq++) {
uint8_t msg[40], rec[40 + WO_TLS_RECORD_OVERHEAD];
uint8_t got[sizeof rec]; uint8_t ct = 0;
for (size_t i = 0; i < sizeof msg; i++) msg[i] = (uint8_t)(i + seq);
int n = wo_tls_record_seal(suite, k, kl, iv, seq,
WO_TLS_CT_APPLICATION_DATA, msg,
sizeof msg, rec);
T_CHECK(n > 0);
int m = wo_tls_record_open(suite, k, kl, iv, seq, rec, (size_t)n,
got, &ct);
T_CHECK(m == (int)sizeof msg);
T_CHECK(ct == WO_TLS_CT_APPLICATION_DATA);
T_CHECK(memcmp(got, msg, sizeof msg) == 0);
}
}
/* A tampered record fails to open; a wrong sequence number fails too. */
{
uint8_t rec[TLSREC_AESLEN], out[TLSREC_AESLEN]; uint8_t ct = 0;
memcpy(rec, TLSREC_AES, TLSREC_AESLEN);
rec[10] ^= 0x01;
T_CHECK(wo_tls_record_open(WO_TLS_AES_128_GCM_SHA256, aeskey, 16, iv,
TLSREC_SEQ, rec, TLSREC_AESLEN, out, &ct) == -1);
memcpy(rec, TLSREC_AES, TLSREC_AESLEN);
T_CHECK(wo_tls_record_open(WO_TLS_AES_128_GCM_SHA256, aeskey, 16, iv,
TLSREC_SEQ + 1, rec, TLSREC_AESLEN, out,
&ct) == -1);
/* A length-field lie is rejected before the AEAD. */
memcpy(rec, TLSREC_AES, TLSREC_AESLEN);
rec[4] ^= 0x01;
T_CHECK(wo_tls_record_open(WO_TLS_AES_128_GCM_SHA256, aeskey, 16, iv,
TLSREC_SEQ, rec, TLSREC_AESLEN, out, &ct) == -1);
}
/* A bad suite id is rejected, not misdispatched. */
{
uint8_t out[64];
T_CHECK(wo_tls_record_seal(99, aeskey, 16, iv, 0, 23, pt, TLSREC_PTLEN,
out) == -1);
}
return t_report("test_tls");
}

View file

@ -0,0 +1,12 @@
/* Generated by scratchpad/gen_tls_record.py (python cryptography). */
#define TLSREC_IV "\xca\xfe\xba\xbe\xca\xfe\xba\xbe\xca\xfe\xba\xbe"
#define TLSREC_AESKEY "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f"
#define TLSREC_CHAKEY "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f"
#define TLSREC_SEQ 0x0102030405060708ULL
#define TLSREC_CT 22
#define TLSREC_PT "\x68\x65\x6c\x6c\x6f\x20\x77\x72\x69\x74\x65\x6f\x6e\x63\x65\x20\x74\x6c\x73"
#define TLSREC_PTLEN 19
#define TLSREC_AES "\x17\x03\x03\x00\x24\x37\x16\x16\xb3\xfc\x57\x75\x92\xab\x49\xf1\x68\xcf\x12\x79\x81\x68\x15\x8e\xb3\x7d\x65\x87\x28\xeb\xa2\x58\x79\xac\x9c\x3a\x6f\x08\xa2\x3e\x3e"
#define TLSREC_AESLEN 41
#define TLSREC_CHA "\x17\x03\x03\x00\x24\xbb\xd8\xe8\x3a\x0b\x08\xf5\x65\x6d\xcc\x12\x4b\x39\x3b\x77\xe2\x2a\x56\xc9\x53\xff\x6d\x6c\x1f\x99\x4a\x86\xf4\xab\x5d\x5d\xaf\x59\x2b\x99\xfb"
#define TLSREC_CHALEN 41