Skip to content
Open
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
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,18 +92,33 @@ trust lens --topic sushi --rollup | trust sign | trust add
| `trust sign [FILE]` | Sign atoms as Verifiable Credentials |
| `trust verify [FILE]` | Verify credentials. Exits 1 if any are invalid |
| `trust id [--credential] [FILE]` | Print atom IDs, or credential IDs (CIDv1, `bafkrei…`) |
| `trust convert --to atom\|credential\|canonical [FILE]` | Convert between formats |
| `trust convert --to FORMAT [--from FORMAT] [FILE]` | Convert between formats (below) |
| `trust add [FILE]` | Add atoms or signed credentials to the local store |
| `trust query [--source] [--target] [--topic] [--signed-only]` | Search the local store |
| `trust lens [AGENT] [--topic] [--depth] [--decay] [--rollup]` | View the graph through an agent's lens |
| `trust info` | Show where keys and data live |
| `trust completions SHELL` | Shell completions |

Input is read from `FILE` or stdin and may be a single JSON document, NDJSON,
or concatenated JSON. Add `--pretty` to any command for readable output. Keys
or concatenated JSON (or `vc+jwt` tokens, one per line). Add `--pretty` to any command for readable output. Keys
and the store live in the platform data directory, or in `$TRUST_HOME`.
`$TRUST_KEY` selects the key.

### Other formats

`trust convert` reads and writes formats other systems understand. Exports
start from the current atoms (signed ones verified, superseded ones left
out); each format's mapping and its sources are in [`doc/formats/`](doc/formats).

| `--to` | `--from` | Format |
|---|---|---|
| `atom`, `credential`, `canonical` | `atom` | Trust Graph itself |
| `vc-jwt` | `vc-jwt` | [`application/vc+jwt`](doc/formats/vc-jwt.md) (W3C VC-JOSE-COSE, `alg: Ed25519`): signed with `--key`, verified strictly; `trust verify` checks them too |
| `caip-261` | `caip-261` | [CAIP-261 `PeerTrustCredential`](doc/formats/caip-261.md), one per source and target |
| `ijv-csv` | | [OpenRank / EigenTrust `i,j,v` CSV](doc/formats/ijv-csv.md) (`--topic`, `--negative drop\|keep`) |
| `atproto-label`, `nostr-label` | | [AT Protocol labels and Nostr NIP-32 events](doc/formats/labels.md), unsigned |
| `schema-org` | | [schema.org `Review` JSON-LD](doc/formats/schema-org.md), for web pages |

## Data model

The v1 format is specified in [`doc/protocol.md`](doc/protocol.md), with a
Expand Down
77 changes: 77 additions & 0 deletions bindings/trustgraph.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,67 @@ export interface LensEntry {
/** Atoms and/or signed credentials. */
export type Item = TrustAtomInput | Credential;

/** A Trust Atom credential secured as `application/vc+jwt` (VC-JOSE-COSE): a compact JWS, `eyJ…`. */
export type VcJwt = string;

/** A CAIP-261 `PeerTrustCredential` (Web of Trust Primitives), unsigned. */
export interface PeerTrustCredential {
"@context": string[];
type: string[];
/** RFC 3339: the latest timestamp of its atoms. */
issuanceDate?: string;
issuer: string | { id: string };
credentialSubject: {
id: string;
trustworthiness: {
/** The atom's content. */
scope?: string;
/** -1..=1: the atom's value. */
level: number;
reason?: string[];
/** Other `extra` fields (a Trust Graph extension). */
extra?: Record<string, string>;
}[];
};
[property: string]: unknown;
}

export interface IjvCsvOptions {
/** Only atoms about this topic. */
topic?: string;
/** Zero and negative values: left out ("drop", the default; EigenTrust needs non-negative trust) or kept. */
negative?: "drop" | "keep";
}

/** An AT Protocol label (`com.atproto.label.defs#label`), unsigned (no `sig`). */
export interface AtprotoLabel {
ver: 1;
/** The labeler: the atom's source (a DID). */
src: string;
/** The atom's target. */
uri: string;
/** `trusted`, `distrusted`, or `trusted-<topic>` / `distrusted-<topic>`. */
val: string;
/** A retraction of an earlier label. */
neg?: true;
/** RFC 3339. */
cts: string;
}

/** An unsigned Nostr NIP-32 label event: add `pubkey`, `id` and `sig` to publish it. */
export interface NostrLabelEvent {
kind: 1985;
created_at: number;
tags: string[][];
content: string;
}

/** A schema.org JSON-LD document: `{"@context": "https://schema.org", "@graph": Review[]}`. */
export interface SchemaOrgDocument {
"@context": "https://schema.org";
"@graph": Record<string, unknown>[];
}

export function version(): string;
/** Generates a new identity from a secure random source. Not deterministic: call it in a client or action, not inside a reactive query. */
export function generateKeypair(): KeyInfo;
Expand All @@ -148,3 +209,19 @@ export function verify(credential: Credential): Verification;
export function lens(items: Item[], root: string, options?: LensOptions | null): LensEntry[];
/** Rollup atoms (unsigned) for `root`'s lens, timestamped `at` (RFC 3339). */
export function rollup(items: Item[], root: string, options: LensOptions | null | undefined, at: string): TrustAtom[];
/** Signs an atom as an `application/vc+jwt` (VC-JOSE-COSE, `alg: "Ed25519"`). `created` stamps an atom without a timestamp. */
export function signVcJwt(atom: Item, secretKeyMultibase: string, created: string): VcJwt;
/** Verifies an `application/vc+jwt` as strictly as `verify`. `credentialId` is the CID of the JWT's bytes. */
export function verifyVcJwt(jwt: VcJwt): Verification;
/** Current atoms (signed ones verified, superseded ones left out) as CAIP-261 `PeerTrustCredential`s, one per source and target. */
export function toPeerTrust(items: Item[]): PeerTrustCredential[];
/** The atoms in a CAIP-261 `PeerTrustCredential`. Its proof is not checked. */
export function fromPeerTrust(credential: PeerTrustCredential): TrustAtom[];
/** Current atoms as an OpenRank / EigenTrust `i,j,v` local-trust CSV. */
export function toIjvCsv(items: Item[], options?: IjvCsvOptions | null): string;
/** Current atoms as unsigned AT Protocol labels, with `neg` labels for superseded ones. */
export function toAtprotoLabels(items: Item[]): AtprotoLabel[];
/** Current atoms as unsigned Nostr NIP-32 label events (kind 1985). */
export function toNostrLabels(items: Item[]): NostrLabelEvent[];
/** Current atoms as schema.org `Review`s with `Rating`s from -1 to 1. */
export function toSchemaOrg(items: Item[]): SchemaOrgDocument;
2 changes: 1 addition & 1 deletion clippy.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
allow-unwrap-in-tests = true
allow-expect-in-tests = true
# Words that are not code in doc comments (".." keeps clippy's defaults).
doc-valid-idents = ["CIDv1", ".."]
doc-valid-idents = ["CIDv1", "OpenRank", "EigenTrust", ".."]
63 changes: 58 additions & 5 deletions crates/trustgraph-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,15 @@ pub enum Command {
/// Sign Trust Atoms (from a file or stdin) as Verifiable Credentials
Sign(SignArgs),

/// Verify signed Trust Atom credentials; exits 1 if any are invalid
/// Verify signed Trust Atom credentials (Data Integrity or vc+jwt); exits
/// 1 if any are invalid
Verify(InputArgs),

/// Print the ID (a bafkrei… CIDv1) of atoms or credentials
Id(IdArgs),

/// Convert atoms and credentials between formats
/// Convert atoms and credentials to and from other formats: vc+jwt,
/// CAIP-261, i,j,v CSV, AT Protocol and Nostr labels, schema.org
Convert(ConvertArgs),

/// Add atoms or signed credentials to the local store
Expand Down Expand Up @@ -167,7 +169,8 @@ pub struct AtomArgs {

#[derive(Debug, Args)]
pub struct InputArgs {
/// JSON or NDJSON input file; `-` or nothing reads stdin
/// JSON or NDJSON input file (or vc+jwt tokens, one per line); `-` or
/// nothing reads stdin
#[arg(value_name = "FILE")]
pub input: Option<PathBuf>,
}
Expand Down Expand Up @@ -204,13 +207,63 @@ pub enum Format {
Credential,
/// The atom's canonical JSON (RFC 8785), exactly as hashed
Canonical,
/// A signed application/vc+jwt (VC-JOSE-COSE, alg Ed25519), signed
/// with --key
VcJwt,
/// Unsigned CAIP-261 peer trust credentials, one per source and target
#[value(name = "caip-261")]
Caip261,
/// An OpenRank / EigenTrust local-trust CSV (i,j,v)
IjvCsv,
/// Unsigned AT Protocol labels (trusted / distrusted, with neg)
AtprotoLabel,
/// Unsigned Nostr NIP-32 label events (kind 1985)
NostrLabel,
/// One schema.org JSON-LD document of Reviews
SchemaOrg,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum InputFormat {
/// Trust Atoms or credentials
Atom,
/// application/vc+jwt tokens, which must verify
VcJwt,
/// CAIP-261 peer trust credentials (proofs are not checked)
#[value(name = "caip-261")]
Caip261,
}

/// What `i,j,v` CSV export does with zero and negative values.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum NegativeArg {
/// Leave them out (EigenTrust needs non-negative trust)
Drop,
/// Write them as they are
Keep,
}

#[derive(Debug, Args)]
pub struct ConvertArgs {
/// Output format
/// Output format [default with --from: atom]
#[arg(long, value_enum, required_unless_present = "from")]
pub to: Option<Format>,

/// Input format [default: atom]
#[arg(long, value_enum)]
pub to: Format,
pub from: Option<InputFormat>,

/// For ijv-csv: only atoms about this topic
#[arg(long)]
pub topic: Option<String>,

/// For ijv-csv: zero and negative values
#[arg(long, value_enum, default_value = "drop")]
pub negative: NegativeArg,

/// For vc-jwt: the key to sign with
#[command(flatten)]
pub key: KeyArg,

#[command(flatten)]
pub input: InputArgs,
Expand Down
78 changes: 68 additions & 10 deletions crates/trustgraph-cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ use clap::CommandFactory;
use jiff::{Timestamp, Unit};
use serde_json::{Value as Json, json};
use trustgraph_core::api;
use trustgraph_core::{Keypair, LensOptions, Query, Record, Supersession, TrustAtom, TrustGraph, credential};
use trustgraph_core::export::ijv::{CsvOptions, Negative};
use trustgraph_core::{Keypair, LensOptions, Query, Record, Supersession, TrustAtom, TrustGraph, credential, jose};

use crate::cli::{
AtomArgs, Cli, Command, ConvertArgs, Format, IdArgs, InputArgs, KeyCommand, LensArgs, QueryArgs, SignArgs,
AtomArgs, Cli, Command, ConvertArgs, Format, IdArgs, InputArgs, InputFormat, KeyCommand, LensArgs, NegativeArg,
QueryArgs, SignArgs,
};
use crate::home::Home;
use crate::io::{Output, read_json};
Expand All @@ -31,7 +33,7 @@ pub fn run<W: Write>(cli: Cli, out: &mut Output<W>) -> Result<Outcome> {
Command::Sign(args) => sign(&home, &args, out),
Command::Verify(args) => verify(&args, out),
Command::Id(args) => id(&args, out),
Command::Convert(args) => convert(&args, out),
Command::Convert(args) => convert(&home, args, out),
Command::Add(args) => add(&home, &args, out),
Command::Query(args) => query(&home, args, out),
Command::Lens(args) => lens(&home, args, out),
Expand Down Expand Up @@ -145,7 +147,10 @@ fn sign<W: Write>(home: &Home, args: &SignArgs, out: &mut Output<W>) -> Result<O
fn verify<W: Write>(args: &InputArgs, out: &mut Output<W>) -> Result<Outcome> {
let mut outcome = Outcome::Success;
for (_, json) in read_items(args)? {
let result = api::verify(&json);
let result = match &json {
Json::String(jwt) => api::verify_vc_jwt(jwt),
_ => api::verify(&json),
};
if !result.valid {
outcome = Outcome::Failed;
}
Expand All @@ -170,18 +175,71 @@ fn id<W: Write>(args: &IdArgs, out: &mut Output<W>) -> Result<Outcome> {
Ok(Outcome::Success)
}

fn convert<W: Write>(args: &ConvertArgs, out: &mut Output<W>) -> Result<Outcome> {
for (n, json) in read_items(&args.input)? {
fn convert<W: Write>(home: &Home, args: ConvertArgs, out: &mut Output<W>) -> Result<Outcome> {
let mut items = read_items(&args.input)?;
match args.from.unwrap_or(InputFormat::Atom) {
InputFormat::Atom => {
if let Some((n, _)) = items.iter().find(|(_, json)| json.is_string()) {
bail!("item {n}: a JWT; use --from vc-jwt to verify and convert it");
}
}
InputFormat::VcJwt => {
for (n, json) in &mut items {
let jwt = json.as_str().with_context(|| format!("item {n}: not a vc+jwt"))?;
*json = jose::verify(jwt).with_context(|| format!("item {n}"))?.credential;
}
}
InputFormat::Caip261 => {
let mut atoms = Vec::new();
for (n, json) in items {
for atom in api::from_peer_trust(&json).with_context(|| format!("item {n}"))? {
atoms.push((n, serde_json::to_value(atom)?));
}
}
items = atoms;
}
}

let to = args.to.unwrap_or(Format::Atom);
let all = || items.iter().map(|(_, json)| json.clone()).collect::<Vec<_>>();
match to {
Format::Atom | Format::Credential | Format::Canonical | Format::VcJwt => {}
Format::Caip261 => return lines(out, &api::to_peer_trust(all())?),
Format::IjvCsv => {
let negative = match args.negative {
NegativeArg::Drop => Negative::Drop,
NegativeArg::Keep => Negative::Keep,
};
out.text(&api::to_ijv_csv(all(), &CsvOptions { topic: args.topic, negative })?)?;
return Ok(Outcome::Success);
}
Format::AtprotoLabel => return lines(out, &api::to_atproto_labels(all())?),
Format::NostrLabel => return lines(out, &api::to_nostr_labels(all())?),
Format::SchemaOrg => return lines(out, &[api::to_schema_org(all())?]),
}
let keypair = if to == Format::VcJwt { Some(home.load_key(&args.key.key)?) } else { None };
let created = now().to_string();
for (n, json) in items {
let context = || format!("item {n}");
match args.to {
Format::Atom => out.json(&api::parse_atom(json).with_context(context)?)?,
Format::Credential => out.json(&api::to_credential(json).with_context(context)?)?,
Format::Canonical => out.line(&api::canonical_atom(json).with_context(context)?)?,
match (&keypair, to) {
(Some(keypair), _) => {
out.line(&api::sign_vc_jwt(json, &keypair.to_secret_multibase(), &created).with_context(context)?)?;
}
(None, Format::Credential) => out.json(&api::to_credential(json).with_context(context)?)?,
(None, Format::Canonical) => out.line(&api::canonical_atom(json).with_context(context)?)?,
(None, _) => out.json(&api::parse_atom(json).with_context(context)?)?,
}
}
Ok(Outcome::Success)
}

fn lines<W: Write, T: serde::Serialize>(out: &mut Output<W>, values: &[T]) -> Result<Outcome> {
for value in values {
out.json(value)?;
}
Ok(Outcome::Success)
}

fn add<W: Write>(home: &Home, args: &InputArgs, out: &mut Output<W>) -> Result<Outcome> {
let mut store = home.open_store()?;
for (n, json) in read_items(args)? {
Expand Down
26 changes: 20 additions & 6 deletions crates/trustgraph-cli/src/io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ use serde::Serialize;
use serde_json::Value as Json;

/// Reads every JSON value from `path` (or stdin for `None` / `-`). Accepts a
/// single document, NDJSON, or concatenated JSON.
/// single document, NDJSON, or concatenated JSON; or whitespace-separated
/// compact JWS tokens (`eyJ…`), returned as JSON strings.
pub fn read_json(path: Option<&Path>) -> Result<Vec<Json>> {
let (reader, name): (Box<dyn Read>, String) = match path {
Some(p) if p.as_os_str() != "-" => (
Expand All @@ -23,11 +24,18 @@ pub fn read_json(path: Option<&Path>) -> Result<Vec<Json>> {
(Box::new(io::stdin().lock()), "stdin".to_owned())
}
};
let values = serde_json::Deserializer::from_reader(reader)
.into_iter::<Json>()
.enumerate()
.map(|(n, v)| v.with_context(|| format!("{name}: item {} is not valid JSON", n + 1)))
.collect::<Result<Vec<_>>>()?;
let mut text = String::new();
BufReader::new(reader).read_to_string(&mut text).with_context(|| format!("reading {name}"))?;
// Compact JWS tokens (`eyJ…`, e.g. application/vc+jwt) become JSON strings.
let values = if text.trim_start().starts_with("eyJ") {
text.split_whitespace().map(|token| Json::String(token.to_owned())).collect()
} else {
serde_json::Deserializer::from_str(&text)
.into_iter::<Json>()
.enumerate()
.map(|(n, v)| v.with_context(|| format!("{name}: item {} is not valid JSON", n + 1)))
.collect::<Result<Vec<_>>>()?
};
if values.is_empty() {
bail!("{name}: no input");
}
Expand Down Expand Up @@ -55,6 +63,12 @@ impl<W: Write> Output<W> {
Ok(())
}

/// Writes text as it is (it should end with a newline).
pub fn text(&mut self, text: &str) -> Result<()> {
self.out.write_all(text.as_bytes())?;
Ok(())
}

pub fn line(&mut self, line: &str) -> Result<()> {
writeln!(self.out, "{line}")?;
Ok(())
Expand Down
Loading
Loading