Challenge signatures and a push report for hosts 7aa2dc9369e3
oxy identity sign-challenge proves possession of a key to a hub (spec §14.3, Draft 0.8f). receive_push_report returns each accepted reference's old and new state and heads plus the changes stored; Backend::after_push gets it.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-27 17:54 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
7aa2dc9369e35c07efc32eacec61613a931b157476a074df7c99ff639953a87e- Depends on
- 05e71ed6cdf1 1983686fb7a7 28fc23d7706a 50a046e439e9 750251b21a96 75167672ebcd 83e0677b07fe 96f7610fa666 982864068a59 e02bf0cd0533 fce44ae9ad29 ff0af1ee299d
- Depended on by
- 767ac6cd0545 02e691881a4e 8e9c64d16ded cb8b8082fa73 2c9ddac3c387 ef26f9a28790 f8c695764c95 d42369abf180 eae595568832 84b9e9ac0ab4 76f752503e7e 119bbf50cb86
- In bookmarks
- agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
- In tags
- v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 31 atoms, 0 tree operations, 9 files
Browse the tree at this change
modified crates/oxy-core/src/identity.rs +57 −0
| … | 150 unchanged lines |
||
| 151 | 151 | Change, |
|
| 152 | 152 | Attest, |
|
| 153 | 153 | Policy, |
|
| 154 | + | /// A server-issued challenge, proving possession of a key to a host |
|
| 155 | + | /// (hub spec §5.3, §5.4). Never a stored object. |
|
| 156 | + | Challenge, |
|
| 154 | 157 | } |
|
| 155 | 158 | ||
| 156 | 159 | impl Domain { |
|
| … | 2 unchanged lines |
||
| 159 | 162 | Domain::Change => "change", |
|
| 160 | 163 | Domain::Attest => "attest", |
|
| 161 | 164 | Domain::Policy => "policy", |
|
| 165 | + | Domain::Challenge => "challenge", |
|
| 162 | 166 | } |
|
| 163 | 167 | } |
|
| 164 | 168 | } |
|
| … | 74 unchanged lines |
||
| 239 | 243 | Ed25519, |
|
| 240 | 244 | #[error("ML-DSA-65 signature invalid")] |
|
| 241 | 245 | MlDsa, |
|
| 246 | + | #[error("a challenge is at least 16 bytes")] |
|
| 247 | + | ShortChallenge, |
|
| 242 | 248 | } |
|
| 243 | 249 | ||
| 244 | 250 | /// Verify a composite signature by `identity` over `target` (spec §14.3). |
|
| … | 231 unchanged lines |
||
| 476 | 482 | assert_ne!(SecretKey::generate().ed_seed, SecretKey::generate().ed_seed); |
|
| 477 | 483 | } |
|
| 478 | 484 | } |
|
| 485 | + | ||
| 486 | + | /// The signing target of a challenge: `BLAKE3-256("oxy1 challenge " || bytes)`, |
|
| 487 | + | /// so a challenge can never collide with a stored object's reference. |
|
| 488 | + | pub fn challenge_target(challenge: &[u8]) -> ObjectRef { |
|
| 489 | + | let mut h = blake3::Hasher::new(); |
|
| 490 | + | h.update(b"oxy1 challenge "); |
|
| 491 | + | h.update(challenge); |
|
| 492 | + | ObjectRef(*h.finalize().as_bytes()) |
|
| 493 | + | } |
|
| 494 | + | ||
| 495 | + | impl SecretKey { |
|
| 496 | + | /// Sign a host's challenge (at least 16 bytes) with this key. |
|
| 497 | + | pub fn sign_challenge(&self, key: &KeyId, challenge: &[u8]) -> Result<Signature, VerifyError> { |
|
| 498 | + | if challenge.len() < 16 { |
|
| 499 | + | return Err(VerifyError::ShortChallenge); |
|
| 500 | + | } |
|
| 501 | + | Ok(self.sign(Domain::Challenge, key, &challenge_target(challenge))) |
|
| 502 | + | } |
|
| 503 | + | } |
|
| 504 | + | ||
| 505 | + | /// Check a challenge signature against the identity that claims it. |
|
| 506 | + | pub fn verify_challenge(identity: &Identity, sig: &Signature, challenge: &[u8]) -> Result<(), VerifyError> { |
|
| 507 | + | if challenge.len() < 16 { |
|
| 508 | + | return Err(VerifyError::ShortChallenge); |
|
| 509 | + | } |
|
| 510 | + | if sig.key != identity.hash() { |
|
| 511 | + | return Err(VerifyError::KeyMismatch); |
|
| 512 | + | } |
|
| 513 | + | verify(identity, sig, Domain::Challenge, &challenge_target(challenge)) |
|
| 514 | + | } |
|
| 515 | + | ||
| 516 | + | #[cfg(test)] |
|
| 517 | + | mod challenge_tests { |
|
| 518 | + | use super::*; |
|
| 519 | + | ||
| 520 | + | #[test] |
|
| 521 | + | fn a_challenge_signature_binds_key_and_bytes() { |
|
| 522 | + | let sk = SecretKey::generate(); |
|
| 523 | + | let id = sk.identity("A", "a@example", 1); |
|
| 524 | + | let key = id.hash(); |
|
| 525 | + | let challenge = [7u8; 32]; |
|
| 526 | + | let sig = sk.sign_challenge(&key, &challenge).unwrap(); |
|
| 527 | + | assert!(verify_challenge(&id, &sig, &challenge).is_ok()); |
|
| 528 | + | assert!(verify_challenge(&id, &sig, &[8u8; 32]).is_err(), "other bytes"); |
|
| 529 | + | let other = SecretKey::generate().identity("B", "b@example", 1); |
|
| 530 | + | assert!(verify_challenge(&other, &sig, &challenge).is_err(), "other identity"); |
|
| 531 | + | assert!(sk.sign_challenge(&key, b"short").is_err()); |
|
| 532 | + | // A challenge target never equals a change signature's target for the same bytes. |
|
| 533 | + | assert_ne!(challenge_target(&challenge), ObjectRef(challenge)); |
|
| 534 | + | } |
|
| 535 | + | } |
modified crates/oxy-core/src/repo/mod.rs +1 −1
| … | 93 unchanged lines |
||
| 94 | 94 | Selection, SnapshotResult, |
|
| 95 | 95 | }; |
|
| 96 | 96 | pub use subrepos::{SubrepoInfo, SubrepoSync}; |
|
| 97 | - | pub use sync::{FetchReport, PullReport}; |
|
| 97 | + | pub use sync::{FetchReport, PullReport, PushReport, RefUpdated}; |
|
| 98 | 98 | pub use tags::TagInfo; |
|
| 99 | 99 | pub use workspace::{SnapshotLock, WorkspaceInfo}; |
|
| 100 | 100 | ||
| … | 1121 unchanged lines |
modified crates/oxy-core/src/repo/sync.rs +48 −11
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | use super::*; |
|
| 4 | 4 | ||
| 5 | + | /// What a push did, for hosts (hub spec §6.12). |
|
| 6 | + | #[derive(Clone, Debug, Default)] |
|
| 7 | + | pub struct PushReport { |
|
| 8 | + | pub results: Vec<RefResult>, |
|
| 9 | + | /// Accepted reference updates, in request order. |
|
| 10 | + | pub updated: Vec<RefUpdated>, |
|
| 11 | + | /// Changes this push stored that were not already present. |
|
| 12 | + | pub imported: Vec<ChangeHash>, |
|
| 13 | + | } |
|
| 14 | + | ||
| 15 | + | #[derive(Clone, Debug)] |
|
| 16 | + | pub struct RefUpdated { |
|
| 17 | + | pub name: String, |
|
| 18 | + | pub old_state: ObjectRef, |
|
| 19 | + | pub new_state: ObjectRef, |
|
| 20 | + | pub heads: Vec<ChangeHash>, |
|
| 21 | + | } |
|
| 22 | + | ||
| 5 | 23 | /// What a fetch brought in. |
|
| 6 | 24 | #[derive(Debug, Clone, PartialEq, Eq, Default)] |
|
| 7 | 25 | pub struct FetchReport { |
|
| … | 440 unchanged lines |
||
| 448 | 466 | policy: Option<SignedPolicy>, |
|
| 449 | 467 | tags: Vec<crate::protocol::TagRef>, |
|
| 450 | 468 | ) -> Result<Vec<RefResult>> { |
|
| 451 | - | let mut results = Vec::new(); |
|
| 469 | + | Ok(self.receive_push_report(bundle, updates, policy, tags)?.results) |
|
| 470 | + | } |
|
| 471 | + | ||
| 472 | + | /// `receive_push` with what a host wants to log: each accepted |
|
| 473 | + | /// reference's old and new state and heads, and the changes this push |
|
| 474 | + | /// stored (hub spec §6.12). |
|
| 475 | + | pub fn receive_push_report( |
|
| 476 | + | &self, |
|
| 477 | + | bundle: Bundle, |
|
| 478 | + | updates: Vec<RefUpdate>, |
|
| 479 | + | policy: Option<SignedPolicy>, |
|
| 480 | + | tags: Vec<crate::protocol::TagRef>, |
|
| 481 | + | ) -> Result<PushReport> { |
|
| 482 | + | let mut report = PushReport::default(); |
|
| 483 | + | let results = &mut report.results; |
|
| 452 | 484 | if let Some(sp) = policy { |
|
| 453 | 485 | match self.adopt_policy_chain(&bundle.policies, &sp, "push received") { |
|
| 454 | 486 | Ok(()) => { |
|
| … | 16 unchanged lines |
||
| 471 | 503 | reason: "policy update was rejected".into(), |
|
| 472 | 504 | }); |
|
| 473 | 505 | } |
|
| 474 | - | return Ok(results); |
|
| 506 | + | return Ok(report); |
|
| 475 | 507 | } |
|
| 476 | 508 | } |
|
| 477 | 509 | } |
|
| 478 | 510 | let policy = self.current_policy()?.map(|(_, sp)| sp.policy); |
|
| 479 | - | if let Err(e) = self.import_bundle(&bundle, policy.is_some()) { |
|
| 480 | - | for u in updates { |
|
| 481 | - | results.push(RefResult { |
|
| 482 | - | name: u.name, |
|
| 483 | - | ok: false, |
|
| 484 | - | reason: e.to_string(), |
|
| 485 | - | }); |
|
| 511 | + | match self.import_bundle(&bundle, policy.is_some()) { |
|
| 512 | + | Ok(imported) => report.imported = imported, |
|
| 513 | + | Err(e) => { |
|
| 514 | + | for u in updates { |
|
| 515 | + | results.push(RefResult { |
|
| 516 | + | name: u.name, |
|
| 517 | + | ok: false, |
|
| 518 | + | reason: e.to_string(), |
|
| 519 | + | }); |
|
| 520 | + | } |
|
| 521 | + | return Ok(report); |
|
| 486 | 522 | } |
|
| 487 | - | return Ok(results); |
|
| 488 | 523 | } |
|
| 489 | 524 | let (op_ref, op) = self.head_op()?; |
|
| 490 | 525 | let mut view = op.view.clone(); |
|
| … | 6 unchanged lines |
||
| 497 | 532 | }; |
|
| 498 | 533 | if ok { |
|
| 499 | 534 | names.push(u.name.clone()); |
|
| 535 | + | let heads = self.store.get_state(&u.new_state).map(|s| s.heads).unwrap_or_default(); |
|
| 536 | + | report.updated.push(RefUpdated { name: u.name.clone(), old_state: u.old_state, new_state: u.new_state, heads }); |
|
| 500 | 537 | } |
|
| 501 | 538 | results.push(RefResult { |
|
| 502 | 539 | name: u.name, |
|
| … | 26 unchanged lines |
||
| 529 | 566 | view, |
|
| 530 | 567 | )?; |
|
| 531 | 568 | } |
|
| 532 | - | Ok(results) |
|
| 569 | + | Ok(report) |
|
| 533 | 570 | } |
|
| 534 | 571 | ||
| 535 | 572 | fn apply_update( |
|
| … | 844 unchanged lines |
modified crates/oxy-server/src/lib.rs +6 −5
| … | 59 unchanged lines |
||
| 60 | 60 | fn authorize(&self, name: &str, access: Access, headers: &HeaderMap) -> Result<(), Failure>; |
|
| 61 | 61 | ||
| 62 | 62 | /// Called after a push was processed, with its per-reference results |
|
| 63 | - | /// (accepted or refused), for logs and cache invalidation. |
|
| 64 | - | fn after_push(&self, _name: &str, _results: &[oxy_core::protocol::RefResult]) {} |
|
| 63 | + | /// (accepted or refused), the states each accepted reference moved |
|
| 64 | + | /// between, and the changes stored, for logs and cache invalidation. |
|
| 65 | + | fn after_push(&self, _name: &str, _report: &oxy_core::repo::PushReport) {} |
|
| 65 | 66 | } |
|
| 66 | 67 | ||
| 67 | 68 | /// A protocol server over any [`Backend`], serialising the pushes to each |
|
| … | 202 unchanged lines |
||
| 270 | 271 | } |
|
| 271 | 272 | } |
|
| 272 | 273 | } |
|
| 273 | - | let results = r.receive_push(bundle, updates, policy, tags)?; |
|
| 274 | - | self.backend.after_push(name, &results); |
|
| 275 | - | ok(wire::encode_push_response(&results)) |
|
| 274 | + | let report = r.receive_push_report(bundle, updates, policy, tags)?; |
|
| 275 | + | self.backend.after_push(name, &report); |
|
| 276 | + | ok(wire::encode_push_response(&report.results)) |
|
| 276 | 277 | } |
|
| 277 | 278 | }) |
|
| 278 | 279 | } |
|
| … | 109 unchanged lines |
modified crates/oxy-server/tests/http.rs +7 −4
| … | 391 unchanged lines |
||
| 392 | 392 | } |
|
| 393 | 393 | Ok(()) |
|
| 394 | 394 | } |
|
| 395 | - | fn after_push(&self, name: &str, results: &[oxy_core::protocol::RefResult]) { |
|
| 395 | + | fn after_push(&self, name: &str, report: &oxy_core::repo::PushReport) { |
|
| 396 | 396 | self.pushes.lock().unwrap().push(format!( |
|
| 397 | - | "{name}: {}", |
|
| 398 | - | results.iter().filter(|r| r.ok).count() |
|
| 397 | + | "{name}: {} ok, {} updated to {} head(s), {} imported", |
|
| 398 | + | report.results.iter().filter(|r| r.ok).count(), |
|
| 399 | + | report.updated.len(), |
|
| 400 | + | report.updated.first().map(|u| u.heads.len()).unwrap_or(0), |
|
| 401 | + | report.imported.len() |
|
| 399 | 402 | )); |
|
| 400 | 403 | } |
|
| 401 | 404 | } |
|
| … | 64 unchanged lines |
||
| 466 | 469 | client.config.set("remote.origin.token", "s3cret").unwrap(); |
|
| 467 | 470 | let results = client.push("origin", false).unwrap(); |
|
| 468 | 471 | assert!(results.iter().all(|r| r.ok), "{results:?}"); |
|
| 469 | - | assert_eq!(pushes.lock().unwrap().as_slice(), ["andy/app: 1"]); |
|
| 472 | + | assert_eq!(pushes.lock().unwrap().as_slice(), ["andy/app: 1 ok, 1 updated to 1 head(s), 1 imported"]); |
|
| 470 | 473 | ||
| 471 | 474 | let hosted = Repo::open(&root.join("repos/andy/app")).unwrap(); |
|
| 472 | 475 | assert_eq!(hosted.bookmark_heads().unwrap().len(), 1); |
|
| … | 111 unchanged lines |
modified docs/plan.md +2 −2
| … | 84 unchanged lines |
||
| 85 | 85 | - [x] Embeddable server: `oxy_server::{Backend, Server, Endpoint, Access, Failure, DirBackend, bearer_token}`; `router` is `DirBackend` on `/{repo}/oxy/v1/{endpoint}`; `after_push` hook. |
|
| 86 | 86 | - [x] Anonymous (zero-author) changes are adopted by the pusher's seal (spec §14.4), found when pushing oxygen's own pre-identity history to the hub. |
|
| 87 | 87 | - [x] Unbound hosted repositories (`Repo::init_bare_unbound`, `bind_id`, `Oxy-Repo-Id` header): an existing repository pushed to an empty hosted one keeps its id (protocol.md §2). |
|
| 88 | - | - [ ] `oxy identity sign-challenge` and `verify_challenge` (hub 0.2). |
|
| 88 | + | - [x] `oxy identity sign-challenge` and `identity::{sign_challenge, verify_challenge, challenge_target}` (hub 0.2). |
|
| 89 | 89 | - [ ] `oxy seal <hash>..` and `--proposal URL` (hub 0.3). |
|
| 90 | 90 | - [ ] `oxy proposal show|land URL` (hub 0.3). |
|
| 91 | 91 | - [ ] Delegate-signed changes from an edit set without a workspace (hub 0.6). |
|
| 92 | 92 | - [ ] `Repo::log_window` and a shared `graph::layout` (hub 0.1 uses `log_state` and its own layout until then). |
|
| 93 | - | - [ ] `receive_push` reporting old/new state and new changes per reference (hub 0.2 pushes log). |
|
| 93 | + | - [x] `receive_push_report` with `PushReport { results, updated: [RefUpdated { name, old_state, new_state, heads }], imported }`; `Backend::after_push` receives it. |
modified docs/spec.md +5 −3
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | | | | |
|
| 4 | 4 | |---|---| |
|
| 5 | - | | **Status** | Draft 0.8e | |
|
| 5 | + | | **Status** | Draft 0.8f | |
|
| 6 | 6 | | **Format version** | 1 | |
|
| 7 | 7 | | **Date** | 2026-09-26 | |
|
| 8 | 8 | | **Audience** | Implementers, security reviewers, and contributors | |
|
| … | 594 unchanged lines |
||
| 603 | 603 | ||
| 604 | 604 | Profiles that cannot compute ML-DSA (§22) do not sign; they produce changes that a proxy signs. |
|
| 605 | 605 | ||
| 606 | + | **Challenges.** A host that must know a client holds a key (linking a key to an account, signing in to a server) sends at least 16 random bytes; the client returns a composite signature in domain `challenge` over `BLAKE3-256("oxy1 challenge " || bytes)`, which can never equal a stored object's reference, so a challenge signature authenticates nothing but the challenge. `oxy identity sign-challenge HEX` prints it. |
|
| 607 | + | ||
| 606 | 608 | ### 14.4 Attestations |
|
| 607 | 609 | ||
| 608 | 610 | ``` |
|
| … | 220 unchanged lines |
||
| 829 | 831 | oxy workspace add|list|forget oxy sparse [PATH].. oxy deepen [N] |
|
| 830 | 832 | oxy fetch [REMOTE] oxy pull [REMOTE] oxy push [REMOTE] [--force] |
|
| 831 | 833 | oxy remote add|remove|list oxy serve oxy lock|unlock PATH |
|
| 832 | - | oxy identity create|show|export|rotate|renew|revoke|delegate |
|
| 834 | + | oxy identity create|show|export|rotate|renew|revoke|delegate|sign-challenge |
|
| 833 | 835 | oxy policy init|show|grant|revoke|root|sign oxy verify [CHANGE] |
|
| 834 | 836 | oxy seal [CHANGE].. seal your unauthenticated and anonymous changes (§14.4) |
|
| 835 | 837 | oxy git import|export|fetch|push (fetch and push go through a bare mirror under .oxy/git/) |
|
| … | 80 unchanged lines |
||
| 916 | 918 | ||
| 917 | 919 | --- |
|
| 918 | 920 | ||
| 919 | - | *Changelog.* Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands. |
|
| 921 | + | *Changelog.* Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands. |
|
| 920 | 922 | ||
| 921 | 923 | Draft 0.8a (2026-09-27): every Draft 0.8 addition is implemented in the reference implementation (§26.1). Clarifications from doing so: an unregistered nested repository is left alone and a tracked subrepo whose directory is absent keeps its pin (§7.8); Oxygen pins are omitted from Git export (§23). |
|
| 922 | 924 | ||
| … | 59 unchanged lines |
modified src/cli/identity.rs +29 −0
| … | 706 unchanged lines |
||
| 707 | 707 | } |
|
| 708 | 708 | Ok(0) |
|
| 709 | 709 | } |
|
| 710 | + | ||
| 711 | + | /// `oxy identity sign-challenge HEX`: the composite signature over a |
|
| 712 | + | /// host's challenge, as hex of the CBOR signature (hub spec §5.3). |
|
| 713 | + | pub fn sign_challenge(challenge_hex: &str, as_json: bool) -> Result<i32> { |
|
| 714 | + | let repo = open()?; |
|
| 715 | + | let Some((key, sk)) = repo.secret_key()? else { |
|
| 716 | + | anyhow::bail!("no secret key: run `oxy identity create` first"); |
|
| 717 | + | }; |
|
| 718 | + | let challenge = hex_bytes(challenge_hex.trim()).ok_or_else(|| anyhow::anyhow!("the challenge must be hex"))?; |
|
| 719 | + | let sig = sk.sign_challenge(&key, &challenge).map_err(|e| anyhow::anyhow!("{e}"))?; |
|
| 720 | + | let encoded = hex_string(&oxy_core::identity::Signature::encode_list(&[sig])); |
|
| 721 | + | if as_json { |
|
| 722 | + | println!("{{\"key\":\"{}\",\"signature\":\"{}\"}}", key.to_hex(), encoded); |
|
| 723 | + | } else { |
|
| 724 | + | println!("{encoded}"); |
|
| 725 | + | } |
|
| 726 | + | Ok(0) |
|
| 727 | + | } |
|
| 728 | + | ||
| 729 | + | fn hex_bytes(s: &str) -> Option<Vec<u8>> { |
|
| 730 | + | if !s.len().is_multiple_of(2) { |
|
| 731 | + | return None; |
|
| 732 | + | } |
|
| 733 | + | (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect() |
|
| 734 | + | } |
|
| 735 | + | ||
| 736 | + | fn hex_string(b: &[u8]) -> String { |
|
| 737 | + | b.iter().map(|x| format!("{x:02x}")).collect() |
|
| 738 | + | } |
modified src/cli/mod.rs +7 −0
| … | 467 unchanged lines |
||
| 468 | 468 | Show, |
|
| 469 | 469 | /// Write your identity object to a file, to hand to a policy holder |
|
| 470 | 470 | Export { path: Option<PathBuf> }, |
|
| 471 | + | /// Sign a host's challenge (hex, at least 16 bytes) to prove you hold your key; prints the signature as hex |
|
| 472 | + | SignChallenge { |
|
| 473 | + | challenge: String, |
|
| 474 | + | #[arg(long)] |
|
| 475 | + | json: bool, |
|
| 476 | + | }, |
|
| 471 | 477 | /// Move to a fresh key pair; the policy follows if it names your key |
|
| 472 | 478 | Rotate, |
|
| 473 | 479 | /// Re-issue your identity with a new expiry (in days from now; 0 = never) |
|
| … | 249 unchanged lines |
||
| 723 | 729 | } => identity::create(name, email, local, expires), |
|
| 724 | 730 | IdentityCmd::Show => identity::show(), |
|
| 725 | 731 | IdentityCmd::Export { path } => identity::export(path), |
|
| 732 | + | IdentityCmd::SignChallenge { challenge, json } => identity::sign_challenge(&challenge, json), |
|
| 726 | 733 | IdentityCmd::Rotate => identity::rotate(), |
|
| 727 | 734 | IdentityCmd::Renew { expires } => identity::renew(expires), |
|
| 728 | 735 | IdentityCmd::Revoke { key, reason } => identity::revoke(key.as_deref(), &reason), |
|
| … | 46 unchanged lines |