Skip to content

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