File espos_sk_tls_policy.h¶
File List > espos_sk > include > espos_sk_tls_policy.h
Go to the documentation of this file
/*
* SPDX-FileCopyrightText: 2026 Dirk Wahrheit
* SPDX-License-Identifier: Apache-2.0
*
* The trust decision for a SignalK server certificate — pure C, no mbedTLS,
* no IDF. sk_tls.c parses the chain and hands the facts here; this file
* decides what to do with them and is driven directly by a host test.
*
* The problem it exists for: a boat's signalk-server almost always presents a
* certificate no public root signed — self-signed, or issued by a CA the owner
* generated once. Verifying against the Mozilla bundle refuses every one of
* them, and an "accept anything" switch would make the setting a decoration.
* So espOS does what SSH does: trust on first use, then hold the server to it.
*
* Two shapes of anchor, because certificates get renewed:
*
* CA anchor the highest CA:TRUE certificate in the presented chain, plus
* the leaf's SAN set. A renewal signed by the same CA for the
* same names is accepted with no operator involvement, which is
* what makes a 90-day certificate survivable on a device nobody
* logs into. Binding the SAN set as well as the CA matters: a
* private CA that signs one host would otherwise vouch for any
* other name it ever signs.
* leaf anchor the SHA-256 of the leaf certificate itself. Where a chain has
* no CA at all, or a leaf carries no SAN — signalk-server's own
* generated self-signed certificate is exactly this — there is
* nothing else to pin, and a renewal then needs one deliberate
* "trust the new certificate" from the operator.
*
* The decision table is the same one for tofu and ca mode: "ca" is TOFU with
* the anchor supplied by the operator up front instead of captured on the
* first handshake, so both walk the same code and are tested together.
*/
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/* Sizes are the record's, so a caller can stack-allocate one of each. The
* SAN set is stored normalised (see espos_sk_tls_san_normalise) and capped:
* a certificate naming more hosts than fit is pinned by the names that did
* fit plus a truncation flag, and truncation refuses to match, so a partial
* set can never widen what is accepted. */
#define ESPOS_SK_TLS_SAN_MAX 256
#define ESPOS_SK_TLS_CN_MAX 64
#define ESPOS_SK_TLS_FP_LEN 32 /* SHA-256, raw bytes */
/* What kind of anchor is stored (persisted as tls_kind, so the values are
* part of the on-device format and must not be renumbered). */
typedef enum {
ESPOS_SK_TLS_ANCHOR_NONE = 0, /* nothing pinned: the next handshake captures one */
ESPOS_SK_TLS_ANCHOR_CA = 1, /* a CA certificate plus the SAN set it may vouch for */
ESPOS_SK_TLS_ANCHOR_LEAF = 2, /* the leaf's own fingerprint */
} espos_sk_tls_anchor_kind_t;
/* How the connection should be verified. Persisted as the sk.tls_trust enum
* string, not as a number. */
typedef enum {
ESPOS_SK_TLS_TRUST_TOFU = 0, /* pin what the first handshake presents */
ESPOS_SK_TLS_TRUST_CA = 1, /* the operator supplied the CA (sk.ca_pem) */
ESPOS_SK_TLS_TRUST_BUNDLE = 2, /* the bundled Mozilla roots, nothing pinned */
} espos_sk_tls_trust_t;
/* The outcome of one decision. */
typedef enum {
ESPOS_SK_TLS_ACCEPT = 0, /* the chain matches what is pinned */
ESPOS_SK_TLS_CAPTURE = 1, /* nothing pinned yet: accept and pin what was presented */
ESPOS_SK_TLS_REJECT = 2, /* pinned, and this is not it */
} espos_sk_tls_verdict_t;
/* Why a decision came out the way it did — the string an operator reads on
* the SignalK page, and what the health condition carries. */
typedef enum {
ESPOS_SK_TLS_R_OK = 0,
ESPOS_SK_TLS_R_FIRST_USE, /* captured: nothing was pinned */
ESPOS_SK_TLS_R_LEAF_CHANGED, /* leaf anchor, and the fingerprint differs */
ESPOS_SK_TLS_R_CA_CHANGED, /* CA anchor, and the presented CA differs */
ESPOS_SK_TLS_R_SAN_CHANGED, /* CA matches but the leaf now names other hosts */
ESPOS_SK_TLS_R_NO_IDENTITY, /* CA anchored, presented leaf has no SAN to compare */
} espos_sk_tls_reason_t;
/* Everything the decision depends on, filled in by the verify callback.
* Deliberately booleans and not certificates: the parsing lives in sk_tls.c,
* the judgement lives here, and a host test writes these five fields by hand. */
typedef struct {
bool has_anchor; /* an anchor of some kind is stored */
bool anchor_is_leaf; /* ... and it is a leaf fingerprint (else a CA) */
bool leaf_matches; /* presented leaf fingerprint == stored one */
bool ca_present; /* the presented chain contains a CA:TRUE certificate */
bool ca_matches; /* ... and its fingerprint == the stored CA's */
bool leaf_has_san; /* the presented leaf carries at least one dNSName/IP SAN */
bool san_matches; /* ... and the normalised set equals the stored one */
} espos_sk_tls_facts_t;
typedef struct {
espos_sk_tls_verdict_t verdict;
espos_sk_tls_reason_t reason;
/* What to pin when the verdict is CAPTURE. A chain with a CA and a leaf
* that names itself is anchored at the CA (renewals survive); anything
* else falls back to the leaf, which is stricter and always available. */
espos_sk_tls_anchor_kind_t capture_as;
} espos_sk_tls_decision_t;
espos_sk_tls_decision_t espos_sk_tls_decide(const espos_sk_tls_facts_t *f);
const char *espos_sk_tls_reason_str(espos_sk_tls_reason_t r);
esp_err_t espos_sk_tls_san_normalise(const char *const *in, size_t n, char *out, size_t out_size,
bool *truncated);
bool espos_sk_tls_san_equal(const char *a, const char *b);
esp_err_t espos_sk_tls_attach(void *ssl_conf);
espos_sk_tls_trust_t espos_sk_tls_trust_mode(void);
#ifdef __cplusplus
}
#endif