sigelo v0.1 — portable agent identity
Status: draft. Wire format sigelo/0. Nothing is stable until v1.0, which will ship sigelo/1.
Scope: how an AI agent proves it is the same agent across worlds that share no infrastructure.
sigelo is a data format and a verification algorithm. There is no server, no registry, and no chain. Two parties who have never communicated can verify a sigelo bundle offline.
1. Design constraints
- No central authority. Any resolver or directory is convenience only. If every sigelo service disappeared, existing bundles must still verify.
- The agent's key is hot. It lives in a process that reads untrusted text all day. Assume it will be stolen. Design for recovery, not prevention.
- Verifiers decide. sigelo never adjudicates whether a claim is true or an issuer trustworthy. It proves who said what, and when.
- Adoption cost is the product. Integrating must take under ~100 lines and one dependency. Every feature is weighed against that.
- Payment is bound, never fused. Identity keys and spending keys are separate objects joined by a proof (§6).
1.1 Offline means self-contained
Everything a verifier needs is in the bundle: the subject's genesis, every rotation with its next genesis, and the genesis of every issuer (§8). A verifier with no prior knowledge of any party and no network verifies the same bundle to the same result as one with both.
2. Primitives
| Purpose | Algorithm |
|---|---|
| Signatures | Ed25519 (RFC 8032), pure, no prehash |
| Hashing | SHA-256 |
| Canonicalization | JCS (RFC 8785), restricted per §3 |
| Binary encoding | multibase z (base58btc) |
| Public keys | multicodec 0xed01 + 32 raw bytes, then multibase |
| Signatures on the wire | the 64 raw Ed25519 bytes, multibase. No multicodec prefix |
| DIDs | did:sigelo: + multibase of the raw 32-byte SHA-256 digest. No multicodec prefix |
| Nonces | multibase of raw bytes: z + base58btc, like keys (zJ6jjrKda7cWz17gvxJ4Fta), never z + hex; 16 random bytes for a genesis. A verifier MUST reject a genesis, binding or invoice nonce that is not z followed by 1 to 63 base58btc digits (at most 64 characters): <slot>: nonce is not z + base58btc (at most 64 characters), fatal in a genesis, per item in a binding (vectors fatal_genesis_nonce_z_hex, binding_nonce_not_multibase, binding_nonce_65_characters, binding_nonce_64_characters). The byte length is not checked. The §5.2 challenge nonce is exempt: opaque, the world's choice |
Keys are points (fatal_genesis_key_all_zero, fatal_issuer_key_identity,
fatal_rotation_recovery_key_y_ge_p, binding_ed25519_test_addr_all_zero_unproven). Every
slot that holds a public key — a genesis key (the bundle's, a next_genesis's, an
issuer's), a rotation's recovery_key, an ed25519-test binding's addr — MUST hold the
canonical encoding (y < p; not x = 0 with the sign bit set) of a curve point that is not of
small order: exactly the keys the §2 verification rule could ever accept a signature under.
All-zero, the identity and the other small-order points, non-canonical spellings and non-points
are malformed, key: not a valid Ed25519 point (non-canonical, off the curve or of small order),
at the slot's severity: fatal for a genesis or rotation, per item for a binding (an
unproven ed25519-test binding was accepted with an addr nothing could ever sign for).
A point with a torsion component but not of small order is a valid key, as it is to the
signature check.
Length before decoding (fatal_genesis_key_too_long, attestation_sig_too_long). Base58
decoding is quadratic in the length, so a verifier MUST bound a multibase value before
decoding it: a public key (key, recovery_key, an ed25519-test addr) is at most 64
characters and a signature at most 100, z included (34 bytes spell at most 47 base58
digits, 64 bytes at most 88; the rest is margin). The alphabet is checked first (a linear scan,
so the count is of ASCII characters): a non-digit is reported as such, then a value over its
bound is rejected, multibase: longer than 64 characters, at the severity of the slot it sits
in — fatal in a genesis or rotation, per item in an attestation or binding.
One suite. No negotiation, no agility, no downgrade surface.
3. Signing input and canonical form
signing_input = "sigelo\n" || JCS(body)
The prefix is exactly seven bytes, 73 69 67 65 6c 6f 0a (sigelo then LF), followed by
the JCS serialization of the body, UTF-8, no trailing newline. The prefix separates sigelo signatures from any other
protocol that signs raw JSON with the same key. Signatures are always detached — never
inside the object they sign.
Type binding. Every signed body carries typ. Verifiers MUST check that typ matches the
slot the object is presented in (an attestation presented as a binding is rejected even with a
valid signature). typ inside the signed bytes is what prevents cross-type confusion.
No non-integer numbers. Signed objects MUST NOT contain floats, exponents, or integers
outside ±2^53−1. Implementations MUST reject any number whose value is not an integer in
that range. JCS number canonicalization is the largest source of cross-language interop
failure; forbidding floats removes the class. Use strings ("0.15") or scaled integers.
Integers are serialized as plain decimal digits with no fraction, exponent, or leading
zeros; -0 is 0. Implementations that see the raw text MAY additionally reject
non-canonical integer spellings such as 1.0 or 1e2; implementations working from parsed
values cannot, and are not required to.
JCS, exactly. Three points of RFC 8785 that a sorted JSON.stringify or
json.dumps(sort_keys=True) does not give you, and that the vectors exercise:
- Object keys are sorted by their UTF-16 code units, not by Unicode code point and not
by UTF-8 bytes. The orders differ once a key contains a character above U+FFFF:
"𝄞"(U+1D11E, encoded as the surrogates D834 DD1E) sorts before"~"(U+FF5E) in JCS and after it by code point.claimsis world-defined, so non-ASCII keys will occur. - String escaping is the ES6
JSON.stringifyset and nothing more:"→\",\→\\, U+0008/0009/000A/000C/000D →\b \t \n \f \r, other control characters below U+0020 →\u00xxwith lowercase hex. Everything else, including U+007F, U+2028, U+2029 and all non-ASCII, is emitted as raw UTF-8. No\/, no\ufor non-ASCII. - No whitespace anywhere.
Duplicate keys. A signed object with the same key twice at any level has no canonical
form. Parsers that keep the last value would silently sign different bytes than parsers
that keep the first. Implementations MUST reject it. This means parsing with a
duplicate-detecting hook, not the language default. A verifier reading bundle text treats a
duplicate key or __proto__ (§3.1) anywhere in it as fatal, since the text has no single reading, but
parses a forbidden number and leaves it to §9 step 2, so it sinks only the item that carries it.
Invalid UTF-8 (raw_invalid_utf8). JSON text is UTF-8 (RFC 8259 §8.1). A document that
is not valid UTF-8 is rejected whole; an implementation MUST decode fatally. A lossy decode
turns the bad bytes into U+FFFD and sinks only the item whose signature then fails, while a
byte-level parser rejects the document: two verifiers, two answers. A leading byte-order mark
is not JSON whitespace and is rejected the same way. This is not a vector, because
test-vectors.json is itself UTF-8 text; a \ud800 escape is valid UTF-8 and stays per item (§3.1).
Nesting depth (raw_fatal_depth_513_in_claims, raw_depth_512_in_claims). A parser MUST
reject a document whose arrays and objects, combined, nest deeper than 512 levels (the
outermost container is level 1), as a parse error: the whole document is rejected, wherever
the deep value sits — in claims too, where a malformed value would sink only its item —
because the limit is on the text, like a duplicate key. The error is reported at the bracket
that opens level 513, an empty container included. A canonicalizer MUST refuse a value nested
deeper than 512 (no conforming parser returns one; an object built in memory can be one, and
then it is malformed like a float, per item where it sits). Rationale: a recursive parser has
a finite stack, and in Go exhausting it is a fatal runtime error that recover() cannot catch —
a 1.6 MB document killed every process embedding the reference verifier, while an iterative
parser answered. A bounded verifier must bound depth, and two verifiers that bound it at
different places give two answers for the same bytes. 512 leaves a bundle's claims 507 levels.
Envelopes. Signed objects travel as:
{ "body": { … }, "sig": "z…" }
Bindings carry sig_id and optionally sig_addr instead of sig. Rotations additionally
carry next_genesis. Nothing in the envelope outside body is signed, but the rules above
apply to the whole envelope: a float anywhere in it makes the item malformed (§9 step 2), and
so does a key outside its members (§3.1).
3.1 Fields, normatively
typ | required | optional |
|---|---|---|
genesis | v typ key recovery created nonce | — |
attestation | v typ iss sub iat exp ctx admission claims | admission_by admission_cost |
binding | v typ id method addr iat exp nonce | — |
rotation | v typ id next iat reason | recovery_key (required iff reason is recovery, forbidden otherwise) |
challenge | v typ did ctx nonce | — |
invoice | v typ did method addr iat exp nonce | amount (string, atomic units) memo |
bundle | v typ genesis rotations bindings attestations issuers | — |
No other top-level keys. A body carrying a key not in its row is malformed. claims is
the one free-form value and may hold anything JCS can serialize. Envelopes are part of the
same rule, both ways: an envelope is exactly its defined members — a rotation envelope body,
sig and next_genesis; a binding envelope body, sig_id and optionally sig_addr; an
attestation envelope body and sig — with sig and sig_id strings. An envelope missing
one, or carrying any other key, is malformed in the same class as its body (§9 step 2): fatal
for a rotation (rotation: unknown envelope field "…"), the item discarded and counted for an
attestation or binding (vectors attestation_envelope_extra_key_int,
attestation_good_and_envelope_extra_keys, binding_envelope_extra_key,
fatal_rotation_envelope_extra_key; binding_envelope_sig_id_only_unproven is the optional
member absent). Nothing outside body is signed, so an extra member is data no signature
authenticates, exactly like an unknown body field, and a verifier that ignored it would accept
what a stricter one discards. A holder that receives an item with extra members (a world whose
router adds a clock, say) keeps only the defined ones before bundling it; the signature does
not cover the rest, so dropping them changes nothing that verifies. The bundle itself is the
bundle row above; a §5.2 challenge answer or §6.3 invoice exchanged as {body, sig} follows
the same rule. v is "sigelo/0"; iat, exp are Unix seconds; created is RFC 3339 UTC in exactly the form §4 fixes.
Field types. iat and exp are JSON integers in [0, 2^53−1], and exp > iat wherever
both appear. Every other field in the table is a string, except recovery (string or null,
§4), claims (free-form) and the bundle's own genesis and four arrays. A string anywhere in
a signed object MUST be a sequence of Unicode scalar values: a lone surrogate (legal as a JSON
\u escape) is malformed, since RFC 8785 requires I-JSON and an encoder that replaced it with
U+FFFD would give two bodies one signing input. Noncharacters (raw_fatal_noncharacter_in_claims_value,
raw_fatal_noncharacter_astral_in_claims_key): RFC 7493 §2.1, which RFC 8785 cites, also
forbids them, so a parser MUST reject a string or key holding U+FDD0–U+FDEF or U+xFFFE /
U+xFFFF in any plane, raw or escaped (an escaped surrogate pair counts as the character it
spells), as a parse error — fatal to the document, reported at the string's opening quote:
noncharacter U+FFFF in string at offset N. A canonicalizer MUST refuse one in a value built
in memory (noncharacter U+FFFF in string), so a conforming library never signs a body no
conforming parser will read. The object key __proto__ is malformed at any
depth: a JavaScript parser that assigns it replaces the object's prototype instead of adding a key.
A type failure is malformation like any other (§9 step 2), never an implementation error.
4. Genesis and identity
An identity is its genesis document. The DID is a hash of it, so nothing inside can be revised afterwards — including the recovery commitment.
{
"v": "sigelo/0",
"typ": "genesis",
"key": "z6Mk…",
"recovery": "sha256:2c5a92ed…",
"created": "2026-09-07T00:00:00Z",
"nonce": "z…"
}
| Field | Meaning |
|---|---|
key | multibase Ed25519 public key — the identity signing key |
recovery | sha256: + hex SHA-256 of the raw 32-byte recovery public key, or null. Exactly sha256: and 64 lowercase hex digits: a verifier MUST reject anything else (genesis: recovery is neither null nor sha256: + 64 lowercase hex), fatally, as in every genesis slot (vectors fatal_genesis_recovery_uppercase_hex, fatal_genesis_recovery_prefix_only). Uppercase hex is the same digest spelled so that no recovery key ever matches it: recovery silently off |
created | RFC 3339 UTC, Z, second precision. Self-asserted and unverifiable; informational only. Its form is checked: see below |
nonce | 16 bytes, multibase. Distinguishes genesis documents that share a key. Random by default; a world with a stable key MAY derive it from the key (e.g. the first 16 bytes of SHA-256 of the raw public key) so its genesis is reproducible without storage; then created MUST be pinned too, since every field is hashed. Uniqueness is what matters, not unpredictability: nothing secret is derived from it |
DID = "did:sigelo:" + multibase_z( SHA-256( JCS(genesis) ) )
created, exactly (fatal_genesis_created_leap_second and its neighbours). A verifier
MUST reject a genesis whose created is not exactly YYYY-MM-DDTHH:MM:SSZ: four-digit year,
a real Gregorian date (30 April, 29 February only in a leap year), hours 00–23, minutes and
seconds 00–59 (no leap second: nothing offline can check one), no fraction, no offset,
uppercase T and Z. The reason is genesis: created is not RFC 3339 UTC (YYYY-MM-DDTHH:MM:SSZ).
Every genesis slot defines an identity (§9 step 2), so this is fatal wherever a genesis sits:
the bundle's own, a next_genesis, an issuer. The value is hashed into the DID and read by
nobody, but a field no one checks is a field two implementations parse differently the day
one of them starts to read it.
Verifiers MUST recompute the DID from the presented genesis and reject on mismatch. First step, every time, not optional. Verifiers MUST compare full DIDs — never prefixes — and UIs MUST NOT truncate them; base58 vanity grinding against a truncated display is cheap.
recovery: null is legal and means theft of the identity key is terminal. Libraries
MUST warn at generation. Worlds MAY refuse attestations to such identities.
Recovery key handling. Generated offline, never loaded into an agent runtime. Only its hash is public until it is used (§7). An attacker with full agent compromise learns a hash.
5. Attestations
One world's signed statement about one identity.
{
"v": "sigelo/0",
"typ": "attestation",
"iss": "did:sigelo:z…",
"sub": "did:sigelo:z…",
"iat": 1757203200,
"exp": 1764979200,
"ctx": "1f916.ai",
"admission": "invite",
"admission_by": "did:sigelo:z…",
"claims": { "joined": "2026-04-02", "posts": 412, "standing": "citizen" }
}
claims is world-defined and opaque to sigelo. Verifiers interpret it according to how
much they trust iss. admission_by is informational; verifiers are not required to resolve it.
admission_by and admission_cost (§5.1) are optional; every other field shown is required.
The signature is verified against the key in the genesis whose DID is iss. A world that
has itself rotated issues new attestations under its new DID; old attestations still verify
against the old genesis. Verifiers do not walk issuer chains in v0.1. So, by design, a key a
world rotated away from still makes attestations that verify under the old DID, whatever
their iat, until each one's exp: nothing in a bundle says the old DID was retired, and a
verifier that knows it was (from the world, out of band) stops trusting that DID itself (THREAT-MODEL
§3.2).
exp is mandatory. There is no revocation list — freshness comes from reissuance.
Recommended lifetime 30–90 days. To signal lost standing, reissue with claims changed.
5.1 Admission taxonomy
admission records what it cost the subject to enter. It is what makes a population
count carry information.
| Value | Meaning |
|---|---|
open | no barrier |
captcha | automated challenge only |
invite | vouched by an existing member; admission_by names them |
payment | money was paid; admission_cost MAY carry a string amount |
human | a human identity was verified by the issuer |
stake | a slashable bond is held |
admission MUST be one of these six values; a verifier discards an attestation carrying any
other as malformed (§9 step 2). New values arrive with a new wire version, not silently.
Issuers MUST NOT overstate. The only remedy for a lying issuer is that verifiers stop
trusting it, which is the correct remedy. A verifier can then read a population of 2,000 as,
say, 1,847 open and 153 invite — a signal, where a raw count was not.
5.2 Proof of control
Before a world attests to a DID it needs the agent to demonstrate it holds the key. Every world would otherwise invent its own handshake, so this one is fixed:
{ "v": "sigelo/0", "typ": "challenge", "did": "did:sigelo:z…", "ctx": "1f916.ai", "nonce": "…" }
These five fields and no others: an agent library MUST refuse a challenge carrying any
additional key, so nothing can be smuggled into the signed bytes. The world chooses nonce
(opaque to the agent, bound by the world to the requesting session and to a short lifetime)
and ctx; the agent fills did with its current DID and
signs the §3 signing input with the identity key; the world recomputes the DID from the
presented genesis, compares full strings, and verifies the signature against genesis.key.
A challenge never appears in a bundle and no bundle slot accepts typ: "challenge", so a
challenge signature cannot be replayed into any other slot. Agent libraries SHOULD refuse to
sign a challenge whose did is not their own. Vector challenge.
6. Payment bindings
Proves that an identity and a payment address share an operator. Cross-signed: both keys sign the identical signing input.
{
"v": "sigelo/0",
"typ": "binding",
"id": "did:sigelo:z…",
"method": "monero",
"addr": "8Bx…",
"iat": 1757203200,
"exp": 1764979200,
"nonce": "z…"
}
Envelope carries sig_id (identity key) and sig_addr (payment key, per-method format).
One-sided signatures are insufficient:
- identity-only proves the identity claims the address — anyone can claim anyone's
- address-only proves wallet control, not that the identity endorsed it
6.1 Proof status
Every binding the verifier returns carries exactly one of three statuses:
proof | Meaning |
|---|---|
proven | sig_id valid and sig_addr present and valid for method |
unproven | sig_id valid, sig_addr absent — the identity claims the address |
unsupported | sig_id valid, sig_addr present, verifier has no routine for method |
Where a payment method has no practical message-signing path, sig_addr MAY be omitted.
The binding is then an unproven claim, whatever the method; unsupported applies only
when a sig_addr is present. Verifiers MUST report the status in the return
type, not as a boolean, and MUST NOT send funds to any address whose status is not proven.
Keep exp short on unproven bindings.
A binding whose sig_id fails, or whose sig_addr is present but invalid for a method
the verifier does support, is discarded, not downgraded. A bad proof is not the same thing
as no proof.
6.1a Method ed25519-test
addr is a multibase Ed25519 public key (§2 encoding) and sig_addr is an ordinary §3
signature by that key. The method exists so the cross-signing rule can be exercised in the
vectors without a wallet. Conformant implementations MUST support it. It carries no payment
semantics and worlds SHOULD NOT accept it as a real binding. Its counterpart opaque-test
is a method no implementation supports; it exists to exercise the unsupported status.
6.2 Monero
method: "monero". addr is a standard (base) address varint(prefix) ‖ B ‖ A ‖
checksum (prefix 18 mainnet, 24 stagenet, 53 testnet) or a subaddress varint(prefix) ‖
D ‖ C ‖ checksum (prefix 42 mainnet, 36 stagenet, 63 testnet), in Monero base58. Integrated
addresses are not accepted as addr: the hash below does not cover the prefix or the payment
id, so the base address's signature verifies for every integrated spelling of it and a payment
id nobody signed for would ride along. sig_addr is a Monero message signature over the
address's own two keys — (B, A) for a standard address, (D, C) for a subaddress:
h = Keccak-256("MoneroMessageSignature\0" ‖ S ‖ V ‖ mode ‖ varint(len) ‖ signing_input)
sig = c ‖ r, c = H_s(h ‖ P ‖ kG), r = k − c·x (P, x) = (S, s) spend mode 0, (V, v) view mode 1
wire = "SigV2" ‖ monero_base58(sig)
standard: (S, s) = (B, b) (V, v) = (A, a)
subaddress: (S, s) = (D, b + m) (V, v) = (C, a·(b + m))
m = H_s("SubAddr\0" ‖ a ‖ le32(major) ‖ le32(minor)), D = B + mG, C = a·D
where signing_input is §3's bytes, H_s(x) = sc_reduce32(Keccak-256(x)) with original
Keccak padding, and verification recomputes R' = c·P + r·G and checks
H_s(h ‖ P ‖ R') = c (monero src/wallet/wallet2.cpp get_message_hash, sign;
src/crypto/crypto.cpp check_signature). Verifiers MUST accept either mode and SHOULD
report which one. Legacy SigV1 is not accepted. Both keys in addr MUST decode as Monero's
check_key decodes them (ge_frombytes_vartime: y < p, and not x = 0 with the sign bit set), or
the binding is not proven, whichever mode signed.
(The subaddress row is wallet2::sign for a non-zero index.) A binding whose SigV2 verifies
against its addr's own keys, in either mode, is proven; one whose signature was made with
another address's keys — a subaddress signed with the base (B, A), say — is discarded.
View mode is the intended mode for a standard address. A view-only wallet holding
(a, B) produces it (through monero-wallet-rpc sign with signature_type: "view" at
index (0,0)), so an agent binds its wallet without ever holding a spend key. The signature
proves "I can see this wallet", which is exactly what a payer needs the receiver to be able to
do. For a subaddress, view mode does not imply a view-only signer: both of its secrets
are derived from b, so a view-only wallet cannot produce either mode, and a subaddress
binding is made by whoever holds the spend key (a keeper, MONERO.md §3).
The hash does not cover the network prefix. One key pair's mainnet, stagenet and testnet
addresses verify the same signature. A verifier MUST check that addr decodes to the
network it expects before treating the binding as proven.
6.3 Invoices
A binding names a wallet; an invoice names where to pay this time. Signed by the identity key, exchanged bilaterally, never in a bundle:
{ "v": "sigelo/0", "typ": "invoice", "did": "did:sigelo:z…", "method": "monero",
"addr": "7…", "iat": 1757203200, "exp": 1757289600, "nonce": "z…", "amount": "150000000000" }
addr is a receive address of the bound wallet, for Monero a fresh subaddress per
counterparty per invoice. The payer checks: the signature against the identity key of did,
iat ≤ now < exp, and that did has a proven binding for the same method in a bundle
it has verified. sigelo cannot prove a subaddress belongs to the bound wallet (that needs the
view key); the identity's signature is the claim, the binding is its anchor, and a payment
to an address the identity did not sign for is the payer's own mistake. amount is a string
of atomic units or absent; memo is free text and, like claims, untrusted data.
Selective disclosure. Monero has no per-subaddress view key: one private view key
covers every subaddress of a wallet, and disclosing it is retroactive and irrevocable. The
primitives are per-payment proofs (get_tx_proof, get_reserve_proof) and one wallet per
relationship whose view key is meant to be shared, all derived from one root seed. See
MONERO.md. Libraries MUST NOT automate view-key disclosure; provide the primitive, make the
caller invoke it deliberately. Bindings, invoices and proofs are what get automated.
7. Rotation and recovery
{
"v": "sigelo/0",
"typ": "rotation",
"id": "did:sigelo:<current>",
"next": "did:sigelo:<new>",
"iat": 1760227200,
"reason": "voluntary",
"recovery_key": "z6Mk…"
}
Envelope carries next_genesis; verifiers MUST check hash(next_genesis) == next.
next MUST differ from id; a self-rotation is structurally invalid.
voluntary — signed by the current identity key. recovery_key absent.
Constraint: next_genesis.recovery MUST equal the current recovery commitment. A
voluntary rotation that changes the commitment is not a candidate (§7.4): it is never
followed, and its presence does not by itself reject the chain. This is what stops an
attacker with a stolen key from installing their own recovery key.
recovery — signed by the recovery key. recovery_key present; SHA-256(raw key) MUST
equal the current recovery commitment (§7.2). next_genesis MAY carry a new commitment,
or null, which retires recovery permanently: no later recovery rotation can validate.
7.1 Precedence
At any node, a valid recovery rotation supersedes any voluntary rotation, regardless of
iat.
An attacker with a stolen key produces a perfectly valid voluntary rotation. The operator
with the offline recovery key overrides it — even if the attacker's rotation is newer, even
if it happened months earlier. Never reorder by timestamp. Vector rotation_recovery
deliberately carries an earlier iat than both hostile rotations.
Two hostile vectors exist because two rules defend this node. rotation_hostile also
changes the recovery commitment and is rejected by §7 before precedence is ever consulted.
rotation_hostile_carried carries the commitment forward and is a fully valid voluntary
rotation; only this rule defeats it. An implementation that skips precedence passes the
first and fails the second (vector chain_precedence_only).
7.2 Where recovery authority lives
The governing commitment is the one in the most recent recovery-signed genesis in the chain, or the original genesis if there has been no recovery. Voluntary rotations carry it forward unchanged (enforced above). Only a recovery rotation can change it — so the operator can retire a recovery key, and an attacker never can.
7.3 Chain rules
Each DID in the chain is rotated from at most once. At a node:
- Collect rotations whose
idis this node. - Among them, the valid recovery rotations (signature, commitment,
nexthash all check; §7.4 lists what disqualifies one): if any, take the one with the latestiat— the operator controls all of them. If two share that latestiat→ REJECT the chain; that is operator error and the fix is to reissue. - Otherwise, the valid voluntary rotations (signature,
nexthash, commitment unchanged); invalid ones are not counted: exactly one → follow it. More than one → REJECT the chain. A fork under a single key is a compromise signal; the remedy is a recovery rotation, not verifier guesswork. Two entries are two candidates even when byte-identical: a bundle MUST NOT carry the same rotation twice, and a verifier does not deduplicate (vectornegative.parity.fatal_duplicate_rotation_is_fork). - None → chain ends here.
- Before following the chosen rotation: if
nextis already in the chain → REJECT the chain. A rotation back to an earlier DID is a cycle. Only a key holder can produce one, it has no legitimate meaning, and a verifier that follows it never terminates. Fail closed rather than loop (vectornegative.cycle).
The chain is walked from the original genesis until step 4 ends it. Because every step
consumes one rotation whose id is the current node and every next is new, the walk
terminates in at most len(rotations) steps. Rotations that were valid but not chosen
(a voluntary rotation superseded by a recovery) are simply not followed; the advice below on
discounting attestations near a recovery is how a verifier accounts for them.
7.4 Two outcomes, two words
A rotation that fails a check is either not a candidate (ignored at its node; the walk continues as if it were absent) or it REJECTs the chain (fatal; the bundle does not verify). Nothing in between.
| Failure | Outcome |
|---|---|
| signature does not verify | not a candidate |
hash(next_genesis) ≠ next | not a candidate |
voluntary, next_genesis.recovery ≠ current commitment | not a candidate |
recovery, SHA-256(recovery_key) ≠ current commitment (wrong or stale key) | not a candidate |
recovery while the current commitment is null | not a candidate |
body or next_genesis malformed, sig or next_genesis missing (§3.1) | REJECT (structure, §9 step 2) |
| two valid voluntary rotations at one node | REJECT (fork) |
two valid recovery rotations sharing the latest iat | REJECT (tie) |
chosen next already in the chain | REJECT (cycle) |
A "not a candidate" rotation may still be a thief's artifact; that is what the advice on
discounting is for. Vectors: negative.rotation_bad_sig, negative.recovery_key_mismatch,
negative.stale_recovery_key and negative.voluntary_changes_recovery are all "not a
candidate" cases and each states the chain that results.
Reputation follows the chain: attestations to any prior DID apply to the current one. After
a recovery, the prior key was compromised for an unknown window. Verifiers SHOULD discount
attestations to the compromised DID with iat near the recovery, and SHOULD treat an
attestation issued to a DID after it was rotated away from as suspect. sigelo cannot
determine when compromise began; only the issuing world can.
iat everywhere is signer-asserted. Time-based logic is advisory unless the signer is trusted.
8. Bundles
{
"v": "sigelo/0",
"typ": "bundle",
"genesis": { … },
"rotations": [ { "body": …, "sig": …, "next_genesis": … } ],
"bindings": [ { "body": …, "sig_id": …, "sig_addr": … } ],
"attestations": [ { "body": …, "sig": … } ],
"issuers": [ { …genesis… }, … ]
}
genesis is the original. Bundles are unsigned; every element carries its own signature.
All four arrays are required and MAY be empty.
issuers carries the genesis documents of the worlds whose attestations appear in the
bundle. It is what makes an attestation from a world the verifier has never heard of
verifiable offline (§1.1). The array holds bare genesis documents, not a DID-keyed map:
the verifier derives each DID by hashing, so there is no key/value pair that could
disagree. A verifier MAY also know issuer genesis documents from elsewhere (its own, or a
locally pinned set); the bundle's copies never override those. An attestation whose iss
matches no presented and no known genesis is discarded in §9 step 4, not fatal to the bundle.
9. Verification algorithm
In this order. Fail closed. Every input ends in a result or in a rejection that names its check; running out of stack or arguments on a large but legal bundle is neither, and a verifier MUST NOT let one decide the outcome (§3 bounds nesting for the same reason).
Input: a bundle and now (Unix seconds). The verifier takes now as a parameter rather
than reading a clock, so results are reproducible and testable.
- Genesis. Check
bundle.genesisstructurally and compute its DID. This is the root of the chain; nothing else in the bundle may name the identity except by this hash. The algorithm takes no expected DID: a caller that has one (a login claim) compares it, in full, againstchain[0]ordidof the result, and treats a mismatch as a failed claim. Vectornegative.genesis_tamperedis that comparison. - Structure. A body is malformed if it has a non-integer number, a duplicate key, an
unknown
v, a missing required field, a top-level key outside its §3.1 row (or an envelope key outside its members, §3.1), atypnot matching its slot, or a field value outside its definition (anadmissionnot in §5.1, a rotation whosenextequals itsid). Malformation in anything that defines the identity is fatal to the bundle: the bundle's own shape,genesis, every rotation body andnext_genesis, every entry inissuers. Malformation anywhere in an individual attestation or binding (body or envelope, a non-integer number or lone surrogate included) discards that item and counts it inrejected, exactly as a bad signature would; the other items still verify. The presenter chose to include it, but an issuer wrote it; one world's bug must not sink its members' bundles. - Chain. Apply §7.3 from the original genesis. Output: current DID, ordered chain of DIDs, governing recovery commitment. Reject on fork, on cycle, or any structural failure.
- Issuers. Hash each document in
bundle.issuersto its DID. Add to the set of known issuer genesis documents; a locally known genesis for the same DID is kept in preference to the presented one (they are identical if both are honest). - Attestations. Each: resolve
issto a known genesis (step 4), verify signature against itskey, checkiat ≤ now < exp, checksub∈ chain. Discard failures individually — one bad attestation does not sink a bundle. - Bindings. Verify
sig_idagainst the identity key of the DID inid(must be ∈ chain). Checkiat ≤ now < exp. Verifysig_addrif present, per method. Tag proof status per §6.1. Discard failures individually. - Return the result below. No scores. No ranking. Weighting is the caller's job.
9.1 Result
{
"did": "did:sigelo:<current>",
"chain": [ "did:sigelo:<original>", …, "did:sigelo:<current>" ],
"recovery": "sha256:…",
"attestations": { "did:sigelo:<iss>": [ { …attestation body… }, … ] },
"bindings": [ { "body": { …binding body… }, "proof": "proven" } ],
"rejected": { "attestations": 2, "bindings": 0 }
}
attestations holds accepted attestation bodies verbatim, grouped by iss, in bundle
order within each issuer. The same attestation presented twice is accepted twice and listed
twice: a verifier does not deduplicate here any more than it does rotations (§7.3) — every
copy verifies, and it reports what was presented — so a caller that counts attestations counts
distinct ones. Key order of that object is informative only; conformance is
compared by value, and an implementation whose objects are unordered is conformant. bindings holds accepted bodies with their proof status, in bundle order.
recovery is the governing commitment, null if the identity has none. rejected counts
what steps 2, 5 and 6 discarded. Implementations MAY attach per-item reasons alongside;
the fields above are the conformance surface (vector bundle, compared as JSON).
10. Test vectors
test-vectors.json: real Ed25519 signatures from documented seeds. Positive groups
include a four-node chain with two hostile rotations defeated by an earlier recovery, a
recovery that changes the commitment followed by a second recovery under the new key, an
attestation whose claims exercise the JCS rules of §3, and a full bundle with its
expected §9.1 result at a fixed now. Negative cases include missing domain prefix, typ
mismatch, stale recovery key, voluntary rotation changing the commitment, a fork, a cycle,
a duplicate key, an integer outside ±2^53−1, an unknown top-level field, and a rotation with a
bad signature. invoice and challenge are the two signed objects that never enter a bundle. A second bundle, bundle_minimal, is the smallest thing that verifies: one
genesis with recovery: null, four empty arrays.
Conformant = every positive vector reproduced byte-for-byte, every vector carrying a bundle
and expect reproduced as a §9.1 result compared by value, every negative rejected for the stated reason. The rotation_recovery
versus rotation_hostile_carried pair is the one that matters.
11. Non-goals for v0.1
Trust scoring · revocation lists · encryption · discovery · personal-data handling in
claims (see THREAT-MODEL §6) · algorithm agility · plugin systems.