Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
32 changes: 32 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,38 @@ Human: "I can review that!"
| `EdgeFlags` | 1 byte | Bitflags: BLOCK, PSEUDO, FOLDER, PARENT, DELETED |
| `SerializedGraphEdge` | 24 bytes | Compact edge: (flags+pos, change, introduced_by) |

#### Endianness: keys that sort are big-endian, opaque values are little-endian

The storage layer encodes integers two ways, and the rule is whether the
bytes ever get sorted:

| Encoded as | Endian | Which |
|------------|--------|-------|
| **Big-endian** | BE | B-tree **keys** that are range-scanned: `encode_vertex`, `encode_inode_vertex`, `encode_position`, `encode_view_seq` (`pristine/tables.rs`) |
| **Little-endian** | LE | Values and ids only ever looked up by exact key: `SerializedGraphEdge`, and the CRDT `TrunkId`/`BranchId`/`LeafId` (`crdt/tables.rs`, `crdt/ids.rs`) |

BE is load-bearing for the keys: byte order has to match numeric order or a
range scan means nothing. The file-local INODE_GRAPH traversal depends on it —

```rust
let lo = encode_inode_vertex(inode, 0, 0, 0);
let hi = encode_inode_vertex(inode, u64::MAX, u64::MAX, u64::MAX);
for row in inode_graph.range::<&[u8; 32]>(&lo..=&hi)? { ... } // every row for one inode
```

For the LE side, sorting never happens, so byte order costs nothing and there
is no reason to pay for BE.

**The two meet in the same files**, so match the *writer*, never the
neighbours: a file that decodes graph vertex keys (BE) can also decode CRDT
ids (LE) within a few lines, and a hand-rolled `id[0..8]` slice reads the
wrong end of a LE id without any visible failure — the id just looks absent.
Decode an id with its own type's `from_bytes` (`TrunkId::from_bytes`,
`BranchId::from_bytes`, `LeafId::from_bytes`) instead of slicing by hand, so
the two sides cannot drift; a mis-decoded id is indistinguishable from an
absent one at runtime, so an `EXTERNAL`-row lookup for it is worth a
`debug_assert`.

### Hash Type Design

Following the original Atomic project, `Hash` is a **type alias** for `Merkle`:
Expand Down
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ members = [
"atomic-repository",
"atomic-semantic",
"atomic-teams",
"atomic-wasm",
"libatomic",
]

Expand Down
76 changes: 71 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 All @@ -150,6 +185,18 @@ pub fn attest_value(mut value: Value, identity: &Identity, keypair: &KeyPair) ->
value
}

/// The signature a value's proof carries — what `--signed` hands to the
/// recording service after a key holder signed `--prepare`'s bytes elsewhere.
pub fn proof_signature(value: &Value) -> Result<Signature> {
let proof: Proof = value
.as_object()
.and_then(|obj| obj.get(PROP_PROOF))
.cloned()
.and_then(|p| serde_json::from_value(p).ok())
.ok_or_else(|| CanonicalError::Proof("node carries no proof".into()))?;
decode_proof_value(&proof.proof_value)
}

/// Generic verify over a JSON-LD value — the same three checks as the typed
/// path: (1) the content hash recomputes over `hashing_view`, (2) the signature
/// verifies over `jcs(signing_view)`, (3) the proof's verificationMethod DID
Expand Down Expand Up @@ -281,6 +328,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
89 changes: 75 additions & 14 deletions atomic-cli/src/commands/intent/attest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use clap::Parser;

use serde_json::Value;

use atomic_canonical::proof::prepare_attestation;
use atomic_canonical::{lift_and_attest, validate_intent, verify};
use atomic_core::pristine::VaultEntryType;
use atomic_identity::IdentityStore;
Expand All @@ -28,6 +29,20 @@ pub struct IntentAttest {
/// Output the attested node as JSON-LD.
#[arg(long)]
pub json: bool,

/// Don't sign: print what a signer holding the identity's key elsewhere
/// (a browser, a hardware token, a remote signing service) must sign —
/// `{"document": …, "signingBytes": "<base64>"}`. Needs only the
/// identity's public key. Complete with `--signed`.
#[arg(long, conflicts_with = "signed")]
pub prepare: bool,

/// Record an attestation signed elsewhere: a JSON file holding the
/// attested node (the `--prepare` document with its proof attached). It
/// must be signed by `--identity`'s key and attest the intent as it is
/// now.
#[arg(long, value_name = "PATH")]
pub signed: Option<std::path::PathBuf>,
}

impl Command for IntentAttest {
Expand Down Expand Up @@ -63,7 +78,7 @@ impl Command for IntentAttest {
)));
}

// Resolve identity + keypair the way `atomic identity sign` does.
// Resolve the identity the way `atomic identity sign` does.
let store = IdentityStore::open_default().map_err(|e| {
CliError::Internal(anyhow::anyhow!("Failed to open identity store: {}", e))
})?;
Expand All @@ -83,20 +98,66 @@ impl Command for IntentAttest {
.to_string(),
})?
};
let keypair = store.load_keypair(&identity.id, None).map_err(|e| {
CliError::Internal(anyhow::anyhow!(
"Failed to load keypair for '{}': {}",
identity.name,
e
))
})?;

// Attest: lift + fill attributedTo (from the identity's did:atomic when
// absent) + hash + sign.
let node = lift_and_attest(&inputs.frontmatter, &inputs.body, &identity, &keypair)
.map_err(|e| CliError::InvalidArgument {
message: format!("could not attest intent: {e}"),
// Signing elsewhere, step 1: say what to sign. The same preparation
// `lift_and_attest` does (author, content hash, canonical bytes), with
// no private key involved.
if self.prepare {
let prepared = prepare_attestation(unattested.to_value(), &identity.public_key);
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"document": prepared.value,
"signingBytes": data_encoding::BASE64.encode(&prepared.signing_bytes),
}))
.unwrap()
);
return Ok(());
}

let node = if let Some(path) = &self.signed {
// Signing elsewhere, step 2: the signature must be over exactly
// what `--prepare` produces for this intent now — so a signature
// over a stale or altered intent is refused, not recorded.
let text = std::fs::read_to_string(path).map_err(CliError::Io)?;
let signed: Value =
serde_json::from_str(&text).map_err(|e| CliError::InvalidArgument {
message: format!("{} is not JSON: {e}", path.display()),
})?;
let expected = prepare_attestation(unattested.to_value(), &identity.public_key).value;
let mut unsigned = signed.clone();
if let Some(obj) = unsigned.as_object_mut() {
obj.remove("proof");
}
if unsigned != expected {
return Err(CliError::InvalidArgument {
message: format!(
"the signed attestation is not of intent {} as it is now (re-run --prepare)",
self.id
),
});
}
serde_json::from_value::<atomic_canonical::CanonicalNode>(signed).map_err(|e| {
CliError::InvalidArgument {
message: format!("not an attested intent: {e}"),
}
})?
} else {
let keypair = store.load_keypair(&identity.id, None).map_err(|e| {
CliError::Internal(anyhow::anyhow!(
"Failed to load keypair for '{}': {}",
identity.name,
e
))
})?;
// Attest: lift + fill attributedTo (from the identity's
// did:atomic when absent) + hash + sign.
lift_and_attest(&inputs.frontmatter, &inputs.body, &identity, &keypair).map_err(
|e| CliError::InvalidArgument {
message: format!("could not attest intent: {e}"),
},
)?
};

// Belt-and-suspenders: re-gate the ATTESTED node — proof + attributedTo
// must now satisfy the gate.
Expand All @@ -111,7 +172,7 @@ impl Command for IntentAttest {

// Self-check: the proof verifies against the signing key before we
// write anything to disk.
verify(&node, &keypair.public).map_err(|e| CliError::InvalidArgument {
verify(&node, &identity.public_key).map_err(|e| CliError::InvalidArgument {
message: format!("attested intent failed self-verification: {e}"),
})?;

Expand Down
Loading