Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,108 changes: 2,039 additions & 69 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ resolver = "2"
members = [
"atomic-agent",
"atomic-canonical",
"atomic-canonical-wasm",
"atomic-cli",
"atomic-core",
"atomic-config",
Expand Down Expand Up @@ -54,6 +55,7 @@ bs58 = "0.5"
blake3 = "1.5"
ed25519-dalek = { version = "2.1", features = ["serde"] }
rand = "0.8"
iroh = "1"

# Compression
zstd = "0.13"
Expand Down
1 change: 1 addition & 0 deletions atomic-canonical-wasm/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pkg/
24 changes: 24 additions & 0 deletions atomic-canonical-wasm/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[package]
name = "atomic-canonical-wasm"
description = "atomic-canonical for the browser: build and check atomic attestations where the signing key lives in WebCrypto"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
publish = false

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
atomic-canonical = { workspace = true }
atomic-identity = { workspace = true }
serde_json = { workspace = true }
wasm-bindgen = "0.2"

# The browser supplies randomness; nothing here draws any, but the
# dependency tree links getrandom, which refuses wasm32 without this.
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.2", features = ["js"] }
20 changes: 20 additions & 0 deletions atomic-canonical-wasm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# atomic-canonical-wasm

`atomic-canonical` for the browser. A page that holds an atomic identity's
Ed25519 key in WebCrypto signs atomic documents without the key ever leaving
the browser: this module builds the canonical document and the bytes to sign
(with the same code the CLI uses), WebCrypto signs them, and the result is an
ordinary `eddsa-jcs-2022` attestation that `atomic` verifies.

```js
import init, { prepareAttestation, attachProof } from "./pkg/atomic_canonical_wasm.js";
await init();
const prepared = prepareAttestation(JSON.stringify(doc), publicKeyBytes);
const sig = await crypto.subtle.sign("Ed25519", key, prepared.signingBytes);
const attested = JSON.parse(attachProof(prepared.document, publicKeyBytes, new Uint8Array(sig)));
```

Also: `verifyAttestation`, `didForPublicKey`, `publicKeyBase32`, `canonicalize`.

Build with `./build.sh [out-dir]`; `node smoke.mjs` (Node ≥ 22, which has
WebCrypto Ed25519) signs, verifies and tamper-checks a document end to end.
11 changes: 11 additions & 0 deletions atomic-canonical-wasm/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/bin/sh
# Build the browser module: pkg/atomic_canonical_wasm{.js,_bg.wasm}.
# Needs the wasm32 target and a wasm-bindgen CLI matching the locked
# wasm-bindgen crate:
# rustup target add wasm32-unknown-unknown
# cargo install wasm-bindgen-cli --version <locked version>
set -eu
here=$(cd "$(dirname "$0")" && pwd)
cargo build -p atomic-canonical-wasm --target wasm32-unknown-unknown --release
wasm-bindgen --target web --out-dir "${1:-$here/pkg}" \
"$here/../target/wasm32-unknown-unknown/release/atomic_canonical_wasm.wasm"
13 changes: 13 additions & 0 deletions atomic-canonical-wasm/smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { readFileSync } from "node:fs";
import init, { prepareAttestation, attachProof, verifyAttestation, didForPublicKey } from "./pkg/atomic_canonical_wasm.js";
await init({ module_or_path: readFileSync(new URL("./pkg/atomic_canonical_wasm_bg.wasm", import.meta.url)) });
const kp = await crypto.subtle.generateKey("Ed25519", false, ["sign", "verify"]);
const pub = new Uint8Array(await crypto.subtle.exportKey("raw", kp.publicKey));
const doc = { "@type": "BasecampLogin", nonce: "abc", "é": [1, 2.5, "x"] };
const p = prepareAttestation(JSON.stringify(doc), pub);
const sig = new Uint8Array(await crypto.subtle.sign("Ed25519", kp.privateKey, p.signingBytes));
const attested = attachProof(p.document, pub, sig);
verifyAttestation(attested, pub);
console.log(didForPublicKey(pub));
console.log(attested);
try { verifyAttestation(attested.replace('"abc"', '"abd"'), pub); console.log("TAMPER NOT DETECTED"); } catch (e) { console.log("tamper rejected:", e.message); }
107 changes: 107 additions & 0 deletions atomic-canonical-wasm/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//! atomic-canonical for the browser.
//!
//! A web page that holds an atomic identity's key in WebCrypto (as a
//! non-extractable Ed25519 key) signs atomic documents without the key ever
//! reaching Rust — or leaving the browser. This crate does everything *but*
//! the signing, with the same code the CLI uses, so the result is an ordinary
//! atomic attestation (`eddsa-jcs-2022`) that `atomic` verifies:
//!
//! ```js
//! const prepared = prepareAttestation(JSON.stringify(doc), publicKeyBytes);
//! const sig = await crypto.subtle.sign("Ed25519", key, prepared.signingBytes);
//! const attested = attachProof(prepared.document, publicKeyBytes, new Uint8Array(sig));
//! ```
//!
//! Every function takes and returns JSON as strings and keys/signatures as
//! bytes, so there is no JS object model to keep in step with the Rust types.

use atomic_canonical::{did, jcs, proof};
use atomic_identity::keypair::PublicKey;
use atomic_identity::signing::Signature;
use wasm_bindgen::prelude::*;

fn public_key(bytes: &[u8]) -> Result<PublicKey, JsError> {
let bytes: &[u8; 32] = bytes
.try_into()
.map_err(|_| JsError::new("an Ed25519 public key is 32 bytes"))?;
PublicKey::from_bytes(bytes).map_err(|e| JsError::new(&e.to_string()))
}

fn parse(json: &str) -> Result<serde_json::Value, JsError> {
serde_json::from_str(json).map_err(|e| JsError::new(&format!("not JSON: {e}")))
}

/// A document ready to sign: the document to hand back to [`attach_proof`],
/// and the bytes the signature must cover.
#[wasm_bindgen]
pub struct Prepared {
document: String,
signing_bytes: Vec<u8>,
}

#[wasm_bindgen]
impl Prepared {
/// The document with `attributedTo` and `contentHash` filled in (JSON).
#[wasm_bindgen(getter)]
pub fn document(&self) -> String {
self.document.clone()
}

/// What to sign with the identity's Ed25519 key.
#[wasm_bindgen(getter, js_name = signingBytes)]
pub fn signing_bytes(&self) -> Vec<u8> {
self.signing_bytes.clone()
}
}

/// Fill in the author and content hash of `document` (JSON) for the key
/// `public_key`, and return it with the bytes to sign.
#[wasm_bindgen(js_name = prepareAttestation)]
pub fn prepare_attestation(document: &str, public_key_bytes: &[u8]) -> Result<Prepared, JsError> {
let pk = public_key(public_key_bytes)?;
let prepared = proof::prepare_attestation(parse(document)?, &pk);
Ok(Prepared {
document: prepared.value.to_string(),
signing_bytes: prepared.signing_bytes,
})
}

/// Attach the proof for `signature` (64 bytes, over the prepared signing
/// bytes) and check it verifies. Returns the attested document (JSON).
#[wasm_bindgen(js_name = attachProof)]
pub fn attach_proof(
document: &str,
public_key_bytes: &[u8],
signature: &[u8],
) -> Result<String, JsError> {
let pk = public_key(public_key_bytes)?;
let sig = Signature::from_slice(signature).map_err(|e| JsError::new(&e.to_string()))?;
let attested = proof::attach_proof(parse(document)?, &pk, &sig);
proof::verify_value(&attested, &pk).map_err(|e| JsError::new(&e.to_string()))?;
Ok(attested.to_string())
}

/// Check an attested document (JSON) against a public key.
#[wasm_bindgen(js_name = verifyAttestation)]
pub fn verify_attestation(document: &str, public_key_bytes: &[u8]) -> Result<(), JsError> {
let pk = public_key(public_key_bytes)?;
proof::verify_value(&parse(document)?, &pk).map_err(|e| JsError::new(&e.to_string()))
}

/// The `did:atomic` identifier for a public key.
#[wasm_bindgen(js_name = didForPublicKey)]
pub fn did_for_public_key(public_key_bytes: &[u8]) -> Result<String, JsError> {
Ok(did::did_for_public_key(&public_key(public_key_bytes)?))
}

/// The key's base32 form — what atomic writes as the `kid` of its tokens.
#[wasm_bindgen(js_name = publicKeyBase32)]
pub fn public_key_base32(public_key_bytes: &[u8]) -> Result<String, JsError> {
Ok(public_key(public_key_bytes)?.to_base32())
}

/// RFC 8785 canonical JSON of `document` — the bytes atomic hashes and signs.
#[wasm_bindgen]
pub fn canonicalize(document: &str) -> Result<String, JsError> {
Ok(jcs::canonicalize(&parse(document)?))
}
64 changes: 59 additions & 5 deletions atomic-canonical/src/proof.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,32 @@ pub fn substance_view(value: &Value) -> Value {
/// path all typed nodes share. Fills `attributedTo` (from the identity's
/// `did:atomic`) when absent, computes the content hash over `hashing_view`,
/// signs `jcs(signing_view)`, and attaches the proof. Returns the value.
pub fn attest_value(mut value: Value, identity: &Identity, keypair: &KeyPair) -> Value {
let did = did::did_for_public_key(&identity.public_key);
///
/// It is [`prepare_attestation`] → sign → [`attach_proof`]; a signer that
/// holds its key outside this process uses those two halves directly.
pub fn attest_value(value: Value, identity: &Identity, keypair: &KeyPair) -> Value {
let prepared = prepare_attestation(value, &identity.public_key);
let signature = Signer::new(keypair).sign(&prepared.signing_bytes);
attach_proof(prepared.value, &identity.public_key, &signature)
}

/// A value made ready to sign: `attributedTo` and `contentHash` filled in,
/// and the exact bytes the signature must cover.
#[derive(Debug, Clone)]
pub struct PreparedAttestation {
/// The value to sign — pass it back to [`attach_proof`] unchanged.
pub value: Value,
/// `jcs(signing_view(value))`: what the Ed25519 signature covers.
pub signing_bytes: Vec<u8>,
}

/// First half of [`attest_value`], for signers that hold the key somewhere
/// else — a browser's WebCrypto, a hardware token, a remote signing service.
/// Everything that must agree with [`verify_value`] (the author, the content
/// hash, the canonical bytes) is computed here, so the external signer only
/// ever signs bytes, and the result is an ordinary atomic attestation.
pub fn prepare_attestation(mut value: Value, public_key: &PublicKey) -> PreparedAttestation {
let did = did::did_for_public_key(public_key);

if let Some(obj) = value.as_object_mut() {
// Fill attributedTo only if there is no non-empty value already.
Expand All @@ -121,7 +145,7 @@ pub fn attest_value(mut value: Value, identity: &Identity, keypair: &KeyPair) ->
.map(|s| !s.is_empty())
.unwrap_or(false);
if !has_author {
obj.insert(PROP_ATTRIBUTED_TO.to_string(), Value::String(did.clone()));
obj.insert(PROP_ATTRIBUTED_TO.to_string(), Value::String(did));
}
}

Expand All @@ -133,13 +157,24 @@ pub fn attest_value(mut value: Value, identity: &Identity, keypair: &KeyPair) ->
}

let signing_bytes = jcs::canonicalize(&signing_view(&value)).into_bytes();
let signature = Signer::new(keypair).sign(&signing_bytes);
PreparedAttestation {
value,
signing_bytes,
}
}

/// Second half of [`attest_value`]: attach the `eddsa-jcs-2022` proof for a
/// signature over [`PreparedAttestation::signing_bytes`] made by
/// `public_key`'s private key. Does not check the signature — run
/// [`verify_value`] on the result for that.
pub fn attach_proof(mut value: Value, public_key: &PublicKey, signature: &Signature) -> Value {
let did = did::did_for_public_key(public_key);
let proof = Proof {
type_: PROOF_TYPE.to_string(),
cryptosuite: CRYPTOSUITE.to_string(),
verification_method: did::verification_method(&did),
proof_purpose: PROOF_PURPOSE.to_string(),
proof_value: encode_proof_value(&signature),
proof_value: encode_proof_value(signature),
};
if let Some(obj) = value.as_object_mut() {
obj.insert(
Expand Down Expand Up @@ -281,6 +316,25 @@ mod tests {
})
}

/// An external signer — handed only the prepared bytes — produces
/// exactly the attestation `attest_value` would, and it verifies.
#[test]
fn prepare_sign_attach_matches_attest_value() {
let (id, kp) = dev_identity();
let prepared = prepare_attestation(minimal_value(), &kp.public);
let signature = Signer::new(&kp).sign(&prepared.signing_bytes);
let external = attach_proof(prepared.value, &kp.public, &signature);

assert_eq!(external, attest_value(minimal_value(), &id, &kp));
verify_value(&external, &kp.public).expect("externally signed value verifies");

// A signature over anything else does not.
let wrong = Signer::new(&kp).sign(b"not the prepared bytes");
let prepared = prepare_attestation(minimal_value(), &kp.public);
let bad = attach_proof(prepared.value, &kp.public, &wrong);
assert!(verify_value(&bad, &kp.public).is_err());
}

#[test]
fn attest_value_then_verify_value_roundtrips() {
let (id, kp) = dev_identity();
Expand Down
2 changes: 2 additions & 0 deletions atomic-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ data-encoding = "2.6"

# Hashing (source-content digest for canonical intent sidecars)
blake3 = { workspace = true }
iroh = { workspace = true }
rand = { workspace = true }

# Open URLs in default browser (for device auth flow)
open = "5"
Expand Down
2 changes: 1 addition & 1 deletion atomic-cli/src/commands/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ mod explain;
mod hooks;
mod identity;
mod lifecycle;
mod owner;
pub(crate) mod owner;
mod status;

use clap::{Args, Subcommand};
Expand Down
Loading
Loading