diff --git a/crates/auths-cli/src/cli.rs b/crates/auths-cli/src/cli.rs index 1c8b4d26..7fd75883 100644 --- a/crates/auths-cli/src/cli.rs +++ b/crates/auths-cli/src/cli.rs @@ -26,6 +26,7 @@ use crate::commands::error_lookup::ErrorLookupCommand; use crate::commands::id::IdCommand; use crate::commands::init::InitCommand; use crate::commands::ipex::IpexCommand; +use crate::commands::keri_emit::KeriEmitCommand; use crate::commands::key::KeyCommand; use crate::commands::key_state::KeyStateCommand; use crate::commands::learn::LearnCommand; @@ -129,6 +130,8 @@ pub enum RootCommand { KeyState(KeyStateCommand), #[command(hide = true, name = "did-webs")] DidWebs(DidWebsCommand), + #[command(hide = true, name = "keri-emit")] + KeriEmit(KeriEmitCommand), #[command(hide = true, name = "tls-cert")] TlsCert(TlsCertCommand), #[command(hide = true)] diff --git a/crates/auths-cli/src/commands/keri_emit.rs b/crates/auths-cli/src/commands/keri_emit.rs new file mode 100644 index 00000000..8c451106 --- /dev/null +++ b/crates/auths-cli/src/commands/keri_emit.rs @@ -0,0 +1,128 @@ +//! `auths keri-emit` — emit a raw KERI event (JSON) from deterministic inputs. +//! +//! A hidden interop surface, like `did-webs` / `key-state`: it takes fixed, +//! caller-supplied inputs (keys, delegator, seals) and prints the canonical event +//! JSON the auths KERI builders produce, so the conformance suite can diff it +//! byte-for-byte against the keripy reference (`eventing.incept(delpre=...)` / +//! `eventing.interact(data=[seal])`). It builds nothing new — it calls the same +//! `auths_keri` finalizers the real delegation path uses. +//! +//! It never touches a KEL or the keychain; it is a pure event serializer. + +use anyhow::{Result, anyhow}; +use auths_keri::{ + CesrKey, DipEvent, DipEventInit, Event, IxnEvent, KeriSequence, Prefix, Said, Seal, Threshold, + VersionString, finalize_dip_event, finalize_ixn_event, +}; +use clap::Parser; + +use crate::config::CliConfig; + +/// Emit a raw KERI event as canonical JSON (interop / conformance surface). +#[derive(Parser, Debug, Clone)] +#[command( + about = "Emit a raw KERI event (dip/ixn) as JSON from deterministic inputs (interop surface)", + after_help = "Examples: + auths keri-emit dip --key DA... --delegator EAbc... [--next EN...] + auths keri-emit ixn --pre EAbc... --sn 1 --prev EPrev... --seal-digest EDev..." +)] +pub struct KeriEmitCommand { + #[command(subcommand)] + pub kind: KeriEmitKind, +} + +/// Which event to emit. +#[derive(clap::Subcommand, Debug, Clone)] +pub enum KeriEmitKind { + /// Delegated inception (`dip`): the delegate self-signs; `di` names the delegator. + Dip(DipArgs), + /// Interaction (`ixn`): anchors one seal in the KEL (e.g. a delegator-side revocation). + Ixn(IxnArgs), +} + +/// Inputs for a delegated inception. +#[derive(Parser, Debug, Clone)] +pub struct DipArgs { + /// The delegate's current signing key, CESR-encoded (qb64) — the same form keripy's `verfer.qb64`. + #[clap(long, value_name = "CESR")] + pub key: String, + /// The delegator's AID prefix (becomes the dip's `di`). + #[clap(long, value_name = "PREFIX")] + pub delegator: String, + /// Optional next-key commitment (pre-rotation digest). Absent → `nt=0`, `n=[]`. + #[clap(long, value_name = "SAID")] + pub next: Option, +} + +/// Inputs for an interaction event. +#[derive(Parser, Debug, Clone)] +pub struct IxnArgs { + /// The AID prefix authoring the interaction (the delegator, for a revocation). + #[clap(long, value_name = "PREFIX")] + pub pre: String, + /// Sequence number of this interaction. + #[clap(long)] + pub sn: u64, + /// Prior event SAID (`p`). + #[clap(long, value_name = "SAID")] + pub prev: String, + /// A digest seal `{d}` to anchor (auths's delegator-side revocation marker: the device prefix). + #[clap(long, value_name = "SAID")] + pub seal_digest: String, +} + +impl KeriEmitCommand { + /// Build the requested event, finalize its SAID, and print the canonical JSON. + pub fn execute(&self, _ctx: &CliConfig) -> Result<()> { + let json = match &self.kind { + KeriEmitKind::Dip(args) => emit_dip(args)?, + KeriEmitKind::Ixn(args) => emit_ixn(args)?, + }; + println!("{json}"); + Ok(()) + } +} + +/// Build + finalize a delegated inception and return its canonical JSON. +fn emit_dip(args: &DipArgs) -> Result { + let (nt, n) = match &args.next { + Some(next) => ( + Threshold::Simple(1), + vec![Said::new_unchecked(next.clone())], + ), + None => (Threshold::Simple(0), vec![]), + }; + let dip = finalize_dip_event(DipEvent::new(DipEventInit { + v: VersionString::placeholder(), + d: Said::default(), + i: Prefix::default(), + s: KeriSequence::new(0), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(args.key.clone())], + nt, + n, + bt: Threshold::Simple(0), + b: vec![], + c: vec![], + a: vec![], + di: Prefix::new_unchecked(args.delegator.clone()), + })) + .map_err(|e| anyhow!("finalize dip: {e}"))?; + serde_json::to_string(&Event::Dip(dip)).map_err(|e| anyhow!("serialize dip: {e}")) +} + +/// Build + finalize an interaction anchoring a single digest seal; return its canonical JSON. +fn emit_ixn(args: &IxnArgs) -> Result { + let ixn = finalize_ixn_event(IxnEvent { + v: VersionString::placeholder(), + d: Said::default(), + i: Prefix::new_unchecked(args.pre.clone()), + s: KeriSequence::new(args.sn as u128), + p: Said::new_unchecked(args.prev.clone()), + a: vec![Seal::Digest { + d: Said::new_unchecked(args.seal_digest.clone()), + }], + }) + .map_err(|e| anyhow!("finalize ixn: {e}"))?; + serde_json::to_string(&Event::Ixn(ixn)).map_err(|e| anyhow!("serialize ixn: {e}")) +} diff --git a/crates/auths-cli/src/commands/mod.rs b/crates/auths-cli/src/commands/mod.rs index 8c6f4923..c976a1cd 100644 --- a/crates/auths-cli/src/commands/mod.rs +++ b/crates/auths-cli/src/commands/mod.rs @@ -26,6 +26,7 @@ pub mod id; pub mod index; pub mod init; pub mod ipex; +pub mod keri_emit; pub mod key; pub mod key_detect; pub mod key_state; diff --git a/crates/auths-cli/src/main.rs b/crates/auths-cli/src/main.rs index e5bc1155..ecb05fe8 100644 --- a/crates/auths-cli/src/main.rs +++ b/crates/auths-cli/src/main.rs @@ -106,6 +106,7 @@ fn run() -> Result<()> { RootCommand::Key(cmd) => cmd.execute(&ctx), RootCommand::KeyState(cmd) => cmd.execute(&ctx), RootCommand::DidWebs(cmd) => cmd.execute(&ctx), + RootCommand::KeriEmit(cmd) => cmd.execute(&ctx), RootCommand::TlsCert(cmd) => cmd.execute(&ctx), RootCommand::Oobi(cmd) => cmd.execute(&ctx), RootCommand::Ipex(cmd) => cmd.execute(&ctx), diff --git a/tests/conformance/oracle.py b/tests/conformance/oracle.py index fccb3430..56f6ec29 100644 --- a/tests/conformance/oracle.py +++ b/tests/conformance/oracle.py @@ -276,3 +276,32 @@ def ipex_admit(sender: str, recipient: str, *, dt: str = ACDC_DT) -> dict: date=dt, ) return json.loads(admit.raw.decode()) + + +# ── Surfaces 7 & 8: delegated inception + delegator-side revocation ixn ─────── +def dip(delegator_pre: str, key_qb64: str, *, next_said: str | None = None) -> dict: + """keripy delegated inception (`dip`) for `key_qb64` under `delegator_pre`. + + Mirrors auths `keri-emit dip`: `eventing.incept(keys=[key], delpre=delegator, + ndigs=[next] or None, code=Blake3_256)`. The delegated AID (`d == i`) and every + field are keripy's own — a byte match proves auths's delegated AID is exactly + what a keripy verifier would compute for the same inputs. + """ + srdr = eventing.incept( + keys=[key_qb64], + delpre=delegator_pre, + ndigs=[next_said] if next_said else None, + code=MtrDex.Blake3_256, + ) + return json.loads(srdr.raw.decode()) + + +def ixn_digest_seal(pre: str, sn: int, prev: str, seal_digest: str) -> dict: + """keripy interaction (`ixn`) anchoring one digest seal `{"d": seal_digest}`. + + Mirrors auths `keri-emit ixn --seal-digest` — auths's delegator-side revocation + marker (a single-author root ixn anchoring the revoked device's prefix as a + digest seal): `eventing.interact(pre, dig=prev, sn=sn, data=[{"d": seal_digest}])`. + """ + srdr = eventing.interact(pre=pre, dig=prev, sn=sn, data=[{"d": seal_digest}]) + return json.loads(srdr.raw.decode()) diff --git a/tests/conformance/test_keripy_conformance.py b/tests/conformance/test_keripy_conformance.py index 958b80ab..a2735acd 100644 --- a/tests/conformance/test_keripy_conformance.py +++ b/tests/conformance/test_keripy_conformance.py @@ -227,3 +227,81 @@ def test_ipex_admit(run_auths, write_json): assert canon(out) == canon(load_vector("ipex-admit.json")) # The prior must thread the grant's SAID (the IPEX loop-closing detail). assert out["p"] == json.loads(grant_res.stdout)["d"] + + +# ── Surface 7: delegated inception (dip) — the Workstream A interop claim ───── +def test_dip_delegated_inception(run_auths): + """auths `keri-emit dip` == keripy delegated inception (byte-for-byte AID). + + The interop claim for the device-delegation model: a delegate auths incepts as + a delegated identifier must compute the SAME delegated AID (`d == i`) keripy + would, or a keripy verifier would reject it. Delegate key = the deterministic + ed2 key; delegator = the ed AID. + """ + delegator = o.icp_ed().pre + key = o.ed2_verfer().qb64 + + out = run_auths(["keri-emit", "dip", "--key", key, "--delegator", delegator]).json + expected = o.dip(delegator, key) + + assert canon(out) == canon(expected), ( + f"\n auths : {canon(out)}\n keripy: {canon(expected)}" + ) + assert out["d"] == out["i"], "a delegated AID is self-addressing (d == i)" + assert out["di"] == delegator, "di names the delegator" + + +# ── Surface 8: delegator-side revocation ixn ───────────────────────────────── +def test_revocation_ixn_is_keripy_parseable(run_auths): + """auths `keri-emit ixn --seal-digest` == keripy interact with a digest seal. + + auths revokes a lost device delegator-side: a single-author root `ixn` + anchoring the device's prefix as a digest seal. This asserts the EVENT is + byte-identical to keripy's `interact(data=[{"d": ...}])` — i.e. a keripy + verifier can parse/replay it. Whether keripy *interprets* that digest seal as a + device revocation is a separate protocol-semantics question (see the interop + findings write-up) — this surface only proves the wire event conforms. + """ + icp = o.icp_ed() + pre, prev = icp.pre, icp.said + device_prefix = o.dip(pre, o.ed2_verfer().qb64)["i"] + + out = run_auths( + [ + "keri-emit", "ixn", + "--pre", pre, + "--sn", "1", + "--prev", prev, + "--seal-digest", device_prefix, + ] + ).json + expected = o.ixn_digest_seal(pre, 1, prev, device_prefix) + + assert canon(out) == canon(expected), ( + f"\n auths : {canon(out)}\n keripy: {canon(expected)}" + ) + assert out["a"] == [{"d": device_prefix}], "the ixn anchors the device-prefix digest seal" + + +# ── Surface 7b: delegated inception WITH pre-rotation (auths's real dip shape) ─ +def test_dip_with_pre_rotation(run_auths): + """auths dip with a next-key commitment (`nt=1`, `n=[…]`) == keripy. + + auths's real delegated inception carries pre-rotation (a next-key digest), so + this proves the *actual* shape — not just the bare form — computes the same + delegated AID keripy would. Any valid CESR digest serves as the commitment + (both sides embed it identically and SAID over it). + """ + delegator = o.icp_ed().pre + key = o.ed2_verfer().qb64 + nxt = o.icp_ed().said # a valid CESR Blake3 digest used as the next-key commitment + + out = run_auths( + ["keri-emit", "dip", "--key", key, "--delegator", delegator, "--next", nxt] + ).json + expected = o.dip(delegator, key, next_said=nxt) + + assert canon(out) == canon(expected), ( + f"\n auths : {canon(out)}\n keripy: {canon(expected)}" + ) + assert out["nt"] == "1" and out["n"] == [nxt], "pre-rotation: nt=1, n=[commitment]"