Skip to content
Merged
68 changes: 34 additions & 34 deletions contracts/shapes.ttl
Original file line number Diff line number Diff line change
Expand Up @@ -1983,11 +1983,11 @@
] ;
sh:property [
sh:path <https://ont.paiml.dev/v1alpha1/model/parityReceipt> ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/ladder-green/node> ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/ladder-green/node/parityReceipt> ;
] ;
.

<https://ont.paiml.dev/v1alpha1/shape/ladder-green/node> a sh:NodeShape ;
<https://ont.paiml.dev/v1alpha1/shape/ladder-green/node/parityReceipt> a sh:NodeShape ;
sh:property [
sh:path <https://ont.paiml.dev/v1alpha1/model/cc> ;
sh:in ( "8.9" "12.1" ) ;
Expand Down Expand Up @@ -2067,11 +2067,11 @@
sh:maxCount 1 ;
sh:class <https://ont.paiml.dev/v1alpha1/parity/Comparator> ;
sh:nodeKind sh:IRI ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/parity-receipt-complete/node> ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/parity-receipt-complete/node/comparator> ;
] ;
.

<https://ont.paiml.dev/v1alpha1/shape/parity-receipt-complete/node> a sh:NodeShape ;
<https://ont.paiml.dev/v1alpha1/shape/parity-receipt-complete/node/comparator> a sh:NodeShape ;
sh:property [
sh:path <https://ont.paiml.dev/v1alpha1/parity/kind> ;
sh:minCount 1 ;
Expand All @@ -2084,11 +2084,11 @@
sh:targetClass <https://ont.paiml.dev/v1alpha1/parity/SelfComparedReceipt> ;
sh:property [
sh:path <https://ont.paiml.dev/v1alpha1/parity/comparator> ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/parity-comparator-self/node> ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/parity-comparator-self/node/comparator> ;
] ;
.

<https://ont.paiml.dev/v1alpha1/shape/parity-comparator-self/node> a sh:NodeShape ;
<https://ont.paiml.dev/v1alpha1/shape/parity-comparator-self/node/comparator> a sh:NodeShape ;
sh:property [
sh:path <https://ont.paiml.dev/v1alpha1/parity/reason> ;
sh:minCount 1 ;
Expand All @@ -2103,11 +2103,11 @@
sh:targetClass <https://ont.paiml.dev/v1alpha1/parity/OracleComparedReceipt> ;
sh:property [
sh:path <https://ont.paiml.dev/v1alpha1/parity/comparator> ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/parity-comparator-oracle/node> ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/parity-comparator-oracle/node/comparator> ;
] ;
.

<https://ont.paiml.dev/v1alpha1/shape/parity-comparator-oracle/node> a sh:NodeShape ;
<https://ont.paiml.dev/v1alpha1/shape/parity-comparator-oracle/node/comparator> a sh:NodeShape ;
sh:property [
sh:path <https://ont.paiml.dev/v1alpha1/parity/comparatorSha> ;
sh:minCount 1 ;
Expand Down Expand Up @@ -2194,7 +2194,7 @@
sh:minCount 1 ;
sh:maxCount 1 ;
sh:nodeKind sh:IRI ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.release/node> ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.release/node/dogfoodReceipt> ;
] ;
sh:property [
sh:path <https://ont.paiml.dev/v1alpha1/release/surface> ;
Expand All @@ -2217,7 +2217,7 @@
] ;
.

<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.release/node> a sh:NodeShape ;
<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.release/node/dogfoodReceipt> a sh:NodeShape ;
sh:property [
sh:path <https://ont.paiml.dev/v1alpha1/release/verdict> ;
sh:minCount 1 ;
Expand All @@ -2239,7 +2239,7 @@
sh:minCount 1 ;
sh:maxCount 1 ;
sh:nodeKind sh:IRI ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.host/node> ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.host/node/hostReceipt> ;
] ;
sh:property [
sh:path <https://ont.paiml.dev/v1alpha1/release/holds> ;
Expand All @@ -2257,7 +2257,7 @@
sh:path <https://ont.paiml.dev/v1alpha1/release/kernelReceipt> ;
sh:minCount 1 ;
sh:nodeKind sh:IRI ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.host/node> ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.host/node/kernelReceipt> ;
] ;
sh:property [
sh:path <https://ont.paiml.dev/v1alpha1/release/dispatchKernel> ;
Expand All @@ -2269,7 +2269,7 @@
] ;
.

<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.host/node> a sh:NodeShape ;
<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.host/node/hostReceipt> a sh:NodeShape ;
sh:property [
sh:path <https://ont.paiml.dev/v1alpha1/release/schema> ;
sh:minCount 1 ;
Expand All @@ -2283,7 +2283,7 @@
] ;
.

<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.host/node> a sh:NodeShape ;
<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.host/node/kernelReceipt> a sh:NodeShape ;
sh:property [
sh:path <https://ont.paiml.dev/v1alpha1/release/fresh> ;
sh:minCount 1 ;
Expand Down Expand Up @@ -2347,11 +2347,11 @@
sh:path <https://ont.paiml.dev/v1alpha1/release/row> ;
sh:minCount 1 ;
sh:nodeKind sh:IRI ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.tokenizer/node> ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.tokenizer/node/row> ;
] ;
.

<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.tokenizer/node> a sh:NodeShape ;
<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.tokenizer/node/row> a sh:NodeShape ;
sh:property [
sh:path <https://ont.paiml.dev/v1alpha1/release/verdict> ;
sh:minCount 1 ;
Expand Down Expand Up @@ -2392,11 +2392,11 @@
sh:path <https://ont.paiml.dev/v1alpha1/release/row> ;
sh:minCount 1 ;
sh:nodeKind sh:IRI ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.kernel/node> ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.kernel/node/row> ;
] ;
.

<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.kernel/node> a sh:NodeShape ;
<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.kernel/node/row> a sh:NodeShape ;
sh:property [
sh:path <https://ont.paiml.dev/v1alpha1/release/verdict> ;
sh:minCount 1 ;
Expand Down Expand Up @@ -2448,11 +2448,11 @@
sh:path <https://ont.paiml.dev/v1alpha1/release/cruxCell> ;
sh:minCount 1 ;
sh:nodeKind sh:IRI ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.crux/node> ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.crux/node/cruxCell> ;
] ;
.

<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.crux/node> a sh:NodeShape ;
<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.crux/node/cruxCell> a sh:NodeShape ;
sh:property [
sh:path <https://ont.paiml.dev/v1alpha1/release/verdict> ;
sh:minCount 1 ;
Expand Down Expand Up @@ -2511,11 +2511,11 @@
sh:minCount 1 ;
sh:maxCount 1 ;
sh:nodeKind sh:IRI ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.refusal/node> ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.refusal/node/row> ;
] ;
.

<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.refusal/node> a sh:NodeShape ;
<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.refusal/node/row> a sh:NodeShape ;
sh:property [
sh:path <https://ont.paiml.dev/v1alpha1/release/verdict> ;
sh:minCount 1 ;
Expand Down Expand Up @@ -2543,11 +2543,11 @@
sh:minCount 1 ;
sh:maxCount 1 ;
sh:nodeKind sh:IRI ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.probe/node> ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.probe/node/row> ;
] ;
.

<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.probe/node> a sh:NodeShape ;
<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.probe/node/row> a sh:NodeShape ;
sh:property [
sh:path <https://ont.paiml.dev/v1alpha1/release/verdict> ;
sh:minCount 1 ;
Expand All @@ -2569,11 +2569,11 @@
sh:minCount 1 ;
sh:maxCount 1 ;
sh:nodeKind sh:IRI ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.model-cell/node> ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.model-cell/node/row> ;
] ;
.

<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.model-cell/node> a sh:NodeShape ;
<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.model-cell/node/row> a sh:NodeShape ;
sh:property [
sh:path <https://ont.paiml.dev/v1alpha1/release/verdict> ;
sh:minCount 1 ;
Expand All @@ -2595,11 +2595,11 @@
sh:minCount 1 ;
sh:maxCount 1 ;
sh:nodeKind sh:IRI ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.effect-cell/node> ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.effect-cell/node/row> ;
] ;
.

<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.effect-cell/node> a sh:NodeShape ;
<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1.effect-cell/node/row> a sh:NodeShape ;
sh:property [
sh:path <https://ont.paiml.dev/v1alpha1/release/verdict> ;
sh:minCount 1 ;
Expand All @@ -2626,11 +2626,11 @@
sh:minCount 1 ;
sh:maxCount 1 ;
sh:nodeKind sh:IRI ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1/node> ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1/node/row> ;
] ;
.

<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1/node> a sh:NodeShape ;
<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v1/node/row> a sh:NodeShape ;
sh:property [
sh:path <https://ont.paiml.dev/v1alpha1/release/verdict> ;
sh:minCount 1 ;
Expand Down Expand Up @@ -2721,7 +2721,7 @@
sh:minCount 1 ;
sh:class <https://ont.paiml.dev/v1alpha1/release/KernelParityCell> ;
sh:nodeKind sh:IRI ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v2.model/node> ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v2.model/node/usesKernel> ;
] ;
sh:property [
sh:path <https://ont.paiml.dev/v1alpha1/release/unregisteredQtype> ;
Expand All @@ -2733,7 +2733,7 @@
] ;
.

<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v2.model/node> a sh:NodeShape ;
<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v2.model/node/usesKernel> a sh:NodeShape ;
sh:property [
sh:path <https://ont.paiml.dev/v1alpha1/release/verdict> ;
sh:minCount 1 ;
Expand Down Expand Up @@ -2768,11 +2768,11 @@
sh:maxCount 1 ;
sh:class <https://ont.paiml.dev/v1alpha1/release/SmokeCell> ;
sh:nodeKind sh:IRI ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v2.model-smoke/node> ;
sh:node <https://ont.paiml.dev/v1alpha1/shape/release-readiness-v2.model-smoke/node/smoke> ;
] ;
.

<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v2.model-smoke/node> a sh:NodeShape ;
<https://ont.paiml.dev/v1alpha1/shape/release-readiness-v2.model-smoke/node/smoke> a sh:NodeShape ;
sh:property [
sh:path <https://ont.paiml.dev/v1alpha1/release/verdict> ;
sh:minCount 1 ;
Expand Down
121 changes: 101 additions & 20 deletions crates/apr-cli/src/commands/serve/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;

/// Digest held by [`AuthGate::deny_all`]: all zeros, which no SHA-256 preimage
/// is known to produce, so no bearer token matches it.
const DENY_ALL_HASH: [u8; 32] = [0u8; 32];

/// Configured authentication state for an `apr serve` instance.
///
/// `expected_hash == None` means auth is disabled — every request passes.
Expand Down Expand Up @@ -46,31 +50,60 @@ impl AuthGate {
Self::from_hash(sha256_32(key.as_bytes()))
}

/// A gate that is enabled and rejects every request. Fail-closed stand-in
/// for a configuration that was set but unusable (#5479): the digest is
/// not reachable from any bearer token.
#[must_use]
pub fn deny_all() -> Self {
Self {
expected_hash: Some(DENY_ALL_HASH),
}
}

/// Pure core of [`Self::try_from_env`]. An empty value counts as unset.
/// A non-empty `APR_API_KEY_HASH` must be 64 hex chars, else `Err` — a
/// plaintext key never papers over a malformed hash.
///
/// # Errors
///
/// Returns the reason when `hash` is set, non-empty and not a SHA-256 hex digest.
pub fn from_values(hash: Option<&str>, plain: Option<&str>) -> Result<Self, &'static str> {
if let Some(hex) = hash.filter(|h| !h.is_empty()) {
return decode_hex_32(hex).map(Self::from_hash);
}
match plain.filter(|p| !p.is_empty()) {
Some(plain) => Ok(Self::from_plain_key(plain)),
None => Ok(Self::disabled()),
}
}

/// Read configuration from env. `APR_API_KEY_HASH` (hex-encoded
/// SHA-256) wins over `APR_API_KEY` (plaintext, hashed in-process).
/// Neither set → disabled with a single stderr warning.
///
/// # Errors
///
/// A set, non-empty, malformed `APR_API_KEY_HASH` refuses to start.
pub fn try_from_env() -> Result<Self, &'static str> {
let hash = std::env::var("APR_API_KEY_HASH").ok();
let plain = std::env::var("APR_API_KEY").ok();
let gate = Self::from_values(hash.as_deref(), plain.as_deref())?;
if !gate.is_enabled() {
eprintln!(
"[apr serve] WARNING: no APR_API_KEY or APR_API_KEY_HASH set; HTTP routes are unauthenticated",
);
}
Ok(gate)
}

/// [`Self::try_from_env`] for callers that cannot return an error: a
/// malformed `APR_API_KEY_HASH` yields [`Self::deny_all`], never an open gate.
#[must_use]
pub fn from_env() -> Self {
if let Ok(hex) = std::env::var("APR_API_KEY_HASH") {
match decode_hex_32(&hex) {
Ok(bytes) => return Self::from_hash(bytes),
Err(reason) => {
eprintln!(
"[apr serve] APR_API_KEY_HASH set but {reason}; ignoring (auth disabled)",
);
return Self::disabled();
}
}
}
if let Ok(plain) = std::env::var("APR_API_KEY") {
if !plain.is_empty() {
return Self::from_plain_key(&plain);
}
}
eprintln!(
"[apr serve] WARNING: no APR_API_KEY or APR_API_KEY_HASH set; HTTP routes are unauthenticated",
);
Self::disabled()
Self::try_from_env().unwrap_or_else(|reason| {
eprintln!("[apr serve] {reason}; rejecting all requests (fail closed)");
Self::deny_all()
})
}

/// True iff the gate has a configured hash.
Expand Down Expand Up @@ -251,4 +284,52 @@ mod tests {
bad.replace_range(0..1, "Z");
assert!(decode_hex_32(&bad).is_err());
}

// #5479: a bad APR_API_KEY_HASH must never yield an open gate.
#[test]
fn from_values_malformed_hash_is_an_error_not_a_disabled_gate() {
for bad in ["zz", "abc", &"g".repeat(64), &"a".repeat(63), " "] {
assert!(
AuthGate::from_values(Some(bad), None).is_err(),
"malformed hash {bad:?} must be refused"
);
assert!(
AuthGate::from_values(Some(bad), Some("secret")).is_err(),
"a plaintext key must not paper over malformed hash {bad:?}"
);
}
}

#[test]
fn from_values_empty_hash_is_unset_so_the_plain_key_applies() {
let g = AuthGate::from_values(Some(""), Some("secret")).expect("empty hash is unset");
assert!(g.is_enabled());
assert!(g.check_bearer(Some("Bearer secret")));
assert!(!g.check_bearer(None));
}

#[test]
fn from_values_valid_hash_wins_over_plain_key() {
let hex = "a".repeat(64);
let g = AuthGate::from_values(Some(&hex), Some("secret")).expect("valid hash");
assert!(g.is_enabled());
assert!(!g.check_bearer(Some("Bearer secret")));
}

#[test]
fn from_values_neither_set_is_disabled() {
assert!(!AuthGate::from_values(None, None).expect("ok").is_enabled());
assert!(!AuthGate::from_values(Some(""), Some(""))
.expect("ok")
.is_enabled());
}

#[test]
fn deny_all_gate_rejects_every_request() {
let g = AuthGate::deny_all();
assert!(g.is_enabled());
for h in [None, Some("Bearer "), Some("Bearer x"), Some("garbage")] {
assert!(!g.check_bearer(h));
}
}
}
Loading
Loading