openaliro
Aliro reader: UWB/CCC core and ESP32-S3/C5/C6 port
Loading...
Searching...
No Matches
aliro_assert.h
1/*
2 * Copyright (c) 2026 asxeem
3 * SPDX-License-Identifier: ISC
4 *
5 * aliro_assert — the presence-assertion protocol for the non-door primitive.
6 *
7 * A presence dongle answers a challenge with a signed statement: "a provisioned
8 * credential is within N cm right now, in response to THIS nonce." The link is
9 * treated as hostile, so the statement is authenticated with ECDSA-P256. Anyone
10 * holding the dongle's public key can verify it, so an assertion becomes a proof
11 * a third party accepts (for example, CI checking that a human was at the machine
12 * when a release was signed). P-256 reuses the curve already used by Aliro.
13 *
14 * Anti-replay is the nonce: the verifier mints a fresh CSPRNG nonce per
15 * challenge, accepts one response for it, then forgets it. A captured assertion
16 * carries a stale nonce and is rejected. A strictly-increasing dongle uptime is
17 * an optional second guard (forward-progress) the verifier can enforce.
18 *
19 * uptime_ms is monotonic since dongle boot, which is enough to order two frames
20 * from one session but says nothing to a third party about WHEN. unix_ms is the
21 * dongle's attested wall clock for exactly that, and is ALIRO_ASSERT_TIME_NONE
22 * on a dongle with no trusted time -- which is why it is a separate field and
23 * not a replacement.
24 *
25 * A distance is only as good as the measurement behind it, so the frame also
26 * carries the range-integrity evidence for that measurement: whether the STS
27 * correlated well enough to trust the timestamp, the quality index it scored,
28 * and how many consecutive agreeing blocks stood behind it. Without those, a
29 * verifier is trusting a number it cannot audit -- it cannot tell a defended
30 * 19 cm from a distance-reduction attack's 19 cm, because both arrive as the
31 * integer 19. The evidence is inside the signed prefix, so it cannot be edited
32 * away, and a frame that does not claim a good STS is rejected outright.
33 *
34 * This module is the wire codec + verifier only. It knows nothing about UWB or
35 * BLE; the dongle firmware fills the fields from a real Aliro ranging round and
36 * the host maps the verdict to a decision. Portable C11 (SHA-256 for credential
37 * identifiers via aliro_hash.c), so the exact codec is host-KAT'd and fuzzed.
38 * That boundary is why trust_level is reported but not thresholded here: the
39 * consensus constant belongs to the UWB layer, so the policy layer above owns
40 * any floor on it.
41 *
42 * Wire version 3. Version 1 (70 bytes, HMAC-only, no alg byte, no wall clock)
43 * was never flashed to a device. Version 2 (111 bytes) reached one bench board
44 * but could not state whether its distance was defended, which is exactly the
45 * claim a presence assertion exists to make, so it is rejected rather than read
46 * without that evidence. No v1 or v2 decoder is kept.
47 */
48#pragma once
49
50#include <stdbool.h>
51#include <stddef.h>
52#include <stdint.h>
53
54#ifdef __cplusplus
55extern "C" {
56#endif
57
58#define ALIRO_ASSERT_NONCE_LEN 16u
59#define ALIRO_ASSERT_CREDID_LEN 8u /* first 8 bytes of SHA-256(credential pub) */
60#define ALIRO_ASSERT_SIG_LEN 64u /* ECDSA-P256 signature, r||s, 32 bytes each */
61#define ALIRO_ASSERT_PUB_LEN 65u /* uncompressed P-256 point, 0x04 || X || Y */
62
63/* The signed prefix; only the trailing signature follows it. */
64#define ALIRO_ASSERT_SIGNED_LEN 51u
65#define ALIRO_ASSERT_WIRE_P256 (ALIRO_ASSERT_SIGNED_LEN + ALIRO_ASSERT_SIG_LEN) /* 115 */
66#define ALIRO_ASSERT_WIRE_MAX ALIRO_ASSERT_WIRE_P256 /* size any receive buffer by this */
67
68#define ALIRO_ASSERT_DIST_NONE 0xFFFFu /* distance_cm sentinel: no valid range */
69#define ALIRO_ASSERT_TIME_NONE 0u /* unix_ms sentinel: no trusted wall clock */
70
71/*
72 * range_flags — what the ranging layer can vouch for about distance_cm.
73 *
74 * STS_OK means every block in the agreeing run behind this distance correlated
75 * its scrambled timestamp sequence well enough to trust. A spoofed early first
76 * path cannot reproduce that sequence, so its STS quality collapses; the bit is
77 * therefore the difference between a measured distance and a claimed one.
78 *
79 * Bits outside FLAGS_KNOWN must be zero. An unknown bit means the frame asserts
80 * a property this verifier cannot evaluate, and a security field that cannot be
81 * read has to fail closed rather than be ignored. Adding a bit later costs a
82 * version bump, which is the intended price.
83 */
84#define ALIRO_ASSERT_RANGE_STS_OK 0x01u /* STS held for every block in the run */
85#define ALIRO_ASSERT_RANGE_FLAGS_KNOWN 0x01u /* every bit this wire version defines */
86
87/* Authentication algorithm, carried in the frame so the verifier never has to
88 * guess and so a frame can never be verified under the algorithm it was not
89 * built for. Values are wire-visible; do not renumber. */
90enum aliro_assert_alg {
91 /* 1 was HMAC-SHA256, retired with the paired-host PAM path it existed for.
92 * Deliberately not reused: a v1 frame must reject as unknown-alg rather
93 * than be reinterpreted under a scheme it was never signed with. */
94 ALIRO_ASSERT_ALG_ECDSA_P256 = 2,
95};
96
97/* Presence status the dongle reports from the ranging round. */
98enum aliro_assert_status {
99 ALIRO_PRESENCE_ABSENT = 0, /* no provisioned credential answered the round */
100 ALIRO_PRESENCE_PRESENT = 1, /* a trusted credential transacted + ranged */
101};
102
103/*
104 * The assertion fields. Wire layout (big-endian multi-byte), the tag covers
105 * every byte before it:
106 * magic(2)=A1 50 | version(1)=03 | alg(1) | status(1) | nonce(16) |
107 * cred_id(8) | distance_cm(2) | range_flags(1) | sts_quality(2) |
108 * trust_level(1) | uptime_ms(8) | unix_ms(8) | sig(64)
109 * = 115 bytes.
110 *
111 * The three integrity fields sit next to distance_cm because they qualify it:
112 * reading the distance without them is reading a number with its provenance
113 * stripped off.
114 */
115struct aliro_assert {
116 uint8_t status; /* enum aliro_assert_status */
117 uint8_t nonce[ALIRO_ASSERT_NONCE_LEN]; /* echoed from the challenge */
118 uint8_t cred_id[ALIRO_ASSERT_CREDID_LEN]; /* which credential answered */
119 uint16_t distance_cm; /* ALIRO_ASSERT_DIST_NONE if none */
120 uint8_t range_flags; /* ALIRO_ASSERT_RANGE_* evidence bits */
121 int16_t sts_quality; /* STS quality index, worst block in the run */
122 uint8_t trust_level; /* agreeing blocks behind the distance */
123 uint64_t uptime_ms; /* dongle monotonic ms at build */
124 uint64_t unix_ms; /* attested wall clock, or TIME_NONE */
125};
126
127/* Verdict / reason codes from aliro_assert_verify. 0 = presence confirmed;
128 * every negative value is a distinct reject reason (for logging + tests). */
129enum aliro_assert_verdict {
130 ALIRO_ASSERT_OK = 0,
131 ALIRO_ASSERT_E_MALFORMED = -1, /* bad length, magic, or version */
132 ALIRO_ASSERT_E_MAC = -2, /* tag mismatch: wrong key or tampered */
133 ALIRO_ASSERT_E_NONCE = -3, /* nonce != challenge: replay / mismatch */
134 ALIRO_ASSERT_E_STALE = -4, /* uptime_ms <= min_uptime_ms */
135 ALIRO_ASSERT_E_ABSENT = -5, /* status != PRESENT */
136 ALIRO_ASSERT_E_RANGE = -6, /* distance_cm > threshold, or no range */
137 ALIRO_ASSERT_E_ALG = -7, /* unknown alg, or not the one being verified */
138 ALIRO_ASSERT_E_CREDENTIAL = -8, /* cred_id != enrolled credential */
139 /* Distance not backed by a good STS, or unknown range_flags bits. */
140 ALIRO_ASSERT_E_INTEGRITY = -9,
141};
142
143/* Frame length for an algorithm, or 0 if the algorithm is unknown. The alg byte
144 * sits at a fixed offset inside the signed prefix, so a stream scanner can read
145 * it at a candidate offset and learn how long the frame is before validating. */
146size_t aliro_assert_wire_len(uint8_t alg);
147
148/* Reads the alg byte of a candidate frame. buf must hold at least
149 * ALIRO_ASSERT_SIGNED_LEN bytes; returns 0 (an invalid alg) otherwise. */
150uint8_t aliro_assert_peek_alg(const uint8_t *buf, size_t len);
151
152/* cred_id = first 8 bytes of SHA-256(cred_pub[65]). Stable id for a credential
153 * public key so the verifier can bind presence to a specific enrolled
154 * credential. */
155void aliro_assert_cred_id(const uint8_t cred_pub[ALIRO_ASSERT_PUB_LEN],
156 uint8_t cred_id[ALIRO_ASSERT_CREDID_LEN]);
157
158/*
159 * Signing seam for the ECDSA-P256 mode.
160 *
161 * The curve arithmetic deliberately does NOT live in this module. Keeping the
162 * codec free of any crypto-backend dependency is what lets it stay portable
163 * C11, model-checkable and fuzzable, and it lets each caller bind whatever
164 * P-256 it already has -- on target that is aliro_ecdsa_p256_sign/verify from
165 * aliro_prim.h, in tests it is a double that can assert exactly which bytes
166 * were presented for signature.
167 *
168 * Both take the signed prefix as msg (never a pre-hash: the backend hashes
169 * internally, matching aliro_prim's ECDSA-P256-SHA256 contract) and return 0
170 * on success, non-zero on failure. sig is r||s, 32 bytes each.
171 */
172typedef int (*aliro_assert_sign_fn)(void *ctx, const uint8_t *msg, size_t msg_len,
173 uint8_t sig[ALIRO_ASSERT_SIG_LEN]);
174typedef int (*aliro_assert_verify_fn)(void *ctx, const uint8_t *msg, size_t msg_len,
175 const uint8_t sig[ALIRO_ASSERT_SIG_LEN]);
176
177/* Serialise an assertion and have sign() sign it, producing an
178 * ALIRO_ASSERT_WIRE_P256-byte frame. Returns 0 and sets *wire_len; -1 if
179 * wire_cap is too small, sign is NULL, or sign() fails. */
180int aliro_assert_build_p256(aliro_assert_sign_fn sign, void *ctx, const struct aliro_assert *a,
181 uint8_t *wire, size_t wire_cap, size_t *wire_len);
182
183/*
184 * Parse + fully verify a frame, delegating authentication to verify(). Checks,
185 * in order: length/magic/version, alg == ECDSA-P256, signature, nonce echo,
186 * enrolled credential id, forward-progress (uptime_ms > min_uptime_ms; pass 0
187 * to skip), status == PRESENT, distance_cm <= threshold_cm, then range
188 * integrity (known flag bits only, and STS_OK set).
189 *
190 * The integrity check is not optional and takes no parameter. A caller cannot
191 * ask for a distance whose measurement was never vouched for, because there is
192 * no threat model in which that answer is useful: the whole value of the
193 * assertion is that the number was measured rather than asserted.
194 *
195 * Returns ALIRO_ASSERT_OK (0) only when all pass; otherwise the first failing
196 * reason. A failed signature reports ALIRO_ASSERT_E_MAC, whatever the backend's
197 * own reason was: from the caller's side they all mean "not authentic".
198 *
199 * *out, when non-NULL, is populated with whatever parsed AFTER the signature
200 * check passed (for logging / cred-allowlist); it is left untouched if the
201 * signature or framing is bad, and must never be trusted for an unlock on a
202 * non-zero return. threshold_cm is inclusive.
203 */
204int aliro_assert_verify_p256(aliro_assert_verify_fn verify, void *ctx, const uint8_t *wire,
205 size_t wire_len, const uint8_t expected_nonce[ALIRO_ASSERT_NONCE_LEN],
206 const uint8_t expected_cred_id[ALIRO_ASSERT_CREDID_LEN],
207 uint16_t threshold_cm, uint64_t min_uptime_ms,
208 struct aliro_assert *out);
209
210#ifdef __cplusplus
211}
212#endif