diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 000000000..5560a3642 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Block accidental mutation of canonical cross-language fixtures. +# To intentionally update these files, run: +# UPDATE_CROSS_LANG_FIXTURES=1 make regen-cross-lang-fixtures +# and commit with UPDATE_CROSS_LANG_FIXTURES=1 in the environment. + +changed="$(git diff --cached --name-only --diff-filter=ACMRD)" + +if [ -z "${changed}" ]; then + exit 0 +fi + +if echo "${changed}" | rg -q '^jacs/tests/fixtures/cross-language/'; then + case "${UPDATE_CROSS_LANG_FIXTURES:-}" in + 1|true|TRUE|yes|YES) + ;; + *) + echo "ERROR: staged cross-language fixture changes detected." >&2 + echo "Set UPDATE_CROSS_LANG_FIXTURES=1 when intentionally regenerating fixtures." >&2 + echo "Recommended flow:" >&2 + echo " UPDATE_CROSS_LANG_FIXTURES=1 make regen-cross-lang-fixtures" >&2 + exit 1 + ;; + esac +fi + +exit 0 diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 8e9d4b52f..edd402053 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -6,12 +6,14 @@ on: paths: - 'jacsnpm/**' - 'jacs/**' # jacsnpm depends on jacs + - 'binding-core/**' # jacsnpm verifyStandalone depends on binding-core - '.github/workflows/nodejs.yml' pull_request: branches: [ "main" ] paths: - 'jacsnpm/**' - 'jacs/**' # jacsnpm depends on jacs + - 'binding-core/**' # jacsnpm verifyStandalone depends on binding-core - '.github/workflows/nodejs.yml' workflow_dispatch: # Allows manual triggering @@ -52,6 +54,15 @@ jobs: working-directory: jacsnpm run: npm run build + - name: Run cross-language tests (hermetic env) + working-directory: jacsnpm + env: + JACS_DATA_DIRECTORY: /tmp/does-not-exist + JACS_KEY_DIRECTORY: /tmp/does-not-exist + JACS_DEFAULT_STORAGE: memory + JACS_KEY_RESOLUTION: hai + run: npm run test:cross-language + - name: Run tests working-directory: jacsnpm run: npm test diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index a88a0e28a..5d46d5f0c 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -39,7 +39,7 @@ jobs: cd /workspace/jacspy && \ /opt/python/cp311-cp311/bin/python3.11 -m venv .venv && \ source .venv/bin/activate && \ - pip install maturin pytest && \ + pip install maturin pytest pytest-asyncio && \ pip install fastmcp mcp starlette && \ make test-python" @@ -88,7 +88,7 @@ jobs: set -euo pipefail uv venv /tmp/jacs-wheel-smoke uv pip install --python /tmp/jacs-wheel-smoke/bin/python dist/jacs-*.whl - uv run --python /tmp/jacs-wheel-smoke/bin/python python - <<'PY' + /tmp/jacs-wheel-smoke/bin/python - <<'PY' import jacs import jacs.simple import jacs.hai diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml new file mode 100644 index 000000000..7951f8b95 --- /dev/null +++ b/.github/workflows/release-cli.yml @@ -0,0 +1,158 @@ +name: Release CLI Binaries + +on: + push: + tags: + - 'cli/v*' + +permissions: + contents: write + +jobs: + verify-version: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.extract.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Extract version from tag + id: extract + run: | + TAG="${GITHUB_REF#refs/tags/cli/v}" + echo "version=$TAG" >> $GITHUB_OUTPUT + + - name: Check Cargo.toml version matches tag + run: | + TAG_VERSION="${{ steps.extract.outputs.version }}" + CARGO_VERSION=$(grep '^version = ' jacs/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') + echo "Cargo version: $CARGO_VERSION, tag: $TAG_VERSION" + if [ "$CARGO_VERSION" != "$TAG_VERSION" ]; then + echo "::error::Version mismatch! jacs/Cargo.toml has $CARGO_VERSION but tag is $TAG_VERSION" + exit 1 + fi + + build: + needs: verify-version + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + target: aarch64-apple-darwin + artifact_name: jacs-cli + asset_name: jacs-cli-${{ needs.verify-version.outputs.version }}-darwin-arm64 + archive: tar.gz + - os: macos-14 + target: x86_64-apple-darwin + artifact_name: jacs-cli + asset_name: jacs-cli-${{ needs.verify-version.outputs.version }}-darwin-x64 + archive: tar.gz + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + artifact_name: jacs-cli + asset_name: jacs-cli-${{ needs.verify-version.outputs.version }}-linux-x64 + archive: tar.gz + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + artifact_name: jacs-cli + asset_name: jacs-cli-${{ needs.verify-version.outputs.version }}-linux-arm64 + archive: tar.gz + - os: windows-latest + target: x86_64-pc-windows-msvc + artifact_name: jacs-cli.exe + asset_name: jacs-cli-${{ needs.verify-version.outputs.version }}-windows-x64 + archive: zip + + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: '1.93' + targets: ${{ matrix.target }} + + - name: Build CLI binary + run: cargo build --release -p jacs --features cli --target ${{ matrix.target }} + + - name: Smoke test (Unix) + if: runner.os != 'Windows' + run: ./target/${{ matrix.target }}/release/jacs --version + + - name: Smoke test (Windows) + if: runner.os == 'Windows' + run: .\target\${{ matrix.target }}\release\jacs.exe --version + + - name: Package (Unix) + if: runner.os != 'Windows' + run: | + cp target/${{ matrix.target }}/release/jacs jacs-cli + tar czf ${{ matrix.asset_name }}.tar.gz jacs-cli + shasum -a 256 ${{ matrix.asset_name }}.tar.gz > ${{ matrix.asset_name }}.tar.gz.sha256 + + - name: Package (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + Copy-Item "target/${{ matrix.target }}/release/jacs.exe" "jacs-cli.exe" + Compress-Archive -Path "jacs-cli.exe" -DestinationPath "${{ matrix.asset_name }}.zip" + Get-FileHash "${{ matrix.asset_name }}.zip" -Algorithm SHA256 | ForEach-Object { "$($_.Hash.ToLower()) ${{ matrix.asset_name }}.zip" } | Out-File "${{ matrix.asset_name }}.zip.sha256" -Encoding ascii + + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.asset_name }} + path: | + ${{ matrix.asset_name }}.* + + release: + needs: [verify-version, build] + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Collect checksums + run: | + cd artifacts + find . -name "*.sha256" -exec cat {} \; > ../sha256sums.txt + cd .. + cat sha256sums.txt + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: cli/v${{ needs.verify-version.outputs.version }} + name: JACS CLI v${{ needs.verify-version.outputs.version }} + body: | + ## JACS CLI v${{ needs.verify-version.outputs.version }} + + Prebuilt CLI binaries for verifying and signing JACS documents. + + ### Install + + Download the binary for your platform, extract, and add to your PATH: + + ```bash + # macOS (Apple Silicon) + curl -LO https://github.com/HumanAssisted/JACS/releases/download/cli/v${{ needs.verify-version.outputs.version }}/jacs-cli-${{ needs.verify-version.outputs.version }}-darwin-arm64.tar.gz + tar xzf jacs-cli-*.tar.gz + sudo mv jacs-cli /usr/local/bin/ + + # Linux (x86_64) + curl -LO https://github.com/HumanAssisted/JACS/releases/download/cli/v${{ needs.verify-version.outputs.version }}/jacs-cli-${{ needs.verify-version.outputs.version }}-linux-x64.tar.gz + tar xzf jacs-cli-*.tar.gz + sudo mv jacs-cli /usr/local/bin/ + ``` + + Or install via npm/pip (ships with `@hai.ai/jacs` and `jacs`). + + ### Verify checksums + ```bash + shasum -a 256 -c sha256sums.txt + ``` + files: | + artifacts/**/*.tar.gz + artifacts/**/*.zip + sha256sums.txt diff --git a/.gitignore b/.gitignore index 88319ec7b..c8a00bab2 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,12 @@ scratch.md jacs.config.json !jacs/jacs.config.json .DS_Store + +# Runtime artifacts from tests +var/ +documents/ +jacsnpm/var/ + +# Generated by quickstart/persistent agent tests +**/jacs_data/ +**/jacs_keys/ diff --git a/Makefile b/Makefile index df59c1a97..1e3cc1396 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,7 @@ release-jacs release-jacspy release-jacsnpm release-all \ retry-jacspy retry-jacsnpm \ version versions check-versions check-version-jacs check-version-jacspy check-version-jacsnpm \ + install-githooks regen-cross-lang-fixtures \ help # ============================================================================ @@ -58,6 +59,18 @@ test-jacsnpm: test: test-jacs +# Regenerate all canonical cross-language fixtures in sequence. +# This intentionally mutates tracked fixture files. +regen-cross-lang-fixtures: + UPDATE_CROSS_LANG_FIXTURES=1 cargo test -p jacs --test cross_language_tests -- --nocapture + cd jacspy && UPDATE_CROSS_LANG_FIXTURES=1 pytest tests/test_cross_language.py -q + cd jacsnpm && UPDATE_CROSS_LANG_FIXTURES=1 npm run test:cross-language --silent + +# Install repo-local git hooks (pre-commit guard for fixture changes). +install-githooks: + git config core.hooksPath .githooks + @echo "Configured git hooks path to .githooks" + # ============================================================================ # VERSION INFO # ============================================================================ @@ -226,6 +239,10 @@ help: @echo " make test-jacs-cli Run CLI integration tests" @echo " make test-jacspy Run Python binding tests" @echo " make test-jacsnpm Run Node.js binding tests" + @echo " make regen-cross-lang-fixtures Regenerate Rust->Python->Node fixtures" + @echo "" + @echo "GIT HOOKS:" + @echo " make install-githooks Configure core.hooksPath=.githooks" @echo "" @echo "DIRECT PUBLISH (local credentials required):" @echo " make publish-jacs Publish to crates.io" diff --git a/README.md b/README.md index ee6d2302c..cca2a1046 100644 --- a/README.md +++ b/README.md @@ -1,215 +1,148 @@ # JACS -**JSON Agent Communication Standard** - Data provenance and cryptographic signing for AI agents. +**Sign it. Prove it.** -- Agent trust infrastructure for a world where AI agents cross organizational boundaries. -**[Documentation](https://humanassisted.github.io/JACS/)** | **[Quick Start](https://humanassisted.github.io/JACS/getting-started/quick-start.html)** | **[API Reference](https://humanassisted.github.io/JACS/nodejs/api.html)** +Cryptographic signatures for AI agent outputs so anyone can verify who said what, whether it was changed, and hold agents accountable. No server. No account. Three lines of code. -## What is JACS? - -JACS is an open data provenance toolkit that lets any AI agent or application sign, verify, and track the origin of data. It works standalone -- no server, no account required. Optionally register with [HAI.ai](https://hai.ai) for cross-organization key discovery and attestation. - -Available as a library for **Python**, **Node.js**, **Go**, and **Rust**, plus a CLI and MCP servers. - -**Why use JACS?** - -- **Data provenance**: Know who created data, when, and whether it's been modified -- **Decentralized by default**: Runs entirely local -- keys and signatures stay on your machine -- **Tamper detection**: Cryptographic hashes catch any change, accidental or malicious -- **Non-repudiation**: Signed actions can't be denied -- **Post-quantum ready**: NIST-standardized ML-DSA (FIPS-204) signatures out of the box - -## First run (minimal setup) - -1. Copy `jacs.config.example.json` to `jacs.config.json` (or use `jacs config create`). -2. Set `JACS_PRIVATE_KEY_PASSWORD` in your environment (never put the password in the config file). -3. Run `jacs agent create` or `jacs init` as documented, then sign/verify as in Quick Start below. - -For runtime signing, set `JACS_PRIVATE_KEY_PASSWORD` (or use a keychain). The CLI can prompt during init; scripts and servers must set the env var. +`pip install jacs` | `npm install @hai.ai/jacs` | `cargo install jacs` ## Quick Start -### Python +Zero-config -- one call creates a persistent agent with keys on disk. -```bash -pip install jacs -``` +### Python ```python -from jacs import simple - -# Load your agent -simple.load('./jacs.config.json') - -# Sign any data -signed = simple.sign_message({'action': 'approve', 'amount': 100}) +import jacs.simple as jacs -# Verify signatures -result = simple.verify(signed.raw) +jacs.quickstart() +signed = jacs.sign_message({"action": "approve", "amount": 100}) +result = jacs.verify(signed.raw) print(f"Valid: {result.valid}, Signer: {result.signer_id}") ``` ### Node.js -```bash -npm install @hai.ai/jacs -``` - ```javascript const jacs = require('@hai.ai/jacs/simple'); -jacs.load('./jacs.config.json'); - -const signed = jacs.signMessage({ action: 'approve', amount: 100 }); -const result = jacs.verify(signed.raw); -console.log(`Valid: ${result.valid}, Signer: ${result.signerId}`); -``` - -### Go - -```go -import jacs "github.com/HumanAssisted/JACS/jacsgo" - -jacs.Load(nil) +async function main() { + await jacs.quickstart(); + const signed = await jacs.signMessage({ action: 'approve', amount: 100 }); + const result = await jacs.verify(signed.raw); + console.log(`Valid: ${result.valid}, Signer: ${result.signerId}`); +} -signed, _ := jacs.SignMessage(map[string]interface{}{"action": "approve"}) -result, _ := jacs.Verify(signed.Raw) -fmt.Printf("Valid: %t, Signer: %s\n", result.Valid, result.SignerID) +main().catch(console.error); ``` ### Rust / CLI ```bash +# Install from source (requires Rust toolchain) cargo install jacs --features cli -# Upgrade to latest (overwrite existing install) -cargo install jacs --features cli --force +# Or download a prebuilt binary from GitHub Releases +# https://github.com/HumanAssisted/JACS/releases -# Create an agent -jacs init - -# Sign a document +jacs quickstart jacs document create -f mydata.json ``` -## Core API (All Languages) - -| Function | Description | -|----------|-------------| -| `create(name, options)` | Create a new agent programmatically (non-interactive) | -| `load(config)` | Load agent from config file | -| `sign_message(data)` | Sign any JSON data | -| `sign_file(path, embed)` | Sign a file | -| `verify(document)` | Verify a signed document (JSON string) | -| `verify_standalone(document, options)` | Verify without loading an agent (one-off) | -| `verify_by_id(id)` | Verify a document by storage ID (`uuid:version`) | -| `register_with_hai(options)` | Register the loaded agent with HAI.ai | -| `get_dns_record(domain, ttl?)` | Get DNS TXT record line for the agent | -| `get_well_known_json()` | Get well-known JSON (e.g. for `/.well-known/jacs-pubkey.json`) | -| `reencrypt_key(old, new)` | Re-encrypt the private key with a new password | -| `verify_self()` | Verify agent integrity | -| `get_public_key()` | Get public key for sharing | -| `audit(options)` | Run a read-only security audit (risks, health checks, summary) | -| `generate_verify_link(document, base_url)` | Generate a shareable hai.ai verification URL for a signed document | +**Signed your first document?** Next: [Verify it without an agent](#verify-a-signed-document) | [Pick your framework integration](#which-integration-should-i-use) | [Full quick start guide](https://humanassisted.github.io/JACS/getting-started/quick-start.html) -## Use Cases +## Verify a Signed Document -These scenarios show how teams use JACS today. Each links to a [detailed walkthrough](USECASES.md). +No agent needed. One command or one function call. -**Prove that pipeline outputs are authentic.** A build service signs every JSON artifact it emits -- deployment configs, test reports, compliance summaries. Downstream teams and auditors verify with a single call; tampering or forgery is caught immediately. [Full scenario](USECASES.md#1-verifying-that-json-files-came-from-a-specific-program) +```bash +jacs verify signed-document.json # CLI -- exit code 0 = valid +jacs verify --remote https://example.com/doc.json --json # fetch + verify +``` -**Run a public agent without exposing the operator.** An AI agent signs every message it sends but only publishes the public key (via DNS or HAI). Recipients verify origin and integrity cryptographically; the operator's identity never touches the internet. [Full scenario](USECASES.md#2-protecting-your-agents-identity-on-the-internet) +```python +result = jacs.verify_standalone(signed_json, key_directory="./keys") # Python, no agent +``` -**Add cryptographic provenance in any language.** Finance, healthcare, or any regulated environment: sign every output with `sign_message()`, verify with `verify()`. The same three-line pattern works identically in Python, Node.js, and Go. Auditors get cryptographic proof instead of trust-only logs. [Full scenario](USECASES.md#4-a-go-node-or-python-agent-with-strong-data-provenance) +```typescript +const r = verifyStandalone(signedJson, { keyDirectory: './keys' }); // Node.js, no agent +``` -### Other use cases +[Full verification guide](https://humanassisted.github.io/JACS/getting-started/verification.html) -- CLI, Python, Node.js, DNS, verification links. -- **Sign AI outputs** -- Wrap any model response or generated artifact with a signature before it leaves your service. Downstream consumers call `verify()` to confirm which agent produced it and that nothing was altered in transit. -- **Sign files and documents** -- Contracts, reports, configs, or any file on disk: `sign_file(path)` attaches a cryptographic signature. Recipients verify the file's integrity and origin without trusting the transport layer. -- **Build MCP servers with signed tool calls** -- Every tool invocation through your MCP server can carry the agent's signature automatically, giving clients proof of which agent executed the call and what it returned. -- **Establish agent-to-agent trust** -- Two or more agents can sign agreements and verify each other's identities using the trust store. Multi-party signatures let you build workflows where each step is attributable. -- **Agreement verification is strict** -- `check_agreement` fails until all required signers have signed, so partial approvals cannot be mistaken for completion. -- **Track data provenance through pipelines** -- As data moves between services, each stage signs its output. The final consumer can walk the signature chain to verify every transformation back to the original source. -- **Verify without loading an agent** -- Use `verify_standalone()` when you just need to check a signature in a lightweight service or script. No config file, no trust store, no agent setup required. -- **Register with HAI.ai for key discovery** -- Publish your agent's public key to [HAI.ai](https://hai.ai) with `register_with_hai()` so other organizations can discover and verify your agent without exchanging keys out-of-band. -- **Audit your JACS setup** -- Call `audit()` to check config, keys, trust store health, and re-verify recent documents. Returns structured risks and health checks so you can catch misconfigurations before they matter. -- **Share verification links** -- Generate a `https://hai.ai/jacs/verify?s=...` URL with `generate_verify_link()` and embed it in emails, Slack messages, or web pages. Recipients click to verify the document without installing anything. -- **Air-gapped and offline environments** -- Set `JACS_KEY_RESOLUTION=local` and distribute public keys manually. JACS works fully offline with no network calls once keys are in the local trust store. +## Which Integration Should I Use? -## MCP Integration +Find the right path in under 2 minutes. [Full decision tree](https://humanassisted.github.io/JACS/getting-started/decision-tree.html) -JACS integrates with Model Context Protocol for authenticated tool calls: +| I use... | Start here | Docs | +|----------|-----------|------| +| Python + LangChain/LangGraph | `from jacs.adapters.langchain import jacs_signing_middleware` | [LangChain Guide](https://humanassisted.github.io/JACS/python/adapters.html) | +| Python + CrewAI | `from jacs.adapters.crewai import jacs_guardrail` | [CrewAI Guide](https://humanassisted.github.io/JACS/python/adapters.html) | +| Python + FastAPI | `from jacs.adapters.fastapi import JacsMiddleware` | [FastAPI Guide](https://humanassisted.github.io/JACS/python/adapters.html) | +| Node.js + Express | `require('@hai.ai/jacs/express')` | [Express Guide](https://humanassisted.github.io/JACS/nodejs/express.html) | +| Node.js + Vercel AI SDK | `require('@hai.ai/jacs/vercel-ai')` | [Vercel AI Guide](https://humanassisted.github.io/JACS/nodejs/vercel-ai.html) | +| Node.js + LangChain.js | `require('@hai.ai/jacs/langchain')` | [LangChain.js Guide](https://humanassisted.github.io/JACS/nodejs/langchain.html) | +| MCP Server (Python) | `from jacs.mcp import create_jacs_mcp_server` | [Python MCP Guide](https://humanassisted.github.io/JACS/python/mcp.html) | +| MCP Server (Node.js) | `require('@hai.ai/jacs/mcp')` | [Node.js MCP Guide](https://humanassisted.github.io/JACS/nodejs/mcp.html) | +| A2A Protocol | `from jacs.a2a import JACSA2AIntegration` | [A2A Guide](https://humanassisted.github.io/JACS/integrations/a2a.html) | +| Rust / CLI | `cargo install jacs --features cli` | [Rust Guide](https://humanassisted.github.io/JACS/rust/installation.html) | +| Any language (standalone) | `import jacs.simple as jacs` | [Simple API](https://humanassisted.github.io/JACS/python/simple-api.html) | -```python -from jacs.mcp import JACSMCPServer -from mcp.server.fastmcp import FastMCP +## Who Is JACS For? -jacs.load("jacs.config.json") -mcp = JACSMCPServer(FastMCP("My Server")) +**Platform teams** building multi-agent systems where agents from different services -- or different organizations -- need to trust each other's outputs. -@mcp.tool() -def my_tool(data: dict) -> dict: - return {"result": "signed automatically"} -``` +**Compliance and security engineers** in regulated industries (finance, healthcare, government) who need cryptographic proof of agent actions, not just log files. -## A2A Integration +**AI framework developers** adding provenance to LangChain, CrewAI, FastAPI, Express, Vercel AI, or MCP pipelines without changing their existing architecture. -JACS provides cryptographic provenance for Google's A2A protocol: +**Researchers and labs** running public-facing agents that need verifiable identity without exposing operator information. -```python -from jacs.a2a import JACSA2AIntegration +## When You DON'T Need JACS -a2a = JACSA2AIntegration("jacs.config.json") -agent_card = a2a.export_agent_card(agent_data) -wrapped = a2a.wrap_artifact_with_provenance(artifact, "task") -``` +Honesty builds trust, so here is when JACS is probably overkill: -JACS A2A interoperability now includes foreign-agent signature verification using configured key resolution (`local`, `dns`, `hai`) and publishes `/.well-known/agent-card.json` plus `/.well-known/jwks.json` for verifier compatibility. See the [A2A interoperability guide](./jacs/docs/jacsbook/src/integrations/a2a.md) for deployment details. +- **Single developer, single service.** If all your agents run inside one process you control and you trust your own logs, standard logging is fine. +- **Internal-only prototypes.** If data never leaves your organization and you are not in a regulated environment, the overhead of cryptographic signing adds no value yet. +- **Simple checksums.** If you only need to detect accidental corruption (not prove authorship), SHA-256 hashes are simpler. -## Verification and key resolution +JACS adds value when data crosses trust boundaries -- between organizations, between services with different operators, or into regulated audit trails. -When verifying signatures, JACS looks up signers' public keys in an order controlled by `JACS_KEY_RESOLUTION` (comma-separated: `local`, `dns`, `hai`). Default is `local,hai` (local trust store first, then HAI key service). For air-gapped use, set `JACS_KEY_RESOLUTION=local`. +## The Trust Blind Spot -## Supported algorithms +A 2026 survey of 29 multi-agent reinforcement learning publications found that **zero** addressed authentication, integrity, or trust between agents (Wittner, "Communication Methods in Multi-Agent RL," [arxiv.org/abs/2601.12886](https://arxiv.org/abs/2601.12886)). Every paper optimized for communication efficiency while assuming a fully trusted environment. -Signing and verification support: **ring-Ed25519**, **RSA-PSS**, **pq2025** (ML-DSA-87, FIPS-204, recommended). `pq-dilithium` is deprecated -- use `pq2025` instead. Set `jacs_agent_key_algorithm` in config or `JACS_AGENT_KEY_ALGORITHM` in the environment. +That assumption holds in a research lab. It does not hold in production, where agents cross organizational boundaries, outputs are consumed by downstream systems, and regulatory auditors expect cryptographic proof. -## Troubleshooting +JACS provides the missing trust layer: identity (who produced this?), integrity (was it changed?), and accountability (can you prove it?). -- **Config not found**: Copy `jacs.config.example.json` to `jacs.config.json` and set required env vars (see First run). -- **Private key decryption failed**: Wrong password or wrong key file. Ensure `JACS_PRIVATE_KEY_PASSWORD` matches the password used when generating keys. -- **Required environment variable X not set**: Set the variable per the [config docs](https://humanassisted.github.io/JACS/); common ones are `JACS_KEY_DIRECTORY`, `JACS_DATA_DIRECTORY`, `JACS_AGENT_PRIVATE_KEY_FILENAME`, `JACS_AGENT_PUBLIC_KEY_FILENAME`, `JACS_AGENT_KEY_ALGORITHM`, `JACS_AGENT_ID_AND_VERSION`. -- **Algorithm detection failed**: Set the `signingAlgorithm` field in the document, or use `JACS_REQUIRE_EXPLICIT_ALGORITHM=true` to require it. +## Post-Quantum Ready -## Post-Quantum Cryptography +JACS supports ML-DSA-87 (FIPS-204) post-quantum signatures alongside classical algorithms (Ed25519, ECDSA P-256/P-384, RSA-PSS). The `pq2025` algorithm preset gives you quantum-resistant signing today, with zero code changes from the standard API. -JACS supports NIST-standardized post-quantum algorithms: +[Algorithm Selection Guide](https://humanassisted.github.io/JACS/advanced/algorithm-guide.html) -- **ML-DSA (FIPS-204)**: Quantum-resistant signatures -- **ML-KEM (FIPS-203)**: Quantum-resistant key encapsulation +## Cross-Language Compatibility -```json -{ - "jacs_agent_key_algorithm": "pq2025" -} -``` +A document signed by a Rust agent can be verified by a Python or Node.js agent, and vice versa. The signature format is language-agnostic -- any JACS binding produces and consumes the same signed JSON. + +Cross-language interoperability is tested on every commit with both Ed25519 and post-quantum (ML-DSA-87) algorithms. Rust generates signed fixtures, then Python and Node.js verify and countersign them. See the test suites: [`jacs/tests/cross_language/`](jacs/tests/cross_language/mod.rs), [`jacspy/tests/test_cross_language.py`](jacspy/tests/test_cross_language.py), [`jacsnpm/test/cross-language.test.js`](jacsnpm/test/cross-language.test.js). -## Repository Structure +## Use Cases -| Directory | Description | -|-----------|-------------| -| [jacs/](./jacs/) | Core Rust library and CLI | -| [jacspy/](./jacspy/) | Python bindings | -| [jacsnpm/](./jacsnpm/) | Node.js bindings | -| [jacsgo/](./jacsgo/) | Go bindings | -| [jacs-mcp/](./jacs-mcp/) | MCP server for agent state and HAI integration | +**Prove that pipeline outputs are authentic.** A build service signs every JSON artifact it emits. Downstream teams and auditors verify with a single call; tampering or forgery is caught immediately. [Full scenario](USECASES.md#1-verifying-that-json-files-came-from-a-specific-program) -## Version +**Run a public agent without exposing the operator.** An AI agent signs every message but only publishes the public key via DNS or HAI. Recipients verify origin and integrity cryptographically; the operator's identity never touches the internet. [Full scenario](USECASES.md#2-protecting-your-agents-identity-on-the-internet) -Current version: **0.6.0** +**Add cryptographic provenance in any language.** Finance, healthcare, or any regulated environment: sign every output with `sign_message()`, verify with `verify()`. The same three-line pattern works identically in Python, Node.js, Rust, and Go. [Full scenario](USECASES.md#4-a-go-node-or-python-agent-with-strong-data-provenance) -## License +## Links -[Apache 2.0 with Common Clause](./LICENSE) - Free for most commercial uses. Contact hello@hai.io for licensing questions. +- [Documentation](https://humanassisted.github.io/JACS/) +- [Decision Tree](https://humanassisted.github.io/JACS/getting-started/decision-tree.html) +- [Algorithm Guide](https://humanassisted.github.io/JACS/advanced/algorithm-guide.html) +- [API Reference](https://humanassisted.github.io/JACS/nodejs/api.html) --- -2024, 2025, 2026 https://hai.ai + +v0.8.0 | [Apache 2.0 with Common Clause](./LICENSE) | [hai.ai](https://hai.ai) diff --git a/binding-core/Cargo.toml b/binding-core/Cargo.toml index bac3b1c73..9a169ef28 100644 --- a/binding-core/Cargo.toml +++ b/binding-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-binding-core" -version = "0.6.1" +version = "0.8.0" edition = "2024" rust-version = "1.93" resolver = "3" diff --git a/binding-core/src/lib.rs b/binding-core/src/lib.rs index 5e53e82a0..92648555d 100644 --- a/binding-core/src/lib.rs +++ b/binding-core/src/lib.rs @@ -173,7 +173,10 @@ fn extract_agreement_payload(value: &Value) -> Value { Value::Null } -fn create_editable_agreement_document(agent: &mut Agent, payload: Value) -> BindingResult { +fn create_editable_agreement_document( + agent: &mut Agent, + payload: Value, +) -> BindingResult { let wrapped = json!({ "jacsType": "artifact", "jacsLevel": "artifact", @@ -195,7 +198,11 @@ fn ensure_editable_agreement_document( ) -> BindingResult { match agent.load_document(document_string) { Ok(doc) => { - let level = doc.value.get("jacsLevel").and_then(|v| v.as_str()).unwrap_or(""); + let level = doc + .value + .get("jacsLevel") + .and_then(|v| v.as_str()) + .unwrap_or(""); if is_editable_level(level) { Ok(doc) } else { @@ -260,6 +267,20 @@ impl AgentWrapper { Ok("Agent loaded".to_string()) } + /// Re-root the internal file storage at `root`. + /// + /// By default `load_by_config` roots the FS backend at the current + /// working directory. `verify_document_standalone` uses this to + /// re-root at `/` so that absolute data/key directory paths work + /// regardless of CWD. + pub fn set_storage_root(&self, root: std::path::PathBuf) -> BindingResult<()> { + let mut agent = self.lock()?; + agent + .set_storage_root(root) + .map_err(|e| BindingCoreError::generic(format!("Failed to set storage root: {}", e)))?; + Ok(()) + } + /// Sign an external agent's document with this agent's registration signature. pub fn sign_agent( &self, @@ -353,6 +374,15 @@ impl AgentWrapper { .map_err(|e| BindingCoreError::signing_failed(format!("Failed to sign string: {}", e))) } + /// Sign multiple messages in a single batch, decrypting the private key only once. + pub fn sign_batch(&self, messages: Vec) -> BindingResult> { + let mut agent = self.lock()?; + let refs: Vec<&str> = messages.iter().map(|s| s.as_str()).collect(); + agent + .sign_batch(&refs) + .map_err(|e| BindingCoreError::signing_failed(format!("Batch sign failed: {}", e))) + } + /// Verify this agent's signature and hash. pub fn verify_agent(&self, agentfile: Option) -> BindingResult { let mut agent = self.lock()?; @@ -472,16 +502,59 @@ impl AgentWrapper { context: Option, agreement_fieldname: Option, ) -> BindingResult { + self.create_agreement_with_options( + document_string, + agentids, + question, + context, + agreement_fieldname, + None, + None, + None, + None, + ) + } + + /// Create an agreement with extended options (timeout, quorum, algorithm constraints). + /// + /// All option parameters are optional: + /// - `timeout`: ISO 8601 deadline after which the agreement expires + /// - `quorum`: minimum number of signatures required (M-of-N) + /// - `required_algorithms`: only accept signatures from these algorithms + /// - `minimum_strength`: "classical" or "post-quantum" + pub fn create_agreement_with_options( + &self, + document_string: &str, + agentids: Vec, + question: Option, + context: Option, + agreement_fieldname: Option, + timeout: Option, + quorum: Option, + required_algorithms: Option>, + minimum_strength: Option, + ) -> BindingResult { + use jacs::agent::agreement::{Agreement, AgreementOptions}; + let mut agent = self.lock()?; let base_doc = ensure_editable_agreement_document(&mut agent, document_string)?; let document_key = base_doc.getkey(); + + let options = AgreementOptions { + timeout, + quorum, + required_algorithms, + minimum_strength, + }; + let agreement_doc = agent - .create_agreement( + .create_agreement_with_options( &document_key, agentids.as_slice(), question.as_deref(), context.as_deref(), agreement_fieldname, + &options, ) .map_err(|e| { BindingCoreError::agreement_failed(format!("Failed to create agreement: {}", e)) @@ -567,10 +640,7 @@ impl AgentWrapper { let pending = doc .agreement_unsigned_agents(Some(agreement_fieldname_key.clone())) .map_err(|e| { - BindingCoreError::agreement_failed(format!( - "Failed to read pending signers: {}", - e - )) + BindingCoreError::agreement_failed(format!("Failed to read pending signers: {}", e)) })?; let signatures = doc @@ -754,6 +824,70 @@ impl AgentWrapper { Ok(()) } + /// Create an ephemeral in-memory agent. No config, no files, no env vars needed. + /// + /// Replaces the inner agent with a freshly created ephemeral agent that + /// lives entirely in memory. Returns a JSON string with agent info + /// (agent_id, name, version, algorithm). + pub fn ephemeral(&self, algorithm: Option<&str>) -> BindingResult { + // Map user-friendly names to internal algorithm strings + let algo = match algorithm.unwrap_or("ed25519") { + "ed25519" => "ring-Ed25519", + "rsa-pss" => "RSA-PSS", + "pq2025" => "pq2025", + other => other, + }; + + let mut agent = Agent::ephemeral(algo).map_err(|e| { + BindingCoreError::agent_load(format!("Failed to create ephemeral agent: {}", e)) + })?; + + let template = jacs::create_minimal_blank_agent("ai".to_string(), None, None, None) + .map_err(|e| { + BindingCoreError::agent_load(format!( + "Failed to create minimal agent template: {}", + e + )) + })?; + let mut agent_json: Value = serde_json::from_str(&template).map_err(|e| { + BindingCoreError::serialization_failed(format!( + "Failed to parse agent template JSON: {}", + e + )) + })?; + if let Some(obj) = agent_json.as_object_mut() { + obj.insert("name".to_string(), json!("ephemeral")); + obj.insert("description".to_string(), json!("Ephemeral JACS agent")); + } + + let instance = agent + .create_agent_and_load(&agent_json.to_string(), true, Some(algo)) + .map_err(|e| { + BindingCoreError::agent_load(format!("Failed to initialize ephemeral agent: {}", e)) + })?; + + let agent_id = instance["jacsId"].as_str().unwrap_or("").to_string(); + let version = instance["jacsVersion"].as_str().unwrap_or("").to_string(); + + // Replace the inner agent with the ephemeral one + let mut inner = self.lock()?; + *inner = agent; + + let info = json!({ + "agent_id": agent_id, + "name": "ephemeral", + "version": version, + "algorithm": algo, + }); + + serde_json::to_string_pretty(&info).map_err(|e| { + BindingCoreError::serialization_failed(format!( + "Failed to serialize ephemeral agent info: {}", + e + )) + }) + } + /// Returns diagnostic information including loaded agent details as a JSON string. pub fn diagnostics(&self) -> String { let mut info = jacs::simple::diagnostics(); @@ -762,8 +896,7 @@ impl AgentWrapper { if agent.ready() { info["agent_loaded"] = json!(true); if let Some(value) = agent.get_value() { - info["agent_id"] = - json!(value.get("jacsId").and_then(|v| v.as_str())); + info["agent_id"] = json!(value.get("jacsId").and_then(|v| v.as_str())); info["agent_version"] = json!(value.get("jacsVersion").and_then(|v| v.as_str())); } @@ -791,16 +924,12 @@ impl AgentWrapper { /// and registering with HAI.ai. /// /// Requires a loaded agent (call `load()` first). - pub fn get_setup_instructions( - &self, - domain: &str, - ttl: u32, - ) -> BindingResult { + pub fn get_setup_instructions(&self, domain: &str, ttl: u32) -> BindingResult { use jacs::agent::boilerplate::BoilerPlate; use jacs::dns::bootstrap::{ DigestEncoding, build_dns_record, dnssec_guidance, emit_azure_cli, - emit_cloudflare_curl, emit_gcloud_dns, emit_plain_bind, - emit_route53_change_batch, tld_requirement_text, + emit_cloudflare_curl, emit_gcloud_dns, emit_plain_bind, emit_route53_change_batch, + tld_requirement_text, }; let agent = self.lock()?; @@ -815,9 +944,9 @@ impl AgentWrapper { )); } - let pk = agent.get_public_key().map_err(|e| { - BindingCoreError::generic(format!("Failed to get public key: {}", e)) - })?; + let pk = agent + .get_public_key() + .map_err(|e| BindingCoreError::generic(format!("Failed to get public key: {}", e)))?; let digest = jacs::dns::bootstrap::pubkey_digest_b64(&pk); let rr = build_dns_record(domain, ttl, agent_id, &digest, DigestEncoding::Base64); @@ -829,8 +958,14 @@ impl AgentWrapper { provider_commands.insert("bind".to_string(), dns_record_bind.clone()); provider_commands.insert("route53".to_string(), emit_route53_change_batch(&rr)); provider_commands.insert("gcloud".to_string(), emit_gcloud_dns(&rr, "YOUR_ZONE_NAME")); - provider_commands.insert("azure".to_string(), emit_azure_cli(&rr, "YOUR_RG", domain, "_v1.agent.jacs")); - provider_commands.insert("cloudflare".to_string(), emit_cloudflare_curl(&rr, "YOUR_ZONE_ID")); + provider_commands.insert( + "azure".to_string(), + emit_azure_cli(&rr, "YOUR_RG", domain, "_v1.agent.jacs"), + ); + provider_commands.insert( + "cloudflare".to_string(), + emit_cloudflare_curl(&rr, "YOUR_ZONE_ID"), + ); let mut dnssec_instructions = std::collections::HashMap::new(); for name in &["aws", "cloudflare", "azure", "gcloud"] { @@ -846,15 +981,16 @@ impl AgentWrapper { }); let well_known_json = serde_json::to_string_pretty(&well_known).unwrap_or_default(); - let hai_url = std::env::var("HAI_API_URL") - .unwrap_or_else(|_| "https://api.hai.ai".to_string()); + let hai_url = + std::env::var("HAI_API_URL").unwrap_or_else(|_| "https://api.hai.ai".to_string()); let hai_registration_url = format!("{}/v1/agents", hai_url.trim_end_matches('/')); let hai_payload = json!({ "agent_id": agent_id, "public_key_hash": digest, "domain": domain, }); - let hai_registration_payload = serde_json::to_string_pretty(&hai_payload).unwrap_or_default(); + let hai_registration_payload = + serde_json::to_string_pretty(&hai_payload).unwrap_or_default(); let hai_registration_instructions = format!( "POST the payload to {} with your HAI API key in the Authorization header.", hai_registration_url @@ -897,7 +1033,8 @@ impl AgentWrapper { serde_json::to_string_pretty(&result).map_err(|e| { BindingCoreError::serialization_failed(format!( - "Failed to serialize setup instructions: {}", e + "Failed to serialize setup instructions: {}", + e )) }) } @@ -939,7 +1076,9 @@ impl AgentWrapper { let client = reqwest::blocking::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() - .map_err(|e| BindingCoreError::network_failed(format!("Failed to build HTTP client: {}", e)))?; + .map_err(|e| { + BindingCoreError::network_failed(format!("Failed to build HTTP client: {}", e)) + })?; let response = client .post(&url) @@ -947,7 +1086,9 @@ impl AgentWrapper { .header("Content-Type", "application/json") .json(&json!({ "agent_json": agent_json })) .send() - .map_err(|e| BindingCoreError::network_failed(format!("HAI registration request failed: {}", e)))?; + .map_err(|e| { + BindingCoreError::network_failed(format!("HAI registration request failed: {}", e)) + })?; if !response.status().is_success() { let status = response.status(); @@ -1015,6 +1156,10 @@ pub struct VerificationResult { pub valid: bool, /// The signer's agent ID from the document's jacsSignature.agentID (empty if unparseable). pub signer_id: String, + /// The signing timestamp from jacsSignature.date (empty if unparseable). + pub timestamp: String, + /// The signer's agent version from jacsSignature.agentVersion (empty if unparseable). + pub agent_version: String, } /// Verify a signed JACS document without loading an agent. @@ -1041,26 +1186,62 @@ pub fn verify_document_standalone( data_directory: Option<&str>, key_directory: Option<&str>, ) -> BindingResult { - fn signer_id_from_doc(doc: &str) -> String { + fn absolutize_dir(raw: &str) -> String { + let p = std::path::PathBuf::from(raw); + if p.is_absolute() { + p.to_string_lossy().to_string() + } else { + std::env::current_dir() + .unwrap_or_else(|_| std::path::PathBuf::from(".")) + .join(p) + .to_string_lossy() + .to_string() + } + } + + fn sig_field(doc: &str, field: &str) -> String { serde_json::from_str::(doc) .ok() .and_then(|v| { v.get("jacsSignature") - .and_then(|s| s.get("agentID")) - .and_then(|id| id.as_str()) + .and_then(|s| s.get(field)) + .and_then(|f| f.as_str()) .map(String::from) }) .unwrap_or_default() } - let signer_id = signer_id_from_doc(signed_document); + let signer_id = sig_field(signed_document, "agentID"); + let timestamp = sig_field(signed_document, "date"); + let agent_version = sig_field(signed_document, "agentVersion"); - let data_dir = data_directory + // Always resolve caller-provided directories to absolute paths so relative + // inputs like "../fixtures" work regardless of process CWD. + let temp_dir = std::env::temp_dir().to_string_lossy().to_string(); + let raw_data_dir = data_directory .map(String::from) - .unwrap_or_else(|| std::env::temp_dir().to_string_lossy().to_string()); - let key_dir = key_directory + .unwrap_or_else(|| temp_dir.clone()); + let raw_key_dir = key_directory .map(String::from) - .unwrap_or_else(|| std::env::temp_dir().to_string_lossy().to_string()); + .unwrap_or_else(|| raw_data_dir.clone()); + + let absolute_data_dir = absolutize_dir(&raw_data_dir); + let absolute_key_dir = absolutize_dir(&raw_key_dir); + + // Verification loads public keys from {data_directory}/public_keys. + // If only key_directory is supplied, use it as the storage root fallback. + let storage_root = if data_directory.is_some() { + absolute_data_dir.clone() + } else if key_directory.is_some() { + absolute_key_dir.clone() + } else { + absolute_data_dir.clone() + }; + + // Re-root storage and keep config dirs empty so path construction remains + // relative to storage_root (e.g., "public_keys/.pem"). + let data_dir = String::new(); + let key_dir = String::new(); let config = Config::new( Some("false".to_string()), @@ -1077,39 +1258,98 @@ pub fn verify_document_standalone( BindingCoreError::serialization_failed(format!("Failed to serialize config: {}", e)) })?; - let config_path = std::env::temp_dir().join("jacs_standalone_verify_config.json"); + let thread_id = format!("{:?}", std::thread::current().id()) + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect::(); + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let config_path = std::env::temp_dir().join(format!( + "jacs_standalone_verify_config_{}_{}_{}.json", + std::process::id(), + thread_id, + nonce + )); std::fs::write(&config_path, &config_json) .map_err(|e| BindingCoreError::generic(format!("Failed to write temp config: {}", e)))?; - struct EnvGuard(std::option::Option); + struct EnvGuard { + saved: Vec<(&'static str, std::option::Option)>, + } impl Drop for EnvGuard { fn drop(&mut self) { - if let Some(ref prev) = self.0 { - // SAFETY: single-threaded test/standalone use; restore of previous value - unsafe { std::env::set_var("JACS_KEY_RESOLUTION", prev) } - } else { - unsafe { std::env::remove_var("JACS_KEY_RESOLUTION") } + for (key, prev) in &self.saved { + if let Some(val) = prev { + // SAFETY: test/standalone path restores process env to prior values. + unsafe { std::env::set_var(key, val) } + } else { + // SAFETY: removing a missing key is a no-op. + unsafe { std::env::remove_var(key) } + } } } } - let _env_guard = if let Some(kr) = key_resolution { - let prev = std::env::var_os("JACS_KEY_RESOLUTION"); + + // Isolate standalone verification from ambient env var pollution. + // Several test suites set JACS_* vars globally; load_config_12factor would + // otherwise override our temp config and silently point key lookups elsewhere. + let isolated_keys: [&'static str; 16] = [ + "JACS_USE_SECURITY", + "JACS_DATA_DIRECTORY", + "JACS_KEY_DIRECTORY", + "JACS_AGENT_PRIVATE_KEY_FILENAME", + "JACS_AGENT_PUBLIC_KEY_FILENAME", + "JACS_AGENT_KEY_ALGORITHM", + "JACS_AGENT_ID_AND_VERSION", + "JACS_DEFAULT_STORAGE", + "JACS_AGENT_DOMAIN", + "JACS_DNS_VALIDATE", + "JACS_DNS_STRICT", + "JACS_DNS_REQUIRED", + "JACS_DATABASE_URL", + "JACS_DATABASE_MAX_CONNECTIONS", + "JACS_DATABASE_MIN_CONNECTIONS", + "JACS_DATABASE_CONNECT_TIMEOUT_SECS", + ]; + let mut saved: Vec<(&'static str, std::option::Option)> = Vec::new(); + for key in isolated_keys { + saved.push((key, std::env::var_os(key))); + // SAFETY: intentionally clearing process env vars for isolated verification. + unsafe { std::env::remove_var(key) } + } + saved.push(( + "JACS_KEY_RESOLUTION", + std::env::var_os("JACS_KEY_RESOLUTION"), + )); + if let Some(kr) = key_resolution { + // SAFETY: set explicit key resolution only for this call. unsafe { std::env::set_var("JACS_KEY_RESOLUTION", kr) } - Some(EnvGuard(prev)) } else { - None - }; + // SAFETY: ensure no inherited override leaks in. + unsafe { std::env::remove_var("JACS_KEY_RESOLUTION") } + } + let _env_guard = EnvGuard { saved }; let result: BindingResult = (|| { let wrapper = AgentWrapper::new(); wrapper.load(config_path.to_string_lossy().to_string())?; + // If re-rooting fails (e.g. directory doesn't exist), fall through to + // return valid=false from the verification step. + let _ = wrapper.set_storage_root(std::path::PathBuf::from(&storage_root)); let valid = wrapper.verify_document(signed_document)?; Ok(VerificationResult { valid, signer_id: signer_id.clone(), + timestamp: timestamp.clone(), + agent_version: agent_version.clone(), }) })(); + // Clean up temp config file + let _ = std::fs::remove_file(&config_path); + match result { Ok(r) => Ok(r), Err(e) => { @@ -1120,6 +1360,8 @@ pub fn verify_document_standalone( Ok(VerificationResult { valid: false, signer_id, + timestamp, + agent_version, }) } else { Err(e) @@ -1376,6 +1618,23 @@ pub fn fetch_remote_key(agent_id: &str, version: &str) -> BindingResult BindingResult { + jacs::dns::bootstrap::verify_agent_dns(agent_json, domain).map_err(|e| { + BindingCoreError::invalid_argument(format!("DNS verification setup failed: {}", e)) + }) +} + // ============================================================================= // Re-exports for convenience // ============================================================================= @@ -1389,6 +1648,15 @@ pub use jacs; #[cfg(test)] mod tests { use super::*; + use std::path::PathBuf; + + fn cross_language_fixtures_dir() -> Option { + let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent()? + .to_path_buf(); + let dir = workspace.join("jacs/tests/fixtures/cross-language"); + if dir.exists() { Some(dir) } else { None } + } #[test] fn verify_standalone_invalid_json_returns_valid_false() { @@ -1438,6 +1706,178 @@ mod tests { assert_eq!(result.signer_id, "some-agent"); } + #[test] + fn verify_standalone_accepts_relative_parent_paths_from_subdir() { + let Some(fixtures_dir) = cross_language_fixtures_dir() else { + eprintln!("Skipping: cross-language fixtures directory not found"); + return; + }; + let signed_path = fixtures_dir.join("python_ed25519_signed.json"); + if !signed_path.exists() { + eprintln!( + "Skipping: fixture '{}' not found", + signed_path.to_string_lossy() + ); + return; + } + let signed = std::fs::read_to_string(&signed_path).expect("read python fixture"); + + let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let jacsnpm_dir = workspace.join("jacsnpm"); + if !jacsnpm_dir.exists() { + eprintln!("Skipping: jacsnpm directory not found"); + return; + } + + struct CwdGuard(PathBuf); + impl Drop for CwdGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.0); + } + } + + let original_cwd = std::env::current_dir().expect("current dir"); + std::env::set_current_dir(&jacsnpm_dir).expect("chdir to jacsnpm"); + let _guard = CwdGuard(original_cwd); + + let rel = "../jacs/tests/fixtures/cross-language"; + let result = verify_document_standalone(&signed, Some("local"), Some(rel), Some(rel)) + .expect("standalone verify should not error"); + assert!(result.valid, "relative parent-path fixture should verify"); + } + + #[test] + fn verify_standalone_accepts_absolute_fixture_paths() { + let Some(fixtures_dir) = cross_language_fixtures_dir() else { + eprintln!("Skipping: cross-language fixtures directory not found"); + return; + }; + let signed_path = fixtures_dir.join("python_ed25519_signed.json"); + if !signed_path.exists() { + eprintln!( + "Skipping: fixture '{}' not found", + signed_path.to_string_lossy() + ); + return; + } + let signed = std::fs::read_to_string(&signed_path).expect("read python fixture"); + let fixtures_abs = fixtures_dir + .canonicalize() + .unwrap_or_else(|_| fixtures_dir.clone()); + let fixtures_abs_str = fixtures_abs.to_string_lossy().to_string(); + + let result = verify_document_standalone( + &signed, + Some("local"), + Some(&fixtures_abs_str), + Some(&fixtures_abs_str), + ) + .expect("standalone verify should not error"); + assert!(result.valid, "absolute-path fixture should verify"); + } + + #[test] + fn verify_standalone_uses_key_directory_when_data_directory_missing() { + let Some(fixtures_dir) = cross_language_fixtures_dir() else { + eprintln!("Skipping: cross-language fixtures directory not found"); + return; + }; + let signed_path = fixtures_dir.join("python_ed25519_signed.json"); + if !signed_path.exists() { + eprintln!( + "Skipping: fixture '{}' not found", + signed_path.to_string_lossy() + ); + return; + } + let signed = std::fs::read_to_string(&signed_path).expect("read python fixture"); + let fixtures_abs = fixtures_dir + .canonicalize() + .unwrap_or_else(|_| fixtures_dir.clone()); + let fixtures_abs_str = fixtures_abs.to_string_lossy().to_string(); + + let result = + verify_document_standalone(&signed, Some("local"), None, Some(&fixtures_abs_str)) + .expect("standalone verify should not error"); + assert!( + result.valid, + "key_directory should be usable as standalone storage root when data_directory is omitted" + ); + } + + #[test] + fn verify_standalone_ignores_polluting_env_overrides() { + let Some(fixtures_dir) = cross_language_fixtures_dir() else { + eprintln!("Skipping: cross-language fixtures directory not found"); + return; + }; + let signed_path = fixtures_dir.join("python_ed25519_signed.json"); + if !signed_path.exists() { + eprintln!( + "Skipping: fixture '{}' not found", + signed_path.to_string_lossy() + ); + return; + } + let signed = std::fs::read_to_string(&signed_path).expect("read python fixture"); + let fixtures_abs = fixtures_dir + .canonicalize() + .unwrap_or_else(|_| fixtures_dir.clone()); + let fixtures_abs_str = fixtures_abs.to_string_lossy().to_string(); + + struct EnvRestore(Vec<(&'static str, Option)>); + impl Drop for EnvRestore { + fn drop(&mut self) { + for (k, v) in &self.0 { + if let Some(val) = v { + // SAFETY: test-only env restoration. + unsafe { std::env::set_var(k, val) } + } else { + // SAFETY: removing missing env vars is safe. + unsafe { std::env::remove_var(k) } + } + } + } + } + + let keys = [ + "JACS_DATA_DIRECTORY", + "JACS_KEY_DIRECTORY", + "JACS_DEFAULT_STORAGE", + "JACS_KEY_RESOLUTION", + ]; + let mut prev = Vec::new(); + for k in keys { + prev.push((k, std::env::var_os(k))); + } + let _restore = EnvRestore(prev); + + // Simulate pollution from earlier tests in the same process. + // SAFETY: test-only env manipulation. + unsafe { + std::env::set_var("JACS_DATA_DIRECTORY", "/tmp/does-not-exist"); + std::env::set_var("JACS_KEY_DIRECTORY", "/tmp/does-not-exist"); + std::env::set_var("JACS_DEFAULT_STORAGE", "memory"); + std::env::set_var("JACS_KEY_RESOLUTION", "hai"); + } + + let result = verify_document_standalone( + &signed, + Some("local"), + Some(&fixtures_abs_str), + Some(&fixtures_abs_str), + ) + .expect("standalone verify should not error"); + + assert!( + result.valid, + "verification should ignore ambient JACS_* env pollution" + ); + } + #[test] fn audit_default_returns_ok_json_has_risks_and_health_checks() { let json = audit(None, None).unwrap(); diff --git a/binding-core/tests/document_sign_verify.rs b/binding-core/tests/document_sign_verify.rs new file mode 100644 index 000000000..ef8299797 --- /dev/null +++ b/binding-core/tests/document_sign_verify.rs @@ -0,0 +1,151 @@ +//! Tests for document signing and verification via binding-core. +//! +//! These validate the underlying APIs that jacs-mcp's jacs_sign_document +//! and jacs_verify_document tools delegate to. + +use jacs_binding_core::AgentWrapper; +use serde_json::{Value, json}; + +fn create_ephemeral_wrapper() -> AgentWrapper { + let wrapper = AgentWrapper::new(); + wrapper + .ephemeral(Some("ed25519")) + .expect("Failed to create ephemeral agent"); + wrapper +} + +#[test] +fn test_sign_document_and_verify_valid() { + let wrapper = create_ephemeral_wrapper(); + + let content = json!({ + "jacsType": "message", + "jacsLevel": "raw", + "content": {"hello": "world"} + }); + + // Sign (create_document with no_save=true) + let signed = wrapper + .create_document(&content.to_string(), None, None, true, None, None) + .expect("create_document should succeed"); + + assert!(!signed.is_empty(), "signed document should not be empty"); + + // Parse to confirm it's valid JSON with JACS fields + let parsed: Value = serde_json::from_str(&signed).expect("signed doc should be valid JSON"); + assert!( + parsed.get("id").is_some() || parsed.get("jacsId").is_some(), + "signed doc should have an id field" + ); + + // Verify using verify_signature (self-signed ephemeral agent) + let valid = wrapper + .verify_signature(&signed, None) + .expect("verify_signature should succeed"); + assert!(valid, "document should verify as valid"); +} + +#[test] +fn test_verify_document_invalid_garbage() { + let wrapper = create_ephemeral_wrapper(); + + // Garbage string should fail verification + let result = wrapper.verify_document("not-a-valid-jacs-document"); + assert!(result.is_err(), "garbage input should return an error"); +} + +#[test] +fn test_verify_document_tampered() { + let wrapper = create_ephemeral_wrapper(); + + let content = json!({ + "jacsType": "message", + "jacsLevel": "raw", + "content": {"data": "original"} + }); + + let signed = wrapper + .create_document(&content.to_string(), None, None, true, None, None) + .expect("create_document should succeed"); + + // Tamper with the signed document by modifying content + let mut parsed: Value = serde_json::from_str(&signed).unwrap(); + if let Some(doc) = parsed.get_mut("jacsDocument") { + if let Some(content_obj) = doc.get_mut("content") { + *content_obj = json!({"data": "tampered"}); + } + } + let tampered = serde_json::to_string(&parsed).unwrap(); + + // Tampered document should fail hash verification + let result = wrapper.verify_document(&tampered); + assert!( + result.is_err(), + "tampered document should fail verification" + ); +} + +#[test] +fn test_sign_batch_returns_signatures_for_each_message() { + let wrapper = create_ephemeral_wrapper(); + + let messages = vec![ + "message one".to_string(), + "message two".to_string(), + "message three".to_string(), + ]; + let signatures = wrapper + .sign_batch(messages.clone()) + .expect("sign_batch should succeed"); + + assert_eq!( + signatures.len(), + messages.len(), + "should return one signature per message" + ); + for (i, sig) in signatures.iter().enumerate() { + assert!(!sig.is_empty(), "signature {} should not be empty", i); + } + + // Each signature should be unique (different messages -> different sigs) + let unique: std::collections::HashSet<&String> = signatures.iter().collect(); + assert_eq!( + unique.len(), + signatures.len(), + "all signatures should be distinct" + ); +} + +#[test] +fn test_sign_batch_empty_input() { + let wrapper = create_ephemeral_wrapper(); + let signatures = wrapper + .sign_batch(vec![]) + .expect("sign_batch with empty input should succeed"); + assert!( + signatures.is_empty(), + "empty input should return empty output" + ); +} + +#[test] +fn test_sign_document_roundtrip() { + let wrapper = create_ephemeral_wrapper(); + + let content = json!({ + "jacsType": "message", + "jacsLevel": "raw", + "content": {"task": "test roundtrip", "value": 42} + }); + + // Sign + let signed = wrapper + .create_document(&content.to_string(), None, None, true, None, None) + .expect("create_document should succeed"); + + // Verify via verify_signature (self-signed) + let valid = wrapper + .verify_signature(&signed, None) + .expect("verify_signature should succeed"); + assert!(valid, "roundtrip: signed document should verify"); +} diff --git a/binding-core/tests/multi_instance.rs b/binding-core/tests/multi_instance.rs new file mode 100644 index 000000000..7fc1227f3 --- /dev/null +++ b/binding-core/tests/multi_instance.rs @@ -0,0 +1,132 @@ +//! Multi-instance tests for binding-core's AgentWrapper. +//! +//! Proves that multiple AgentWrapper instances can coexist and operate +//! concurrently through the binding layer. + +use jacs_binding_core::AgentWrapper; +use serde_json::{Value, json}; +use std::sync::Arc; +use std::thread; + +fn create_ephemeral_wrapper(algo: &str) -> (AgentWrapper, String) { + let wrapper = AgentWrapper::new(); + let info_json = wrapper + .ephemeral(Some(algo)) + .expect("Failed to create ephemeral agent"); + let info: Value = serde_json::from_str(&info_json).expect("Bad agent info JSON"); + let agent_id = info["agent_id"].as_str().unwrap().to_string(); + (wrapper, agent_id) +} + +#[test] +fn test_two_wrappers_different_ids() { + let (_, id_a) = create_ephemeral_wrapper("ed25519"); + let (_, id_b) = create_ephemeral_wrapper("rsa-pss"); + + assert_ne!(id_a, id_b, "Two AgentWrappers must have different IDs"); +} + +#[test] +fn test_wrapper_sign_and_self_verify() { + let (wrapper, _) = create_ephemeral_wrapper("ed25519"); + + let doc_content = json!({ + "jacsType": "message", + "jacsLevel": "raw", + "content": {"hello": "world"} + }); + + let signed = wrapper + .create_document(&doc_content.to_string(), None, None, true, None, None) + .expect("create_document failed"); + + // Use verify_signature (uses agent's own key) rather than verify_document + // (which tries external key resolution unsuitable for ephemeral agents). + let valid = wrapper + .verify_signature(&signed, None) + .expect("verify_signature failed"); + assert!(valid, "Wrapper should verify its own signed document"); +} + +#[test] +fn test_concurrent_wrappers() { + let (wrapper_a, _) = create_ephemeral_wrapper("ed25519"); + let (wrapper_b, _) = create_ephemeral_wrapper("ed25519"); + + // AgentWrapper is Clone (Arc> inside) + let wa = Arc::new(wrapper_a); + let wb = Arc::new(wrapper_b); + + const N: usize = 5; + + let a = Arc::clone(&wa); + let handle_a = thread::spawn(move || { + let mut docs = Vec::new(); + for i in 0..N { + let content = json!({ + "jacsType": "message", + "jacsLevel": "raw", + "content": {"from": "A", "i": i} + }); + let signed = a + .create_document(&content.to_string(), None, None, true, None, None) + .expect("Wrapper A create_document failed"); + docs.push(signed); + } + docs + }); + + let b = Arc::clone(&wb); + let handle_b = thread::spawn(move || { + let mut docs = Vec::new(); + for i in 0..N { + let content = json!({ + "jacsType": "message", + "jacsLevel": "raw", + "content": {"from": "B", "i": i} + }); + let signed = b + .create_document(&content.to_string(), None, None, true, None, None) + .expect("Wrapper B create_document failed"); + docs.push(signed); + } + docs + }); + + let docs_a = handle_a.join().expect("Thread A panicked"); + let docs_b = handle_b.join().expect("Thread B panicked"); + + assert_eq!(docs_a.len(), N); + assert_eq!(docs_b.len(), N); + + // Verify each wrapper's documents with itself (using verify_signature) + for doc in &docs_a { + assert!(wa.verify_signature(doc, None).expect("verify failed")); + } + for doc in &docs_b { + assert!(wb.verify_signature(doc, None).expect("verify failed")); + } +} + +#[test] +fn test_cross_verification_fails() { + let (wrapper_a, _) = create_ephemeral_wrapper("ed25519"); + let (wrapper_b, _) = create_ephemeral_wrapper("ed25519"); + + let content = json!({ + "jacsType": "message", + "jacsLevel": "raw", + "content": {"signed_by": "A"} + }); + + let signed = wrapper_a + .create_document(&content.to_string(), None, None, true, None, None) + .expect("Wrapper A signing failed"); + + // Wrapper B verifying A's document with B's key should fail + let result = wrapper_b.verify_signature(&signed, None); + assert!( + result.is_err(), + "Wrapper B should fail to verify Wrapper A's document (different keys)" + ); +} diff --git a/examples/multi_agent_agreement.py b/examples/multi_agent_agreement.py new file mode 100644 index 000000000..8b90ce2cb --- /dev/null +++ b/examples/multi_agent_agreement.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Multi-agent agreement with cryptographic proof -- zero setup. + +Three agents negotiate and co-sign a deployment proposal using JACS. +Demonstrates quorum (2-of-3), timeout, independent verification, +and a full crypto proof chain. + +Run: + python examples/multi_agent_agreement.py +""" + +import json +import os +import shutil +import tempfile +from datetime import datetime, timedelta, timezone + +from jacs.client import JacsClient + +DEMO_PASSWORD = "Demo!Str0ng#Pass42" + + +def create_agent(name: str, base: str) -> JacsClient: + """Create a persistent agent in a shared workspace.""" + keys_dir = os.path.join(base, f"{name}_keys") + os.makedirs(keys_dir, exist_ok=True) + cfg = os.path.join(base, f"{name}.config.json") + + # Write a config that uses agent-specific keys but shared data dir + from jacs import SimpleAgent + agent, info = SimpleAgent.create_agent( + name=name, + password=DEMO_PASSWORD, + algorithm="ring-Ed25519", + data_directory=os.path.join(base, "shared_data"), + key_directory=keys_dir, + config_path=cfg, + ) + + # Load through JacsClient for the high-level API + client = JacsClient(config_path=cfg) + print(f" {name}: {client.agent_id}") + return client + + +def main() -> None: + base = tempfile.mkdtemp(prefix="jacs_demo_") + os.environ["JACS_PRIVATE_KEY_PASSWORD"] = DEMO_PASSWORD + print(f"Working directory: {base}\n") + + # -- Step 1: Create three agents ----------------------------------------- + print("Step 1 -- Create agents") + alice = create_agent("alice", base) + bob = create_agent("bob", base) + mediator = create_agent("mediator", base) + + # -- Step 2: Alice proposes an agreement ---------------------------------- + print("\nStep 2 -- Alice proposes an agreement") + proposal = { + "proposal": "Deploy model v2 to production", + "conditions": [ + "passes safety audit", + "approved by 2 of 3 signers", + ], + } + deadline = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat() + + agreement = alice.create_agreement( + document=proposal, + agent_ids=[alice.agent_id, bob.agent_id, mediator.agent_id], + question="Do you approve deployment of model v2?", + context="Production rollout pending safety audit sign-off.", + quorum=2, + timeout=deadline, + ) + print(f" Agreement ID : {agreement.document_id}") + print(f" Quorum : 2 of 3") + print(f" Deadline : {deadline}") + + # -- Step 3: Alice signs -------------------------------------------------- + print("\nStep 3 -- Alice signs") + agreement = alice.sign_agreement(agreement) + print(f" Signed by Alice ({alice.agent_id[:12]}...)") + + # -- Step 4: Bob co-signs ------------------------------------------------- + print("\nStep 4 -- Bob co-signs") + agreement = bob.sign_agreement(agreement) + print(f" Signed by Bob ({bob.agent_id[:12]}...)") + + # -- Step 5: Mediator countersigns ---------------------------------------- + print("\nStep 5 -- Mediator countersigns") + agreement = mediator.sign_agreement(agreement) + print(f" Signed by Mediator ({mediator.agent_id[:12]}...)") + + # -- Step 6: Check agreement status --------------------------------------- + print("\nStep 6 -- Agreement status") + status = alice.check_agreement(agreement) + print(f" Complete : {status.complete}") + print(f" Pending : {status.pending}") + for s in status.signers: + label = "signed" if s.signed else "pending" + print(f" {s.agent_id[:12]}... {label}" + + (f" at {s.signed_at}" if s.signed_at else "")) + + # -- Step 7: Independent verification ------------------------------------- + print("\nStep 7 -- Independent verification") + for name, client in [("Alice", alice), ("Bob", bob), ("Mediator", mediator)]: + result = client.verify(agreement.raw_json) + print(f" {name} verifies: valid={result.valid}") + + # -- Cleanup -------------------------------------------------------------- + shutil.rmtree(base, ignore_errors=True) + print(f"\nDone. Temp files cleaned up.") + + +if __name__ == "__main__": + main() diff --git a/examples/multi_agent_agreement.ts b/examples/multi_agent_agreement.ts new file mode 100644 index 000000000..13dfcca63 --- /dev/null +++ b/examples/multi_agent_agreement.ts @@ -0,0 +1,95 @@ +#!/usr/bin/env npx ts-node +/** + * Multi-agent agreement with cryptographic proof -- zero setup. + * + * Three agents negotiate and co-sign a deployment proposal using JACS. + * Demonstrates quorum (2-of-3), timeout, independent verification, + * and a full crypto proof chain. + * + * Run: + * npx ts-node --compiler-options '{"module":"commonjs","moduleResolution":"node","esModuleInterop":true}' examples/multi_agent_agreement.ts + */ + +import { JacsClient } from '../jacsnpm/client'; + +async function main(): Promise { + // -- Step 1: Create three ephemeral agents ---------------------------------- + console.log('Step 1 -- Create agents'); + const alice = await JacsClient.ephemeral('ring-Ed25519'); + const bob = await JacsClient.ephemeral('ring-Ed25519'); + const mediator = await JacsClient.ephemeral('ring-Ed25519'); + + console.log(` Alice : ${alice.agentId}`); + console.log(` Bob : ${bob.agentId}`); + console.log(` Mediator : ${mediator.agentId}`); + + // -- Step 2: Alice proposes an agreement ------------------------------------ + console.log('\nStep 2 -- Alice proposes an agreement'); + const proposal = { + proposal: 'Deploy model v2 to production', + conditions: ['passes safety audit', 'approved by 2 of 3 signers'], + }; + + const deadline = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + const agentIds = [alice.agentId, bob.agentId, mediator.agentId]; + + let agreement = await alice.createAgreement(proposal, agentIds, { + question: 'Do you approve deployment of model v2?', + context: 'Production rollout pending safety audit sign-off.', + quorum: 2, + timeout: deadline, + }); + console.log(` Agreement ID : ${agreement.documentId}`); + console.log(` Quorum : 2 of 3`); + console.log(` Deadline : ${deadline}`); + + // -- Step 3: Alice signs ---------------------------------------------------- + console.log('\nStep 3 -- Alice signs'); + agreement = await alice.signAgreement(agreement); + console.log(` Signed by Alice (${alice.agentId.substring(0, 12)}...)`); + + // -- Step 4: Bob co-signs --------------------------------------------------- + console.log('\nStep 4 -- Bob co-signs'); + agreement = await bob.signAgreement(agreement); + console.log(` Signed by Bob (${bob.agentId.substring(0, 12)}...)`); + + // -- Step 5: Mediator countersigns ------------------------------------------ + console.log('\nStep 5 -- Mediator countersigns'); + agreement = await mediator.signAgreement(agreement); + console.log(` Signed by Mediator (${mediator.agentId.substring(0, 12)}...)`); + + // -- Step 6: Inspect agreement status --------------------------------------- + console.log('\nStep 6 -- Agreement status'); + const doc = JSON.parse(agreement.raw); + const ag = doc.jacsAgreement; + const sigCount = ag.signatures?.length ?? 0; + const quorum = ag.quorum ?? ag.agentIDs.length; + const complete = sigCount >= quorum; + + console.log(` Signatures : ${sigCount} of ${ag.agentIDs.length}`); + console.log(` Quorum met : ${complete}`); + for (const sig of ag.signatures ?? []) { + console.log( + ` ${sig.agentID.substring(0, 12)}... signed at ${sig.date} (${sig.signingAlgorithm})`, + ); + } + + // -- Step 7: Independent self-verification ---------------------------------- + console.log('\nStep 7 -- Independent self-verification'); + for (const [name, client] of [ + ['Alice', alice], + ['Bob', bob], + ['Mediator', mediator], + ] as const) { + const result = await client.verifySelf(); + console.log(` ${name} verifies self: valid=${result.valid}`); + } + + // -- Cleanup ---------------------------------------------------------------- + alice.dispose(); + bob.dispose(); + mediator.dispose(); + console.log('\nDone.'); +} + +main().catch(console.error); diff --git a/jacs-mcp/Cargo.toml b/jacs-mcp/Cargo.toml index e1dc4abdc..87084f641 100644 --- a/jacs-mcp/Cargo.toml +++ b/jacs-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-mcp" -version = "0.6.1" +version = "0.8.0" edition = "2024" rust-version = "1.93" description = "MCP server for JACS: data provenance and cryptographic signing of agent state, with optional HAI.ai integration" @@ -21,7 +21,7 @@ http = [] anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } -rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", branch = "main", features = ["server", "transport-io", "macros"], optional = true } +rmcp = { version = "0.12", features = ["server", "transport-io", "macros"], optional = true } tokio = { version = "1", features = ["rt-multi-thread", "macros"], optional = true } jacs = { path = "../jacs", default-features = true } jacs-binding-core = { path = "../binding-core", features = ["hai"] } @@ -31,6 +31,7 @@ schemars = "1.0" uuid = { version = "1", features = ["v4"] } url = "2" sha2 = "0.10.8" +chrono = { version = "0.4", features = ["serde"] } [dev-dependencies] assert_cmd = "2" diff --git a/jacs-mcp/README.md b/jacs-mcp/README.md index 7301f5901..af7c79d22 100644 --- a/jacs-mcp/README.md +++ b/jacs-mcp/README.md @@ -6,7 +6,7 @@ JACS (JSON Agent Communication Standard) ensures that every file, memory, or con ## What can it do? -The server exposes **14 tools** in four categories: +The server exposes **21 tools** in five categories: ### Agent State (Data Provenance) @@ -34,6 +34,42 @@ Sign, verify, and manage files that represent agent state (memories, skills, pla |------|-------------| | `jacs_audit` | Run a read-only security audit and health checks (risks, health_checks, summary). Optional: `config_path`, `recent_n`. | +### Messaging + +Send and receive cryptographically signed messages between agents: + +| Tool | Description | +|------|-------------| +| `jacs_message_send` | Create and sign a message for another agent | +| `jacs_message_update` | Update and re-sign an existing message | +| `jacs_message_agree` | Verify and co-sign a received message | +| `jacs_message_receive` | Verify a received message and extract its content | + +### Document Sign / Verify + +Sign and verify arbitrary documents without requiring file paths or agent state metadata: + +| Tool | Description | +|------|-------------| +| `jacs_sign_document` | Sign arbitrary JSON content to create a signed JACS document for attestation | +| `jacs_verify_document` | Verify a signed JACS document given its full JSON string (hash + signature check) | + +### Agreements (Multi-Party) + +Create multi-party cryptographic agreements — multiple agents formally commit to a shared decision: + +| Tool | Description | +|------|-------------| +| `jacs_create_agreement` | Create an agreement specifying which agents must sign, with optional quorum (M-of-N), timeout, and algorithm constraints | +| `jacs_sign_agreement` | Co-sign an existing agreement, adding your agent's cryptographic signature | +| `jacs_check_agreement` | Check agreement status: who signed, quorum met, expired, who still needs to sign | + +**Use agreements when agents need to:** +- Approve a deployment, data transfer, or configuration change +- Reach consensus on a proposal (e.g., 2-of-3 signers required) +- Enforce that only post-quantum algorithms are used for signing +- Set a deadline after which the agreement expires + ### HAI Integration (Optional) Register with [HAI.ai](https://hai.ai) for cross-organization trust and key distribution: @@ -208,6 +244,57 @@ Adopt an external file as signed agent state, marking its origin as "adopted". - `source_url` (optional): URL where the content was originally obtained - `description` (optional): Description of the adopted state +### jacs_create_agreement + +Create a multi-party cryptographic agreement that other agents can co-sign. + +**Parameters:** +- `document` (required): JSON document that all parties will agree to +- `agent_ids` (required): List of agent IDs (UUIDs) that are parties to this agreement +- `question` (optional): Human-readable question for signers (e.g., "Do you approve deploying model v2?") +- `context` (optional): Additional context to help signers decide +- `timeout` (optional): ISO 8601 deadline after which the agreement expires (e.g., "2025-12-31T23:59:59Z") +- `quorum` (optional): Minimum signatures required (M-of-N). If omitted, all agents must sign. +- `required_algorithms` (optional): Only allow these signing algorithms: `RSA-PSS`, `ring-Ed25519`, `pq-dilithium`, `pq2025` +- `minimum_strength` (optional): `classical` (any algorithm) or `post-quantum` (pq-dilithium/pq2025 only) + +### jacs_sign_agreement + +Co-sign an existing agreement, adding your agent's cryptographic signature. + +**Parameters:** +- `signed_agreement` (required): The full agreement JSON to sign +- `agreement_fieldname` (optional): Custom agreement field name (default: `jacsAgreement`) + +### jacs_check_agreement + +Check the status of an agreement. + +**Parameters:** +- `signed_agreement` (required): The agreement JSON to check +- `agreement_fieldname` (optional): Custom agreement field name (default: `jacsAgreement`) + +**Returns:** `complete`, `quorum_met`, `expired`, `signatures_collected`, `signatures_required`, `signed_by`, `unsigned` + +### jacs_sign_document + +Sign arbitrary JSON content to create a cryptographically signed JACS document. + +**Parameters:** +- `content` (required): The JSON content to sign +- `content_type` (optional): MIME type of the content (default: `application/json`) + +**Returns:** `success`, `signed_document` (full signed JACS envelope), `content_hash` (SHA-256), `jacs_document_id` + +### jacs_verify_document + +Verify a signed JACS document given its full JSON string. Checks both the content hash and cryptographic signature. Use this when you have a signed document in memory (e.g. from an approval context or message payload). + +**Parameters:** +- `document` (required): The full signed JACS document JSON string + +**Returns:** `success`, `valid`, `signer_id` (optional -- extracted from document if available), `message` + ### fetch_agent_key Fetch a public key from HAI's key distribution service. diff --git a/jacs-mcp/src/hai_tools.rs b/jacs-mcp/src/hai_tools.rs index a458f181e..1c53a0eca 100644 --- a/jacs-mcp/src/hai_tools.rs +++ b/jacs-mcp/src/hai_tools.rs @@ -638,6 +638,77 @@ pub struct AdoptStateResult { pub error: Option, } +// ============================================================================= +// Document Sign/Verify Request/Response Types +// ============================================================================= + +/// Parameters for verifying a raw signed JACS document string. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct VerifyDocumentParams { + /// The full JACS signed document as a JSON string. + #[schemars(description = "The full signed JACS document JSON string to verify")] + pub document: String, +} + +/// Result of verifying a signed document. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct VerifyDocumentResult { + /// Whether the operation completed without error. + pub success: bool, + + /// Whether the document's hash and signature are valid. + pub valid: bool, + + /// The signer's agent ID, if available. + #[serde(skip_serializing_if = "Option::is_none")] + pub signer_id: Option, + + /// Human-readable status message. + pub message: String, + + /// Error message if verification failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Parameters for signing arbitrary content as a JACS document. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SignDocumentParams { + /// The JSON content string to sign. + #[schemars(description = "The JSON content to sign as a JACS document")] + pub content: String, + + /// Optional MIME type of the content (default: "application/json"). + #[schemars(description = "MIME type of the content (default: 'application/json')")] + pub content_type: Option, +} + +/// Result of signing a document. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SignDocumentResult { + /// Whether the operation succeeded. + pub success: bool, + + /// The full signed JACS document as a JSON string. + #[serde(skip_serializing_if = "Option::is_none")] + pub signed_document: Option, + + /// SHA-256 hash of the signed document content. + #[serde(skip_serializing_if = "Option::is_none")] + pub content_hash: Option, + + /// The JACS document ID assigned to the signed document. + #[serde(skip_serializing_if = "Option::is_none")] + pub jacs_document_id: Option, + + /// Human-readable status message. + pub message: String, + + /// Error message if signing failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + // ============================================================================= // Message Request/Response Types // ============================================================================= @@ -781,11 +852,172 @@ pub struct MessageReceiveResult { pub error: Option, } +// ============================================================================= +// Agreement Types — Multi-party cryptographic agreements +// ============================================================================= + +/// Parameters for creating a multi-party agreement. +/// +/// An agreement is a document that multiple agents must sign. Use this when agents +/// need to formally commit to a shared decision — for example, approving a deployment, +/// authorizing a data transfer, or reaching consensus on a proposal. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CreateAgreementParams { + /// The document to create an agreement for, as a JSON string. + /// This is the content all parties will be agreeing to. + #[schemars( + description = "JSON document that all parties will agree to. Can be any valid JSON object." + )] + pub document: String, + + /// List of agent IDs (UUIDs) that must sign this agreement. + /// Include your own agent ID if you want to be a required signer. + #[schemars(description = "List of agent IDs (UUIDs) that are parties to this agreement")] + pub agent_ids: Vec, + + /// A human-readable question summarizing what signers are agreeing to. + #[schemars(description = "Question for signers, e.g. 'Do you approve deploying model v2?'")] + pub question: Option, + + /// Additional context to help signers make their decision. + #[schemars(description = "Additional context for signers")] + pub context: Option, + + /// ISO 8601 deadline. The agreement expires if not fully signed by this time. + /// Example: "2025-12-31T23:59:59Z" + #[schemars( + description = "ISO 8601 deadline after which the agreement expires. Example: '2025-12-31T23:59:59Z'" + )] + pub timeout: Option, + + /// Minimum number of signatures required (M-of-N). If omitted, ALL agents must sign. + /// For example, quorum=2 with 3 agent_ids means any 2 of 3 signers is sufficient. + #[schemars( + description = "Minimum signatures required (M-of-N). If omitted, all agents must sign." + )] + pub quorum: Option, + + /// Only allow agents using these algorithms to sign. + /// Values: "RSA-PSS", "ring-Ed25519", "pq-dilithium", "pq2025" + #[schemars( + description = "Only allow these signing algorithms. Values: 'RSA-PSS', 'ring-Ed25519', 'pq-dilithium', 'pq2025'" + )] + pub required_algorithms: Option>, + + /// Minimum cryptographic strength: "classical" (any algorithm) or "post-quantum" (pq-dilithium, pq2025 only). + #[schemars(description = "Minimum crypto strength: 'classical' or 'post-quantum'")] + pub minimum_strength: Option, +} + +/// Result of creating an agreement. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CreateAgreementResult { + pub success: bool, + + /// The JACS document ID of the agreement. + #[serde(skip_serializing_if = "Option::is_none")] + pub agreement_id: Option, + + /// The full signed agreement JSON. Pass this to other agents for signing. + #[serde(skip_serializing_if = "Option::is_none")] + pub signed_agreement: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Parameters for signing an existing agreement. +/// +/// Use this after receiving an agreement document from another agent. +/// Your agent will cryptographically co-sign it, adding your signature +/// to the agreement's signature list. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SignAgreementParams { + /// The full signed agreement JSON document to co-sign. + #[schemars( + description = "The full agreement JSON to sign. Obtained from jacs_create_agreement or from another agent." + )] + pub signed_agreement: String, + + /// Optional custom agreement field name (default: 'jacsAgreement'). + #[schemars(description = "Custom agreement field name (default: 'jacsAgreement')")] + pub agreement_fieldname: Option, +} + +/// Result of signing an agreement. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SignAgreementResult { + pub success: bool, + + /// The updated agreement JSON with your signature added. + #[serde(skip_serializing_if = "Option::is_none")] + pub signed_agreement: Option, + + /// Number of signatures now on the agreement. + #[serde(skip_serializing_if = "Option::is_none")] + pub signature_count: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Parameters for checking agreement status. +/// +/// Use this to see how many agents have signed, whether quorum is met, +/// and whether the agreement has expired. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CheckAgreementParams { + /// The full signed agreement JSON document to check. + #[schemars(description = "The agreement JSON to check status of")] + pub signed_agreement: String, + + /// Optional custom agreement field name (default: 'jacsAgreement'). + #[schemars(description = "Custom agreement field name (default: 'jacsAgreement')")] + pub agreement_fieldname: Option, +} + +/// Result of checking an agreement's status. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CheckAgreementResult { + pub success: bool, + + /// Whether the agreement is complete (quorum met, not expired, all signatures valid). + pub complete: bool, + + /// Total agents required to sign. + pub total_agents: usize, + + /// Number of valid signatures collected. + pub signatures_collected: usize, + + /// Minimum signatures required (quorum). Equals total_agents if no quorum set. + pub signatures_required: usize, + + /// Whether quorum has been met. + pub quorum_met: bool, + + /// Whether the agreement has expired (past timeout). + pub expired: bool, + + /// List of agent IDs that have signed. + #[serde(skip_serializing_if = "Option::is_none")] + pub signed_by: Option>, + + /// List of agent IDs that have NOT signed yet. + #[serde(skip_serializing_if = "Option::is_none")] + pub unsigned: Option>, + + /// Timeout deadline (if set). + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + /// Format a SystemTime as an ISO 8601 UTC timestamp string. fn format_iso8601(t: std::time::SystemTime) -> String { - let d = t - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default(); + let d = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); let secs = d.as_secs(); // Simple conversion: seconds -> year/month/day/hour/min/sec // Using a basic algorithm that handles dates from 1970 onwards @@ -1020,6 +1252,46 @@ impl HaiMcpServer { Use this to validate authenticity before processing a message from another agent.", Self::jacs_message_receive_schema(), ), + // --- Agreement tools --- + Tool::new( + "jacs_create_agreement", + "Create a multi-party cryptographic agreement. Use this when multiple agents need \ + to formally agree on something — like approving a deployment, authorizing a data \ + transfer, or ratifying a decision. You specify which agents must sign, an optional \ + quorum (e.g., 2-of-3), a timeout deadline, and algorithm constraints. Returns a \ + signed agreement document to pass to other agents for co-signing.", + Self::jacs_create_agreement_schema(), + ), + Tool::new( + "jacs_sign_agreement", + "Co-sign an existing agreement. Use this after receiving an agreement document from \ + another agent. Your cryptographic signature is added to the agreement. The updated \ + document can then be passed to the next signer or checked for completion.", + Self::jacs_sign_agreement_schema(), + ), + Tool::new( + "jacs_check_agreement", + "Check the status of an agreement: how many agents have signed, whether quorum is \ + met, whether it has expired, and which agents still need to sign. Use this to \ + decide whether an agreement is complete and ready to act on.", + Self::jacs_check_agreement_schema(), + ), + // --- Document sign/verify tools --- + Tool::new( + "jacs_sign_document", + "Sign arbitrary JSON content to create a cryptographically signed JACS document. \ + Use this for attestation -- when you want to prove that content was signed by \ + this agent. Returns the signed envelope with hash and document ID.", + Self::jacs_sign_document_schema(), + ), + Tool::new( + "jacs_verify_document", + "Verify a signed JACS document given its full JSON string. Checks both the \ + content hash and cryptographic signature. Use this when you have a signed \ + document in memory (e.g. from an approval context or message payload) and \ + need to confirm its integrity and authenticity.", + Self::jacs_verify_document_schema(), + ), ] } @@ -1166,6 +1438,46 @@ impl HaiMcpServer { _ => serde_json::Map::new(), } } + + fn jacs_create_agreement_schema() -> serde_json::Map { + let schema = schemars::schema_for!(CreateAgreementParams); + match serde_json::to_value(schema) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + } + } + + fn jacs_sign_agreement_schema() -> serde_json::Map { + let schema = schemars::schema_for!(SignAgreementParams); + match serde_json::to_value(schema) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + } + } + + fn jacs_check_agreement_schema() -> serde_json::Map { + let schema = schemars::schema_for!(CheckAgreementParams); + match serde_json::to_value(schema) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + } + } + + fn jacs_sign_document_schema() -> serde_json::Map { + let schema = schemars::schema_for!(SignDocumentParams); + match serde_json::to_value(schema) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + } + } + + fn jacs_verify_document_schema() -> serde_json::Map { + let schema = schemars::schema_for!(VerifyDocumentParams); + match serde_json::to_value(schema) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + } + } } // Implement the tool router for the server @@ -2471,11 +2783,11 @@ impl HaiMcpServer { // Sign the document let result = match self.agent.create_document( &doc_string, - None, // custom_schema - None, // outputfilename - true, // no_save - None, // attachments - None, // embed + None, // custom_schema + None, // outputfilename + true, // no_save + None, // attachments + None, // embed ) { Ok(signed_doc_string) => { let doc_id = serde_json::from_str::(&signed_doc_string) @@ -2514,41 +2826,42 @@ impl HaiMcpServer { Parameters(params): Parameters, ) -> String { // Load the existing document by ID - let existing_doc_string: Option = match self.agent.verify_document_by_id(¶ms.jacs_id) { - Ok(true) => { - // Document verified, now retrieve it. We need the stored document. - // Use get_agent_json to get agent context, then load via ID. - // The verify_document_by_id already loaded it; we need to get it from storage. - // Fall through to attempt update_document with the new content. - None - } - Ok(false) => { - let result = MessageUpdateResult { - success: false, - jacs_document_id: None, - signed_message: None, - error: Some(format!( - "Existing document '{}' failed signature verification", - params.jacs_id - )), - }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); - } - Err(e) => { - let result = MessageUpdateResult { - success: false, - jacs_document_id: None, - signed_message: None, - error: Some(format!( - "Failed to load document '{}': {}", - params.jacs_id, e - )), - }; - return serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("Error: {}", e)); - } - }; + let existing_doc_string: Option = + match self.agent.verify_document_by_id(¶ms.jacs_id) { + Ok(true) => { + // Document verified, now retrieve it. We need the stored document. + // Use get_agent_json to get agent context, then load via ID. + // The verify_document_by_id already loaded it; we need to get it from storage. + // Fall through to attempt update_document with the new content. + None + } + Ok(false) => { + let result = MessageUpdateResult { + success: false, + jacs_document_id: None, + signed_message: None, + error: Some(format!( + "Existing document '{}' failed signature verification", + params.jacs_id + )), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + Err(e) => { + let result = MessageUpdateResult { + success: false, + jacs_document_id: None, + signed_message: None, + error: Some(format!( + "Failed to load document '{}': {}", + params.jacs_id, e + )), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + }; let content_type = params .content_type @@ -2614,9 +2927,7 @@ impl HaiMcpServer { original_document_id: None, agreement_document_id: None, signed_agreement: None, - error: Some( - "Original message signature verification failed".to_string(), - ), + error: Some("Original message signature verification failed".to_string()), }; return serde_json::to_string_pretty(&result) .unwrap_or_else(|e| format!("Error: {}", e)); @@ -2664,11 +2975,11 @@ impl HaiMcpServer { // Sign the agreement document let result = match self.agent.create_document( &doc_string, - None, // custom_schema - None, // outputfilename - true, // no_save - None, // attachments - None, // embed + None, // custom_schema + None, // outputfilename + true, // no_save + None, // attachments + None, // embed ) { Ok(signed_agreement_string) => { let agreement_id = @@ -2781,7 +3092,10 @@ impl HaiMcpServer { timestamp, signature_valid, error: if !signature_valid { - Some("Message signature is INVALID — content may have been tampered with".to_string()) + Some( + "Message signature is INVALID — content may have been tampered with" + .to_string(), + ) } else { None }, @@ -2789,6 +3103,407 @@ impl HaiMcpServer { serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) } + + // ========================================================================= + // Agreement tools — multi-party cryptographic agreements + // ========================================================================= + + /// Create a multi-party agreement that other agents can co-sign. + /// + /// The agreement specifies which agents must sign, optional quorum (M-of-N), + /// timeout, and algorithm constraints. The returned document should be passed + /// to other agents for signing via `jacs_sign_agreement`. + #[tool( + name = "jacs_create_agreement", + description = "Create a multi-party cryptographic agreement. Specify which agents must sign, \ + optional quorum (e.g., 2-of-3), timeout deadline, and algorithm constraints. \ + Returns a signed agreement document to pass to other agents for co-signing." + )] + pub async fn jacs_create_agreement( + &self, + Parameters(params): Parameters, + ) -> String { + // Create the base document first + let signed_doc = match self.agent.create_document( + ¶ms.document, + None, // custom_schema + None, // outputfilename + true, // no_save + None, // attachments + None, // embed + ) { + Ok(doc) => doc, + Err(e) => { + let result = CreateAgreementResult { + success: false, + agreement_id: None, + signed_agreement: None, + error: Some(format!("Failed to create document: {}", e)), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + }; + + // Create the agreement on the document + let result = match self.agent.create_agreement_with_options( + &signed_doc, + params.agent_ids, + params.question, + params.context, + None, // agreement_fieldname (use default) + params.timeout, + params.quorum, + params.required_algorithms, + params.minimum_strength, + ) { + Ok(agreement_string) => { + let agreement_id = serde_json::from_str::(&agreement_string) + .ok() + .and_then(|v| v.get("id").and_then(|id| id.as_str()).map(String::from)) + .unwrap_or_else(|| "unknown".to_string()); + + CreateAgreementResult { + success: true, + agreement_id: Some(agreement_id), + signed_agreement: Some(agreement_string), + error: None, + } + } + Err(e) => CreateAgreementResult { + success: false, + agreement_id: None, + signed_agreement: None, + error: Some(format!("Failed to create agreement: {}", e)), + }, + }; + + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + + /// Co-sign an existing agreement. + /// + /// Adds this agent's cryptographic signature to the agreement. The agent's + /// algorithm must satisfy any constraints specified when the agreement was created. + #[tool( + name = "jacs_sign_agreement", + description = "Co-sign an existing agreement. Adds your agent's cryptographic signature. \ + The agreement may have algorithm constraints that your agent must satisfy." + )] + pub async fn jacs_sign_agreement( + &self, + Parameters(params): Parameters, + ) -> String { + let result = match self + .agent + .sign_agreement(¶ms.signed_agreement, params.agreement_fieldname) + { + Ok(signed_string) => { + // Count signatures + let sig_count = + if let Ok(v) = serde_json::from_str::(&signed_string) { + v.get("jacsAgreement") + .and_then(|a| a.get("signatures")) + .and_then(|s| s.as_array()) + .map(|arr| arr.len()) + .unwrap_or(0) + } else { + 0 + }; + + SignAgreementResult { + success: true, + signed_agreement: Some(signed_string), + signature_count: Some(sig_count), + error: None, + } + } + Err(e) => SignAgreementResult { + success: false, + signed_agreement: None, + signature_count: None, + error: Some(format!("Failed to sign agreement: {}", e)), + }, + }; + + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + + /// Check the status of an agreement. + /// + /// Returns whether quorum is met, which agents have signed, whether the + /// agreement has expired, and how many more signatures are needed. + #[tool( + name = "jacs_check_agreement", + description = "Check agreement status: who has signed, whether quorum is met, \ + whether it has expired, and who still needs to sign." + )] + pub async fn jacs_check_agreement( + &self, + Parameters(params): Parameters, + ) -> String { + // Parse the agreement to extract status without full verification + let doc: serde_json::Value = match serde_json::from_str(¶ms.signed_agreement) { + Ok(v) => v, + Err(e) => { + let result = CheckAgreementResult { + success: false, + complete: false, + total_agents: 0, + signatures_collected: 0, + signatures_required: 0, + quorum_met: false, + expired: false, + signed_by: None, + unsigned: None, + timeout: None, + error: Some(format!("Failed to parse agreement JSON: {}", e)), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + }; + + let fieldname = params + .agreement_fieldname + .unwrap_or_else(|| "jacsAgreement".to_string()); + + let agreement = match doc.get(&fieldname) { + Some(a) => a, + None => { + let result = CheckAgreementResult { + success: false, + complete: false, + total_agents: 0, + signatures_collected: 0, + signatures_required: 0, + quorum_met: false, + expired: false, + signed_by: None, + unsigned: None, + timeout: None, + error: Some(format!("No '{}' field found in document", fieldname)), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + }; + + // Extract agent IDs + let agent_ids: Vec = agreement + .get("agentIDs") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + // Extract signatures + let signatures = agreement + .get("signatures") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + let signed_by: Vec = signatures + .iter() + .filter_map(|sig| { + sig.get("agentID") + .and_then(|v| v.as_str()) + .map(String::from) + }) + .collect(); + + let signed_set: std::collections::HashSet<&str> = + signed_by.iter().map(|s| s.as_str()).collect(); + let unsigned: Vec = agent_ids + .iter() + .filter(|id| !signed_set.contains(id.as_str())) + .cloned() + .collect(); + + // Quorum + let quorum = agreement + .get("quorum") + .and_then(|v| v.as_u64()) + .map(|q| q as usize) + .unwrap_or(agent_ids.len()); + let quorum_met = signed_by.len() >= quorum; + + // Timeout + let timeout_str = agreement + .get("timeout") + .and_then(|v| v.as_str()) + .map(String::from); + let expired = timeout_str + .as_ref() + .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()) + .map(|deadline| chrono::Utc::now() > deadline) + .unwrap_or(false); + + let complete = quorum_met && !expired; + + let result = CheckAgreementResult { + success: true, + complete, + total_agents: agent_ids.len(), + signatures_collected: signed_by.len(), + signatures_required: quorum, + quorum_met, + expired, + signed_by: Some(signed_by), + unsigned: Some(unsigned), + timeout: timeout_str, + error: None, + }; + + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + + // ========================================================================= + // Document Sign / Verify tools + // ========================================================================= + + /// Sign arbitrary JSON content to create a cryptographically signed JACS document. + #[tool( + name = "jacs_sign_document", + description = "Sign arbitrary JSON content to create a signed JACS document for attestation." + )] + pub async fn jacs_sign_document( + &self, + Parameters(params): Parameters, + ) -> String { + // Validate content is valid JSON + let content_value: serde_json::Value = match serde_json::from_str(¶ms.content) { + Ok(v) => v, + Err(e) => { + let result = SignDocumentResult { + success: false, + signed_document: None, + content_hash: None, + jacs_document_id: None, + message: "Content is not valid JSON".to_string(), + error: Some(e.to_string()), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + }; + + // Wrap content in a JACS-compatible envelope if it doesn't already have jacsType + let doc_to_sign = if content_value.get("jacsType").is_some() { + params.content.clone() + } else { + let wrapper = serde_json::json!({ + "jacsType": "document", + "jacsLevel": "raw", + "content": content_value, + }); + wrapper.to_string() + }; + + // Sign via create_document (no_save=true) + match self + .agent + .create_document(&doc_to_sign, None, None, true, None, None) + { + Ok(signed_doc_string) => { + // Extract document ID and compute content hash + let doc_id = serde_json::from_str::(&signed_doc_string) + .ok() + .and_then(|v| v.get("id").and_then(|id| id.as_str()).map(String::from)); + + let hash = { + let mut hasher = Sha256::new(); + hasher.update(signed_doc_string.as_bytes()); + format!("{:x}", hasher.finalize()) + }; + + let result = SignDocumentResult { + success: true, + signed_document: Some(signed_doc_string), + content_hash: Some(hash), + jacs_document_id: doc_id, + message: "Document signed successfully".to_string(), + error: None, + }; + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + Err(e) => { + let result = SignDocumentResult { + success: false, + signed_document: None, + content_hash: None, + jacs_document_id: None, + message: "Failed to sign document".to_string(), + error: Some(e.to_string()), + }; + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + } + } + + /// Verify a signed JACS document given its full JSON string. + #[tool( + name = "jacs_verify_document", + description = "Verify a signed JACS document's hash and cryptographic signature." + )] + pub async fn jacs_verify_document( + &self, + Parameters(params): Parameters, + ) -> String { + if params.document.is_empty() { + let result = VerifyDocumentResult { + success: false, + valid: false, + signer_id: None, + message: "Document string is empty".to_string(), + error: Some("EMPTY_DOCUMENT".to_string()), + }; + return serde_json::to_string_pretty(&result) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + + // Try verify_signature first (works for both self-signed and external docs) + match self.agent.verify_signature(¶ms.document, None) { + Ok(valid) => { + // Try to extract signer ID from the document + let signer_id = serde_json::from_str::(¶ms.document) + .ok() + .and_then(|v| { + v.get("jacsSignature") + .and_then(|sig| sig.get("agentId").or_else(|| sig.get("agentID"))) + .and_then(|id| id.as_str()) + .map(String::from) + }); + + let result = VerifyDocumentResult { + success: true, + valid, + signer_id, + message: if valid { + "Document verified successfully".to_string() + } else { + "Document signature verification failed".to_string() + }, + error: None, + }; + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + Err(e) => { + let result = VerifyDocumentResult { + success: false, + valid: false, + signer_id: None, + message: format!("Verification failed: {}", e), + error: Some(e.to_string()), + }; + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + } + } } // Implement the tool handler for the server @@ -2927,7 +3642,7 @@ mod tests { #[test] fn test_tools_list() { let tools = HaiMcpServer::tools(); - assert_eq!(tools.len(), 18); + assert_eq!(tools.len(), 23, "HaiMcpServer should expose 23 tools"); let names: Vec<&str> = tools.iter().map(|t| &*t.name).collect(); assert!(names.contains(&"fetch_agent_key")); @@ -2948,6 +3663,11 @@ mod tests { assert!(names.contains(&"jacs_message_update")); assert!(names.contains(&"jacs_message_agree")); assert!(names.contains(&"jacs_message_receive")); + assert!(names.contains(&"jacs_create_agreement")); + assert!(names.contains(&"jacs_sign_agreement")); + assert!(names.contains(&"jacs_check_agreement")); + assert!(names.contains(&"jacs_sign_document")); + assert!(names.contains(&"jacs_verify_document")); } #[test] @@ -3108,4 +3828,50 @@ mod tests { let epoch = std::time::UNIX_EPOCH; assert_eq!(format_iso8601(epoch), "1970-01-01T00:00:00Z"); } + + #[test] + fn test_create_agreement_params_schema() { + let schema = schemars::schema_for!(CreateAgreementParams); + let json = serde_json::to_string_pretty(&schema).unwrap(); + assert!(json.contains("document")); + assert!(json.contains("agent_ids")); + assert!(json.contains("timeout")); + assert!(json.contains("quorum")); + assert!(json.contains("required_algorithms")); + assert!(json.contains("minimum_strength")); + } + + #[test] + fn test_sign_agreement_params_schema() { + let schema = schemars::schema_for!(SignAgreementParams); + let json = serde_json::to_string_pretty(&schema).unwrap(); + assert!(json.contains("signed_agreement")); + assert!(json.contains("agreement_fieldname")); + } + + #[test] + fn test_check_agreement_params_schema() { + let schema = schemars::schema_for!(CheckAgreementParams); + let json = serde_json::to_string_pretty(&schema).unwrap(); + assert!(json.contains("signed_agreement")); + } + + #[test] + fn test_tool_list_includes_agreement_tools() { + // Verify the 3 new agreement tools are in the tool list + let tools = HaiMcpServer::tools(); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + assert!( + names.contains(&"jacs_create_agreement"), + "Missing jacs_create_agreement" + ); + assert!( + names.contains(&"jacs_sign_agreement"), + "Missing jacs_sign_agreement" + ); + assert!( + names.contains(&"jacs_check_agreement"), + "Missing jacs_check_agreement" + ); + } } diff --git a/jacs/Cargo.toml b/jacs/Cargo.toml index f7097e7bc..ab345bff6 100644 --- a/jacs/Cargo.toml +++ b/jacs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs" -version = "0.6.1" +version = "0.8.0" edition = "2024" rust-version = "1.93" resolver = "3" @@ -113,6 +113,7 @@ tokio = { version = "1.0", features = ["rt-multi-thread"], optional = true } [dev-dependencies] +jacs-binding-core = { path = "../binding-core" } color-eyre = "0.6" criterion = "0.6.0" mdbook = "0.4.48" @@ -192,6 +193,10 @@ otlp-tracing = [ name = "sign_and_check_sig" harness = false +[[bench]] +name = "agreement_benchmarks" +harness = false + [package.metadata.cargo-install] bin = ["jacs"] diff --git a/jacs/benches/agreement_benchmarks.rs b/jacs/benches/agreement_benchmarks.rs new file mode 100644 index 000000000..614a251c4 --- /dev/null +++ b/jacs/benches/agreement_benchmarks.rs @@ -0,0 +1,115 @@ +//! Benchmarks for multi-party agreement creation, signing, and verification. +//! +//! Measures: +//! - Agreement creation + N-party signing for N in {2, 5, 10, 25} +//! - Concurrent SimpleAgent instantiation and signing + +use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main}; +use jacs::simple::SimpleAgent; +use serde_json::json; +use std::sync::Arc; +use std::thread; + +fn configure_criterion() -> Criterion { + Criterion::default() + .sample_size(10) + .measurement_time(std::time::Duration::from_secs(30)) + .confidence_level(0.95) +} + +/// Create N ephemeral agents with Ed25519 keys (fast key gen). +/// Returns (agent, agent_id) pairs. +fn create_agents(n: usize) -> Vec<(SimpleAgent, String)> { + (0..n) + .map(|_| { + let (agent, info) = + SimpleAgent::ephemeral(Some("ed25519")).expect("Failed to create ephemeral agent"); + (agent, info.agent_id) + }) + .collect() +} + +/// Benchmark: create agreement + N agents sign it. +fn bench_agreement_n_party(c: &mut Criterion) { + let mut group = c.benchmark_group("agreement_sign"); + + for n in [2, 5, 10, 25] { + group.bench_with_input(BenchmarkId::new("agents", n), &n, |b, &n| { + let agents = create_agents(n); + let agent_ids: Vec = agents.iter().map(|(_, id)| id.clone()).collect(); + let doc_data = json!({"proposal": "benchmark test", "version": n}); + let doc_str = doc_data.to_string(); + + b.iter(|| { + black_box({ + // Agent 0 creates the agreement + let agreement = agents[0] + .0 + .create_agreement(&doc_str, &agent_ids, Some("Do you agree?"), None) + .expect("create_agreement"); + + // Each agent signs in sequence + let mut current_doc = agreement.raw; + for (agent, _) in &agents { + let signed = agent.sign_agreement(¤t_doc).expect("sign_agreement"); + current_doc = signed.raw; + } + + // Verify the final agreement + agents[0] + .0 + .check_agreement(¤t_doc) + .expect("check_agreement"); + }); + }); + }); + } + group.finish(); +} + +/// Benchmark: concurrent signing from N SimpleAgent instances. +fn bench_concurrent_signing(c: &mut Criterion) { + let mut group = c.benchmark_group("concurrent_sign"); + + for n in [10, 50, 100] { + group.bench_with_input(BenchmarkId::new("agents", n), &n, |b, &n| { + // Pre-create agents (setup, not measured) + let agents: Vec> = (0..n) + .map(|_| { + let (agent, _) = SimpleAgent::ephemeral(Some("ed25519")) + .expect("Failed to create ephemeral agent"); + Arc::new(agent) + }) + .collect(); + let data = json!({"action": "benchmark", "concurrent": true}); + + b.iter(|| { + black_box({ + let handles: Vec<_> = agents + .iter() + .map(|agent| { + let agent = Arc::clone(agent); + let data = data.clone(); + thread::spawn(move || { + let signed = agent.sign_message(&data).unwrap(); + agent.verify(&signed.raw).unwrap(); + }) + }) + .collect(); + + for handle in handles { + handle.join().expect("thread panicked"); + } + }); + }); + }); + } + group.finish(); +} + +criterion_group! { + name = benches; + config = configure_criterion(); + targets = bench_agreement_n_party, bench_concurrent_signing +} +criterion_main!(benches); diff --git a/jacs/benches/sign_and_check_sig.rs b/jacs/benches/sign_and_check_sig.rs index a848ab1ad..52e517468 100644 --- a/jacs/benches/sign_and_check_sig.rs +++ b/jacs/benches/sign_and_check_sig.rs @@ -2,7 +2,9 @@ use criterion::{Criterion, black_box, criterion_group, criterion_main}; use jacs::agent::Agent; use jacs::agent::boilerplate::BoilerPlate; use jacs::agent::document::DocumentTraits; +use jacs::simple::SimpleAgent; use log::debug; +use serde_json::json; use jacs::agent::DOCUMENT_AGENT_SIGNATURE_FIELDNAME; use jacs::storage::jenv::set_env_var; @@ -152,6 +154,24 @@ fn benchmark_pq(c: &mut Criterion) { }); } +fn benchmark_pq2025(c: &mut Criterion) { + // Use SimpleAgent::ephemeral to create a pq2025 agent with in-memory keys + let (agent, _info) = + SimpleAgent::ephemeral(Some("pq2025")).expect("Failed to create ephemeral pq2025 agent"); + let documents = generate_synthetic_data(BENCH_SAMPLE_SIZE); + c.bench_function("pq2025", |b| { + for document in &documents { + let data: serde_json::Value = serde_json::from_str(document).unwrap(); + b.iter(|| { + black_box({ + let signed = agent.sign_message(&data).unwrap(); + agent.verify(&signed.raw).unwrap(); + }); + }) + } + }); +} + fn benchmark_ring(c: &mut Criterion) { set_enc_to_ring(); let documents = generate_synthetic_data(BENCH_SAMPLE_SIZE); @@ -182,6 +202,6 @@ fn benchmark_ring(c: &mut Criterion) { criterion_group! { name = benches; config = configure_criterion(); - targets = benchmark_rsa, benchmark_pq, benchmark_ring + targets = benchmark_rsa, benchmark_pq, benchmark_pq2025, benchmark_ring } criterion_main!(benches); diff --git a/jacs/docs/jacsbook/book/404.html b/jacs/docs/jacsbook/book/404.html index 5d9b62b0d..a58158003 100644 --- a/jacs/docs/jacsbook/book/404.html +++ b/jacs/docs/jacsbook/book/404.html @@ -89,7 +89,7 @@