From 30972530c9256c1d58453b1245fdf8957f1dffd1 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Sat, 15 Aug 2026 13:29:51 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(testkit):=20dapp-author=20testing=20to?= =?UTF-8?q?ols=20=E2=80=94=20local-key=20Nido=20accounts=20+=20local=20aut?= =?UTF-8?q?h=20simulation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements nidohq/nido#188. Lets a dapp author create and exercise a Nido smart account from local keys — no WebAuthn passkey, no live network — and simulate authorization locally. TS — @nidohq/testkit (packages/testkit): - local signers for every verifier the account supports, each signing the auth digest with the bytes that verifier accepts: - secp256r1 → a local P-256 key building a WebAuthn assertion (the same webauthn-verifier, minus the passkey), via a ported buildSyntheticAssertion; - ed25519 → 64-byte signature; - ml-dsa-65 → post-quantum signature (@noble/post-quantum). - createLocalAccount: real deployer+salt C-address derivation + the account's perch PolicyDoc + a real doc_hash (perch canonical vendored, byte-identical). - simulateCheckAuth: mirrors do_check_auth + perch policy evaluation (function allowlist, arg predicates, expiry, N-of-N signer floor) → Kleene verdict with a readable trace. - reachableCalls / isNarrowing: perch reachable-call + attenuation analysis. - 12 vitest tests (all three signers round-trip; account derivation; the ci-publish allow/deny/expiry/is-self/zero-sig matrix; attenuation). Rust — nido-testkit (crates/testkit): - test_p256_key + build_contract_assertion: the contract-test twin of the TS secp256r1 path (deterministic local key + synthetic WebAuthn assertion), pure-crypto, no soroban dep. 2 tests. Honesty: only secp256r1 has a deployed verifier today. ed25519 and ML-DSA verifiers, and perch policies themselves, are modelled ahead of their on-chain contracts (ML-DSA groundwork = #143; perch = stellar-registry/perch) — the simulator IS the perch interpreter until integration lands. Roadmap: swap the TS simulation for soroban-env in the browser (wasmi) + rs-soroban-sdk#1657's local-storage cache for lazy testnet pulls. Consumed by examples/perch-authz-console (next). Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + Cargo.lock | 8 + Cargo.toml | 1 + crates/testkit/Cargo.toml | 10 ++ crates/testkit/src/lib.rs | 138 ++++++++++++++++++ package-lock.json | 186 ++++++++++++++---------- packages/testkit/README.md | 83 +++++++++++ packages/testkit/package.json | 36 +++++ packages/testkit/src/account.ts | 73 ++++++++++ packages/testkit/src/auth.ts | 78 ++++++++++ packages/testkit/src/checkauth.ts | 140 ++++++++++++++++++ packages/testkit/src/crypto.ts | 67 +++++++++ packages/testkit/src/index.ts | 75 ++++++++++ packages/testkit/src/perch/analysis.ts | 45 ++++++ packages/testkit/src/perch/canonical.ts | 79 ++++++++++ packages/testkit/src/perch/policy.ts | 96 ++++++++++++ packages/testkit/src/signer.ts | 46 ++++++ packages/testkit/src/testkit.test.ts | 113 ++++++++++++++ packages/testkit/src/verifiers.ts | 79 ++++++++++ packages/testkit/tsconfig.json | 19 +++ 20 files changed, 1295 insertions(+), 78 deletions(-) create mode 100644 crates/testkit/Cargo.toml create mode 100644 crates/testkit/src/lib.rs create mode 100644 packages/testkit/README.md create mode 100644 packages/testkit/package.json create mode 100644 packages/testkit/src/account.ts create mode 100644 packages/testkit/src/auth.ts create mode 100644 packages/testkit/src/checkauth.ts create mode 100644 packages/testkit/src/crypto.ts create mode 100644 packages/testkit/src/index.ts create mode 100644 packages/testkit/src/perch/analysis.ts create mode 100644 packages/testkit/src/perch/canonical.ts create mode 100644 packages/testkit/src/perch/policy.ts create mode 100644 packages/testkit/src/signer.ts create mode 100644 packages/testkit/src/testkit.test.ts create mode 100644 packages/testkit/src/verifiers.ts create mode 100644 packages/testkit/tsconfig.json diff --git a/.gitignore b/.gitignore index d080f933..7f548ccc 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ node_modules/ packages/frontend/dist/ packages/passkey-sdk/dist/ packages/stellar-wallets-kit-module/dist/ +packages/testkit/dist/ packages/contract-bindings/*/dist/ # Astro build cache diff --git a/Cargo.lock b/Cargo.lock index 86c6bc51..f23fd0d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1176,6 +1176,14 @@ dependencies = [ "stellar-accounts", ] +[[package]] +name = "nido-testkit" +version = "0.1.0" +dependencies = [ + "p256", + "sha2", +] + [[package]] name = "nido-webauthn-verifier" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 05989b39..c940555b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "crates/integration-tests", + "crates/testkit", "crates/zk-bench", "contracts/*", # Nested under contracts/vendor/, so not matched by the "contracts/*" glob. diff --git a/crates/testkit/Cargo.toml b/crates/testkit/Cargo.toml new file mode 100644 index 00000000..b5c9e0bc --- /dev/null +++ b/crates/testkit/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "nido-testkit" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Rust dapp-author test fixtures for Nido: deterministic local P-256 keys + synthetic WebAuthn assertions — the contract-test twin of @nidohq/testkit's secp256r1 path." + +[dependencies] +p256 = { version = "0.13", features = ["ecdsa"] } +sha2 = "0.10" diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs new file mode 100644 index 00000000..edd1a853 --- /dev/null +++ b/crates/testkit/src/lib.rs @@ -0,0 +1,138 @@ +//! Rust dapp-author test fixtures for Nido — the contract-test twin of +//! `@nidohq/testkit`'s secp256r1 path. +//! +//! Gives a contract test a deterministic local P-256 key and a WebAuthn-shaped +//! assertion the `webauthn-verifier` accepts, with no authenticator. Mirrors +//! `buildSyntheticAssertion` in the TS testkit and `build_contract_assertion` +//! in `crates/integration-tests`. +//! +//! Pure crypto — no `soroban-sdk` dependency — so it drops into any test. +//! Composing the auth digest (`sha256(payload || xdr(context_rule_ids))`) and +//! deploying the account belong to the soroban-integrated harness; this crate +//! is the signer half every one of those tests needs. + +use p256::ecdsa::signature::hazmat::PrehashSigner; +use p256::ecdsa::{Signature, SigningKey}; +use sha2::{Digest, Sha256}; + +/// A WebAuthn assertion built from a raw P-256 key. +#[derive(Clone, Debug)] +pub struct ContractAssertion { + /// 37 bytes: 32-byte rpIdHash (zero — verifier skips it) + flags + counter. + pub authenticator_data: Vec, + pub client_data_json: Vec, + /// 64-byte r‖s, low-S normalized. + pub signature: Vec, +} + +/// A deterministic P-256 signing key from a seed — reproducible across runs. +#[must_use] +pub fn test_p256_key(seed: u64) -> SigningKey { + // Hash the seed until the 32 bytes are a valid, non-zero scalar (< n). + let mut counter = 0u64; + loop { + let mut h = Sha256::new(); + h.update(b"nido-testkit:p256:"); + h.update(seed.to_le_bytes()); + h.update(counter.to_le_bytes()); + let bytes = h.finalize(); + if let Ok(key) = SigningKey::from_slice(&bytes) { + return key; + } + counter += 1; + } +} + +fn sha256(bytes: &[u8]) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(bytes); + h.finalize().into() +} + +/// URL-safe base64 without padding (RFC 4648 §5), matching the TS testkit. +fn b64url(input: &[u8]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::new(); + for chunk in input.chunks(3) { + let b = [ + chunk[0], + *chunk.get(1).unwrap_or(&0), + *chunk.get(2).unwrap_or(&0), + ]; + let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + let count = chunk.len(); + out.push(ALPHABET[((n >> 18) & 63) as usize] as char); + out.push(ALPHABET[((n >> 12) & 63) as usize] as char); + if count > 1 { + out.push(ALPHABET[((n >> 6) & 63) as usize] as char); + } + if count > 2 { + out.push(ALPHABET[(n & 63) as usize] as char); + } + } + out +} + +/// Build a WebAuthn assertion over the 32-byte `payload` (the auth digest) with +/// `key`. The verifier reconstructs `sha256(authData || sha256(clientData))` and +/// checks the challenge equals `base64url(payload)`. +#[must_use] +pub fn build_contract_assertion(key: &SigningKey, payload: &[u8; 32]) -> ContractAssertion { + let challenge = b64url(payload); + let client_data_json = format!( + "{{\"type\":\"webauthn.get\",\"challenge\":\"{challenge}\",\"origin\":\"https://example.com\",\"crossOrigin\":false}}" + ) + .into_bytes(); + + let mut authenticator_data = vec![0u8; 37]; + authenticator_data[32] = 0x1d; // UP|UV|BE|BS + + let cd_hash = sha256(&client_data_json); + let mut msg = authenticator_data.clone(); + msg.extend_from_slice(&cd_hash); + let digest = sha256(&msg); + + let mut sig: Signature = key.sign_prehash(&digest).expect("p256 prehash sign"); + if let Some(normalized) = sig.normalize_s() { + sig = normalized; // Stellar contract auth requires low-S + } + + ContractAssertion { + authenticator_data, + client_data_json, + signature: sig.to_bytes().to_vec(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use p256::ecdsa::signature::hazmat::PrehashVerifier; + use p256::ecdsa::VerifyingKey; + + #[test] + fn deterministic_key_is_reproducible() { + assert_eq!(test_p256_key(7).to_bytes(), test_p256_key(7).to_bytes()); + assert_ne!(test_p256_key(7).to_bytes(), test_p256_key(8).to_bytes()); + } + + #[test] + fn assertion_verifies_like_the_webauthn_verifier() { + let key = test_p256_key(42); + let payload = [0x11u8; 32]; + let a = build_contract_assertion(&key, &payload); + + // Reconstruct the signed digest and verify — what the verifier does. + let mut msg = a.authenticator_data.clone(); + msg.extend_from_slice(&sha256(&a.client_data_json)); + let digest = sha256(&msg); + + let vk = VerifyingKey::from(&key); + let sig = Signature::from_slice(&a.signature).unwrap(); + assert!(vk.verify_prehash(&digest, &sig).is_ok()); + + // The challenge binds to the payload. + let cd = String::from_utf8(a.client_data_json).unwrap(); + assert!(cd.contains(&b64url(&payload))); + } +} diff --git a/package-lock.json b/package-lock.json index 9443dc6b..d944df8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -249,7 +249,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@csstools/css-calc": "^2.1.3", @@ -1004,21 +1004,6 @@ } } }, - "node_modules/@creit.tech/stellar-wallets-kit/node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, "node_modules/@creit.tech/xbull-wallet-connect": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@creit.tech/xbull-wallet-connect/-/xbull-wallet-connect-0.4.0.tgz", @@ -1036,7 +1021,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -1056,7 +1041,7 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -1080,7 +1065,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -1108,7 +1093,7 @@ "version": "3.0.5", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -1131,7 +1116,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -3124,6 +3109,10 @@ "resolved": "packages/stellar-wallets-kit-module", "link": true }, + "node_modules/@nidohq/testkit": { + "resolved": "packages/testkit", + "link": true + }, "node_modules/@nidohq/webauthn-verifier": { "resolved": "packages/contract-bindings/webauthn-verifier", "link": true @@ -3171,6 +3160,62 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@noble/post-quantum": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.7.0.tgz", + "integrity": "sha512-IH2tpuGV4vBMdpCCua2BN7EuUICtmGp6DlBMNBYAYcL6QQ7eHt85GjLyD7ZT6Qx/xgIPIMqsSLDGvYqOm8Vqag==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "~2.3.0", + "@noble/curves": "~2.3.0", + "@noble/hashes": "~2.3.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum/node_modules/@noble/ciphers": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.3.0.tgz", + "integrity": "sha512-Clu/xdfgVTf9o7ngLOURaxePwR0j8sjclKEtVij10/jGulwFsPWCvvRgG/XjUVf8Nei+jLG6uwyXzUTGY1DQrw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum/node_modules/@noble/curves": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.3.0.tgz", + "integrity": "sha512-v7cY+4oWYPQszRj6ZFGzTVL7uP2TaLo1xMhWHzYC5wj0ZhOXQ5x+sBre8rF3hi8cAoi0bh1qXoovoOkdFtvqEg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.3.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum/node_modules/@noble/hashes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", + "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noir-lang/acvm_js": { "version": "1.0.0-beta.18", "resolved": "https://registry.npmjs.org/@noir-lang/acvm_js/-/acvm_js-1.0.0-beta.18.tgz", @@ -9601,21 +9646,6 @@ "ws": "^7.5.1" } }, - "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/ws": { "version": "7.5.11", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", @@ -12077,7 +12107,7 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^3.2.0", @@ -12091,7 +12121,7 @@ "version": "0.8.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/csstype": { @@ -12104,7 +12134,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "whatwg-mimetype": "^4.0.0", @@ -12201,7 +12231,7 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/decode-named-character-reference": { @@ -14370,7 +14400,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "whatwg-encoding": "^3.1.1" @@ -14439,7 +14469,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -14460,7 +14490,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -14483,7 +14513,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -14948,7 +14978,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/is-property": { @@ -15243,21 +15273,6 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, - "node_modules/jayson/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/jayson/node_modules/ws": { "version": "7.5.11", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", @@ -15327,7 +15342,7 @@ "version": "25.0.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "cssstyle": "^4.1.0", @@ -15610,7 +15625,7 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/magic-string": { @@ -17070,7 +17085,7 @@ "version": "2.2.23", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/object-assign": { @@ -18877,7 +18892,7 @@ "version": "0.7.1", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/rxjs": { @@ -18974,7 +18989,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/sax": { @@ -18990,7 +19005,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" @@ -19760,7 +19775,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/text-encoding-utf-8": { @@ -19865,7 +19880,7 @@ "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tldts-core": "^6.1.86" @@ -19878,7 +19893,7 @@ "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/to-buffer": { @@ -19921,7 +19936,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "tldts": "^6.1.32" @@ -19934,7 +19949,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -21925,7 +21940,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" @@ -21948,7 +21963,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -21959,7 +21974,7 @@ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "iconv-lite": "0.6.3" @@ -21972,7 +21987,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -21982,7 +21997,7 @@ "version": "14.2.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tr46": "^5.1.0", @@ -22199,7 +22214,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=18" @@ -22209,7 +22224,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/xrpl": { @@ -22602,6 +22617,7 @@ "@nidohq/spending-limit-policy": "*", "@nidohq/status-message": "*", "@nidohq/stellar-wallets-kit-module": "*", + "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "@noir-lang/noir_js": "1.0.0-beta.18", "@scure/bip39": "^2.2.0", @@ -22697,6 +22713,20 @@ "peerDependencies": { "@creit.tech/stellar-wallets-kit": "^2.0.0" } + }, + "packages/testkit": { + "name": "@nidohq/testkit", + "version": "0.1.0", + "dependencies": { + "@noble/curves": "^2.2.0", + "@noble/hashes": "^2.2.0", + "@noble/post-quantum": "^0.7.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.9.3", + "vitest": "^4.1.7" + } } } } diff --git a/packages/testkit/README.md b/packages/testkit/README.md new file mode 100644 index 00000000..5cb4267b --- /dev/null +++ b/packages/testkit/README.md @@ -0,0 +1,83 @@ +# @nidohq/testkit + +Dapp-author testing tools for Nido: create and exercise a smart account from +**local keys** — no WebAuthn passkey, no live network — and simulate +authorization locally. + +Tracking issue + request intake: **nidohq/nido#188**. + +## Why + +The production way to stand up a Nido account is a passkey ceremony: a real +browser, a user gesture, an authenticator. That's impossible in a unit test or a +CI job. This package gives you a Nido account from an in-process keypair, so a +test can answer *"does this call authorize under this account's policy?"* with a +plain assertion. + +## Quick start + +```ts +import { localSigner, createLocalAccount, simulateCheckAuth, contract, isSelf, rule } from '@nidohq/testkit'; + +// A CI key (ed25519) and an admin (secp256r1 — same curve the webauthn-verifier +// checks, but from a local key instead of a passkey). +const admin = localSigner({ id: 'admin', algorithm: 'secp256r1' }); +const ci = localSigner({ id: 'ci', algorithm: 'ed25519' }); + +const account = createLocalAccount({ + signers: [admin, ci], + rules: [ + rule({ name: 'admin-root', scope: { type: 'self-admin' }, signedBy: ['admin'] }), + rule({ name: 'ci-publish', scope: contract(REGISTRY), signedBy: ['ci'], + functions: ['publish', 'publish_hash'], args: [{ index: 1, pred: isSelf() }] }), + ], +}); + +account.address; // derived C-address (real deployer+salt derivation) +account.docHash; // the perch policy's real doc_hash + +// The ci key may publish as self... +simulateCheckAuth(account, { contract: REGISTRY, fn: 'publish', args: [/* … */] }, ['ci']).verdict; // 'allow' +// ...but not call anything else. +simulateCheckAuth(account, { contract: REGISTRY, fn: 'set_admin', args: [/* … */] }, ['ci']).verdict; // 'deny' +``` + +## Verifiers + +A signer's `algorithm` maps to the verifier its key is checked by: + +| algorithm | verifier | on-chain today | +|--------------|-----------------------------|----------------| +| `secp256r1` | `webauthn-verifier` | ✅ (driven by a local P-256 key here) | +| `ed25519` | ed25519 verifier | ⚠️ simulated — no `External` ed25519 verifier yet | +| `ml-dsa-65` | post-quantum verifier (#143)| ⚠️ simulated — groundwork in nido#143 | + +The ed25519 and ML-DSA verifiers, and perch policies themselves, are modelled +**ahead of their on-chain contracts** so the testkit can demonstrate the target +multi-verifier, perch-policied account. The simulator *is* the perch interpreter +until perch is integrated on-chain. + +## Roadmap — from simulation to a real VM, still offline + +`simulateCheckAuth` is a faithful TS model today. The endgame, behind the same +call so your test code never changes: + +1. Run the real **`soroban-env` in the browser** — it's `wasmi`, so it compiles + to wasm and runs the same host functions the network does. +2. Back it with **[stellar/rs-soroban-sdk#1657](https://github.com/stellar/rs-soroban-sdk/pull/1657)**'s + local-storage cache, so the env lazily pulls ledger entries from testnet and + caches them — fork-testnet-locally. + +Result: tests run fully local by default, touch the network only for state they +read, and are real-VM-accurate. + +## API + +- `localSigner({ id, algorithm, verifier?, keypair? })` → `LocalSigner` +- `createLocalAccount({ signers, network?, factory?, salt?, rules? })` → `LocalAccount` +- `simulateCheckAuth(account, context, signedBy[])` → `{ verdict, authDigest, matchedRule, reasons, signerChecks }` +- `reachableCalls(policy)`, `isNarrowing(parent, child)` — perch reachable-call + attenuation analysis +- `computeAuthDigest`, `buildSyntheticAssertion`, `verifySignature`, `VERIFIERS` +- perch policy: `rule`, `contract`, `selfAdmin`, `isSelf`, `addressEq`, `stringIn`, `stringPrefix`, `u32Eq`, `docHash` + +See `examples/perch-authz-console/` for a full dapp built on this. diff --git a/packages/testkit/package.json b/packages/testkit/package.json new file mode 100644 index 00000000..40aab5cd --- /dev/null +++ b/packages/testkit/package.json @@ -0,0 +1,36 @@ +{ + "name": "@nidohq/testkit", + "version": "0.1.0", + "description": "Dapp-author testing tools for Nido: create and exercise a smart account from local keys (ed25519, secp256r1, ML-DSA-65) with no passkey, and simulate authorization locally.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run", + "check": "tsc --noEmit" + }, + "dependencies": { + "@noble/curves": "^2.2.0", + "@noble/hashes": "^2.2.0", + "@noble/post-quantum": "^0.7.0", + "@stellar/stellar-sdk": "^15.1.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.9.3", + "vitest": "^4.1.7" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/testkit/src/account.ts b/packages/testkit/src/account.ts new file mode 100644 index 00000000..9335e691 --- /dev/null +++ b/packages/testkit/src/account.ts @@ -0,0 +1,73 @@ +// Create a Nido smart account from local signers — derive its C-address and +// build its authorization policy as a perch PolicyDoc with a real doc_hash. +// +// Address derivation is the real Soroban deployer+salt scheme (identical to the +// factory's on-chain `get_c_address`); the policy layer is perch (a +// stellar-registry/perch library — not yet integrated on-chain in nido, so the +// PolicyDoc + doc_hash are the target-state model the simulator enforces). + +import { sha256 } from '@noble/hashes/sha2.js'; +import { xdr, hash, Address, StrKey } from '@stellar/stellar-sdk'; +import { docHash } from './perch/canonical.js'; +import { rule, selfAdmin, type PolicyDoc, type Rule } from './perch/policy.js'; +import type { LocalSigner } from './signer.js'; + +/** Registry-fallback factory (the deployer whose salt derives the C-address). */ +export const DEFAULT_FACTORY = 'CBQKB6GYPO7P2CGDKN7KYLEFEBBN6FY5NXZJ7HNR43ZK2DDOU5N7NCV5'; + +export const TESTNET_PASSPHRASE = 'Test SDF Network ; September 2015'; + +export interface CreateAccountOptions { + signers: LocalSigner[]; + /** Network passphrase (default: testnet). */ + network?: string; + /** Deployer contract whose salt derives the address (default: the factory). */ + factory?: string; + /** 32-byte salt (default: sha256 of the first signer's public key). */ + salt?: Uint8Array; + /** Override the default policy rules (default: one self-admin N-of-N rule). */ + rules?: Rule[]; +} + +export interface LocalAccount { + /** Derived smart-account C-address. */ + readonly address: string; + readonly signers: LocalSigner[]; + readonly policy: PolicyDoc; + /** Real perch doc_hash of `policy`. */ + readonly docHash: string; + readonly network: string; +} + +/** The real deployer+salt contract-id derivation (matches on-chain). */ +export function deriveAccountAddress(factory: string, salt: Uint8Array, passphrase: string): string { + const preimage = xdr.HashIdPreimage.envelopeTypeContractId( + new xdr.HashIdPreimageContractId({ + networkId: hash(Buffer.from(passphrase, 'utf-8')), + contractIdPreimage: xdr.ContractIdPreimage.contractIdPreimageFromAddress( + new xdr.ContractIdPreimageFromAddress({ + address: Address.fromString(factory).toScAddress(), + salt: Buffer.from(salt), + }), + ), + }), + ); + return StrKey.encodeContract(hash(preimage.toXDR())); +} + +export function createLocalAccount(opts: CreateAccountOptions): LocalAccount { + const first = opts.signers[0]; + if (!first) throw new Error('createLocalAccount: at least one signer is required'); + + const network = opts.network ?? TESTNET_PASSPHRASE; + const factory = opts.factory ?? DEFAULT_FACTORY; + const salt = opts.salt ?? sha256(first.publicKey); + const address = deriveAccountAddress(factory, salt, network); + + const signerDecls = opts.signers.map((s) => ({ id: s.id, verifier: s.verifier, key: s.publicKeyHex })); + const rules = + opts.rules ?? [rule({ name: 'admin-root', scope: selfAdmin(), signedBy: opts.signers.map((s) => s.id) })]; + + const policy: PolicyDoc = { version: 1, network, signers: signerDecls, rules }; + return { address, signers: opts.signers, policy, docHash: docHash(policy), network }; +} diff --git a/packages/testkit/src/auth.ts b/packages/testkit/src/auth.ts new file mode 100644 index 00000000..4af9515b --- /dev/null +++ b/packages/testkit/src/auth.ts @@ -0,0 +1,78 @@ +// The authorization digest a Nido account asks its signers to sign, and the +// WebAuthn-shaped assertion a secp256r1 signer produces. Ported from +// @nidohq/passkey-sdk (auth.ts / syntheticAssertion.ts) so the testkit stays +// self-contained and bundles cleanly for a static example. + +import { p256 } from '@noble/curves/nist.js'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { xdr, hash } from '@stellar/stellar-sdk'; + +/** A WebAuthn assertion built from a raw P-256 key — no authenticator. The + * webauthn-verifier checks `digest == sha256(authData || sha256(clientData))` + * and that the clientData challenge equals base64url(the auth digest). */ +export interface SyntheticAssertion { + authenticatorData: Uint8Array; // 37 bytes + clientDataJSON: Uint8Array; + signature: Uint8Array; // 64-byte r‖s, low-S +} + +/** The digest every signer signs: `sha256(signature_payload || xdr(context_rule_ids))`. + * Mirrors OZ `do_check_auth` and the Rust `compute_auth_digest`. Binds the + * signature to the specific context rule (rule-substitution replay defense). */ +export function computeAuthDigest( + signaturePayload: Uint8Array, + contextRuleIds: readonly number[] = [0], +): Uint8Array { + const ctxIdsXdr = xdr.ScVal.scvVec(contextRuleIds.map((id) => xdr.ScVal.scvU32(id))).toXDR(); + const preimage = new Uint8Array(signaturePayload.length + ctxIdsXdr.length); + preimage.set(signaturePayload, 0); + preimage.set(ctxIdsXdr, signaturePayload.length); + return Uint8Array.from(hash(Buffer.from(preimage))); +} + +/** Build a WebAuthn assertion over `payload32` (the auth digest) with a raw + * P-256 scalar. Ported verbatim from passkey-sdk's `buildSyntheticAssertion`, + * using @noble sha256 so it is synchronous. */ +export function buildSyntheticAssertion(privateKeyD: Uint8Array, payload32: Uint8Array): SyntheticAssertion { + if (payload32.byteLength !== 32) throw new Error('buildSyntheticAssertion: payload must be 32 bytes'); + + const challenge = bytesToB64u(payload32); + const clientDataJSON = new TextEncoder().encode( + `{"type":"webauthn.get","challenge":"${challenge}","origin":"https://example.com","crossOrigin":false}`, + ); + const authenticatorData = new Uint8Array(37); + authenticatorData[32] = 0x1d; // UP|UV|BE|BS flags; rpIdHash left zero (verifier skips it) + + const cdHash = sha256(clientDataJSON); + const msg = new Uint8Array(authenticatorData.length + cdHash.length); + msg.set(authenticatorData, 0); + msg.set(cdHash, authenticatorData.length); + const digest = sha256(msg); + + const signature = p256.sign(digest, privateKeyD, { prehash: false, lowS: true }); + return { authenticatorData, clientDataJSON, signature }; +} + +/** Verify a synthetic assertion the way the webauthn-verifier would: the + * challenge must equal the auth digest, and the P-256 signature must verify + * over `sha256(authData || sha256(clientData))`. */ +export function verifySyntheticAssertion( + authDigest: Uint8Array, + publicKeySec1: Uint8Array, + a: SyntheticAssertion, +): boolean { + const expectedChallenge = bytesToB64u(authDigest); + const clientData = JSON.parse(new TextDecoder().decode(a.clientDataJSON)) as { challenge?: string }; + if (clientData.challenge !== expectedChallenge) return false; + const cdHash = sha256(a.clientDataJSON); + const msg = new Uint8Array(a.authenticatorData.length + cdHash.length); + msg.set(a.authenticatorData, 0); + msg.set(cdHash, a.authenticatorData.length); + const digest = sha256(msg); + return p256.verify(a.signature, digest, publicKeySec1, { prehash: false, lowS: true }); +} + +export function bytesToB64u(b: Uint8Array): string { + const s = btoa(String.fromCharCode(...b)); + return s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} diff --git a/packages/testkit/src/checkauth.ts b/packages/testkit/src/checkauth.ts new file mode 100644 index 00000000..cd308ec9 --- /dev/null +++ b/packages/testkit/src/checkauth.ts @@ -0,0 +1,140 @@ +// Simulate a Nido account's __check_auth locally and return a Kleene verdict. +// +// Mirrors OZ `do_check_auth` (context-rule match → signer authentication over +// the auth digest → policy enforcement) with the policy layer being perch: a +// rule's function allowlist, argument predicates, expiry, and signer floor are +// evaluated here (perch isn't on-chain yet, so the simulator IS the interpreter). +// On-chain is boolean (allow / trap); `abstain` is a testkit-side convenience +// meaning "no rule even applies to this call". + +import { sha256 } from '@noble/hashes/sha2.js'; +import { bytesToHex } from '@noble/hashes/utils.js'; +import { computeAuthDigest } from './auth.js'; +import { verifySignature } from './verifiers.js'; +import type { LocalAccount } from './account.js'; +import type { ArgPred, Rule } from './perch/policy.js'; + +export type SimArg = + | { type: 'address'; value: string } + | { type: 'u32'; value: number } + | { type: 'string'; value: string } + | { type: 'symbol'; value: string } + | { type: 'i128'; value: bigint | string } + | { type: 'bytes'; value: Uint8Array }; + +export interface SimContext { + /** Target contract for a CallContract rule; omit for a self-admin call. */ + contract?: string; + fn: string; + args?: SimArg[]; + /** Current ledger sequence (for expiry). Default 0. */ + ledger?: number; +} + +export type Verdict = 'allow' | 'deny' | 'abstain'; + +export interface SimResult { + verdict: Verdict; + /** hex of the digest each signer signed. */ + authDigest: string; + matchedRule?: string; + reasons: string[]; + signerChecks: { id: string; verifier: string; ok: boolean }[]; +} + +function stableContext(ctx: SimContext): string { + const args = (ctx.args ?? []).map((a) => + a.type === 'bytes' + ? { type: a.type, value: bytesToHex(a.value) } + : a.type === 'i128' + ? { type: a.type, value: String(a.value) } + : a, + ); + return JSON.stringify({ contract: ctx.contract ?? null, fn: ctx.fn, args, ledger: ctx.ledger ?? 0 }); +} + +function scopeMatches(rule: Rule, account: LocalAccount, ctx: SimContext): boolean { + if (rule.scope.type === 'self-admin') return ctx.contract === undefined || ctx.contract === account.address; + return ctx.contract === rule.scope.address; +} + +function argSatisfies(pred: ArgPred, arg: SimArg | undefined, account: LocalAccount): boolean { + if (!arg) return false; + switch (pred.type) { + case 'is-self': + return arg.type === 'address' && arg.value === account.address; + case 'address-eq': + return arg.type === 'address' && arg.value === pred.address; + case 'u32-eq': + return arg.type === 'u32' && arg.value === pred.value; + case 'string-in': + return (arg.type === 'string' || arg.type === 'symbol') && pred.values.includes(arg.value); + case 'string-prefix': + return (arg.type === 'string' || arg.type === 'symbol') && arg.value.startsWith(pred.prefix); + } +} + +export function simulateCheckAuth(account: LocalAccount, ctx: SimContext, signedBy: string[]): SimResult { + const ledger = ctx.ledger ?? 0; + const reasons: string[] = []; + + // 1. Find the rule whose scope this call falls under. + const idx = account.policy.rules.findIndex((r) => scopeMatches(r, account, ctx)); + if (idx < 0) { + return { verdict: 'abstain', authDigest: '', reasons: ['no rule applies to this call'], signerChecks: [] }; + } + const rule = account.policy.rules[idx]!; + + // 2. Digest bound to this rule; authenticate the signers that signed it. + const payload = sha256(new TextEncoder().encode(stableContext(ctx))); + const digest = computeAuthDigest(payload, [idx]); + const signerChecks = signedBy.map((id) => { + const s = account.signers.find((x) => x.id === id); + if (!s) return { id, verifier: '(unknown)', ok: false }; + return { id, verifier: s.verifier, ok: verifySignature(s.algorithm, digest, s.publicKey, s.signAuth(digest)) }; + }); + const authenticated = new Set(signerChecks.filter((c) => c.ok).map((c) => c.id)); + + const base: Omit = { + authDigest: bytesToHex(digest), + matchedRule: rule.name, + reasons, + signerChecks, + }; + const deny = (why: string): SimResult => { + reasons.push(why); + return { verdict: 'deny', ...base }; + }; + + // 3. Expiry (perch "dead at or after"; OZ valid_until is inclusive one below). + const notAfter = rule['not-after-ledger']; + if (notAfter !== undefined && ledger >= notAfter) return deny(`rule expired (ledger ${ledger} ≥ ${notAfter})`); + + // 4. Function allowlist. + if (rule.functions && !rule.functions.includes(ctx.fn)) { + return deny(`function ${ctx.fn}() not in [${rule.functions.join(', ')}]`); + } + + // 5. Argument predicates. + for (const c of rule.args ?? []) { + if (!argSatisfies(c.pred, ctx.args?.[c.index], account)) { + return deny(`arg[${c.index}] fails ${c.pred.type}`); + } + } + + // 6. Signer sufficiency: perch injects MinSigners(n) = every referenced signer + // (N-of-N), the on-chain floor when a policy is attached. + if (rule.principals.type === 'all') { + const missing = rule.principals.signers.filter((id) => !authenticated.has(id)); + if (missing.length) return deny(`missing signature from [${missing.join(', ')}]`); + } + + // 7. Cumulative cap is a stateful sibling policy — not evaluable from a single + // call. Surface it rather than silently ignore. + if (rule.cap) { + reasons.push(`cap ≤ ${rule.cap.limit} / ${rule.cap['period-ledgers']} ledgers applies (stateful; not checked per-call)`); + } + + reasons.push('authorized'); + return { verdict: 'allow', ...base }; +} diff --git a/packages/testkit/src/crypto.ts b/packages/testkit/src/crypto.ts new file mode 100644 index 00000000..15ab3797 --- /dev/null +++ b/packages/testkit/src/crypto.ts @@ -0,0 +1,67 @@ +// Local keypairs for every verifier a Nido account supports — generated and +// signing entirely in-process, with no WebAuthn passkey. Each algorithm maps to +// a verifier contract: secp256r1 → webauthn-verifier, ed25519 → the ed25519 +// verifier path, ml-dsa-65 → the post-quantum verifier (nido#143). +// +// A `sign(digest)` takes the 32-byte auth digest a Nido account would ask a +// signer to sign and returns the raw signature bytes that verifier expects: +// - ed25519 → 64-byte signature +// - secp256r1 → 64-byte compact r‖s, low-S normalized (what the verifier wants) +// - ml-dsa-65 → the ML-DSA-65 signature over the digest + +import { ed25519 } from '@noble/curves/ed25519.js'; +import { p256 } from '@noble/curves/nist.js'; +import { ml_dsa65 } from '@noble/post-quantum/ml-dsa.js'; +import { randomBytes } from '@noble/hashes/utils.js'; + +export type Algorithm = 'ed25519' | 'secp256r1' | 'ml-dsa-65'; + +export interface RawKeypair { + readonly algorithm: Algorithm; + readonly secretKey: Uint8Array; + /** Public key in the form the verifier canonicalizes: ed25519 32B, secp256r1 + * 65B uncompressed, ml-dsa-65 the encoded public key. */ + readonly publicKey: Uint8Array; + /** Sign the 32-byte auth digest, returning raw verifier-shaped bytes. */ + sign(digest: Uint8Array): Uint8Array; +} + +export function ed25519Keypair(secret?: Uint8Array): RawKeypair { + const sk = secret ?? ed25519.utils.randomSecretKey(); + return { + algorithm: 'ed25519', + secretKey: sk, + publicKey: ed25519.getPublicKey(sk), + sign: (digest) => ed25519.sign(digest, sk), + }; +} + +export function secp256r1Keypair(secret?: Uint8Array): RawKeypair { + const sk = secret ?? p256.utils.randomSecretKey(); + return { + algorithm: 'secp256r1', + secretKey: sk, + publicKey: p256.getPublicKey(sk, false), // 65-byte uncompressed, as the webauthn-verifier expects + sign: (digest) => p256.sign(digest, sk, { prehash: false }), // 64-byte compact r‖s, low-S + }; +} + +export function mlDsa65Keypair(seed?: Uint8Array): RawKeypair { + const s = seed ?? randomBytes(32); + const { publicKey, secretKey } = ml_dsa65.keygen(s); + return { + algorithm: 'ml-dsa-65', + secretKey, + publicKey, + sign: (digest) => ml_dsa65.sign(digest, secretKey), // @noble: sign(message, secretKey) + }; +} + +/** Generate a fresh local keypair for the given algorithm. */ +export function generateKeypair(algorithm: Algorithm): RawKeypair { + switch (algorithm) { + case 'ed25519': return ed25519Keypair(); + case 'secp256r1': return secp256r1Keypair(); + case 'ml-dsa-65': return mlDsa65Keypair(); + } +} diff --git a/packages/testkit/src/index.ts b/packages/testkit/src/index.ts new file mode 100644 index 00000000..43c4c32d --- /dev/null +++ b/packages/testkit/src/index.ts @@ -0,0 +1,75 @@ +// @nidohq/testkit — create and exercise a Nido smart account from local keys +// (ed25519, secp256r1, ML-DSA-65), with no passkey, and simulate authorization +// locally. See the tracking issue for scope + roadmap (soroban-env in the +// browser via wasmi + rs-soroban-sdk#1657 cache for lazy testnet pulls). + +export { + generateKeypair, + ed25519Keypair, + secp256r1Keypair, + mlDsa65Keypair, + type Algorithm, + type RawKeypair, +} from './crypto.js'; + +export { localSigner, type LocalSigner, type LocalSignerOptions } from './signer.js'; + +export { + VERIFIERS, + verifySignature, + type VerifierInfo, + type SignatureData, +} from './verifiers.js'; + +export { + createLocalAccount, + deriveAccountAddress, + DEFAULT_FACTORY, + TESTNET_PASSPHRASE, + type LocalAccount, + type CreateAccountOptions, +} from './account.js'; + +export { + simulateCheckAuth, + type SimContext, + type SimArg, + type SimResult, + type Verdict, +} from './checkauth.js'; + +export { + computeAuthDigest, + buildSyntheticAssertion, + verifySyntheticAssertion, + type SyntheticAssertion, +} from './auth.js'; + +// perch policy surface (vendored / mirrored from @stellar-registry/perch) +export { canonicalJson, docHash, CANON_VERSION } from './perch/canonical.js'; +export { + rule, + selfAdmin, + contract, + isSelf, + addressEq, + stringIn, + stringPrefix, + u32Eq, + type PolicyDoc, + type Rule, + type RuleInit, + type SignerDecl, + type Scope, + type Principals, + type ArgPred, + type ArgConstraint, + type CapConstraint, +} from './perch/policy.js'; +export { + reachableCalls, + isNarrowing, + type ReachableScope, + type FnSet, + type NarrowingResult, +} from './perch/analysis.js'; diff --git a/packages/testkit/src/perch/analysis.ts b/packages/testkit/src/perch/analysis.ts new file mode 100644 index 00000000..2a24edaf --- /dev/null +++ b/packages/testkit/src/perch/analysis.ts @@ -0,0 +1,45 @@ +// Reachable-call analysis + monotone-attenuation check over a PolicyDoc — the +// TS mirror of perch's reachable_calls / is_narrowing (perch #19 PR4/PR8), used +// by the example to visualize "what can each key do" and to verify a narrowing. + +import type { PolicyDoc } from './policy.js'; + +export type FnSet = { kind: 'any' } | { kind: 'only'; functions: string[] }; + +export interface ReachableScope { + rule: string; + scope: string; // 'self-admin' or a contract address + functions: FnSet; +} + +/** Every (rule, scope, function-set) the policy can authorize. */ +export function reachableCalls(policy: PolicyDoc): ReachableScope[] { + return policy.rules.map((r) => ({ + rule: r.name, + scope: r.scope.type === 'self-admin' ? 'self-admin' : r.scope.address, + functions: r.functions ? { kind: 'only', functions: r.functions } : { kind: 'any' }, + })); +} + +function covers(parent: FnSet, child: FnSet): boolean { + if (parent.kind === 'any') return true; + if (child.kind === 'any') return false; + return child.functions.every((f) => parent.functions.includes(f)); +} + +export type NarrowingResult = { ok: true } | { ok: false; rule: string; reason: string }; + +/** Whether `child` only narrows `parent`: every (scope, function) the child can + * authorize is one the parent already could. The fail-closed subset check that + * makes attenuation safe. */ +export function isNarrowing(parent: PolicyDoc, child: PolicyDoc): NarrowingResult { + const p = reachableCalls(parent); + for (const cs of reachableCalls(child)) { + const ps = p.find((x) => x.scope === cs.scope); + if (!ps) return { ok: false, rule: cs.rule, reason: `scope ${cs.scope} is not in the parent` }; + if (!covers(ps.functions, cs.functions)) { + return { ok: false, rule: cs.rule, reason: `functions widen beyond the parent on ${cs.scope}` }; + } + } + return { ok: true }; +} diff --git a/packages/testkit/src/perch/canonical.ts b/packages/testkit/src/perch/canonical.ts new file mode 100644 index 00000000..627bf1f5 --- /dev/null +++ b/packages/testkit/src/perch/canonical.ts @@ -0,0 +1,79 @@ +// Canonical JSON (RFC 8785 / JCS subset) and doc_hash. +// +// VENDORED from @stellar-registry/perch (packages/perch-js/src/canonical.ts) — +// that package is not yet published to npm, and the testkit must produce a +// doc_hash byte-identical to on-chain perch. Kept verbatim so the two never +// drift; if perch bumps CANON_VERSION, re-vendor. Source of truth: perch's +// CANONICAL.md. Do not "improve" the escaping here — its whole purpose is to +// match the Rust canon.rs byte-for-byte. + +import { sha256 } from '@noble/hashes/sha2.js'; +import { bytesToHex } from '@noble/hashes/utils.js'; + +/** Canonical-form version, mirroring perch-ir's CANON_VERSION. A format + * identifier, not part of the hash preimage. */ +export const CANON_VERSION = 1; + +const HEX = '0123456789abcdef'; + +/** Write `s` as a canonical JSON string literal per RFC 8785 §3.2.2.2 — + * implemented directly (not `JSON.stringify`) so the hashed bytes are defined + * here, not inherited from a runtime serializer. */ +function writeString(s: string): string { + let out = '"'; + for (const ch of s) { + switch (ch) { + case '"': out += '\\"'; break; + case '\\': out += '\\\\'; break; + case '\b': out += '\\b'; break; + case '\t': out += '\\t'; break; + case '\n': out += '\\n'; break; + case '\f': out += '\\f'; break; + case '\r': out += '\\r'; break; + default: { + const code = ch.codePointAt(0)!; + if (code < 0x20) { + out += '\\u00' + HEX[(code >> 4) & 0xf] + HEX[code & 0xf]; + } else { + out += ch; + } + } + } + } + return out + '"'; +} + +function write(v: unknown): string { + if (v === null) { + throw new Error('canonical form must not contain null'); + } + switch (typeof v) { + case 'string': + return writeString(v); + case 'number': + if (!Number.isInteger(v)) throw new Error(`non-integer number in canonical form: ${v}`); + return String(v); + case 'boolean': + return v ? 'true' : 'false'; + case 'object': { + if (Array.isArray(v)) return `[${v.map(write).join(',')}]`; + const obj = v as Record; + const keys = Object.keys(obj) + .filter((k) => obj[k] !== undefined) + .sort(); + return `{${keys.map((k) => `${writeString(k)}:${write(obj[k])}`).join(',')}}`; + } + default: + throw new Error(`unserializable value in canonical form: ${typeof v}`); + } +} + +/** Serialize a policy document to its canonical JSON form. */ +export function canonicalJson(doc: unknown): string { + return write(doc); +} + +/** Lowercase-hex SHA-256 of the canonical JSON bytes — the document's identity. */ +export function docHash(doc: unknown): string { + return bytesToHex(sha256(new TextEncoder().encode(canonicalJson(doc)))); +} diff --git a/packages/testkit/src/perch/policy.ts b/packages/testkit/src/perch/policy.ts new file mode 100644 index 00000000..03461b86 --- /dev/null +++ b/packages/testkit/src/perch/policy.ts @@ -0,0 +1,96 @@ +// The perch PolicyDoc wire shape (kebab-case keys), plus small constructors. +// Mirrors @stellar-registry/perch's schema so `docHash` here equals on-chain +// perch. Types are the wire form directly — the testkit builds documents +// programmatically, so a full zod parse is not needed (perch validates on +// compile). + +export type Scope = { type: 'self-admin' } | { type: 'contract'; address: string }; + +export type Principals = + | { type: 'all'; signers: string[] } + | { type: 'self-authenticating'; policy: string; 'install-param-hex': string; ack: string }; + +export type ArgPred = + | { type: 'is-self' } + | { type: 'address-eq'; address: string } + | { type: 'string-in'; values: string[] } + | { type: 'string-prefix'; prefix: string } + | { type: 'u32-eq'; value: number }; + +export interface ArgConstraint { + index: number; + pred: ArgPred; +} + +export interface CapConstraint { + token?: string; + /** decimal string (i128); a string, not a number — the canonical form carries + * only u32 numbers. */ + limit: string; + 'period-ledgers': number; +} + +export interface SignerDecl { + id: string; + /** verifier contract C-address. */ + verifier: string; + /** hex-encoded key material, opaque to perch. */ + key: string; +} + +export interface Rule { + name: string; + scope: Scope; + principals: Principals; + functions?: string[]; + args?: ArgConstraint[]; + 'not-after-ledger'?: number; + cap?: CapConstraint; +} + +export interface PolicyDoc { + version: 1; + network?: string; + signers: SignerDecl[]; + rules: Rule[]; +} + +// -- argument predicate constructors (wire shape) -- +export const isSelf = (): ArgPred => ({ type: 'is-self' }); +export const addressEq = (address: string): ArgPred => ({ type: 'address-eq', address }); +export const stringIn = (values: string[]): ArgPred => ({ type: 'string-in', values }); +export const stringPrefix = (prefix: string): ArgPred => ({ type: 'string-prefix', prefix }); +export const u32Eq = (value: number): ArgPred => ({ type: 'u32-eq', value }); + +/** Drop `undefined` optionals so the object matches the canonical form (which + * omits absent fields rather than emitting null). */ +function compact>(o: T): T { + for (const k of Object.keys(o)) if (o[k] === undefined) delete o[k]; + return o; +} + +export interface RuleInit { + name: string; + scope: Scope; + signedBy: string[]; + functions?: string[]; + args?: ArgConstraint[]; + notAfterLedger?: number; + cap?: CapConstraint; +} + +/** Build one rule in wire shape from a friendly init. */ +export function rule(init: RuleInit): Rule { + return compact({ + name: init.name, + scope: init.scope, + principals: { type: 'all', signers: init.signedBy }, + functions: init.functions, + args: init.args, + 'not-after-ledger': init.notAfterLedger, + cap: init.cap, + }) as Rule; +} + +export const selfAdmin = (): Scope => ({ type: 'self-admin' }); +export const contract = (address: string): Scope => ({ type: 'contract', address }); diff --git a/packages/testkit/src/signer.ts b/packages/testkit/src/signer.ts new file mode 100644 index 00000000..128300c0 --- /dev/null +++ b/packages/testkit/src/signer.ts @@ -0,0 +1,46 @@ +// A local signer: an in-process keypair bound to a verifier, that can produce +// the auth-payload signature over an account's auth digest — no passkey. + +import { bytesToHex } from '@noble/hashes/utils.js'; +import { generateKeypair, type Algorithm, type RawKeypair } from './crypto.js'; +import { buildSyntheticAssertion } from './auth.js'; +import { VERIFIERS, type SignatureData } from './verifiers.js'; + +export interface LocalSigner { + readonly id: string; + readonly algorithm: Algorithm; + /** verifier contract C-address this signer's key is checked by. */ + readonly verifier: string; + readonly publicKey: Uint8Array; + readonly publicKeyHex: string; + /** Sign the account's 32-byte auth digest, returning the payload signature. */ + signAuth(authDigest: Uint8Array): SignatureData; +} + +export interface LocalSignerOptions { + id: string; + algorithm: Algorithm; + /** Override the verifier address (defaults to the algorithm's verifier). */ + verifier?: string; + /** Reuse an existing keypair instead of generating one. */ + keypair?: RawKeypair; +} + +export function localSigner(opts: LocalSignerOptions): LocalSigner { + const kp = opts.keypair ?? generateKeypair(opts.algorithm); + const verifier = opts.verifier ?? VERIFIERS[opts.algorithm].address; + return { + id: opts.id, + algorithm: opts.algorithm, + verifier, + publicKey: kp.publicKey, + publicKeyHex: bytesToHex(kp.publicKey), + signAuth(authDigest) { + if (opts.algorithm === 'secp256r1') { + // The webauthn-verifier consumes a WebAuthn assertion, not a raw sig. + return { kind: 'webauthn', assertion: buildSyntheticAssertion(kp.secretKey, authDigest) }; + } + return { kind: 'raw', bytes: kp.sign(authDigest) }; + }, + }; +} diff --git a/packages/testkit/src/testkit.test.ts b/packages/testkit/src/testkit.test.ts new file mode 100644 index 00000000..c14b070a --- /dev/null +++ b/packages/testkit/src/testkit.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { StrKey } from '@stellar/stellar-sdk'; +import { localSigner } from './signer.js'; +import { verifySignature, VERIFIERS } from './verifiers.js'; +import { computeAuthDigest } from './auth.js'; +import { createLocalAccount } from './account.js'; +import { simulateCheckAuth } from './checkauth.js'; +import { rule, contract, isSelf } from './perch/policy.js'; +import { reachableCalls, isNarrowing } from './perch/analysis.js'; + +const REGISTRY = 'CCA7QAA6OD6LQJTU2MKN6EAS5I52QIFPAYMMQYSU7KHWTGT26AN6N2AL'; +const ALGS = ['ed25519', 'secp256r1', 'ml-dsa-65'] as const; + +describe('local signers — every verifier round-trips', () => { + for (const algorithm of ALGS) { + it(`${algorithm}: a fresh local key signs and verifies its own auth digest`, () => { + const s = localSigner({ id: 'k', algorithm }); + const digest = computeAuthDigest(new Uint8Array(32).fill(3), [0]); + const sig = s.signAuth(digest); + expect(verifySignature(algorithm, digest, s.publicKey, sig)).toBe(true); + // a different digest must not verify + const other = computeAuthDigest(new Uint8Array(32).fill(9), [0]); + expect(verifySignature(algorithm, other, s.publicKey, sig)).toBe(false); + expect(s.verifier).toBe(VERIFIERS[algorithm].address); + }); + } +}); + +describe('createLocalAccount', () => { + it('derives a valid contract C-address and a real perch doc_hash', () => { + const admin = localSigner({ id: 'admin', algorithm: 'secp256r1' }); + const acct = createLocalAccount({ signers: [admin] }); + expect(StrKey.isValidContract(acct.address)).toBe(true); + expect(acct.docHash).toMatch(/^[0-9a-f]{64}$/); + // deterministic: same key ⇒ same address + hash + const again = createLocalAccount({ signers: [admin] }); + expect(again.address).toBe(acct.address); + expect(again.docHash).toBe(acct.docHash); + }); + + it('supports a multi-verifier account (secp256r1 + ed25519 + ML-DSA)', () => { + const signers = ALGS.map((algorithm, i) => localSigner({ id: `s${i}`, algorithm })); + const acct = createLocalAccount({ signers }); + expect(acct.policy.signers).toHaveLength(3); + expect(new Set(acct.policy.signers.map((s) => s.verifier)).size).toBe(3); + }); +}); + +describe('simulateCheckAuth — the ci-publish policy', () => { + const admin = localSigner({ id: 'admin', algorithm: 'secp256r1' }); + const ci = localSigner({ id: 'ci', algorithm: 'ed25519' }); + const acct = createLocalAccount({ + signers: [admin, ci], + rules: [ + rule({ name: 'admin-root', scope: { type: 'self-admin' }, signedBy: ['admin'] }), + rule({ + name: 'ci-publish', + scope: contract(REGISTRY), + signedBy: ['ci'], + functions: ['publish', 'publish_hash'], + args: [{ index: 1, pred: isSelf() }], + notAfterLedger: 55_000_000, + }), + ], + }); + const ctx = (fn: string, author: string, ledger = 54_000_000) => ({ + contract: REGISTRY, + fn, + args: [{ type: 'u32' as const, value: 0 }, { type: 'address' as const, value: author }], + ledger, + }); + + it('allows the ci key to publish as self', () => { + expect(simulateCheckAuth(acct, ctx('publish_hash', acct.address), ['ci']).verdict).toBe('allow'); + }); + it('denies a function outside the allowlist', () => { + expect(simulateCheckAuth(acct, ctx('set_admin', acct.address), ['ci']).verdict).toBe('deny'); + }); + it('denies when the author is not self', () => { + const other = createLocalAccount({ signers: [localSigner({ id: 'x', algorithm: 'ed25519' })] }).address; + expect(simulateCheckAuth(acct, ctx('publish', other), ['ci']).verdict).toBe('deny'); + }); + it('denies with no signature (the zero-signature attack)', () => { + expect(simulateCheckAuth(acct, ctx('publish', acct.address), []).verdict).toBe('deny'); + }); + it('denies an expired rule', () => { + expect(simulateCheckAuth(acct, ctx('publish', acct.address, 55_000_001), ['ci']).verdict).toBe('deny'); + }); +}); + +describe('perch analysis — reachable + attenuation', () => { + const acct = createLocalAccount({ + signers: [localSigner({ id: 'ci', algorithm: 'ed25519' })], + rules: [ + rule({ name: 'ci-publish', scope: contract(REGISTRY), signedBy: ['ci'], functions: ['publish', 'publish_hash'] }), + ], + }); + + it('reports reachable calls', () => { + const r = reachableCalls(acct.policy); + expect(r[0]).toMatchObject({ rule: 'ci-publish', scope: REGISTRY }); + }); + + it('accepts a narrowing and rejects a widening', () => { + const narrowed = structuredClone(acct.policy); + narrowed.rules[0]!.functions = ['publish']; + expect(isNarrowing(acct.policy, narrowed).ok).toBe(true); + + const widened = structuredClone(acct.policy); + widened.rules[0]!.functions = ['publish', 'publish_hash', 'set_admin']; + expect(isNarrowing(acct.policy, widened).ok).toBe(false); + }); +}); diff --git a/packages/testkit/src/verifiers.ts b/packages/testkit/src/verifiers.ts new file mode 100644 index 00000000..a8d70211 --- /dev/null +++ b/packages/testkit/src/verifiers.ts @@ -0,0 +1,79 @@ +// The verifier contracts a Nido account's signers point at. Each `Signer` is +// `External(verifier, key)`, and `check_auth` calls `verifier.verify(digest, +// key, sig)`. Only secp256r1 has a deployed verifier today; ed25519 and +// ML-DSA-65 are modelled here ahead of their on-chain contracts (see `onChain`) +// so the testkit can demonstrate the target multi-verifier account. + +import { ed25519 } from '@noble/curves/ed25519.js'; +import { p256 } from '@noble/curves/nist.js'; +import { ml_dsa65 } from '@noble/post-quantum/ml-dsa.js'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { StrKey } from '@stellar/stellar-sdk'; +import type { Algorithm } from './crypto.js'; +import { verifySyntheticAssertion, type SyntheticAssertion } from './auth.js'; + +/** The data an `External` signer contributes to the auth payload: a raw + * signature (ed25519 / ML-DSA) or a WebAuthn assertion (secp256r1). */ +export type SignatureData = + | { kind: 'raw'; bytes: Uint8Array } + | { kind: 'webauthn'; assertion: SyntheticAssertion }; + +export interface VerifierInfo { + algorithm: Algorithm; + address: string; + /** Whether a verifier contract for this algorithm is deployed on-chain today. + * ed25519 and ML-DSA are simulated ahead of their contracts. */ + onChain: boolean; + label: string; + note?: string; +} + +/** Deterministic placeholder C-address for a verifier that isn't deployed yet. */ +function simAddress(name: string): string { + return StrKey.encodeContract(Buffer.from(sha256(new TextEncoder().encode(`nido-testkit:${name}`)))); +} + +export const VERIFIERS: Record = { + secp256r1: { + algorithm: 'secp256r1', + // The real, deployed stateless webauthn-verifier (registry fallback). + address: 'CACVGSAHYFBXY4LJKWW5B57LAAXHCZVDZOANUTYPLNV6HHQI4Q35EGMY', + onChain: true, + label: 'WebAuthn / secp256r1', + note: 'Deployed webauthn-verifier; here driven by a local P-256 key instead of a passkey.', + }, + ed25519: { + algorithm: 'ed25519', + address: simAddress('ed25519-verifier'), + onChain: false, + label: 'ed25519', + note: 'No External ed25519 verifier on-chain yet; simulated. Classic accounts sign via Delegated today.', + }, + 'ml-dsa-65': { + algorithm: 'ml-dsa-65', + address: simAddress('ml-dsa-65-verifier'), + onChain: false, + label: 'ML-DSA-65 (post-quantum)', + note: 'Groundwork in nido#143; simulated here ahead of the guest-wasm verifier contract.', + }, +}; + +/** Verify a signature the way the algorithm's verifier contract would. */ +export function verifySignature( + algorithm: Algorithm, + authDigest: Uint8Array, + publicKey: Uint8Array, + sig: SignatureData, +): boolean { + switch (algorithm) { + case 'ed25519': + return sig.kind === 'raw' && ed25519.verify(sig.bytes, authDigest, publicKey); + case 'ml-dsa-65': + return sig.kind === 'raw' && ml_dsa65.verify(sig.bytes, authDigest, publicKey); + case 'secp256r1': + return sig.kind === 'webauthn' && verifySyntheticAssertion(authDigest, publicKey, sig.assertion); + } +} + +// re-export so consumers don't need a second import for the raw p256 type +export { p256 }; diff --git a/packages/testkit/tsconfig.json b/packages/testkit/tsconfig.json new file mode 100644 index 00000000..e21672d9 --- /dev/null +++ b/packages/testkit/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} From fbec0792dbd37037b368400cdfa5a32353fea0b6 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Sat, 15 Aug 2026 17:33:25 -0400 Subject: [PATCH 2/2] fix(testkit): simulateCheckAuth evaluates every matching rule, not just the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A call can fall under more than one rule of the same scope (e.g. two self-admin rules signed by different keys). The old first-match logic denied a call the first rule couldn't satisfy even when a later rule could — so a self-admin op signed only by the ML-DSA key was wrongly denied because admin-root (needing the admin key) matched first. Now it tries all scope-matching rules and authorizes if any does (matching OZ, where the caller nominates a rule via context_rule_ids), else returns the first deny. +2 tests: a self-admin call authorized by a non-first rule (the ML-DSA signer), and denied when no matching rule is satisfied. Co-Authored-By: Claude Opus 4.8 --- packages/testkit/src/checkauth.ts | 66 ++++++++++++++++------------ packages/testkit/src/testkit.test.ts | 25 +++++++++++ 2 files changed, 64 insertions(+), 27 deletions(-) diff --git a/packages/testkit/src/checkauth.ts b/packages/testkit/src/checkauth.ts index cd308ec9..76cd59db 100644 --- a/packages/testkit/src/checkauth.ts +++ b/packages/testkit/src/checkauth.ts @@ -74,18 +74,17 @@ function argSatisfies(pred: ArgPred, arg: SimArg | undefined, account: LocalAcco } } -export function simulateCheckAuth(account: LocalAccount, ctx: SimContext, signedBy: string[]): SimResult { - const ledger = ctx.ledger ?? 0; +/** Evaluate one rule (at index `idx`) against the call: digest → authenticate + * signers → expiry / function / args / signer-floor. */ +function evalRule( + account: LocalAccount, + ctx: SimContext, + ledger: number, + rule: Rule, + idx: number, + signedBy: string[], +): SimResult { const reasons: string[] = []; - - // 1. Find the rule whose scope this call falls under. - const idx = account.policy.rules.findIndex((r) => scopeMatches(r, account, ctx)); - if (idx < 0) { - return { verdict: 'abstain', authDigest: '', reasons: ['no rule applies to this call'], signerChecks: [] }; - } - const rule = account.policy.rules[idx]!; - - // 2. Digest bound to this rule; authenticate the signers that signed it. const payload = sha256(new TextEncoder().encode(stableContext(ctx))); const digest = computeAuthDigest(payload, [idx]); const signerChecks = signedBy.map((id) => { @@ -94,7 +93,6 @@ export function simulateCheckAuth(account: LocalAccount, ctx: SimContext, signed return { id, verifier: s.verifier, ok: verifySignature(s.algorithm, digest, s.publicKey, s.signAuth(digest)) }; }); const authenticated = new Set(signerChecks.filter((c) => c.ok).map((c) => c.id)); - const base: Omit = { authDigest: bytesToHex(digest), matchedRule: rule.name, @@ -106,35 +104,49 @@ export function simulateCheckAuth(account: LocalAccount, ctx: SimContext, signed return { verdict: 'deny', ...base }; }; - // 3. Expiry (perch "dead at or after"; OZ valid_until is inclusive one below). + // Expiry (perch "dead at or after"). const notAfter = rule['not-after-ledger']; if (notAfter !== undefined && ledger >= notAfter) return deny(`rule expired (ledger ${ledger} ≥ ${notAfter})`); - - // 4. Function allowlist. + // Function allowlist. if (rule.functions && !rule.functions.includes(ctx.fn)) { return deny(`function ${ctx.fn}() not in [${rule.functions.join(', ')}]`); } - - // 5. Argument predicates. + // Argument predicates. for (const c of rule.args ?? []) { - if (!argSatisfies(c.pred, ctx.args?.[c.index], account)) { - return deny(`arg[${c.index}] fails ${c.pred.type}`); - } + if (!argSatisfies(c.pred, ctx.args?.[c.index], account)) return deny(`arg[${c.index}] fails ${c.pred.type}`); } - - // 6. Signer sufficiency: perch injects MinSigners(n) = every referenced signer - // (N-of-N), the on-chain floor when a policy is attached. + // Signer sufficiency: perch injects MinSigners(n) = every referenced signer. if (rule.principals.type === 'all') { const missing = rule.principals.signers.filter((id) => !authenticated.has(id)); if (missing.length) return deny(`missing signature from [${missing.join(', ')}]`); } - - // 7. Cumulative cap is a stateful sibling policy — not evaluable from a single - // call. Surface it rather than silently ignore. + // Cumulative cap is a stateful sibling policy — surface it, not per-call. if (rule.cap) { reasons.push(`cap ≤ ${rule.cap.limit} / ${rule.cap['period-ledgers']} ledgers applies (stateful; not checked per-call)`); } - reasons.push('authorized'); return { verdict: 'allow', ...base }; } + +export function simulateCheckAuth(account: LocalAccount, ctx: SimContext, signedBy: string[]): SimResult { + const ledger = ctx.ledger ?? 0; + + // Every rule this call could fall under (OZ lets the caller nominate a rule + // via context_rule_ids; the sim tries them all and authorizes if any rule + // does — matching "can these signers authorize this call?"). + const matching: Array<[Rule, number]> = []; + account.policy.rules.forEach((r, i) => { + if (scopeMatches(r, account, ctx)) matching.push([r, i]); + }); + if (matching.length === 0) { + return { verdict: 'abstain', authDigest: '', reasons: ['no rule applies to this call'], signerChecks: [] }; + } + + let firstDeny: SimResult | null = null; + for (const [rule, idx] of matching) { + const res = evalRule(account, ctx, ledger, rule, idx, signedBy); + if (res.verdict === 'allow') return res; + if (!firstDeny) firstDeny = res; + } + return firstDeny as SimResult; +} diff --git a/packages/testkit/src/testkit.test.ts b/packages/testkit/src/testkit.test.ts index c14b070a..0d21c258 100644 --- a/packages/testkit/src/testkit.test.ts +++ b/packages/testkit/src/testkit.test.ts @@ -88,6 +88,31 @@ describe('simulateCheckAuth — the ci-publish policy', () => { }); }); +describe('simulateCheckAuth — tries every matching rule', () => { + const admin = localSigner({ id: 'admin', algorithm: 'secp256r1' }); + const pq = localSigner({ id: 'pq', algorithm: 'ml-dsa-65' }); + const acct = createLocalAccount({ + signers: [admin, pq], + rules: [ + rule({ name: 'admin-root', scope: { type: 'self-admin' }, signedBy: ['admin'] }), + rule({ name: 'pq-admin', scope: { type: 'self-admin' }, signedBy: ['pq'] }), + ], + }); + + it('authorizes a self-admin call via a non-first rule (the ML-DSA signer)', () => { + // set_admin on self-admin, signed only by pq → admin-root denies (missing + // admin) but pq-admin authorizes. Must not stop at the first matching rule. + const res = simulateCheckAuth(acct, { contract: acct.address, fn: 'set_admin' }, ['pq']); + expect(res.verdict).toBe('allow'); + expect(res.matchedRule).toBe('pq-admin'); + }); + + it('denies when no matching rule is satisfied', () => { + const res = simulateCheckAuth(acct, { contract: acct.address, fn: 'set_admin' }, []); + expect(res.verdict).toBe('deny'); + }); +}); + describe('perch analysis — reachable + attenuation', () => { const acct = createLocalAccount({ signers: [localSigner({ id: 'ci', algorithm: 'ed25519' })],