Skip to content

File espos_sk_token_sm.h

File List > espos_sk > include > espos_sk_token_sm.h

Go to the documentation of this file

/*
 * SPDX-FileCopyrightText: 2026 Dirk Wahrheit
 * SPDX-License-Identifier: Apache-2.0
 *
 * SignalK access-token state machine — pure C. Drives the access-request
 * flow of the SignalK security API:
 *
 *   IDLE ──POST /signalk/v1/access/requests──▶ REQUESTED (poll href, backoff 5 s → 60 s)
 *          ├─ 404 security disabled ─────────▶ OPEN (no token needed)
 *          └─ 403 device requests disabled ──▶ DENIED
 *   REQUESTED ─ COMPLETED/APPROVED ▶ store token ▶ VERIFYING ▶ APPROVED
 *             ─ COMPLETED/DENIED   ▶ DENIED (no auto retry)
 *             ─ href gone (404/500 not found) ▶ IDLE (re-request)
 *   APPROVED  ─ periodic GET /signalk/v1/api/self with the token
 *             ─ 401/403 ▶ token invalidated ▶ IDLE
 *   VERIFYING ─ 200 ▶ APPROVED (self URN learned)   ─ 401/403 ▶ IDLE
 *   any leg   ─ the server's certificate is not the trusted one ▶ CERT_ERROR
 *               (token kept, flat 60 s retry, stream held down)
 *
 * Two rules that are not obvious from the diagram:
 *   - over plaintext a single 401 does NOT clear the token (anything on the
 *     path can answer one); two consecutive unauthorised answers do. Over TLS
 *     one is conclusive. See plain_unauth_streak below.
 *   - a TLS server skips the VERIFYING leg after an approval: the WebSocket
 *     upgrade carries the same token and rejects it just as clearly, so the
 *     leg would only buy a second handshake per reconnect.
 *
 * The machine never touches storage or HTTP itself: it asks the port to
 * do things (start a request, poll, verify, save/clear) and is fed the
 * outcomes as events. It runs unchanged on the host under test.
 */
#pragma once

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"

#ifdef __cplusplus
extern "C" {
#endif

#define ESPOS_SK_HOST_MAX  64
#define ESPOS_SK_SELF_MAX  128
#define ESPOS_SK_HREF_MAX  128
#define ESPOS_SK_TOKEN_MAX 1024
#define ESPOS_SK_MSG_MAX   96

typedef enum {
    ESPOS_SK_TOK_NO_SERVER = 0, /* nothing to talk to yet */
    ESPOS_SK_TOK_IDLE,          /* server known; a request will be sent */
    ESPOS_SK_TOK_REQUESTED,     /* pending approval; polling href */
    ESPOS_SK_TOK_VERIFYING,     /* have a token; confirming it works */
    ESPOS_SK_TOK_APPROVED,      /* token works */
    ESPOS_SK_TOK_DENIED,        /* admin denied (or device requests disabled); waits for user */
    ESPOS_SK_TOK_OPEN,          /* server security disabled: no token needed */
    ESPOS_SK_TOK_ERROR,         /* transient trouble (server unreachable); retrying with backoff */
    /* The server's certificate is not the one this device trusts. Not an
     * error to back off from: an exponential retry would have the device
     * checking once an hour by the time somebody looks at it, and the fix
     * (renew the certificate, or trust the new one) lands from outside and
     * should be noticed within a minute. So a flat 60 s, and the token is
     * kept — it is the transport that is wrong, not the credential. The
     * stream stays down while this holds (espos_sk_stream_allowed() is false):
     * falling back to plaintext would send the token to whoever answered. */
    ESPOS_SK_TOK_CERT_ERROR,
} espos_sk_tok_state_t;

typedef struct {
    char host[ESPOS_SK_HOST_MAX];
    uint16_t port;
    /* Scheme for this server: https/wss when true, http/ws when false. Per
     * server and not global, which is what makes sk.scheme = auto possible: a
     * discovered server's scheme comes from the mDNS service type it answered
     * on, a manual host's from one redirect probe, and the value is cached
     * with the server rather than re-decided per call. Effective only in
     * builds with CONFIG_ESPOS_SK_TLS; see docs/signalk.md. */
    bool tls;
    char self[ESPOS_SK_SELF_MAX];   /* vessel self URN if known (mDNS TXT / learned), else "" */
} espos_sk_server_t;

typedef struct {
    char client_id[40];             /* persistent UUID */
    char description[80];
    char permissions[12];           /* readonly | readwrite | admin */
    uint32_t check_interval_ms;     /* token re-verification while APPROVED */
} espos_sk_tok_cfg_t;

/* Persistent state handed in at start and written back through the port. */
typedef struct {
    char token[ESPOS_SK_TOKEN_MAX];
    char token_self[ESPOS_SK_SELF_MAX];   /* the server the token belongs to */
    char pending_href[ESPOS_SK_HREF_MAX];
    char pending_host[ESPOS_SK_HOST_MAX];
    uint16_t pending_port;
    char pending_self[ESPOS_SK_SELF_MAX];
} espos_sk_tok_store_t;

typedef enum {
    ESPOS_SK_EV_START,            /* arg: NULL */
    ESPOS_SK_EV_STOP,
    ESPOS_SK_EV_SERVER,           /* arg: const espos_sk_server_t* (NULL = no server) */
    ESPOS_SK_EV_REQUEST_RESULT,   /* arg: const espos_sk_http_result_t* */
    ESPOS_SK_EV_POLL_RESULT,      /* arg: const espos_sk_http_result_t* */
    ESPOS_SK_EV_VERIFY_RESULT,    /* arg: const espos_sk_http_result_t* */
    ESPOS_SK_EV_TIMER,
    ESPOS_SK_EV_MANUAL_TOKEN,     /* arg: const char* token */
    ESPOS_SK_EV_RETRY,            /* user asks to request again (from DENIED/ERROR) */
    ESPOS_SK_EV_UNAUTHORIZED,     /* some other SK call got 401/403 with our token */
    ESPOS_SK_EV_CONFIG,           /* arg: const espos_sk_tok_cfg_t* */
    /* The TLS layer refused the server. arg: const char* reason (may be NULL).
     * Distinct from EV_UNAUTHORIZED because the token is fine and must be
     * kept: throwing it away over a certificate change would mean a fresh
     * approval in the server UI every time the certificate is renewed. */
    ESPOS_SK_EV_CERT_ERROR,
} espos_sk_tok_event_t;

typedef struct {
    int http_status;              /* 0 = transport failure (unreachable) */
    /* request / poll */
    char state[12];               /* "PENDING" | "COMPLETED" | "" */
    char permission[12];          /* "APPROVED" | "DENIED" | "" */
    char href[ESPOS_SK_HREF_MAX];
    char token[ESPOS_SK_TOKEN_MAX];
    char message[ESPOS_SK_MSG_MAX];
    /* verify */
    char self[ESPOS_SK_SELF_MAX];
    /* The transport, not the reply: the connection never got far enough to
     * have an HTTP status because the certificate was refused. http_status is
     * 0 in that case, which on its own reads as "server unreachable" and would
     * back off for five minutes over something an operator can fix in ten
     * seconds. */
    bool cert_error;
    char cert_reason[64];
} espos_sk_http_result_t;

/* Port: everything the machine needs from the outside world. Actions are
 * asynchronous — the port answers with the *_RESULT events. */
typedef struct {
    void (*http_request)(void *ctx, const espos_sk_server_t *srv, const espos_sk_tok_cfg_t *cfg);
    void (*http_poll)(void *ctx, const espos_sk_server_t *srv, const char *href);
    void (*http_verify)(void *ctx, const espos_sk_server_t *srv, const char *token);
    void (*store_save)(void *ctx, const espos_sk_tok_store_t *st);
    void (*arm_timer)(void *ctx, uint32_t ms);
    void (*cancel_timer)(void *ctx);
    uint32_t (*now_ms)(void *ctx);
    uint32_t (*random)(void *ctx);
    void (*status_changed)(void *ctx);
} espos_sk_tok_port_t;

typedef struct {
    espos_sk_tok_state_t state;
    espos_sk_server_t server;
    bool has_server;
    bool has_token;
    char pending_href[ESPOS_SK_HREF_MAX];
    uint32_t next_action_ms;      /* when the next poll/retry/check is due (port clock) */
    uint32_t poll_interval_ms;    /* current poll backoff */
    uint32_t requested_since_ms;
    uint32_t approved_since_ms;
    uint32_t last_check_ms;
    int last_http_status;
    char last_error[ESPOS_SK_MSG_MAX];
    uint32_t request_count, approve_count, deny_count, unauthorized_count, cert_error_count;
    bool busy;                    /* an HTTP action is in flight */
} espos_sk_tok_status_t;

typedef struct espos_sk_tok_sm {
    const espos_sk_tok_port_t *port;
    void *ctx;
    espos_sk_tok_cfg_t cfg;
    espos_sk_tok_store_t store;
    espos_sk_tok_status_t st;
    bool started;
    bool reeval;                  /* server/token changed while an action was in flight */
    uint32_t error_backoff_ms;
    /* Separate from error_backoff_ms on purpose: a duplicate pending request is
     * a wait for a person, not a fault to retry out of, so it has its own
     * ladder and its own (shorter) ceiling. Sharing one counter would let an
     * unreachable server stretch the approval wait, or vice versa. */
    uint32_t dup_backoff_ms;
    uint32_t timer_due_ms;
    /* Consecutive unauthorised answers over a PLAINTEXT connection. On http a
     * single 401 is not proof the token died: anything on the path can answer
     * one — a captive portal, a proxy, a router's "you are not logged in"
     * page — and throwing the token away over it costs a trip to the server's
     * admin UI to approve the device again. Over TLS nothing can inject an
     * answer, so one 401 is conclusive there and this counter is not used.
     * Reset by any 200. (SensESP's should_clear_token_on_status.) */
    uint32_t plain_unauth_streak;
} espos_sk_tok_sm_t;

void espos_sk_tok_init(espos_sk_tok_sm_t *sm, const espos_sk_tok_port_t *port, void *ctx,
                       const espos_sk_tok_cfg_t *cfg, const espos_sk_tok_store_t *store);
void espos_sk_tok_event(espos_sk_tok_sm_t *sm, espos_sk_tok_event_t ev, const void *arg);
const espos_sk_tok_status_t *espos_sk_tok_status(const espos_sk_tok_sm_t *sm);
/* Current token ("" if none/unusable). Valid to read on the machine's task. */
const char *espos_sk_tok_token(const espos_sk_tok_sm_t *sm);
const char *espos_sk_tok_state_str(espos_sk_tok_state_t s);

#ifdef __cplusplus
}
#endif