Skip to main content

mkit_cli/grants/
owner.rs

1//! Owner signing for `mkit grant create`, `mkit epoch bump` and
2//! `mkit visibility set` (WP-2.13, R-155).
3//!
4//! One module, three ways to get the SPEC-WRITE-GRANTS §4 signature:
5//!
6//! * **Native.** `ed25519` signs BLAKE3(statement) with the configured mkit
7//!   signing key (the key `mkit commit` and the auth v2 envelope use), so the
8//!   namespace is `ed25519-<pubkey>`. `secp256k1-eip191` signs the EIP-191
9//!   digest with a software-keystore secp256k1 key through
10//!   [`KeySigner::sign_prehash_recoverable_secp256k1`]; the namespace is the
11//!   key's `0x` address. Hardware and OS-native keys can't do this and say so.
12//! * **Print, then import.** `--print-statement` writes the exact statement
13//!   bytes for a wallet or authenticator. The signature comes back with
14//!   `--statement-file <that file> --signature <r‖s‖v hex>` (EIP-191), or
15//!   `--webauthn-assertion <file>` (P-256, which needs a pinned relying party).
16//!   Imported values are normalized (`v` of 0/1 becomes 27/28, a high `s`
17//!   becomes low `s` with `v` flipped, a DER signature becomes raw low-`s`)
18//!   and never trusted as given.
19//! * Whatever the source, [`produce`] verifies the finished header with the
20//!   `mkit-attest` verifier before anyone stores or sends it.
21
22use std::fmt::Write as _;
23use std::path::Path;
24use std::sync::{Arc, Mutex};
25
26use base64::Engine as _;
27use base64::engine::general_purpose::URL_SAFE_NO_PAD;
28use clap::{Args, ValueEnum};
29use mkit_attest::eth;
30use mkit_attest::grant::{
31    Namespace, OwnerScheme, RelyingParty, RepositoryIdentity, SignedHeader, WebAuthnAssertion,
32    webauthn_challenge,
33};
34use mkit_core::hash::{hash, to_hex_bytes};
35use mkit_keystore::{Algorithm, KeyRef, KeySelector, KeySigner, open_backend};
36use mkit_transport_connect::EnvelopeSigner;
37
38use super::{HeaderError, verify_epoch_header, verify_grant_header, verify_visibility_header};
39use crate::config::Config;
40
41/// Largest statement file or assertion file read.
42const MAX_IMPORT_BYTES: u64 = 64 * 1024;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
45pub enum SchemeArg {
46    #[value(name = "ed25519")]
47    Ed25519,
48    #[value(name = "secp256k1-eip191")]
49    Secp256k1Eip191,
50    #[value(name = "webauthn-p256")]
51    WebAuthnP256,
52}
53
54impl From<SchemeArg> for OwnerScheme {
55    fn from(arg: SchemeArg) -> Self {
56        match arg {
57            SchemeArg::Ed25519 => Self::Ed25519,
58            SchemeArg::Secp256k1Eip191 => Self::Secp256k1Eip191,
59            SchemeArg::WebAuthnP256 => Self::WebAuthnP256,
60        }
61    }
62}
63
64/// The signing flags shared by the three commands.
65#[derive(Debug, Clone, Default, Args)]
66pub struct OwnerArgs {
67    /// Owner signature scheme. Default `ed25519`: the configured mkit signing
68    /// key. `secp256k1-eip191` uses a software-keystore secp256k1 key
69    /// (`key.secp256k1_ref`).
70    #[arg(long, value_enum, value_name = "SCHEME")]
71    pub scheme: Option<SchemeArg>,
72    /// Print the exact statement bytes to stdout (and the digest or challenge
73    /// to sign on stderr) instead of signing, for a wallet or authenticator.
74    #[arg(long)]
75    pub print_statement: bool,
76    /// Import a signature over the statement in this file (written with
77    /// --print-statement): use with --signature or --webauthn-assertion.
78    #[arg(long, value_name = "FILE")]
79    pub statement_file: Option<std::path::PathBuf>,
80    /// A 65-byte `r‖s‖v` EIP-191 signature in hex, from a wallet. `v` may be
81    /// 0, 1, 27 or 28; a high `s` is normalized.
82    #[arg(long, value_name = "HEX", requires = "statement_file")]
83    pub signature: Option<String>,
84    /// A `WebAuthn` assertion as JSON with base64url fields `publicKey` (64
85    /// bytes, x‖y), `authenticatorData`, `clientDataJSON` and `signature`
86    /// (DER or raw). Needs `grant.webauthn_rp` in the user config.
87    #[arg(
88        long,
89        value_name = "FILE",
90        requires = "statement_file",
91        conflicts_with = "signature"
92    )]
93    pub webauthn_assertion: Option<std::path::PathBuf>,
94}
95
96/// A key that can sign natively.
97pub struct NativeOwner {
98    namespace: Namespace,
99    scheme: OwnerScheme,
100    inner: NativeKey,
101}
102
103enum NativeKey {
104    Ed25519(Arc<dyn EnvelopeSigner>),
105    Secp256k1(Mutex<Box<dyn KeySigner>>),
106}
107
108impl std::fmt::Debug for NativeOwner {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        f.debug_struct("NativeOwner")
111            .field("namespace", &self.namespace.to_string())
112            .field("scheme", &self.scheme)
113            .finish_non_exhaustive()
114    }
115}
116
117impl NativeOwner {
118    /// An `ed25519` owner. The signer signs a raw 32-byte digest, which is
119    /// exactly what §4 needs: BLAKE3 of the statement.
120    ///
121    /// # Errors
122    /// A public key that is not 32 bytes of hex.
123    pub fn ed25519(signer: Arc<dyn EnvelopeSigner>) -> Result<Self, String> {
124        let key = mkit_core::hash::from_hex(&signer.public_key_hex())
125            .map_err(|e| format!("signing key is not an Ed25519 public key: {e}"))?;
126        Ok(Self {
127            namespace: Namespace::Ed25519(key),
128            scheme: OwnerScheme::Ed25519,
129            inner: NativeKey::Ed25519(signer),
130        })
131    }
132
133    /// A `secp256k1-eip191` owner from a keystore signer.
134    ///
135    /// # Errors
136    /// A key that is not secp256k1.
137    pub fn secp256k1(signer: Box<dyn KeySigner>) -> Result<Self, String> {
138        if signer.algorithm() != Algorithm::Secp256k1 {
139            return Err("the configured key is not a secp256k1 key".to_owned());
140        }
141        let public = signer
142            .public_key()
143            .map_err(|e| format!("keystore public key: {e}"))?;
144        let point = k256::ecdsa::VerifyingKey::from_sec1_bytes(public.as_bytes())
145            .map_err(|e| format!("keystore secp256k1 public key: {e}"))?
146            .to_sec1_point(false);
147        let xy: [u8; 64] = point
148            .as_bytes()
149            .get(1..65)
150            .and_then(|b| b.try_into().ok())
151            .ok_or("keystore secp256k1 public key has an unexpected encoding")?;
152        let address = eth::address_secp256k1(&xy).map_err(|e| e.to_string())?;
153        Ok(Self {
154            namespace: Namespace::Address(address),
155            scheme: OwnerScheme::Secp256k1Eip191,
156            inner: NativeKey::Secp256k1(Mutex::new(signer)),
157        })
158    }
159
160    /// Open `key.secp256k1_ref` from the configured keystore.
161    ///
162    /// # Errors
163    /// A missing key or a backend that can't open it.
164    pub fn open_secp256k1(cfg: &Config) -> Result<Self, String> {
165        let text = cfg.key.secp256k1_ref_or_fallback();
166        let key_ref = text
167            .parse::<KeyRef>()
168            .map_err(|e| format!("key.secp256k1_ref `{text}`: {e}"))?;
169        let store =
170            open_backend(key_ref.backend()).map_err(|e| format!("keystore backend: {e}"))?;
171        let selector = KeySelector::new(key_ref.label().to_owned(), Some(Algorithm::Secp256k1))
172            .map_err(|e| format!("key.secp256k1_ref `{text}`: {e}"))?;
173        let opener = store.opener().ok_or_else(|| {
174            format!(
175                "keystore backend `{}` does not support opening keys",
176                key_ref.backend()
177            )
178        })?;
179        let signer = opener.open(&selector).map_err(|e| {
180            format!(
181                "no secp256k1 key `{text}` — run `mkit key generate --backend {} --algorithm secp256k1 --label {}` first: {e}",
182                key_ref.backend(),
183                key_ref.label()
184            )
185        })?;
186        Self::secp256k1(signer)
187    }
188
189    #[must_use]
190    pub fn namespace(&self) -> &Namespace {
191        &self.namespace
192    }
193
194    #[must_use]
195    pub fn scheme(&self) -> OwnerScheme {
196        self.scheme
197    }
198
199    /// The §4 signature blob over `statement`.
200    ///
201    /// # Errors
202    /// The signer refused (locked, hardware, unsupported).
203    pub fn sign(&self, statement: &[u8]) -> Result<Vec<u8>, String> {
204        match &self.inner {
205            NativeKey::Ed25519(signer) => {
206                let hex = signer.sign_hex(&hash(statement))?;
207                hex::decode(hex).map_err(|e| format!("signer returned invalid hex: {e}"))
208            }
209            NativeKey::Secp256k1(signer) => {
210                let mut guard = signer
211                    .lock()
212                    .map_err(|_| "keystore signer mutex poisoned".to_owned())?;
213                let sig = guard
214                    .sign_prehash_recoverable_secp256k1(&eth::eip191_hash(statement))
215                    .map_err(|e| match e {
216                        mkit_keystore::Error::UnsupportedOperation(_) => format!(
217                            "this key can't sign secp256k1-eip191 natively ({e}); only software-keystore secp256k1 keys can — use --print-statement and import a wallet signature instead"
218                        ),
219                        other => format!("keystore signing failed: {other}"),
220                    })?;
221                Ok(sig.to_vec())
222            }
223        }
224    }
225}
226
227/// How a statement gets its signature.
228#[derive(Debug)]
229pub enum Plan {
230    /// Build the statement, sign it with a local key.
231    Native(NativeOwner),
232    /// Build the statement for `namespace` and print it.
233    Print { namespace: Namespace },
234    /// The statement and signature were made elsewhere.
235    Import {
236        statement: Vec<u8>,
237        scheme: OwnerScheme,
238        blob: Vec<u8>,
239    },
240}
241
242/// What the caller knows when choosing a plan.
243#[allow(missing_debug_implementations)] // holds a closure
244pub struct SignCtx<'a> {
245    /// Resolves the native Ed25519 key. Called only when needed.
246    pub ed25519: &'a dyn Fn() -> Result<NativeOwner, String>,
247    pub cfg: &'a Config,
248    pub relying_parties: &'a [RelyingParty],
249}
250
251/// Choose how to sign. `namespace_hint` is the namespace the command asked
252/// for (`--namespace`, or the repository's), if any.
253///
254/// # Errors
255/// Contradictory flags, an unreadable import, or no usable key.
256pub fn resolve(
257    args: &OwnerArgs,
258    namespace_hint: Option<Namespace>,
259    ctx: &SignCtx<'_>,
260) -> Result<Plan, String> {
261    if let Some(path) = &args.statement_file {
262        if args.print_statement {
263            return Err("--print-statement and --statement-file can't be combined".to_owned());
264        }
265        let statement = read_statement_file(path)?;
266        let (scheme, blob) = match (&args.signature, &args.webauthn_assertion) {
267            (Some(hex), None) => (OwnerScheme::Secp256k1Eip191, import_eip191(hex)?.to_vec()),
268            (None, Some(file)) => (
269                OwnerScheme::WebAuthnP256,
270                import_webauthn(file, ctx.relying_parties)?,
271            ),
272            _ => {
273                return Err(
274                    "--statement-file needs --signature <hex> or --webauthn-assertion <file>"
275                        .to_owned(),
276                );
277            }
278        };
279        if let Some(chosen) = args.scheme
280            && OwnerScheme::from(chosen) != scheme
281        {
282            return Err(format!(
283                "--scheme {} doesn't match the imported signature ({})",
284                OwnerScheme::from(chosen).token(),
285                scheme.token()
286            ));
287        }
288        return Ok(Plan::Import {
289            statement,
290            scheme,
291            blob,
292        });
293    }
294    if args.print_statement {
295        let namespace = match namespace_hint {
296            Some(ns) => ns,
297            None => *native(args, ctx)?.namespace(),
298        };
299        return Ok(Plan::Print { namespace });
300    }
301    let owner = native(args, ctx)?;
302    if let Some(hint) = namespace_hint
303        && hint != *owner.namespace()
304    {
305        return Err(format!(
306            "the signing key owns namespace {}, not {hint}; to sign for {hint} with a wallet or authenticator use --print-statement",
307            owner.namespace()
308        ));
309    }
310    Ok(Plan::Native(owner))
311}
312
313fn native(args: &OwnerArgs, ctx: &SignCtx<'_>) -> Result<NativeOwner, String> {
314    match args.scheme.map_or(OwnerScheme::Ed25519, OwnerScheme::from) {
315        OwnerScheme::Ed25519 => (ctx.ed25519)(),
316        OwnerScheme::Secp256k1Eip191 => NativeOwner::open_secp256k1(ctx.cfg),
317        _ => Err(
318            "webauthn-p256 signatures come from an authenticator: run with --print-statement, sign the printed challenge, then pass --statement-file and --webauthn-assertion"
319                .to_owned(),
320        ),
321    }
322}
323
324fn read_bounded(path: &Path, what: &str) -> Result<Vec<u8>, String> {
325    std::fs::File::open(path)
326        .and_then(|file| super::read_bounded(file, MAX_IMPORT_BYTES))
327        .map_err(|e| format!("{what} {}: {e}", path.display()))
328}
329
330fn read_statement_file(path: &Path) -> Result<Vec<u8>, String> {
331    let bytes = read_bounded(path, "statement file")?;
332    if bytes.last() == Some(&b'\n') {
333        return Err(format!(
334            "statement file {} ends with a line feed, but a statement has none; write it with `--print-statement > {}` and don't edit it",
335            path.display(),
336            path.display()
337        ));
338    }
339    Ok(bytes)
340}
341
342/// A wallet's `r‖s‖v` in hex, normalized to the single form a verifier
343/// accepts (§4.4).
344///
345/// # Errors
346/// Not 65 bytes of hex, or `v`/`r`/`s` out of range.
347pub fn import_eip191(hex_text: &str) -> Result<[u8; 65], String> {
348    let text = hex_text.trim();
349    let text = text
350        .strip_prefix("0x")
351        .or_else(|| text.strip_prefix("0X"))
352        .unwrap_or(text);
353    let bytes = hex::decode(text).map_err(|e| format!("--signature is not hex: {e}"))?;
354    let sig: [u8; 65] = bytes
355        .as_slice()
356        .try_into()
357        .map_err(|_| format!("--signature must be 65 bytes (r‖s‖v), got {}", bytes.len()))?;
358    eth::normalize_eip191_signature(sig).map_err(|e| format!("--signature: {e}"))
359}
360
361/// Build a `webauthn-p256` blob from an assertion file (see
362/// [`OwnerArgs::webauthn_assertion`]). Refused unless a relying party is
363/// pinned.
364///
365/// # Errors
366/// No pinned relying party, a malformed file, or a signature that isn't DER
367/// or low-`s` raw.
368pub fn import_webauthn(path: &Path, rps: &[RelyingParty]) -> Result<Vec<u8>, String> {
369    if rps.is_empty() {
370        return Err(HeaderError::WebAuthnNotPinned.to_string());
371    }
372    let bytes = read_bounded(path, "WebAuthn assertion")?;
373    let json: serde_json::Value =
374        serde_json::from_slice(&bytes).map_err(|e| format!("WebAuthn assertion: not JSON: {e}"))?;
375    let field = |name: &str| -> Result<Vec<u8>, String> {
376        let text = json
377            .get(name)
378            .and_then(serde_json::Value::as_str)
379            .ok_or_else(|| format!("WebAuthn assertion: missing string field `{name}`"))?;
380        URL_SAFE_NO_PAD
381            .decode(text.trim_end_matches('='))
382            .map_err(|e| format!("WebAuthn assertion: `{name}` is not base64url: {e}"))
383    };
384    let public_key: [u8; 64] = field("publicKey")?
385        .as_slice()
386        .try_into()
387        .map_err(|_| "WebAuthn assertion: `publicKey` must be 64 bytes (x‖y)".to_owned())?;
388    let signature = field("signature")?;
389    let raw: [u8; 64] = match <[u8; 64]>::try_from(signature.as_slice()) {
390        Ok(raw) => {
391            eth::p256_check_raw_low_s(&raw).map_err(|e| {
392                format!("WebAuthn assertion: raw `signature` must be low-s ({e}); supply the DER form and mkit will normalize it")
393            })?;
394            raw
395        }
396        Err(_) => eth::p256_der_to_low_s_raw(&signature)
397            .map_err(|e| format!("WebAuthn assertion: `signature` is not strict DER ({e})"))?,
398    };
399    WebAuthnAssertion {
400        public_key,
401        authenticator_data: field("authenticatorData")?,
402        client_data_json: field("clientDataJSON")?,
403        signature: raw,
404    }
405    .encode()
406    .map_err(|e| format!("WebAuthn assertion: {e}"))
407}
408
409/// Which statement a header carries, and so which verification applies.
410#[derive(Debug, Clone, Copy)]
411pub enum Kind<'a> {
412    Grant,
413    Epoch,
414    Visibility(&'a RepositoryIdentity),
415}
416
417/// A statement with a verified owner signature.
418#[derive(Debug, Clone)]
419pub struct Signed {
420    pub statement: Vec<u8>,
421    pub header: String,
422    pub scheme: OwnerScheme,
423}
424
425/// The outcome of [`produce`].
426#[derive(Debug)]
427pub enum Produced {
428    Signed(Signed),
429    Print {
430        statement: Vec<u8>,
431        namespace: Namespace,
432    },
433}
434
435/// Carry out a [`Plan`]. `build` makes the statement bytes for a namespace and
436/// is not called for an import. The header is verified against `kind` before
437/// it is returned.
438///
439/// # Errors
440/// The statement couldn't be built, the signer refused, or the verifier
441/// rejected the result (the message names the rule).
442pub fn produce(
443    plan: Plan,
444    build: impl FnOnce(&Namespace) -> Result<Vec<u8>, String>,
445    kind: Kind<'_>,
446    rps: &[RelyingParty],
447    now_ms: i64,
448) -> Result<Produced, String> {
449    let (statement, scheme, blob) = match plan {
450        Plan::Print { namespace } => {
451            let statement = build(&namespace)?;
452            return Ok(Produced::Print {
453                statement,
454                namespace,
455            });
456        }
457        Plan::Native(owner) => {
458            let statement = build(owner.namespace())?;
459            let blob = owner.sign(&statement)?;
460            (statement, owner.scheme(), blob)
461        }
462        Plan::Import {
463            statement,
464            scheme,
465            blob,
466        } => (statement, scheme, blob),
467    };
468    let header = SignedHeader {
469        statement: statement.clone(),
470        scheme,
471        blob,
472    }
473    .encode()
474    .map_err(|e| format!("header: {e}"))?;
475    let rejected = |e: HeaderError| format!("the owner signature was rejected: {e}");
476    match kind {
477        Kind::Grant => verify_grant_header(&header, rps).map(|_| ()),
478        Kind::Epoch => verify_epoch_header(&header, rps, now_ms).map(|_| ()),
479        Kind::Visibility(repository) => {
480            verify_visibility_header(&header, repository, rps, now_ms).map(|_| ())
481        }
482    }
483    .map_err(rejected)?;
484    Ok(Produced::Signed(Signed {
485        statement,
486        header,
487        scheme,
488    }))
489}
490
491/// Text for stderr when printing a statement to sign elsewhere.
492#[must_use]
493pub fn signing_instructions(statement: &[u8], namespace: &Namespace) -> String {
494    let mut out = format!(
495        "statement bytes: {} (namespace {namespace})\n",
496        statement.len()
497    );
498    match namespace {
499        Namespace::Ed25519(_) => {
500            let _ = writeln!(
501                out,
502                "ed25519 message (BLAKE3 of the statement): {}",
503                to_hex_bytes(&hash(statement))
504            );
505        }
506        Namespace::Address(_) => {
507            let _ = writeln!(
508                out,
509                "secp256k1-eip191: sign the statement bytes as an EIP-191 personal message (digest 0x{})",
510                to_hex_bytes(&eth::eip191_hash(statement))
511            );
512            let _ = writeln!(
513                out,
514                "webauthn-p256: use this WebAuthn challenge (base64url): {}",
515                webauthn_challenge(statement)
516            );
517        }
518    }
519    out
520}
521
522#[cfg(test)]
523mod tests {
524    use k256::ecdsa::SigningKey as K256Key;
525    use mkit_attest::grant::{Capabilities, Grant, RepoScope};
526
527    use super::*;
528
529    struct DalekSigner(ed25519_dalek::SigningKey);
530    impl EnvelopeSigner for DalekSigner {
531        fn public_key_hex(&self) -> String {
532            to_hex_bytes(&self.0.verifying_key().to_bytes())
533        }
534        fn sign_hex(&self, message: &[u8; 32]) -> Result<String, String> {
535            use ed25519_dalek::Signer as _;
536            Ok(to_hex_bytes(&self.0.sign(message).to_bytes()))
537        }
538    }
539
540    /// A keystore-shaped signer over a fixed k256 key, for exercising the
541    /// module without a keystore on disk. The keystore's own tests cover
542    /// `SoftwareSigner`.
543    struct K256KeySigner {
544        key: K256Key,
545        label: mkit_keystore::KeyLabel,
546    }
547    impl KeySigner for K256KeySigner {
548        fn algorithm(&self) -> Algorithm {
549            Algorithm::Secp256k1
550        }
551        fn label(&self) -> &mkit_keystore::KeyLabel {
552            &self.label
553        }
554        fn metadata(&self) -> mkit_keystore::Result<mkit_keystore::KeyMetadata> {
555            unreachable!()
556        }
557        fn public_key(&self) -> mkit_keystore::Result<mkit_keystore::PublicKeyBytes> {
558            Ok(mkit_keystore::PublicKeyBytes::new(
559                self.key
560                    .verifying_key()
561                    .to_sec1_point(true)
562                    .as_bytes()
563                    .to_vec(),
564            ))
565        }
566        fn keyid(&self) -> mkit_keystore::Result<mkit_keystore::KeyId> {
567            unreachable!()
568        }
569        fn sign(&mut self, _msg: &[u8]) -> mkit_keystore::Result<Vec<u8>> {
570            unreachable!()
571        }
572        fn sign_prehash_recoverable_secp256k1(
573            &mut self,
574            prehash: &[u8; 32],
575        ) -> mkit_keystore::Result<[u8; 65]> {
576            let (sig, recid) = self.key.sign_prehash_recoverable(prehash);
577            let mut out = [0u8; 65];
578            out[..64].copy_from_slice(&sig.normalize_s().to_bytes());
579            out[64] = 27 + recid.to_byte();
580            if sig.normalize_s() != sig {
581                out[64] = if out[64] == 27 { 28 } else { 27 };
582            }
583            Ok(out)
584        }
585    }
586
587    fn k256_owner(seed: u8) -> NativeOwner {
588        NativeOwner::secp256k1(Box::new(K256KeySigner {
589            key: K256Key::from_slice(&[seed; 32]).unwrap(),
590            label: mkit_keystore::KeyLabel::new("test").unwrap(),
591        }))
592        .unwrap()
593    }
594
595    fn ed_owner(seed: u8) -> NativeOwner {
596        NativeOwner::ed25519(Arc::new(DalekSigner(
597            ed25519_dalek::SigningKey::from_bytes(&[seed; 32]),
598        )))
599        .unwrap()
600    }
601
602    const NOW: i64 = 1_800_000_000_000;
603
604    fn grant_for(ns: &Namespace) -> Vec<u8> {
605        Grant {
606            namespace: *ns,
607            scope: RepoScope::Namespace,
608            grantee: [7; 32],
609            capabilities: Capabilities::Read,
610            audiences: vec!["https://git.example.com".to_owned()],
611            ref_scopes: None,
612            epoch: 0,
613            created_ms: NOW,
614            expiry_ms: NOW + 60_000,
615            nonce: [9; 32],
616        }
617        .encode()
618        .unwrap()
619    }
620
621    #[test]
622    fn native_ed25519_and_secp256k1_grants_verify() {
623        for owner in [ed_owner(3), k256_owner(0x11)] {
624            let scheme = owner.scheme();
625            let Produced::Signed(signed) = produce(
626                Plan::Native(owner),
627                |ns| Ok(grant_for(ns)),
628                Kind::Grant,
629                &[],
630                NOW,
631            )
632            .unwrap() else {
633                panic!("expected a signed header")
634            };
635            assert_eq!(signed.scheme, scheme);
636            assert!(verify_grant_header(&signed.header, &[]).is_ok());
637        }
638    }
639
640    #[test]
641    fn wallet_signatures_are_normalized_for_every_v_spelling_and_high_s() {
642        let owner = k256_owner(0x22);
643        let ns = *owner.namespace();
644        let statement = grant_for(&ns);
645        let good: [u8; 65] = owner.sign(&statement).unwrap().try_into().unwrap();
646        // The high-s twin: n - s, v flipped.
647        let n = hex::decode("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141")
648            .unwrap();
649        let mut twin = good;
650        let mut borrow = 0i16;
651        for i in (0..32).rev() {
652            let d = i16::from(n[i]) - i16::from(good[32 + i]) - borrow;
653            borrow = i16::from(d < 0);
654            twin[32 + i] = (d + 256 * borrow).to_le_bytes()[0];
655        }
656        twin[64] = if good[64] == 27 { 28 } else { 27 };
657        for base in [good, twin] {
658            for v_style in [0u8, 27] {
659                let mut wallet = base;
660                wallet[64] = base[64] - 27 + v_style;
661                let imported = import_eip191(&hex::encode(wallet)).unwrap();
662                assert_eq!(imported, good, "v = {}", wallet[64]);
663                let Produced::Signed(signed) = produce(
664                    Plan::Import {
665                        statement: statement.clone(),
666                        scheme: OwnerScheme::Secp256k1Eip191,
667                        blob: imported.to_vec(),
668                    },
669                    |_| unreachable!("imports don't build"),
670                    Kind::Grant,
671                    &[],
672                    NOW,
673                )
674                .unwrap() else {
675                    panic!()
676                };
677                assert!(verify_grant_header(&signed.header, &[]).is_ok());
678            }
679        }
680        // 0x prefix accepted, wrong length and bad v refused.
681        assert!(import_eip191(&format!("0x{}", hex::encode(good))).is_ok());
682        assert!(import_eip191(&format!("0X{}", hex::encode(good))).is_ok());
683        assert!(import_eip191(&hex::encode(&good[..64])).is_err());
684        let mut bad_v = good;
685        bad_v[64] = 5;
686        assert!(import_eip191(&hex::encode(bad_v)).is_err());
687    }
688
689    #[test]
690    fn wallet_vectors_from_the_golden_file_normalize() {
691        let golden: serde_json::Value = serde_json::from_str(include_str!(
692            "../../../../tests/golden/grants/eth-primitives.json"
693        ))
694        .unwrap();
695        for case in golden["high_s"].as_array().unwrap() {
696            let high = case["signature"].as_str().unwrap();
697            let low = case["normalized"].as_str().unwrap();
698            let high_bytes = hex::decode(high).unwrap();
699            for v_style in [0u8, 27] {
700                let mut sig = high_bytes.clone();
701                sig[64] = sig[64] - 27 + v_style;
702                assert_eq!(
703                    hex::encode(import_eip191(&hex::encode(sig)).unwrap()),
704                    low,
705                    "{}",
706                    case["name"]
707                );
708            }
709        }
710    }
711
712    #[test]
713    fn a_signature_from_another_key_is_rejected_naming_the_rule() {
714        let owner = k256_owner(0x22);
715        let statement = grant_for(owner.namespace());
716        let other = k256_owner(0x33).sign(&statement).unwrap();
717        let error = produce(
718            Plan::Import {
719                statement,
720                scheme: OwnerScheme::Secp256k1Eip191,
721                blob: other,
722            },
723            |_| unreachable!(),
724            Kind::Grant,
725            &[],
726            NOW,
727        )
728        .unwrap_err();
729        assert!(error.contains("owner mismatch"), "{error}");
730    }
731
732    #[test]
733    fn webauthn_import_is_refused_without_a_pinned_relying_party() {
734        let dir = tempfile::tempdir().unwrap();
735        let file = dir.path().join("assertion.json");
736        std::fs::write(&file, b"{}").unwrap();
737        let error = import_webauthn(&file, &[]).unwrap_err();
738        assert!(error.contains("pinned relying party"), "{error}");
739    }
740
741    #[test]
742    fn webauthn_der_signatures_are_normalized_to_low_s() {
743        let golden: serde_json::Value = serde_json::from_str(include_str!(
744            "../../../../tests/golden/grants/eth-primitives.json"
745        ))
746        .unwrap();
747        let case = golden["p256_der"]
748            .as_array()
749            .unwrap()
750            .iter()
751            .find(|c| c["name"] == "high-s")
752            .unwrap();
753        let der = hex::decode(case["der"].as_str().unwrap()).unwrap();
754        let low = hex::decode(case["raw_low_s"].as_str().unwrap()).unwrap();
755        let b64 = |b: &[u8]| URL_SAFE_NO_PAD.encode(b);
756        let dir = tempfile::tempdir().unwrap();
757        let file = dir.path().join("assertion.json");
758        std::fs::write(
759            &file,
760            serde_json::json!({
761                "publicKey": b64(&[1u8; 64]),
762                "authenticatorData": b64(&[0u8; 37]),
763                "clientDataJSON": b64(b"{}"),
764                "signature": b64(&der),
765            })
766            .to_string(),
767        )
768        .unwrap();
769        let rps = vec![RelyingParty::new("example.com", ["https://example.com"]).unwrap()];
770        let blob = import_webauthn(&file, &rps).unwrap();
771        let parsed = WebAuthnAssertion::parse(&blob).unwrap();
772        assert_eq!(parsed.signature.as_slice(), low.as_slice());
773        // A raw high-s signature is refused rather than silently altered.
774        let raw_high = hex::decode(case["raw_high_s"].as_str().unwrap()).unwrap();
775        std::fs::write(
776            &file,
777            serde_json::json!({
778                "publicKey": b64(&[1u8; 64]),
779                "authenticatorData": b64(&[0u8; 37]),
780                "clientDataJSON": b64(b"{}"),
781                "signature": b64(&raw_high),
782            })
783            .to_string(),
784        )
785        .unwrap();
786        assert!(import_webauthn(&file, &rps).unwrap_err().contains("low-s"));
787    }
788
789    #[test]
790    fn print_plan_returns_the_statement_and_instructions_name_the_digests() {
791        let owner = k256_owner(0x44);
792        let ns = *owner.namespace();
793        let Produced::Print {
794            statement,
795            namespace,
796        } = produce(
797            Plan::Print { namespace: ns },
798            |ns| Ok(grant_for(ns)),
799            Kind::Grant,
800            &[],
801            NOW,
802        )
803        .unwrap()
804        else {
805            panic!()
806        };
807        assert_eq!(namespace, ns);
808        let text = signing_instructions(&statement, &namespace);
809        assert!(text.contains(&to_hex_bytes(&eth::eip191_hash(&statement))));
810        assert!(text.contains(&webauthn_challenge(&statement)));
811    }
812
813    #[test]
814    fn namespace_hint_must_match_the_native_key() {
815        let owner = ed_owner(5);
816        let cfg = Config::with_defaults();
817        let make = || Ok(ed_owner(5));
818        let ctx = SignCtx {
819            ed25519: &make,
820            cfg: &cfg,
821            relying_parties: &[],
822        };
823        let args = OwnerArgs {
824            scheme: None,
825            print_statement: false,
826            statement_file: None,
827            signature: None,
828            webauthn_assertion: None,
829        };
830        let other = Namespace::Address([1; 20]);
831        assert!(
832            resolve(&args, Some(other), &ctx)
833                .unwrap_err()
834                .contains("--print-statement")
835        );
836        assert!(resolve(&args, Some(*owner.namespace()), &ctx).is_ok());
837    }
838
839    #[test]
840    fn statement_files_with_a_trailing_line_feed_are_refused() {
841        let dir = tempfile::tempdir().unwrap();
842        let file = dir.path().join("s.txt");
843        std::fs::write(&file, b"x\n").unwrap();
844        assert!(
845            read_statement_file(&file)
846                .unwrap_err()
847                .contains("line feed")
848        );
849    }
850}