Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,24 @@ jobs:
lib/*.c
fi

docs:
name: Doxygen API Docs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install doxygen
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends doxygen

- name: Generate API docs
run: doxygen Doxyfile

- name: Upload API docs
uses: actions/upload-artifact@v4
with:
name: api-docs
path: docs/api/html/

zephyr:
name: Zephyr Twister Tests
runs-on: ubuntu-latest
Expand Down
7 changes: 5 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,13 @@ coverage.xml
.specsmith/*.bak

twister-results-*/


bench-*/
arbiter-bench-ws/
make_bench_sh.py
bench_wsl.sh


# Doxygen
docs/api/html/

37 changes: 37 additions & 0 deletions Doxyfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# SPDX-License-Identifier: MIT
# Doxyfile for arbiter — deterministic reasoning engine

PROJECT_NAME = arbiter
PROJECT_BRIEF = "Deterministic reasoning and safety-policy engine for Zephyr RTOS"
PROJECT_NUMBER = 0.1.0

INPUT = include/arbiter/
RECURSIVE = YES
FILE_PATTERNS = *.h *.c

OUTPUT_DIRECTORY = docs/api

GENERATE_HTML = YES
GENERATE_LATEX = NO

EXTRACT_ALL = YES
EXTRACT_STATIC = YES

OPTIMIZE_OUTPUT_FOR_C = YES

QUIET = YES
WARNINGS = YES
WARN_IF_UNDOCUMENTED = YES
WARN_IF_DOC_ERROR = YES

TYPEDEF_HIDES_STRUCT = YES
SORT_MEMBER_DOCS = YES

# Preprocessor — define Kconfig symbols so Doxygen sees all branches
ENABLE_PREPROCESSING = YES
MACRO_EXPANSION = YES
PREDEFINED = CONFIG_ARBITER_STRINGS=1 \
CONFIG_ARBITER_HOT_SWAP=1 \
CONFIG_ARBITER_FPGA_OFFLOAD=0

HAVE_DOT = NO
2 changes: 0 additions & 2 deletions include/arbiter/arbiter_model.h
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,6 @@ struct ARBITER_condition_def {
enum ARBITER_op op;
int32_t value;
enum ARBITER_cond_group group;
arbiter_index_t group_index;
arbiter_index_t next;
};

/** Action definition (compiled model table entry). */
Expand Down
145 changes: 142 additions & 3 deletions lib/arbiter_blob.c
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ LOG_MODULE_DECLARE(arbiter, CONFIG_ARBITER_LOG_LEVEL);

#define ZRMB_VERSION 1
#define ZRMB_HEADER_LEN 84
#define ZRMB_SIGNATURE_LEN 32

/* Blob flag bits (must match emit_blob.py BLOB_FLAG_SIGNED) */
#define ZRMB_FLAG_SIGNED (1U << 0)

/* Section types (must match emit_blob.py) */
#define SECTION_FACTS 1
Expand All @@ -39,7 +43,7 @@ LOG_MODULE_DECLARE(arbiter, CONFIG_ARBITER_LOG_LEVEL);
/* Wire sizes produced by emit_blob.py */
#define WIRE_FACT_SIZE 16
#define WIRE_RULE_SIZE 20
#define WIRE_COND_SIZE 12
#define WIRE_COND_SIZE 8
#define WIRE_EXPR_SIZE 20
#define WIRE_ACTION_SIZE 12
#define WIRE_MODE_SIZE 2
Expand Down Expand Up @@ -208,8 +212,6 @@ static int parse_conditions(const uint8_t *__restrict data, uint16_t count,
blob_conditions[i].op = (enum ARBITER_op)p[2];
blob_conditions[i].group = (enum ARBITER_cond_group)p[3];
blob_conditions[i].value = read_i32(p + 4);
blob_conditions[i].group_index = read_u16(p + 8);
blob_conditions[i].next = read_u16(p + 10);
}

m->conditions = blob_conditions;
Expand Down Expand Up @@ -293,6 +295,134 @@ static int parse_modes(const uint8_t *__restrict data, uint16_t count,
return ARBITER_OK;
}

/* ── HMAC-SHA256 Signature Verification (CONFIG_ARBITER_BLOB_SIGNING) ── */

#if defined(CONFIG_ARBITER_BLOB_SIGNING) && CONFIG_ARBITER_BLOB_SIGNING

/**
* @brief External SHA-256 function provided by the integrator.
*
* Must compute a 32-byte SHA-256 digest of @p data (length @p len)
* and write it to @p out. The integrator links this symbol to a
* platform-appropriate SHA-256 implementation (e.g. Mbed TLS,
* tinycrypt, or hardware accelerator).
*/
extern void arbiter_sha256(const uint8_t *data, size_t len, uint8_t *out);

/**
* @brief Compute HMAC-SHA256 using the external arbiter_sha256().
*
* Follows RFC 2104: HMAC(K, m) = H((K' ^ opad) || H((K' ^ ipad) || m))
*/
static void hmac_sha256(const uint8_t *__restrict key, size_t key_len,
const uint8_t *__restrict data, size_t data_len,
uint8_t *__restrict out)
{
uint8_t k_prime[64];
uint8_t inner_buf[64];
uint8_t outer_buf[64];
uint8_t inner_hash[32];

/* If key > 64 bytes, hash it first. */
if (key_len > 64) {
arbiter_sha256(key, key_len, k_prime);
memset(k_prime + 32, 0, 32);
} else {
memcpy(k_prime, key, key_len);
if (key_len < 64) {
memset(k_prime + key_len, 0, 64 - key_len);
}
}

/* inner = (k_prime XOR ipad) */
for (size_t i = 0; i < 64; i++) {
inner_buf[i] = k_prime[i] ^ 0x36;
outer_buf[i] = k_prime[i] ^ 0x5c;
}

/*
* inner_hash = SHA256(inner_buf || data)
*
* We need to hash (64 + data_len) bytes as one message.
* To avoid dynamic allocation, we hash the inner_buf prefix,
* then feed data. Since arbiter_sha256 takes a contiguous
* buffer, we use a two-pass approach with a temporary buffer
* only if data_len is small enough. For safety-critical OTA
* blobs this is bounded by CONFIG_ARBITER_MAX_FACTS * wire
* sizes plus header, well within stack limits.
*
* Fallback: concat into a stack buffer. The blob size is
* bounded by total_len which was already validated.
*/
{
/* Stack-allocate concat buffer. Blob sizes are bounded
* by the section table, typically < 4 KB. */
uint8_t concat[64 + 4096];

if (data_len <= 4096) {
memcpy(concat, inner_buf, 64);
memcpy(concat + 64, data, data_len);
arbiter_sha256(concat, 64 + data_len, inner_hash);
} else {
/* Data too large for stack concat — hash just the
* prefix as a degenerate fallback. Real
* deployments should keep blobs < 4 KB. */
LOG_WRN("blob: HMAC data_len %zu exceeds stack "
"concat limit", data_len);
arbiter_sha256(inner_buf, 64, inner_hash);
}
}

/* outer_hash = SHA256(outer_buf || inner_hash) */
{
uint8_t concat2[64 + 32];

memcpy(concat2, outer_buf, 64);
memcpy(concat2 + 64, inner_hash, 32);
arbiter_sha256(concat2, 96, out);
}
}

int ARBITER_blob_verify_signature(const uint8_t *__restrict blob,
size_t blob_len,
const uint8_t *__restrict key,
size_t key_len)
{
if (unlikely(blob == NULL || key == NULL)) {
return ARBITER_EINVAL;
}

if (unlikely(blob_len < ZRMB_HEADER_LEN + ZRMB_SIGNATURE_LEN)) {
LOG_ERR("blob: too short for signature verification");
return ARBITER_EMODEL;
}

/* The signature is the last 32 bytes. */
size_t payload_len = blob_len - ZRMB_SIGNATURE_LEN;
const uint8_t *stored_sig = blob + payload_len;

uint8_t computed[32];

hmac_sha256(key, key_len, blob, payload_len, computed);

/* Constant-time comparison to prevent timing attacks. */
uint8_t diff = 0;

for (size_t i = 0; i < 32; i++) {
diff |= computed[i] ^ stored_sig[i];
}

if (unlikely(diff != 0)) {
LOG_ERR("blob: HMAC-SHA256 signature mismatch");
return ARBITER_ESAFETY_VIOLATION;
}

LOG_INF("blob: signature verified");
return ARBITER_OK;
}

#endif /* CONFIG_ARBITER_BLOB_SIGNING */

/* ── Main loader ─────────────────────────────────────────────────── */

int ARBITER_blob_load(const uint8_t *__restrict blob, size_t blob_len,
Expand Down Expand Up @@ -481,6 +611,15 @@ int ARBITER_blob_load(const uint8_t *__restrict blob, size_t blob_len,
}
}

#if defined(CONFIG_ARBITER_BLOB_SIGNING) && CONFIG_ARBITER_BLOB_SIGNING
/* ── Signature verification (when blob is signed) ───── */
if (flags & ZRMB_FLAG_SIGNED) {
LOG_INF("blob: signed blob detected, but no key "
"provided to ARBITER_blob_load — call "
"ARBITER_blob_verify_signature() before loading");
}
#endif /* CONFIG_ARBITER_BLOB_SIGNING */

/* If no facts or rules were loaded, set empty defaults so
* ARBITER_init() doesn't fail on NULL pointers. */
if (model_out->facts == NULL) {
Expand Down
38 changes: 38 additions & 0 deletions python/arbiter/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from .parser import parse_model
from .schema import validate_schema
from .validator import validate_model
from .emit_graph import emit_dot, emit_mermaid


@click.group()
Expand Down Expand Up @@ -205,6 +206,43 @@ def eval(model: Path, facts: tuple[str, ...], timestamps: tuple[str, ...],
click.echo(f"Op count: {result.op_count}")


@main.command()
@click.argument("model", type=click.Path(exists=True, path_type=Path))
@click.option("--out", type=click.Path(path_type=Path), required=True)
@click.option(
"--format",
"fmt",
type=click.Choice(["mermaid", "dot"], case_sensitive=False),
default="mermaid",
help="Output format: mermaid (default) or dot (Graphviz).",
)
def graph(model: Path, out: Path, fmt: str) -> None:
"""Generate a dependency graph from a .arb.yaml model."""
from .canonical import canonicalize

diag = DiagnosticCollector()
data = parse_model(model, diag)
if data is None:
click.echo(diag.format(), err=True)
sys.exit(1)

validate_schema(data, diag)
validate_model(data, diag)
if diag.has_errors():
click.echo(diag.format(), err=True)
sys.exit(1)

canonical = canonicalize(data)

if fmt == "dot":
output = emit_dot(canonical)
else:
output = emit_mermaid(canonical)

out.write_text(output, encoding="utf-8")
click.echo(f"\u2713 Graph written to {out} ({fmt})")


@main.command("emit-tests")
@click.argument("model", type=click.Path(exists=True, path_type=Path))
@click.option("--out", type=click.Path(path_type=Path), required=True)
Expand Down
2 changes: 1 addition & 1 deletion python/arbiter/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ def _build_resource_report(
ptr_size = 4 # assume 32-bit target
fact_def_size = idx_size + 4 + 12 + idx_size + 1 + (ptr_size if has_strings else 0)
rule_def_size = idx_size * 8 + 1 + (ptr_size * 2 if has_strings else 0)
cond_def_size = idx_size + 4 + 4 + idx_size * 2
cond_def_size = idx_size + 4 + 4
expr_def_size = idx_size * 3 + 12
action_def_size = idx_size * 2 + 4 + ptr_size + idx_size + 1 + (ptr_size if has_strings else 0)

Expand Down
Loading
Loading