open-standards/wai/wai-rs/src/signer_id.rs
Transaction Science 72e3300752 wai: key discovery, signing authority and module revocation
jwp-receipts §7: a did:web signer_id names a web origin, which publishes
a key log at /.well-known/wai-keys: revisions signed by a root key that
pre-commits to its successor, listing the origin's keys, the authorities
each holds, and whether each is active, retired or revoked. A verifier
that has anchored the origin, pinned or fetched, confirms an accepted
receipt, claim or module grant against the log offline, and reports the
outcome beside the item's own verdict, never in it. Hardware-class
energy, lawful-basis and pre-emption authorities are confirmed only
under a pinned anchor. Revocation refuses every item under a revoked key.

§7.9 authorises decoder and reconstruct modules, and pinned parameter
sets, by SHA-256 digest through signed module grants from the sink's
module origins, revokes them by digest in the key log, and bounds how
long a revoked module keeps running by grant_max_s + status_max_age_s,
under ceilings of 86400 s and 3600 s. Every failure, offline included,
leaves the module not held and dispatch falls back.

wai-rs gains signer_id (ungated) and key_log (feature key_discovery),
the wai_signer_vectors and wai-keys binaries, and the signer-conformance
corpus with an independent Python verifier. The reference meters and
the world demo sign under labels. A section-reference guard checks that
every section SPEC.md or an extension cites in another of them exists.

This commit is the stream as merged onto link 14. The commits after it
reconcile it with the parameter-set pin and the energy reports.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 07:12:20 -04:00

389 lines
16 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! What a `signer_id` names — `extensions/jwp-receipts.md` §7.1.
//!
//! Every WAI receipt family that signs a `signer_pubkey` signs a `signer_id` beside it.
//! This module reads that string, and nothing else: it has no dependency, no I/O and no
//! clock, so every surface that shows a signer can say what its id is before any key log
//! is consulted (the confirmation itself is `crate::key_log`, feature `key_discovery`).
//!
//! A `signer_id` has one of three forms:
//!
//! - **origin-bound** — `did:web:<host>[%3A<port>]#<key-id>`: it names the web origin
//! whose key log, published at `<origin>/.well-known/wai-keys`, lists the signer's key;
//! - **Ed25519 `did:key`** — `did:key:z` and the base58btc encoding of `0xed 0x01` and a
//! 32-byte key, in its one canonical spelling: it names that key and nothing else;
//! - **label** — any other string. A verifier reads nothing from a label.
//!
//! Each origin and key-id has one spelling, so a signer cannot be confirmed under a
//! second spelling of an origin a verifier anchored: a capital letter in the host, an
//! escape other than `%3A`, a path, a missing fragment, the default port written out and
//! an IP address are not the origin-bound form.
/// Where an origin publishes its key log, after the origin (jwp-receipts §7.1).
pub const KEY_LOG_PATH: &str = "/.well-known/wai-keys";
/// The form of a `signer_id` (jwp-receipts §7.1).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SignerIdForm {
/// `did:web:<host>[%3A<port>]#<key-id>`: the web origin (`https://<host>[:<port>]`)
/// and the key-id its key log lists the key under.
OriginBound { origin: String, key_id: String },
/// An Ed25519 `did:key` in its canonical spelling, naming this key.
DidKey([u8; 32]),
/// Anything else: the signer's own label, from which a verifier reads nothing.
Label,
}
/// The level a confirmation reports beside an item's own verdict (jwp-receipts §7).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Level {
/// An origin the verifier anchored lists the item's key, in force, with its authority.
Confirmed,
/// What the verifier holds contradicts the item's claim to speak for its `signer_id`.
Refused,
/// The verifier holds nothing it can confirm against.
Unconfirmed,
}
impl Level {
pub fn label(self) -> &'static str {
match self {
Level::Confirmed => "confirmed",
Level::Refused => "refused",
Level::Unconfirmed => "unconfirmed",
}
}
}
/// Read a `signer_id` under §7.1's grammar.
pub fn classify(signer_id: &str) -> SignerIdForm {
if let Some(rest) = signer_id.strip_prefix("did:web:")
&& let Some((authority, key_id)) = rest.split_once('#')
&& key_id_ok(key_id)
{
let (host, port) = match authority.split_once("%3A") {
Some((h, p)) => (h, Some(p)),
None => (authority, None),
};
if host_ok(host) && port.is_none_or(port_ok) {
let origin = match port {
Some(p) => format!("https://{host}:{p}"),
None => format!("https://{host}"),
};
return SignerIdForm::OriginBound { origin, key_id: key_id.to_owned() };
}
return SignerIdForm::Label;
}
if let Some(enc) = signer_id.strip_prefix("did:key:z")
&& let Some(bytes) = b58_decode(enc)
&& bytes.len() == 34
&& bytes[0] == 0xed
&& bytes[1] == 0x01
{
let key: [u8; 32] = bytes[2..].try_into().expect("34 bytes less the two-byte prefix");
// One spelling per key: the text must be what encoding the key gives.
if did_key(&key) == signer_id {
return SignerIdForm::DidKey(key);
}
}
SignerIdForm::Label
}
/// The Ed25519 `did:key` of a key: `did:key:z` + base58btc(`0xed 0x01` || key).
pub fn did_key(key: &[u8; 32]) -> String {
format!("did:key:z{}", multibase_ed25519(key))
}
/// The base58btc text (without the multibase `z`) of `0xed 0x01` || key — the
/// `publicKeyMultibase` of an Ed25519 key, less its leading `z`.
pub fn multibase_ed25519(key: &[u8; 32]) -> String {
let mut b = Vec::with_capacity(34);
b.extend_from_slice(&[0xed, 0x01]);
b.extend_from_slice(key);
b58_encode(&b)
}
/// Read a `publicKeyMultibase` value holding an Ed25519 key (`z` + base58btc of
/// `0xed 0x01` || key), in its canonical spelling.
pub fn read_multibase_ed25519(text: &str) -> Option<[u8; 32]> {
let enc = text.strip_prefix('z')?;
let b = b58_decode(enc)?;
if b.len() != 34 || b[0] != 0xed || b[1] != 0x01 {
return None;
}
let key: [u8; 32] = b[2..].try_into().ok()?;
(multibase_ed25519(&key) == enc).then_some(key)
}
/// The key log's location for an origin of §7.1's form.
pub fn key_log_url(origin: &str) -> String {
format!("{origin}{KEY_LOG_PATH}")
}
/// The `did:web` DID (no fragment) of an origin of §7.1's form, or `None` for a string
/// that is not one: `https://host` → `did:web:host`, `https://host:8443` →
/// `did:web:host%3A8443`.
pub fn did_web_of_origin(origin: &str) -> Option<String> {
let rest = origin.strip_prefix("https://")?;
let (host, port) = match rest.split_once(':') {
Some((h, p)) => (h, Some(p)),
None => (rest, None),
};
if !host_ok(host) || !port.is_none_or(port_ok) {
return None;
}
Some(match port {
Some(p) => format!("did:web:{host}%3A{p}"),
None => format!("did:web:{host}"),
})
}
/// Whether `origin` is an origin of §7.1's form: `https://` and a host, and a port where
/// one other than 443 is written.
pub fn origin_ok(origin: &str) -> bool {
did_web_of_origin(origin).is_some()
}
/// §7.4 step 1: the origin and key-id of an origin-bound `signer_id`, or the level and
/// reason label at which the confirmation stops.
pub fn step1(signer_id: &str, signer_pubkey: &[u8; 32]) -> Result<(String, String), (Level, &'static str)> {
match classify(signer_id) {
SignerIdForm::OriginBound { origin, key_id } => Ok((origin, key_id)),
SignerIdForm::DidKey(k) if k != *signer_pubkey => Err((Level::Refused, "signer_id_names_another_key")),
SignerIdForm::DidKey(_) | SignerIdForm::Label => Err((Level::Unconfirmed, "signer_id_not_origin_bound")),
}
}
/// What a surface that holds no key log can report about a signer: its level and reason
/// after §7.4 step 1, with an origin-bound id stopping at step 2 (`origin_not_anchored`).
pub fn level_without_anchors(signer_id: &str, signer_pubkey: &[u8; 32]) -> (Level, &'static str) {
match step1(signer_id, signer_pubkey) {
Ok(_) => (Level::Unconfirmed, "origin_not_anchored"),
Err(e) => e,
}
}
/// A host of §7.1: dot-separated labels of 1–63 characters from `a-z`, `0-9` and `-`,
/// none beginning or ending with `-`, at least two labels, the last not all digits, at
/// most 253 characters in all.
fn host_ok(host: &str) -> bool {
let labels: Vec<&str> = host.split('.').collect();
host.len() <= 253
&& labels.len() >= 2
&& labels.iter().all(|l| {
(1..=63).contains(&l.len())
&& l.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
&& !l.starts_with('-')
&& !l.ends_with('-')
})
&& !labels.last().is_some_and(|l| l.bytes().all(|b| b.is_ascii_digit()))
}
/// A port of §7.1: 1–65535 in decimal, no leading zero, not 443.
fn port_ok(p: &str) -> bool {
!p.is_empty()
&& p.len() <= 5
&& p.bytes().all(|b| b.is_ascii_digit())
&& !p.starts_with('0')
&& p.parse::<u32>().is_ok_and(|n| (1..=65535).contains(&n) && n != 443)
}
/// A key-id of §7.1: 1–64 characters from `A-Z`, `a-z`, `0-9`, `-`, `_` and `.`.
pub fn key_id_ok(k: &str) -> bool {
(1..=64).contains(&k.len()) && k.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
}
const B58: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
/// The length of the base58btc text of `0xed 0x01` and a 32-byte key: every such value
/// lies between 58^46 and 58^47, so its one spelling has exactly this many characters.
/// A longer text is not that spelling, and is refused before any decoding: decoding is
/// quadratic in its input, and a `signer_id` arrives from whoever signed the item.
const ED25519_B58_LEN: usize = 47;
/// base58btc (the Bitcoin alphabet), each leading zero byte written as `1`.
fn b58_encode(input: &[u8]) -> String {
let zeros = input.iter().take_while(|&&b| b == 0).count();
let mut digits: Vec<u8> = Vec::new(); // base-58 digits, least significant first
for &byte in &input[zeros..] {
let mut carry = u32::from(byte);
for d in digits.iter_mut() {
carry += u32::from(*d) << 8;
*d = (carry % 58) as u8;
carry /= 58;
}
while carry > 0 {
digits.push((carry % 58) as u8);
carry /= 58;
}
}
let mut s = String::with_capacity(zeros + digits.len());
s.extend(std::iter::repeat_n('1', zeros));
s.extend(digits.iter().rev().map(|&d| B58[d as usize] as char));
s
}
/// The inverse of [`b58_encode`] for a text of at most [`ED25519_B58_LEN`] characters;
/// `None` for a longer text or a character outside the alphabet.
fn b58_decode(s: &str) -> Option<Vec<u8>> {
if s.len() > ED25519_B58_LEN {
return None;
}
let zeros = s.bytes().take_while(|&b| b == b'1').count();
let mut bytes: Vec<u8> = Vec::new(); // base-256, least significant first
for c in s.bytes().skip(zeros) {
let mut carry = B58.iter().position(|&a| a == c)? as u32;
for b in bytes.iter_mut() {
carry += u32::from(*b) * 58;
*b = (carry & 0xff) as u8;
carry >>= 8;
}
while carry > 0 {
bytes.push((carry & 0xff) as u8);
carry >>= 8;
}
}
let mut out = vec![0u8; zeros];
out.extend(bytes.iter().rev());
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn unhex32(s: &str) -> [u8; 32] {
let v: Vec<u8> = (0..64).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect();
v.try_into().unwrap()
}
#[test]
fn origin_bound_form_and_its_origin() {
assert_eq!(
classify("did:web:publisher.example#d1"),
SignerIdForm::OriginBound { origin: "https://publisher.example".into(), key_id: "d1".into() }
);
assert_eq!(
classify("did:web:publisher.example%3A8443#m_1.v-2"),
SignerIdForm::OriginBound { origin: "https://publisher.example:8443".into(), key_id: "m_1.v-2".into() }
);
assert_eq!(key_log_url("https://publisher.example"), "https://publisher.example/.well-known/wai-keys");
assert_eq!(did_web_of_origin("https://publisher.example:8443").as_deref(), Some("did:web:publisher.example%3A8443"));
assert_eq!(did_web_of_origin("https://publisher.example:443"), None);
}
/// Each origin has one spelling: every other spelling is a label, so it can never be
/// confirmed under an origin the verifier anchored.
#[test]
fn boundary_spellings() {
let long_id = "k".repeat(65);
let labels = [
"did:web:Publisher.example#d1",
"did:web:publisher.example:users:a#k",
"did:web:publisher.example%3a8443#d1",
"did:web:publisher.example%3A443#d1",
"did:web:publisher.example%3A0443#d1",
"did:web:publisher.example%3A65536#d1",
"did:web:publisher.example%3A#d1",
"did:web:publisher.example",
"did:web:publisher.example#",
"did:web:127.0.0.1#d1",
"did:web:-a.example#d1",
"did:web:a-.example#d1",
"did:web:publisher..example#d1",
"did:web:localhost#d1",
"did:web:publisher.example#a/b",
"did:web:publisher.example#a#b",
"did:web:publisher.example/#d1",
];
for l in labels {
assert_eq!(classify(l), SignerIdForm::Label, "{l}");
}
assert_eq!(classify(&format!("did:web:publisher.example#{long_id}")), SignerIdForm::Label);
assert!(matches!(classify(&format!("did:web:publisher.example#{}", &long_id[..64])), SignerIdForm::OriginBound { .. }));
assert!(matches!(classify("did:web:publisher.example%3A65535#d1"), SignerIdForm::OriginBound { .. }));
assert!(matches!(classify("did:web:a1.b2c.example#d1"), SignerIdForm::OriginBound { .. }));
}
/// Every DID-shaped id the tree's pins, tests and demos write is a label (or, for a
/// canonical `did:key`, names one key): none is origin-bound.
#[test]
fn tree_ids_are_labels() {
for id in [
"did:key:pin",
"did:key:demo-lab",
"did:key:z6Mk",
"did:key:test",
"did:key:wai-quantum-cli",
"did:web:pin#audio",
"did:web:wai#prov",
"did:web:meter",
"did:web:publisher.example",
"did:example:meter",
"did:example:wai-meter",
] {
assert_eq!(classify(id), SignerIdForm::Label, "{id}");
}
}
/// Cross-checked against literals another pack in the repository computed
/// (`jcp/conformance/v1/vectors.json`) and against keys from the claim corpus's seeds.
#[test]
fn did_key_literals() {
let cases = [
(
"8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c",
"did:key:z6Mkon3Necd6NkkyfoGoHxid2znGc59LU3K7mubaRcFbLfLX",
),
(
"8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394",
"did:key:z6Mko9hTggMwjSTEaJaPUfE6tqcy2xvU6BnNq3e3o8qVBiyH",
),
];
for (k, d) in cases {
let key = unhex32(k);
assert_eq!(did_key(&key), d);
assert_eq!(classify(d), SignerIdForm::DidKey(key));
assert_eq!(step1(d, &key), Err((Level::Unconfirmed, "signer_id_not_origin_bound")));
assert_eq!(step1(d, &[0u8; 32]), Err((Level::Refused, "signer_id_names_another_key")));
assert_eq!(read_multibase_ed25519(&d["did:key:".len()..]), Some(key));
}
}
/// A `did:key` that decodes to a key but is not that key's one spelling — an extra
/// leading `1` adds a zero byte — is a label.
#[test]
fn did_key_noncanonical_is_label() {
let key = unhex32("8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c");
let canonical = did_key(&key);
let extra = canonical.replacen("did:key:z", "did:key:z1", 1);
assert_eq!(classify(&extra), SignerIdForm::Label);
assert_eq!(classify("did:key:z0OIl"), SignerIdForm::Label);
assert_eq!(b58_decode("1112").unwrap(), vec![0, 0, 0, 1]);
assert_eq!(b58_encode(&[0, 0, 0, 1]), "1112");
}
/// An over-long `did:key` is a label, and is refused before it is decoded: a
/// `signer_id` is chosen by whoever signed the item, and decoding is quadratic.
#[test]
fn long_did_key_is_refused_before_decoding() {
assert!(b58_decode(&"2".repeat(ED25519_B58_LEN + 1)).is_none());
assert!(b58_decode(&"2".repeat(ED25519_B58_LEN)).is_some());
let key = unhex32("8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c");
assert_eq!(did_key(&key).len(), "did:key:z".len() + ED25519_B58_LEN);
let long = format!("did:key:z{}", "2".repeat(4 << 20));
let t = std::time::Instant::now();
assert_eq!(classify(&long), SignerIdForm::Label);
assert_eq!(read_multibase_ed25519(&long["did:key:".len()..]), None);
assert_eq!(level_without_anchors(&long, &key), (Level::Unconfirmed, "signer_id_not_origin_bound"));
assert!(t.elapsed() < std::time::Duration::from_secs(5), "{:?}", t.elapsed());
}
#[test]
fn level_without_anchors_stops_at_step_two() {
assert_eq!(
level_without_anchors("did:web:publisher.example#d1", &[0; 32]),
(Level::Unconfirmed, "origin_not_anchored")
);
assert_eq!(level_without_anchors("did:key:wai-quantum-cli", &[0; 32]), (Level::Unconfirmed, "signer_id_not_origin_bound"));
}
}