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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,16 @@ A manifest is driven through the full chain: signature (Stages 2-6), canary (Sta
- `--now` sets the verified-time reference for the canary and origin-expiry checks (defaults to the corpus clock).
- `--fetched-onion` is the onion address the manifest was fetched from; with it, Stage 9 origin binding runs. Omit to skip Stage 9.
- `--content-index` is the served `/content_index.json`; when the manifest declares `content_root`, Stage 9b verifies it (and its absence with a declared `content_root` surfaces the fetch failure).
- `--expected-runtime-pubkey` is the manifest's `canary.runtime_pubkey`; for a content or transaction document, it is the key the signature is checked against. Without it, a content/transaction has no authorized key and reports a signature rejection.

Skipped stages are reported, so an accept is never mistaken for a full-pipeline pass.

```sh
entangled-tool verify --input manifest.json --fetched-onion dkptfye...onion --content-index content_index.json
entangled-tool verify --input post.json --expected-runtime-pubkey jzFtzi...F7o
```

Content and transaction documents are verified through signature only here; their binding checks need the fetch path / submit body, which a future revision will accept.
A content document is signed by a runtime key, and only the manifest declares which runtime key is authorized. So a content or transaction document is verified in the context of its manifest: pass `--expected-runtime-pubkey` to check its signature against the authorized key. See `examples/blog` for the full manifest-plus-content flow.

### `build <kind> --input <unsigned.json> (--key-seed-file <path> | --key-seed-hex <64 hex>) [--now <time>]`

Expand Down
102 changes: 62 additions & 40 deletions examples/blog/README.md
Original file line number Diff line number Diff line change
@@ -1,62 +1,84 @@
# Example: a minimal Entangled blog post
# Example: a minimal Entangled site

This directory is a runnable example of authoring a content document with
`entangled-tool`. It contains:
This directory is a runnable, end-to-end example: it authors a content document
from Markdown, signs it, builds the manifest that anchors the site, and verifies
both. It shows the relationship Entangled is built on - the manifest authorizes a
runtime key, the content is signed by that key, and verification checks the
content in the manifest's context.

## Files

- `post.md` - a Markdown post exercising every construct the `content` command
maps (headings, marks, a list, a code block, a quote, links, an image, a
divider);
- `assets/photo.png` - a 16x16 PNG the post references, so the image block's
hash and dimensions are read from a real file;
- `post.unsigned.json` - the unsigned content document `content` produces from
`post.md`, checked in as a reference of the output;
- `post.json` - that document signed with `build content`, also checked in as a
reference. It is signed with the public test runtime seed from the spec
corpus (`ENTANGLED-v1.0-runtime-test0001`), not a real key; do not reuse it.
Regenerating with the flow below reproduces these two files byte for byte
(the conversion and signature are deterministic).
- `post.unsigned.json` / `post.json` - the content document `content` produces
from `post.md`, and that document signed with `build content`;
- `manifest.unsigned.json` / `manifest.json` - the site manifest and its signed
form. The manifest declares the publisher key, the carrier origin, and the
canary, whose `runtime_pubkey` is the key that signs the content.

All four `.json` files are checked in as reference output. They are signed with
the public test seeds from the spec corpus (`ENTANGLED-v1.0-publisher-test01`,
`...-runtime-test0001`, `...-origin-test00001`), not real keys; do not reuse
them. Regenerating with the flow below reproduces them byte for byte, since the
conversion and the Ed25519 signatures are deterministic.

## The full flow

From this directory:
The example uses the corpus test seeds so the steps are reproducible. For a real
site you would generate fresh seeds with `keygen` and store them offline.

```sh
# 1. Generate a runtime key seed and keep it (store it offline for real use).
entangled-tool keygen runtime --seed-file runtime.seed >/dev/null 2>&1 \
|| entangled-tool keygen runtime | tee /dev/stderr | \
sed -n 's/^seed_hex: //p' > runtime.seed
# 0. The three role seeds (here, the public corpus test seeds).
printf '454e54414e474c45442d76312e302d7075626c69736865722d74657374303100' > publisher.seed
printf '454e54414e474c45442d76312e302d72756e74696d652d746573743030303100' > runtime.seed
printf '454e54414e474c45442d76312e302d6f726967696e2d74657374303030303100' > origin.seed

# 1. Derive the public material. Note the runtime_pubkey (it goes in the
# manifest canary) and the origin onion address (it is the carrier address).
entangled-tool keygen publisher --seed-file publisher.seed
entangled-tool keygen runtime --seed-file runtime.seed
entangled-tool keygen origin --seed-file origin.seed

# 2. Convert the Markdown into an unsigned content document. The image path
# /assets/photo.png is read from ./assets/photo.png (the Markdown file's
# directory), and the hash, media type, and dimensions are filled in.
entangled-tool content \
--markdown post.md \
--path /articles/first \
--title "A first post" \
--published-at 2026-05-07T00:00:00Z \
> post.unsigned.json

# 3. Sign it with the runtime key.
# /assets/photo.png is read from ./assets/photo.png, and its hash, media
# type, and dimensions are filled in.
entangled-tool content --markdown post.md --path /articles/first \
--title "A first post" --published-at 2026-05-07T00:00:00Z > post.unsigned.json

# 3. Sign the content with the runtime key.
entangled-tool build content --input post.unsigned.json --key-seed-file runtime.seed > post.json
```

`post.json` is now a signed content document ready to publish at
`/articles/first`.
# 4. Sign the manifest with the publisher key. manifest.unsigned.json already
# declares the runtime_pubkey and origin from step 1.
entangled-tool build manifest --input manifest.unsigned.json \
--key-seed-file publisher.seed --now 2026-05-07T00:00:00Z > manifest.json

# 5. Verify the manifest from its onion address: this runs the signature,
# canary (Stage 8), and origin binding (Stage 9) checks.
entangled-tool verify --input manifest.json \
--fetched-onion dkptfyethnbfsj7qsxscia4w6lg4yssjca2gdrqlk457qav2lkna4xqd.onion

# 6. Verify the content in the manifest's context: pass the manifest's
# canary.runtime_pubkey so the content signature is checked against the key
# the manifest authorizes.
entangled-tool verify --input post.json \
--expected-runtime-pubkey jzFtziEJkbIdjI15I4u3ni3bBa6IFElyyjEmMVSGF7o
```

Step 1 prints a secret seed; in a real ceremony you would generate it once,
store it offline, and reuse the file. See the top-level README's "Handling key
seeds" section.
Steps 5 and 6 both report `accept`; step 5 also prints `canary_state: Fresh`.

## A note on verifying content
## Why the content needs the manifest

`entangled-tool verify` checks a content document's signature against the
runtime key the **manifest** authorizes for the site. A content document on its
own does not carry that key, so verifying `post.json` standalone reports a
signature rejection (the command has no authorized runtime key to check
against) - this is expected, not a failure of the document. In a real client
the content is verified in the context of the verified manifest. `verify` is
most useful here against a manifest, where it runs the canary, origin-binding,
and content-index stages too.
A content document carries no key of its own: it is signed by a runtime key, and
only the manifest says which runtime key is authorized (its
`canary.runtime_pubkey`). So verifying `post.json` standalone, without
`--expected-runtime-pubkey`, reports a signature rejection - there is no
authorized key to check against. That is the whole point of the manifest:
identity and authorization live there, not in the individual document. This is
also where the canary, origin binding, and content-index checks run.

## What gets rejected

Expand Down
1 change: 1 addition & 0 deletions examples/blog/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"canary":{"issued_at":"2026-05-07T00:00:00Z","next_expected":"2026-06-06T00:00:00Z","runtime_pubkey":"jzFtziEJkbIdjI15I4u3ni3bBa6IFElyyjEmMVSGF7o","statement":"No warrants received."},"kind":"manifest","min_refresh_interval":3600,"navigation":[{"label":"Home","path":"/"}],"origin":{"address":"dkptfyethnbfsj7qsxscia4w6lg4yssjca2gdrqlk457qav2lkna4xqd.onion","carrier":"tor-v3","origin_pubkey":"Gp8y4JM7Qlkn8JXkJAOW8s3MSkkQNGHGC1c7-AK6Wpo"},"publisher_pubkey":"moyzpl3i5hUIcMNRLPMxir4sdmSO3gO79gLUtvYDWxc","sig":"hhT2nXE_AqT-WGNjKns28vKI1HDx9SMzKOMRTwt8DmxJtFc8EXbwHpE2fvu5l5uXhshcYHC4shE3UU_wDG3PAQ","spec_version":"1.0","state_policy":[],"updated":"2026-05-07T00:00:00Z"}
24 changes: 24 additions & 0 deletions examples/blog/manifest.unsigned.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"spec_version": "1.0",
"publisher_pubkey": "moyzpl3i5hUIcMNRLPMxir4sdmSO3gO79gLUtvYDWxc",
"origin": {
"carrier": "tor-v3",
"address": "dkptfyethnbfsj7qsxscia4w6lg4yssjca2gdrqlk457qav2lkna4xqd.onion",
"origin_pubkey": "Gp8y4JM7Qlkn8JXkJAOW8s3MSkkQNGHGC1c7-AK6Wpo"
},
"canary": {
"runtime_pubkey": "jzFtziEJkbIdjI15I4u3ni3bBa6IFElyyjEmMVSGF7o",
"issued_at": "2026-05-07T00:00:00Z",
"next_expected": "2026-06-06T00:00:00Z",
"statement": "No warrants received."
},
"state_policy": [],
"navigation": [
{
"label": "Home",
"path": "/"
}
],
"min_refresh_interval": 3600,
"updated": "2026-05-07T00:00:00Z"
}
8 changes: 8 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,14 @@ pub struct VerifyArgs {
/// Manifest documents only.
#[arg(long)]
pub content_index: Option<std::path::PathBuf>,

/// The runtime public key (base64url) the manifest authorizes, i.e. its
/// canary.runtime_pubkey. Supply it to verify a content or transaction
/// document's signature in the context of its manifest. Without it, a
/// content/transaction is checked with no authorized key and reports a
/// signature rejection. Content and transaction documents only.
#[arg(long)]
pub expected_runtime_pubkey: Option<String>,
}

#[derive(Debug, clap::Args)]
Expand Down
47 changes: 34 additions & 13 deletions src/commands/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ pub fn run(args: VerifyArgs) -> Result<(), Error> {
let kind = document_kind(&bytes)?;
match kind.as_str() {
"manifest" => verify_manifest(&args, &bytes, &now),
"content" => verify_content(&bytes),
"transaction" => verify_transaction(&bytes),
"content" => verify_content(&args, &bytes),
"transaction" => verify_transaction(&args, &bytes),
other => Err(format!("unsupported document kind: {other}").into()),
}
}
Expand Down Expand Up @@ -95,33 +95,54 @@ fn verify_manifest(args: &VerifyArgs, bytes: &[u8], now: &EntangledTimestamp) ->
Ok(())
}

fn verify_content(bytes: &[u8]) -> Result<(), Error> {
// No verified manifest is available at the CLI to supply the authorized
// runtime key, so signature verification reaches Stage 6 with no key and
// reports E_SIG_INVALID_KEY. A future revision will accept the manifest.
let placeholder = RuntimePubkey::from_bytes([0u8; 32]);
match parse_and_verify_content(bytes, &placeholder) {
fn verify_content(args: &VerifyArgs, bytes: &[u8]) -> Result<(), Error> {
let (runtime_pk, has_key) = runtime_key(args)?;
match parse_and_verify_content(bytes, &runtime_pk) {
Ok(_) => {
println!("verdict: accept");
println!("note: signature only; supply the authorizing manifest for full verification");
print_runtime_note(has_key);
Ok(())
}
Err(d) => report_reject(&d),
}
}

fn verify_transaction(bytes: &[u8]) -> Result<(), Error> {
let placeholder = RuntimePubkey::from_bytes([0u8; 32]);
match parse_and_verify_transaction(bytes, &placeholder, None) {
fn verify_transaction(args: &VerifyArgs, bytes: &[u8]) -> Result<(), Error> {
let (runtime_pk, has_key) = runtime_key(args)?;
match parse_and_verify_transaction(bytes, &runtime_pk, None) {
Ok(_) => {
println!("verdict: accept");
println!("note: signature only; supply the authorizing manifest for full verification");
print_runtime_note(has_key);
Ok(())
}
Err(d) => report_reject(&d),
}
}

/// Resolve the runtime key to verify a content/transaction signature against:
/// the manifest-authorized key from `--expected-runtime-pubkey` when given, or
/// a placeholder otherwise (in which case the signature check has no authorized
/// key and will reject). Returns the key and whether a real one was supplied.
fn runtime_key(args: &VerifyArgs) -> Result<(RuntimePubkey, bool), Error> {
match args.expected_runtime_pubkey.as_deref() {
Some(b64) => {
let key = RuntimePubkey::try_from(b64)
.map_err(|e| format!("--expected-runtime-pubkey is invalid: {e}"))?;
Ok((key, true))
}
None => Ok((RuntimePubkey::from_bytes([0u8; 32]), false)),
}
}

fn print_runtime_note(has_key: bool) {
if !has_key {
println!(
"note: no authorizing runtime key given; pass --expected-runtime-pubkey \
(the manifest's canary.runtime_pubkey) to verify against the manifest"
);
}
}

fn report_reject(diag: &Diagnostic) -> Result<(), Error> {
println!("verdict: reject");
println!("diagnostic: {}", diag.code);
Expand Down
Loading