Skip to content

File espos_httpd_auth_policy.h

File List > espos_httpd > include > espos_httpd_auth_policy.h

Go to the documentation of this file

/*
 * SPDX-FileCopyrightText: 2026 Dirk Wahrheit
 * SPDX-License-Identifier: Apache-2.0
 *
 * REST authentication policy — pure C. Decides whether one request may reach
 * a protected endpoint, keeps the login sessions and throttles failed keys:
 *
 *   portal, no key set ──────────────────▶ allowed (this is where the first key is set)
 *   portal, key set, recovery window ────▶ allowed (physical presence was proved)
 *   portal, key set, no window ──────────▶ judged like any other request, below
 *   no key configured ───────────────────▶ allowed, or 403 when the build requires a key
 *   Authorization: Bearer <key> ─ match ─▶ allowed        ─ miss ─▶ 401 (counted)
 *   Cookie: espos_sid=<id> ─ live session ▶ allowed; a state change also needs
 *                                          Origin (or Referer) host == Host, else 403
 *   nothing ─────────────────────────────▶ 401
 *   ESPOS_HTTPD_AUTH_FAIL_MAX misses within ESPOS_HTTPD_AUTH_FAIL_WINDOW_S
 *                                        ▶ every key check answers 429 for ESPOS_HTTPD_AUTH_LOCKOUT_S
 *
 * The soft-AP network is NOT exempt once a key is set. It used to be, on the
 * reasoning that whoever joined the device's own access point was standing
 * next to it; that does not hold, because the access point is open by default
 * (wifi.portal_psk) and espos_wifi raises it unattended -- permanently when no
 * station network is configured, which is the normal setup for an
 * Ethernet-only device, and after wifi.portal_after_s otherwise. So anyone
 * within radio range was an operator (espOS #154). What proves presence now is
 * a recovery window, opened by espos_httpd_auth_recovery_open() -- on a device
 * from a run of power cycles, which nobody outside the room can produce.
 *
 * Key and session-id comparisons run in constant time over the maximum
 * length, so neither the bytes nor the length of a secret shows in the
 * response time. The machine never reads a header or a clock itself: the
 * caller hands it the parsed request and a port with the clock and the
 * entropy source, so it runs unchanged on the host under test
 * (test/host/espos_httpd_auth_test). espos_httpd owns the one instance a
 * device runs and serialises every call with its own lock; nothing here is
 * thread-safe on its own.
 */
#pragma once

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

#ifdef __cplusplus
extern "C" {
#endif

#define ESPOS_HTTPD_AUTH_KEY_MAX       64 /* bytes of an API key, excluding NUL (httpd.api_key maxLength) */
#define ESPOS_HTTPD_AUTH_KEY_MIN       8  /* shorter keys are accepted with a warning, not refused */
#define ESPOS_HTTPD_AUTH_SID_LEN       32 /* hex characters of a session id: 128 bits */
#define ESPOS_HTTPD_AUTH_FAIL_MAX      5  /* failed key checks that start a lockout */
#define ESPOS_HTTPD_AUTH_FAIL_WINDOW_S 60 /* counted within this window */
#define ESPOS_HTTPD_AUTH_LOCKOUT_S     30 /* how long every key check then answers 429 */
/* Longest recovery window, and a day is already generous for "somebody is at
 * the device". Also a correctness bound: the deadline is compared as a signed
 * difference on a wrapping clock, so a window of more than INT32_MAX seconds
 * would read as already past -- a caller asking for one would get a window
 * that was shut rather than one that never closed. */
#define ESPOS_HTTPD_AUTH_RECOVERY_MAX_S 86400

/* How a request authenticated itself. Values are ABI: append, never renumber. */
typedef enum {
    ESPOS_HTTPD_AUTH_NONE = 0,   /* no valid credential (the open device reports this too) */
    ESPOS_HTTPD_AUTH_BEARER = 1, /* Authorization: Bearer <key> matched */
    ESPOS_HTTPD_AUTH_COOKIE = 2, /* espos_sid named a live session */
    ESPOS_HTTPD_AUTH_PORTAL = 3, /* arrived on the soft-AP interface: exempt */
    ESPOS_HTTPD_AUTH_METHOD_MAX
} espos_httpd_auth_method_t;

typedef enum {
    ESPOS_HTTPD_AUTH_ALLOW = 0,
    ESPOS_HTTPD_AUTH_UNAUTHORIZED = 1,     /* 401: no or invalid credential */
    ESPOS_HTTPD_AUTH_FORBIDDEN_ORIGIN = 2, /* 403: cookie session, state change, Origin does not match Host */
    ESPOS_HTTPD_AUTH_UNCONFIGURED = 3,     /* 403: the build requires a key and none is configured */
    ESPOS_HTTPD_AUTH_THROTTLED = 4,        /* 429: too many failed key checks */
    ESPOS_HTTPD_AUTH_VERDICT_MAX
} espos_httpd_auth_verdict_t;

/* One login. The table is the caller's (sized by CONFIG_ESPOS_HTTPD_MAX_SESSIONS on a device). */
typedef struct {
    char id[ESPOS_HTTPD_AUTH_SID_LEN + 1];
    uint32_t expires_s;   /* port clock */
    uint32_t last_seen_s; /* LRU order when the table is full */
    bool used;
} espos_httpd_auth_session_t;

/* Everything the machine needs from the outside world. */
typedef struct {
    uint32_t (*now_s)(void *ctx);  /* monotonic seconds, wraps; differences only */
    uint32_t (*random)(void *ctx); /* 32 bits from a real entropy source (esp_random) */
} espos_httpd_auth_port_t;

/* What the decision needs to know about one request. Strings are borrowed
 * for the duration of the call and may be NULL when the header is absent. */
typedef struct {
    const char *bearer;     /* the token of an Authorization: Bearer header */
    const char *cookie_sid; /* the value of the espos_sid cookie */
    const char *host;       /* the Host header */
    const char *origin;     /* the Origin header, else the Referer header */
    bool state_changing;    /* anything but GET, HEAD and OPTIONS */
    bool from_portal;       /* the local socket address is the soft-AP's */
} espos_httpd_auth_request_t;

typedef struct espos_httpd_auth_policy {
    const espos_httpd_auth_port_t *port;
    void *ctx;
    espos_httpd_auth_session_t *sessions;
    size_t session_count;
    uint32_t ttl_s;   /* lifetime of a new session */
    bool require_key; /* CONFIG_ESPOS_HTTPD_AUTH_REQUIRED: refuse until a key exists */
    char key[ESPOS_HTTPD_AUTH_KEY_MAX + 1];
    /* The failure throttle: how many misses since the window opened, and
     * until when the lockout they caused lasts. Global, not per client —
     * a device has no way to tell clients apart that is worth the RAM. */
    uint32_t fail_count;
    uint32_t fail_first_s;
    uint32_t lockout_until_s;
    bool locked;
    /* The recovery window: until when the portal is exempt again. A flag
     * beside the deadline, like the lockout above, because the port clock
     * wraps and no instant can mean "closed". */
    uint32_t recovery_until_s;
    bool recovery;
} espos_httpd_auth_policy_t;

void espos_httpd_auth_policy_init(espos_httpd_auth_policy_t *p, const espos_httpd_auth_port_t *port, void *ctx,
                                  espos_httpd_auth_session_t *sessions, size_t session_count, uint32_t ttl_s,
                                  bool require_key);

void espos_httpd_auth_policy_set_key(espos_httpd_auth_policy_t *p, const char *key);
void espos_httpd_auth_policy_set_ttl(espos_httpd_auth_policy_t *p, uint32_t ttl_s);
bool espos_httpd_auth_policy_configured(const espos_httpd_auth_policy_t *p);
bool espos_httpd_auth_policy_required(const espos_httpd_auth_policy_t *p);

espos_httpd_auth_verdict_t espos_httpd_auth_policy_check_key(espos_httpd_auth_policy_t *p, const char *presented);
bool espos_httpd_auth_policy_throttled(const espos_httpd_auth_policy_t *p);

void espos_httpd_auth_policy_recovery_open(espos_httpd_auth_policy_t *p, uint32_t seconds);
uint32_t espos_httpd_auth_policy_recovery_s_left(const espos_httpd_auth_policy_t *p);
uint32_t espos_httpd_auth_policy_retry_after_s(const espos_httpd_auth_policy_t *p);

bool espos_httpd_auth_policy_session_open(espos_httpd_auth_policy_t *p, char *id_out, size_t id_size);
bool espos_httpd_auth_policy_session_valid(espos_httpd_auth_policy_t *p, const char *id);
void espos_httpd_auth_policy_session_close(espos_httpd_auth_policy_t *p, const char *id);
void espos_httpd_auth_policy_sessions_clear(espos_httpd_auth_policy_t *p);
size_t espos_httpd_auth_policy_sessions_live(espos_httpd_auth_policy_t *p);

espos_httpd_auth_verdict_t espos_httpd_auth_policy_decide(espos_httpd_auth_policy_t *p,
                                                          const espos_httpd_auth_request_t *rq,
                                                          espos_httpd_auth_method_t *method);

bool espos_httpd_auth_origin_matches(const char *origin_or_referer, const char *host);

const char *espos_httpd_auth_method_str(espos_httpd_auth_method_t m);

#ifdef __cplusplus
}
#endif