The Auspex Mail Protocol, Version 1

Abstract

This document specifies version 1 of the Auspex protocol, a mail protocol for Urbit ships. Every Auspex message is signed by the networking key of the ship that wrote it. A reply or a forward carries the signed messages on the path from the thread root to the message being answered, so that any recipient can verify every message on that path independently, including messages written by ships it has never communicated with. This document defines the message format, the message identifier and signing digest, the verification procedure and its three verdicts, the rules for merging and bounding received chains, content-addressed attachments, the delivery transport, and a capability discovery mechanism. It also defines the conformance test vectors that a second implementation is expected to reproduce.

Status of This Memo

This document is a protocol proposal published by its author. It is not an Internet-Draft or an RFC. It is not a product of the IETF or of any other standards body, and no such body has reviewed or endorsed it. The document follows the conventions of the RFC series because those conventions are familiar to implementers.

The protocol described here is implemented and deployed. The unsigned structure defined in Section 4.1 is frozen. Other parts of this document may be revised. Comments are solicited and should be filed as issues at https://github.com/nisfeb/auspex.

Table of Contents

1 Introduction

Auspex is mail between Urbit ships. It differs from chat protocols on the same network in one respect that shapes the whole design. An Auspex message is evidence that can be handed to a third party. A message is signed by its author's ship, and the signature is carried with the message wherever the message goes. A recipient of a forwarded conversation verifies each message in it against the key of the ship that wrote it. The recipient does not need to trust the forwarder.

Three properties follow from this goal and are developed in the rest of the document.

  1. The set of signed fields is fixed. A signature covers a noun of an exact shape, and a change to that shape would cause every existing message to read as a forgery. Section 3 defines how the protocol changes without altering that shape.
  2. Verification is performed per message and never per conversation. A single delivery routinely contains messages with different verification results. Section 5 defines the procedure and the three possible verdicts.
  3. The courier is irrelevant. Any ship may deliver a chain of messages to any other ship. Authority rests in the signatures alone. Section 8 defines delivery.

This document is written for an implementer who has an Urbit ship and access to its key store, and who does not have the reference implementation's runtime. The reference implementation is a Grubbery nexus [GRUBBERY]. Nothing about Grubbery is normative except the single transport defined in Section 8.

1.1 Requirements Language

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all capitals, as shown here.

1.2 Terminology

Ship
An Urbit node, identified by a numeric address of type @p. Galaxies, stars, and planets are ships whose public keys are registered in Azimuth.
Azimuth
The public key infrastructure of the Urbit network for galaxies, stars, and planets. Each ship holds a local snapshot of it.
Groundwire
A public key infrastructure for comets that anchors identity in the Bitcoin blockchain [GROUNDWIRE]. A ship that runs Groundwire verifies a peer's key attestation against the chain and then installs the peer's key in Jael, at a life.
Moon
A ship whose identity is issued by a parent ship. A moon's key is not registered in Azimuth.
Comet
A ship with a self-generated address that is derived from its own public key. A comet's key is not registered in Azimuth.
Groundwire comet
A comet whose key is attested through Groundwire. A Groundwire comet may be confidential, in which case it cannot be discovered through the network. A peer verifies such a comet only after it has been given the comet's attestation.
Fake ship
A ship booted in development mode, outside the live network. Every key pair on a fake network is derived deterministically from the ship address. See Section 5.6.
Life
The revision number of a ship's networking key. It starts at 1 and increases by one at each key rotation.
Jael
The kernel module of a ship that stores the ship's private keys and the local Azimuth snapshot. It answers key queries through the scry interface.
Scry
A synchronous, side-effect-free read from a ship's namespace. This document writes a scry as a Hoon .^ expression.
Remote scry
A read of a path in another ship's published namespace. The kernel request that performs one is called a keen. A keen requires no prior relationship between the two ships.
Noun
An atom or a cell. An atom is a natural number of any size. A cell is an ordered pair of nouns. Every value in this protocol is a noun.
Mark
A name that identifies the type of a noun in transit.
Poke
A one-way, acknowledged message from one ship to an application on another ship. A positive acknowledgement is an ack. A negative acknowledgement is a nack.
jam
The canonical serialization of a noun as a single atom, as defined in [HOON].
mug
A 31-bit non-cryptographic hash of a noun, as defined in [HOON]. The standard containers use it for ordering.
shax, shas, shaf, sham
Hash functions from [HOON]. shax is SHA-256. (shas sal ruz) is a salted SHA-256, computed as SHA-256 over the bitwise exclusive-or of the salt sal and (shax ruz). (shaf sal ruz) folds the 256-bit result of (shas sal ruz) to 128 bits by exclusive-or of its two halves. (sham yux) is a 128-bit hash of an arbitrary noun. For a cell it is (shaf %sham (jam yux)). For an atom it is (shaf %mash yux).
Message
An unsigned structure together with a signature over its digest. See Section 4.
Chain
A list of messages. See Section 6.1.
Thread
The tree formed by all messages that descend from one root message through their prev fields.
Copy
One signed instance of a message. Two copies share a message identifier and differ in signature.
Verdict
The result of verifying one copy. It is one of %verified, %unverified, or %forged.
Nexus
An application hosted by the Grubbery runtime. The Auspex reference implementation is a nexus.
Grub
A file in a Grubbery nexus tree.
Blob
The bytes of an attachment, stored and retrieved by content hash.

1.3 Notation

Data structures and reference procedures are given in Hoon. A name with a leading +, such as +merge, names a procedure ("arm") of the reference library [AUSPEX-LIB]. A name with a leading $, such as $unsigned, names a type. A name with a leading %, such as %forged, is a constant term.

The atom types used in this document are as follows.

Type Meaning
@p ship address
@ud unsigned decimal integer
@t UTF-8 text, stored least significant byte first
@tas a term, which is lowercase ASCII text with hyphens
@da absolute date, 128 bits
@dr time interval
@ux, @uv, @uw unsigned integer printed in base 16, base 32, or base 64

The symbol ~ is null and is the atom 0. A (unit x) is either ~ or the cell [~ x]. A (list x) is either ~ or the cell [item rest]. A (set x) is the ordered tree described in Section 4.3.

Hoon prints decimal numbers with a dot between each group of three digits, so that 100.000 in a Hoon excerpt is one hundred thousand. Prose and tables in this document use plain decimal numbers. Hoon excerpts are reproduced exactly.

1.4 Authority of the Reference Implementation

The reference library [AUSPEX-LIB] is the authoritative definition of the message format and of every procedure quoted in this document. Where this document and the library disagree, the library is correct and the disagreement is a defect in this document.

2 Protocol Overview

An author composes a message by building a nine-field structure called unsigned. The message identifier is a hash of that structure. The author signs a domain-separated digest of the same hash with its networking key and attaches the signature. The result is a message.

A message that answers or forwards another message names its parent in the prev field. A thread is therefore a tree. When a ship sends a message, it sends a chain. The chain contains the new message and every stored copy of each message on the path from the thread root to the new message's parent. It contains nothing from sibling branches.

A sender first reads the recipient's published capabilities over remote scry. If the two ships share a protocol version and the chain fits the recipient's published limits, the sender pokes the chain to the recipient.

A receiver applies size limits to the incoming chain and refuses the chain whole if any limit is exceeded. It then retrieves one public key for each distinct signer named in the chain, verifies every message, and records one verdict per copy. It determines which thread the chain belongs to from the content of the chain. It merges the chain into its stored thread, discards excess copies in a defined order, and stores the result. Forged messages are stored and labelled. They are never dropped.

Attachment bytes do not travel in a chain. A message carries a descriptor with a content hash for each attachment. A ship that wants the bytes retrieves them by hash over remote scry, from any ship that holds them, and checks them against the hash.

Read state, labels, filters, drafts, and the verdicts themselves are local to each ship. They are never signed and never transmitted.

3 Versioning

3.1 The Version Is the Mark

The protocol version is the name of the wire mark. The mark %auspex-chain is version 1.

The payload contains no version field, and an implementation MUST NOT add one. A version field would be covered by the message identifier and by the signature. Changing its value would invalidate every message signed before the change.

A breaking change is published as a new mark name. An implementation that changes the format MUST publish the new format under a different mark, for example %auspex-chain-2. It MUST leave the meaning of %auspex-chain exactly as defined in this document.

3.2 Breaking Changes

A change is breaking if it alters either of the following.

Both are breaking for the same reason. The message identifier is (sham unsigned), and the signature covers (shaf %auspex (sham unsigned)). The hash runs over the noun itself, so the order of the cells is significant. A message signed against a different shape has a different identifier and a signature that verifies against nothing.

Stored messages cannot be migrated to a new shape. A rewritten message carries a signature that no longer matches its contents, and every peer would read it as %forged. Presenting genuine mail as forgery is a worse outcome than refusing it.

The following changes are not breaking, because they affect nothing that a signature covers.

3.3 Unknown Marks

A receiver MUST NOT accept a poke that carries a mark it does not implement, and it MUST NOT coerce the payload of such a poke into %auspex-chain. A noun that arrives under an unknown mark is a message for a protocol that the receiver does not speak. It is not a malformed chain. An implementation that guesses at such a payload can turn a valid message of a later version into an apparent forgery under version 1.

A receiver SHOULD nack such a poke where its transport allows. On the reference transport a poke of an unknown mark is never acknowledged at all. See Section 8.4. The discovery mechanism in Section 9 exists so that a sender does not issue such a poke.

4 Message Format

4.1 The unsigned Structure

The unsigned structure contains every field that a signature covers.

+$  unsigned
  $:  from=ship
      life=@ud
      to=(set ship)
      subj=@t
      body=@t
      body-mime=@t
      sent=@da
      prev=(unit msg-id)
      attachments=(list attachment)
  ==

As a noun, unsigned is a right-nested nine-tuple.

[from [life [to [subj [body [body-mime [sent [prev attachments]]]]]]]]

The structure has no header, no version, and no padding. The message identifier is computed over exactly this noun. The order of the nine fields is therefore part of the protocol. An implementation that builds the cell in any other order produces a different identifier and a signature that cannot be verified.

# Field Type Noun shape Meaning
1 from @p atom The ship that signed the message.
2 life @ud atom The key revision under which the signature was made.
3 to (set ship) ordered tree, or ~ when empty The visible recipients.
4 subj @t atom The subject.
5 body @t atom The message body.
6 body-mime @t atom The media type of body. The empty value means text/plain.
7 sent @da atom The time of sending, by the author's clock.
8 prev (unit msg-id) ~, or [~ id] The identifier of the parent message, or ~ for a thread root.
9 attachments (list attachment) ~, or [item rest] Attachment descriptors. Never attachment bytes.

This structure is frozen. No field may be added, removed, reordered, or retyped within version 1.

4.2 Field Semantics

from
The ship that signed. A verifier MUST retrieve the key for the pair [from life]. It MUST NOT retrieve the key of any other ship. A message whose from names a ship that did not sign it is a forgery, and the mismatch is what verification detects.
life
The key revision used to sign. It travels with the message because a signature has to remain verifiable after the author rotates keys. A message signed under life 3 stays verifiable after the author rotates to life 4, because the verifier retrieves the key for the life that the message names. One ship can therefore have several keys in use at once. A key table MUST be indexed by the pair [ship life] and never by ship alone. The value is under the control of an attacker. A modified life names a key that the verifier does not hold, and the verdict in that case is %unverified and never %forged. See Section 5.4.
to
The visible recipients. The field is signed so that a relay cannot alter the audience that a message claimed. A message MUST NOT name more than 100 recipients. The noun encoding is normative and is defined in Section 4.3. The protocol has no blind-copy field. A blind-copied recipient receives the same signed message as every other recipient, with the same identifier and the same visible recipients. Nothing about the blind copy is signed, and no commitment to it is signed.
subj
The subject. It MUST NOT exceed 1000 bytes.
body
The message body. It MUST NOT exceed 100000 bytes.
body-mime
The media type of body. The empty value means text/plain. The field is signed because the rendering instruction is part of the message. An intermediary must not be able to change whether a body is presented as plain text or as some other type. The value MUST NOT exceed 128 bytes and MUST NOT contain control bytes. See Section 4.4 for the rules that govern its use.
sent
The author's clock at the time of sending. The value is for display and for ordering within a thread, and for nothing else. An attacker controls it completely. A receiver MUST NOT derive thread identity from sent. A receiver MUST NOT reject a message because its sent value lies in the past or in the future.
prev
The identifier of the message that this message answers or forwards. The value ~ marks a thread root. The field names a message and not a thread. That is sufficient, because a message identifier is a hash of the full contents of a message. It identifies exactly one message and therefore exactly one thread.
attachments
A list of attachment descriptors, defined in Section 7.1. The list is inside unsigned, so the signature and the message identifier both cover it. Replacing an attached file invalidates the signature. A message MUST NOT carry more than 16 descriptors.

The byte length of a text field is measured as (met 3 field).

4.3 Noun Encoding of the to Field

This section is normative.

The to field is a (set ship), and the message identifier hashes the tree that represents the set. The identifier of a message with more than one recipient cannot be reproduced from a list of ships alone. It depends on the shape of the tree. An implementation that builds the tree differently computes a different identifier and produces a signature that no other implementation can verify.

A (set ship) has the following type in [HOON].

+$  nlr  $@(~ [n=ship l=nlr r=nlr])

The empty set is the atom 0. A node is the right-nested triple [n [l r]], where n is the item, l is the left subtree, and r is the right subtree.

The tree is a treap. It is ordered in two ways at once.

An implementation MUST build the tree exactly as the +put:in procedure of [HOON] does. That procedure inserts each ship in turn and rotates the tree to restore the heap property. The procedures +sy and +gas:in use the same insertion. The result is canonical. A set built from the same ships in any order of insertion is the same noun. Two ships whose users entered the same recipients in different orders therefore compute the same message identifier.

An implementation MUST NOT substitute a sorted list, a map, or a tree with any other balancing rule.

As a worked example, consider the set that contains the ships ~zod, ~nec, and ~bud.

Ship Address as an atom mug of the address
~zod 0 2046756072
~nec 1 1901865568
~bud 2 1904972904

The resulting noun is as follows.

[0 [2 [1 0 0] 0] 0]

In this noun ~zod is the root. ~bud is the left child of ~zod. ~nec is the left child of ~bud. Every right subtree is empty. The jam of this noun is 0x1.54e2.c859, which prints as 0w5.kUIxp in base 64. The jam of the empty set is 0w2.

The test case named three-recipients in [AUSPEX-VECTORS] fixes this encoding. Its to field holds these three ships. Every other signed case in the vectors names a single recipient and therefore tests nothing about the shape of the tree.

4.4 Hostile Fields

Three kinds of field are supplied by a possible attacker and arrive already signed. They are body-mime, the name of each attachment, and the mime of each attachment.

A signature shows that the author chose a value. It shows nothing about whether the value is safe to use. A recipient cannot repair a signed field without destroying the signature that makes the message evidence. The only point at which such a value can be refused is the receiving boundary, before the message is stored.

A receiver MUST refuse a chain in which any body-mime, attachment name, or attachment mime exceeds its length limit or contains a byte with a value of 0x1f or lower, or the byte 0x7f. The reference predicate is +text-ok.

++  text-ok
  |=  [t=@t m=@ud]
  ^-  ?
  ?&  (lte (met 3 t) m)
      %+  levy  (trip t)
      |=(c=@tD &((gth c 0x1f) !=(c 0x7f)))
  ==

The mime value is a candidate for an HTTP Content-Type header, where a carriage return or a line feed permits header injection. The name value is a candidate for a file name.

A renderer MUST NOT act on body-mime directly. It MUST compare the value with a fixed list of permitted types and MUST treat any other value as text/plain. It MUST NOT copy the value into a header of any kind. A renderer reports body-mime to the user. It does not obey it. The reference client renders every body as plain text and informs the user when a message requested another type.

An implementation MUST sanitize an attachment name before using it in a Content-Disposition header or in a file system path. This requirement is additional to the boundary check above.

The sent field is hostile in a different way. It is always well formed, and it may still be false. It is safe to display and unsafe as the basis of any decision.

4.5 Message Identifier

++  id
  |=  u=unsigned
  ^-  msg-id
  (sham u)

The message identifier has type @uv. It is (sham unsigned), the hash of the whole nine-tuple, with no salt and no wrapping. The identifier covers exactly what the signature covers. Two ships that hold the same message compute the same identifier without coordination.

An implementation MUST NOT compute a message identifier in any other way. An implementation MUST NOT include the signature in the computation. Two copies of one message that differ only in signature share an identifier. Section 6.4 depends on this property.

4.6 Digest

++  digest
  |=  u=unsigned
  ^-  @
  (shaf %auspex (sham u))

The digest is the value that is signed. It is (shaf %auspex (sham unsigned)). The term %auspex is a domain separation tag, passed as the salt of shaf.

An implementation MUST compute the digest with shaf and the salt %auspex. An implementation MUST NOT sign an unsalted sham, a message body, or any other preimage. The reason is given in Section 13.1.

An earlier deployment of this software was named urmail and used the salt %urmail. A message salted with %urmail is not a version 1 Auspex message.

4.7 Signed Message

+$  msg    [=unsigned sig=@ux]
+$  chain  (list msg)

A message is the cell [unsigned sig]. The signature is outside unsigned. It is covered neither by the message identifier nor by itself. This is what allows two signed copies of one message to exist and to be distinguished from each other.

5 Signing and Verification

5.1 Signing

To sign a message, an author performs the following steps.

  1. Read the ship's current life from Jael with the scry .^(@ud %j /=life=/(scot %p our)).
  2. Build the $unsigned, with from set to the author's own ship and life set to the value read in step 1.
  3. Compute the digest of the $unsigned as defined in Section 4.6.
  4. Read the ship's private networking key at that life from Jael with the scry .^(ring %j /=vein=/(scot %ud life)). Jael answers this scry only when the requester is the ship itself.
  5. Sign the digest.

The reference signing procedure is as follows.

++  sign-with
  |=  [=ring msg=@]
  ^-  @ux
  (sigh:as:(nol:nu:cric:crypto ring) msg)

The signature MUST be made with the key of the ship named in from, at the life named in life. A signature made with any other key is a forgery, and a verifier will detect it.

5.2 Verification

To verify one message, a verifier performs the following steps.

  1. Compute the digest from the message's own unsigned.
  2. Obtain the public key for the pair [from life] as described in Section 5.3.
  3. If no key is available, assign the verdict %unverified and stop.
  4. Check the signature against the digest with the key. Assign %verified if the check succeeds and %forged if it fails.

The reference verification procedure is as follows.

++  verify-with
  |=  [=pass sig=@ux msg=@]
  ^-  ?
  (safe:as:(com:nu:cric:crypto pass) sig msg)

Verification applies to a message and never to a chain. A single chain routinely yields several verdicts. A forwarded chain whose forwarder is known to the verifier and whose original author is not yields %verified for one message and %unverified for another.

An implementation MUST NOT compute a single verdict for a chain. It MUST NOT allow the verdict of one message to influence the verdict of another. It MUST NOT present a verdict for a thread as a whole. A thread that contains one unverified message is not an unverified thread.

5.3 Key Retrieval

The public key for a pair [who life] is read from Jael with the %puby scry.

.^((unit [crypto-suite=@ud =pass]) %j /=puby=/(scot %p who)/(scot %ud life))

The %puby scry returns ~ for a ship that is absent from the local Azimuth snapshot. It does not block. An implementation SHOULD use %puby. An implementation SHOULD NOT use the %deed scry, which blocks and therefore stalls the process that issues it.

The current life of a ship is read with the %life scry.

.^(@ud %j /=life=/(scot %p who))

The following transcript was recorded on a live planet on 8 September 2026.

> .^(@ud %j /=life=/~zod)
6
> .^((unit [@ud @]) %j /=puby=/~zod/6)
[~ [1 2.224.943.983…]]

Crypto suite 1 is the suite named %b in the kernel. Fake ships derive their keys under the same suite. One verification procedure therefore serves both live ships and fake ships.

Jael answers a scry only when the date in the request is exactly the current time of the ship. A request at any other date blocks. An implementation MUST issue Jael scries with the current time. It MUST NOT issue one from a context that has no current time. It MUST NOT store a Jael answer under a date and treat it as current later.

An implementation SHOULD perform one key retrieval for each distinct pair [ship life] that appears in a chain, and not one for each message. Section 6.7 bounds the number of such pairs.

5.4 Verdicts

+$  verdict  ?(%verified %unverified %forged)
Verdict Condition
%verified A key was found for [from life], and the signature is valid under that key.
%forged A key was found for [from life], and the signature is not valid under that key.
%unverified No key was found for [from life].

The reference procedure is as follows.

=/  k  (~(get by keys) [from.unsigned.m life.unsigned.m])
?~  k  %unverified
?~  u.k  %unverified
?:  (verify-with u.u.k sig.m (digest unsigned.m))
  %verified
%forged

A missing key MUST NOT produce the verdict %forged. The absence of a key is not a finding about a signature. A verifier cannot distinguish an honest ship whose key it never retrieved from a modified life field that names a key it does not hold. A verdict of forgery against a ship whose key was never available is a false accusation, and the three-verdict scheme exists to prevent it.

A receiver MUST store a %forged message and MUST present it as forged. A forged message is evidence. A receiver MUST NOT drop, hide, or filter a %forged message. A filter that could suppress a message would allow an attacker who learns a user's filter rules to conceal the evidence of a forgery. A receiver SHOULD exclude %forged messages from unread counts and from the default inbox ordering. A receiver MUST return %forged messages from search, labelled as forged.

A verdict is local state. It records how one ship read one signature against its own Azimuth snapshot at one moment. A verdict is never transmitted. See Section 10.

5.5 Moons and Comets

Under version 1, a third party cannot verify a message from a moon or from a comet. This is a property of the key infrastructure and not of the message format. Jael releases a moon's key only to the moon's parent. It resolves a comet's key only when the comet asks about itself. Mail from a moon therefore verifies on its parent and on no other ship. On every other ship, this entire class of sender receives the verdict %unverified.

This is the reason that %unverified ranks above %forged when copies are discarded (Section 6.5). Without that ranking, junk copies could displace the only genuine copy of a moon's message.

Both limitations can be removed without a change to the format. A comet's address is derived from its key, so a comet's signature can be checked with no retrieval at all. A moon's signature can be checked if an attestation from its parent travels with the chain. The signatures are already present in every chain. Both changes are additive.

5.6 Fake Ships

On a fake network, every key pair is derived from the ship address.

++  fake-core  |=(who=ship (pit:nu:cric:crypto 512 who %b ~))
++  fake-ring  |=(who=ship `ring`sec:ex:(fake-core who))
++  fake-pass  |=(who=ship `pass`pub:ex:(fake-core who))

A verifier determines whether its own ship is fake by asking Jael.

.^(? %j /=fake=)

The %puby scry has no special handling for fake ships. On a fake ship it returns ~ for nearly every peer, because a fake ship holds no Azimuth snapshot. Without further handling, every message in a development environment would receive the verdict %unverified.

A verifier MUST therefore check %fake first. When the answer is true, the verifier MUST derive the peer's public key as pub:ex:(pit:nu:cric:crypto 512 who %b ~) and MUST NOT scry %puby. The %deed scry handles fake ships in the same way.

Two consequences follow.

6 Chains and Threads

6.1 Chain

A chain is a list of messages and nothing more. It has no header, no thread identifier, and no sender field. It carries no ordering guarantee. The order of a chain is chosen by the ship that sent it, and that ship may be hostile.

A chain represents one path from a thread root to one message. It does not represent a whole thread. Threads branch whenever two participants answer the same message. A chain holds the conversation that leads to one message, which is what a recipient needs in order to verify that message in context.

6.2 Thread Identity

A thread identifier is the message identifier of the thread's root message. Two ships that hold the same conversation agree on its identifier without coordination, because the root message is identical on both.

A receiver determines the thread of an incoming chain with the procedure +thread-key, under the following rules.

  1. The identity of an established thread is immutable. If the pair [id sig] of any message in the incoming chain matches a copy that the receiver already holds in some thread, the chain belongs to that thread, whatever else the chain contains.

  2. Otherwise, the chain is a first contact, and the thread is identified by the unique root in the chain. The roots are the messages with prev equal to ~. They are counted by distinct identifier and not by message.

    =/  roots  (skim c |=(m=msg ?=(~ prev.unsigned.m)))
    =/  root-ids
      (~(gas in *(set msg-id)) (turn roots |=(m=msg (id unsigned.m))))
    ?.  =(1 ~(wyt in root-ids))  ~|(%auspex-no-unique-root !!)
    (snag 0 ~(tap in root-ids))

    Counting by identifier is required. A relay may forward the genuine root together with a copy that carries a modified signature. Both copies have prev equal to ~, because prev is part of the signed content that they share. Counting by message would reject that chain, and the chain is otherwise legitimate.

  3. A receiver MUST refuse a chain that contains no root, and a chain that contains more than one distinct root identifier. The reference procedure raises an error in both cases. A receiver MUST contain that error and treat it as a refusal. See Section 8.4.

  4. A receiver MUST NOT derive thread identity from the order of the incoming list or from any sent value. An attacker controls both.

The procedure +root of the reference library returns the first message of a chain as supplied. It is intended for a chain that the local ship built. An implementation MUST NOT apply it to an incoming chain.

6.3 Path Selection on Send

A sender MUST transmit the messages on the path from the thread root to the message being answered or forwarded, together with the new message. It MUST NOT transmit anything else.

++  path-chain
  |=  [c=chain i=msg-id]
  ^-  chain
  =/  keep=(set msg-id)  (~(gas in *(set msg-id)) (ancestors (prev-map c) i))
  (merge ~ (skim c |=(m=msg (~(has in keep) (id unsigned.m)))))

The following rules apply.

The result is a valid chain. Every prev in it resolves within it, and it contains the unique root. The recipient therefore assigns it to the same thread identifier, and every message in it verifies independently.

One case requires an addition to the path.

++  with-root
  |=  [c=chain p=chain]
  ^-  chain
  ?:  (lien p |=(m=msg ?=(~ prev.unsigned.m)))  p
  (merge p (skim c |=(m=msg ?=(~ prev.unsigned.m))))

A well-formed path already ends at a root, and +with-root leaves it unchanged. The procedure acts only on the path of an orphan (Section 6.8). Such a path contains no root, and a recipient would refuse it under rule 3 of Section 6.2. The send would appear successful to the sender and would be discarded by the recipient. The procedure adds the thread root in that case. Every participant already holds the root, so adding it discloses nothing.

A sender MUST apply to its outgoing chain the same limits that a receiver applies on arrival (Section 6.7). Every send carries the path it answers. One oversized message would therefore cause every later message on the same path to be refused by every recipient, with no indication to anyone. The moment of composition is the only point at which a person can still correct the problem.

6.4 Merging

A receiver merges an incoming chain into the chain it has stored for the same thread.

++  merge
  |=  [old=chain new=chain]
  ^-  chain
  =/  key  |=(m=msg [(id unsigned.m) sig.m])

The procedure +merge removes duplicates by the pair [id sig]. It MUST NOT remove duplicates by identifier alone. A message is a duplicate of another only when both the contents and the signature match.

The reason is an attack that is described in Section 13.3. In outline, two messages can share an identifier and carry different signatures, one valid and one not. If duplicates were detected by identifier alone, the copy that arrived first would displace the other. With the pair as the key, both copies are retained, each receives its own verdict, and the reader decides what to display.

The same pair identifies a copy wherever a copy is named.

The table that maps each message to its parent is the one deliberate exception. It is indexed by identifier, because prev is inside unsigned and every copy of one identifier therefore carries the same prev. Two copies that differ in signature are a single node of the thread tree.

The procedure also removes duplicates within the incoming chain itself. A peer may supply a chain that repeats a message, and on first contact there is no stored chain to compare against.

The merged chain is sorted by sent, then by identifier, then by signature. This is a total order. Two ships that merge the same messages produce the same list.

6.5 Pruning

A receiver holds at most 4 copies of any one message identifier. The procedure +prune enforces this bound.

++  prune
  |=  [c=chain vs=(map [msg-id @ux] verdict) max-copies=@ud]
  ^-  chain

The following requirements apply.

The general rule is as follows, and an implementation MUST follow it. A limit on incoming data is enforced by refusal. A limit on merged state is enforced by discarding. Refusing a delivery because of the state of the store penalizes a legitimate sender for excess that may consist entirely of an attacker's data.

6.6 Verdict Persistence

The procedure +freeze combines the verdicts from a new delivery with the verdicts already stored.

?:  ?=(?(%verified %forged) (~(gut by acc) -.i.new %unverified))
  $(new t.new)

The verdicts %verified and %forged are definitive for a given pair [id sig]. The digest and the key are both fixed by the pair, so the two verdicts can never contradict each other. A stored %verified or %forged verdict MUST NOT be overwritten.

The verdict %unverified is not a finding about the signature. It records only that the key was absent at one moment. A stored %unverified verdict MUST remain replaceable by the verdict of a later delivery that arrives after the key has become available.

6.7 Limits

A receiver MUST enforce each of the following limits on an incoming chain. A receiver MUST refuse a chain that exceeds a limit. It MUST NOT truncate the chain or any field in it. A chain that violates a limit is not partly trustworthy, and an altered signed field is a forgery.

Name Value Bounds
max-chain 1000 messages in one chain, and distinct identifiers in one thread
max-body 100000 bytes in one body
max-subj 1000 bytes in one subject
max-to 100 recipients of one message
max-mime 128 bytes in body-mime, and in an attachment mime
max-attach 16 attachments on one message
max-blob 262144 claimed size in bytes of one attachment
max-name 256 bytes in an attachment name
max-depth 64 ancestors between a root and a leaf
max-signers 128 distinct pairs [ship life] in one chain
max-copies 4 signed copies of one identifier. Enforced by discarding (Section 6.5).
max-threads 10000 distinct threads held by one ship. Applies to new threads only (Section 6.9).

The values of two limits were chosen for reasons that an implementer would not otherwise see.

The value of max-depth reflects a cost on the read path. The reference implementation stores a message under its ancestry. The storage key of a message has one segment of about 34 bytes for each ancestor, and the keys are rebuilt whenever the mail tree is read. The cost is quadratic in depth. With max-chain as the only bound, one hostile linear chain could hold a thread at depth 1000 permanently. In measurement, 200 messages at depth 200 cost about 1.8 times as much as 200 messages at depth 2. The limit is checked on the incoming chain and again on the merged chain, because two chains that are each within the limit can combine to exceed it. The cost of the limit is that a thread deeper than 64 messages accepts no further replies.

The limit max-signers is the only limit that bounds work performed outside the receiving application. Every other limit bounds bytes or nodes. This limit bounds key retrievals, because a key costs one scry for each distinct pair [ship life]. Without the limit, one junk chain could name a thousand signers and cause a thousand sequential retrievals and a thousand signature verifications [RFC8032]. A forged signature costs exactly as much to check as a valid one. The value was chosen with reference to max-to. One message may name 100 recipients, so a thread in which every recipient replies once has 100 signers and is legitimate. The value 128 accommodates that case and leaves room for key rotations, which cause one ship to appear as two signers.

A receiver's limits on its own attachment store are not part of the protocol. The reference implementation holds at most 1000 blobs and at most 33554432 bytes of blobs. Attachment bytes enter a ship only through a local action and never through a delivered chain.

6.8 Orphans and Cycles

An orphan is a message whose prev names an identifier that is not present in the chain or in the stored thread. The ancestry walk stops at an orphan. A receiver MUST store an orphan and place it as a root of its own subtree. It MUST NOT drop it. Only hostile input produces an orphan, because a forwarded path is complete by construction and pruning never discards the last copy of an identifier. Refusing to store a message is nevertheless a worse outcome than filing it at the wrong depth. When the parent of an orphan later arrives, the orphan takes its place beneath the parent.

A cycle of prev values would require a hash preimage loop and does not occur in practice. The ancestry walk operates on input supplied by an attacker all the same. An implementation MUST bound the walk so that it terminates. The reference implementation bounds it by the number of distinct identifiers in the chain.

The placement of orphans does not relax rule 3 of Section 6.2. A chain that contains no root at all is refused, because there is nothing from which to derive a thread identity.

6.9 Receiver Processing

A receiver MUST process an incoming chain in the order given in this section. Limits are checked first, then signatures are verified, then the thread is identified, then the chain is merged, pruned, and stored. Nothing is written until every signature in the incoming chain has a verdict.

  1. Decode. Interpret the payload as a $chain. A payload that is not a well-formed chain MUST be refused without an error escaping the handler. See Section 8.4. An empty chain requires no action.

  2. Check the limits on the incoming chain. Check max-chain, max-body, max-subj, max-to, max-attach together with the attachment descriptor limits, max-mime, max-depth, and max-signers, in that order. A chain that exceeds any limit is refused whole. The max-signers check comes last among the limits and before any key retrieval, because key retrieval is the cost that it bounds.

  3. Build the key table. Perform one retrieval for each distinct pair [ship life] in the chain. Check whether the local ship is fake once, and retrieve keys as described in Sections 5.3 and 5.6.

  4. Verify every message. Produce one verdict for each pair [id sig].

  5. Identify the thread as described in Section 6.2. A chain without a unique root is refused at this step.

  6. Merge the incoming chain with the stored chain of that thread, as described in Section 6.4.

  7. Check the limits on the merged state.

  8. Combine the verdicts, then prune. Verdicts are combined first (Section 6.6), because pruning needs a verdict for every message in the merged chain. That includes messages stored by an earlier delivery and absent from this one. The merged chain is then pruned (Section 6.5).

  9. Store the pruned chain. Each copy is stored under its pair [id sig] together with its verdict. A delivery that results in no write MUST be treated as no change.

  10. Apply local filing. New mail returns an archived thread to the inbox. The receiver's own filters may then add labels and may archive the thread again. Nothing in this step can suppress a message, because the chain and its verdicts are already stored.

  11. Signal the change. An implementation that notifies its clients of new mail MUST do so only if stored state changed. A notification for a refused delivery, or for a repeated delivery that wrote nothing, would allow any remote ship to impose the cost of a full refresh on every connected client.

In the reference implementation, every refusal in this procedure is a normal return that records a reason in a local log. The poke is acknowledged and the chain is discarded. Only a payload that cannot be decoded, or a failure of the transport, produces a nack. A sender therefore cannot distinguish a refused chain from an accepted one. An implementation MAY nack a refusal instead. An implementation MUST NOT terminate abnormally on a refusal. See Section 8.4.

7 Attachments

7.1 Attachment Descriptor

+$  attachment
  $:  name=@t          ::  original filename
      size=@ud         ::  bytes
      mime=@t          ::  content type
      hash=@uv         ::  (sham octs) over the contents
  ==

An attachment descriptor is the right-nested four-tuple [name [size [mime hash]]]. Descriptors are held in the attachments field of unsigned. They are therefore covered by the signature and by the message identifier. A descriptor adds about 100 bytes to a message whatever the size of the file. A chain is transmitted whole on every send, and for that reason the bytes of an attachment are never part of a chain.

7.2 Content Address

++  blob-hash
  |=  =octs
  ^-  @uv
  (sham octs)

The hash field is (sham octs), where octs is the cell [length bytes]. The hash is computed over that cell and not over the byte atom alone. An atom does not record leading zero bytes, so two files that differ only in leading zeros are the same atom. The octs cell carries the length, and the two files therefore have different addresses. The construction also binds the size to the address. A false statement of the size is a false statement of the address.

A blob is acceptable if and only if it hashes to the address under which it was requested.

++  blob-ok
  |=  [=octs h=@uv]
  ^-  ?
  =(h (blob-hash octs))

7.3 Attachment Limits

The limits max-blob, max-attach, max-name, and max-mime of Section 6.7 apply to descriptors. The reference predicate for one descriptor is as follows.

++  attach-ok
  |=  a=attachment
  ^-  ?
  ?&  (lte size.a max-blob)
      (text-ok name.a max-name)
      (text-ok mime.a max-mime)
  ==

The value of max-blob is 262144 bytes. A blob is served over remote scry, which divides a response into network packets and has no means of resuming a partial transfer. The value keeps a single retrieval small.

A receiver MUST NOT treat the size field as evidence of the size of the bytes. The field is checked against max-blob on arrival because refusing an oversized claim at the boundary costs less than discovering it during retrieval. The hash is the authority. A receiver measures and hashes the bytes on arrival, whatever size stated.

7.4 Retrieval

Attachment bytes are never pushed to a recipient. A receiver stores a chain as soon as it arrives. A ship that wants an attachment it does not hold retrieves the bytes on demand, by hash.

Retrieval MUST be the result of an explicit local action. Delivery of a chain MUST NOT cause retrieval. A chain from an unknown ship that names many attachments must not cause the receiving ship to issue many requests.

Retrieval uses a keen. Remote scry is the only read channel on the network that requires no permission. Other read mechanisms require a prior grant, and between ships with no grant they stall and do not fail. The asymmetry suits this protocol. Any ship that holds the bytes can serve them, and the hash proves them.

A ship that holds a blob publishes it in the namespace of its hosting agent at the following path.

++  blob-spur
  |=  h=@uv
  ^-  path
  /auspex/blob/[(scot %uv h)]

A retrieving ship requests the following path, which MUST correspond exactly to the published path.

++  blob-keen-path
  |=  [agent=@ta h=@uv case=@ud]
  ^-  path
  %+  weld  `path`[%g %x (scot %ud case) agent %$ %'1' ~]
  (blob-spur h)

The request path has the form /g/x/<case>/<agent>//1/auspex/blob/<hash>. Its segments are as follows.

g, x
The Gall vane, and the read mode that returns a value.
<case>
The revision of the published path. A path that has been published once and never withdrawn is bound at case 1.
<agent>
The Gall agent whose namespace is read. In the reference implementation this is %grubbery, the hosting agent, and not the nexus. An implementation hosted by another agent names that agent.
The empty segment
Required. Gall routes a request path to the published namespace only when this segment is empty. Any other value routes the request to the agent's private read handler. A Hoon path literal cannot express an empty segment. The path MUST be constructed from its segments.
1
The namespace version marker that Gall requires.

The path has no segment that identifies a revision of the content. The bytes of a blob are fixed by its name, so no mechanism for discovering revisions is needed. Such mechanisms require permission.

The response is a pair of a mark and a noun. The mark MUST be %auspex-blob and the noun MUST be an $octs. A retrieving ship MUST discard any other response.

Case 1 serves every blob that has never been withdrawn. Withdrawing a published path consumes its case number permanently. A blob that is restricted (Section 7.5) and later published again is bound at case 2. The reference implementation requests cases 1 through 3 in turn. Each miss costs one timeout, and only a blob that has been restricted incurs that cost.

A publisher MUST NOT publish to a path without first establishing that the path is unbound. Publishing to a bound path binds the next case number. A publisher that republished on every user request would move the binding beyond the range that peers probe. The attachment would then be unavailable to every peer, permanently, and no error would be reported to anyone.

A retrieving ship MUST validate a response before accepting it. The reference implementation applies the following checks in order, and discards the response at the first failure.

?~  res.b                              → discard: the fetch missed
?.  (lte p.u.res.b max-blob:uc)        → discard: 'blob too large'
?.  (gte p.u.res.b (met 3 q.u.res.b))  → discard: 'blob malformed'
?.  (blob-ok:uc u.res.b hash.b)        → discard: 'blob hash mismatch'

A blob whose contents do not hash to the requested address MUST be discarded. The failure MUST NOT be attributed to the author of the message. The ship named in a retrieval request is only an indication of where the bytes may be found, and it confers nothing. The hash proves the bytes. The blob store is a cache. The loss of a blob is the loss of a file, and never the loss of a message or a signature.

A ship that accepts a blob publishes it in turn, because it is now one of the ships that hold the bytes.

7.5 Restriction

A blob is public by default. A chain can be forwarded to anyone, and an attachment readable only by the original recipients would make every forward carry a file that its recipient cannot read.

A ship may restrict a blob. Restriction withdraws the ship's own copy from its published namespace. Every ship that retrieves a blob republishes it, so the first successful retrieval creates a second source that the first ship cannot withdraw.

Restriction therefore provides exactly one guarantee. Bytes that a ship has not yet served can no longer be obtained from that ship. An implementation MUST present restriction to the user as unpublishing. It MUST NOT present restriction as revocation or as access control. The property that makes a chain portable also makes a blob impossible to recall, and the design accepts this consequence of using the hash as the authority.

The visibility of a blob is local state and is never transmitted. See Section 10.

The HTTP interface through which the reference client uploads and downloads blobs is outside the scope of this document.

8 Transport

8.1 Delivery

A sender delivers a chain by poking the recipient's Auspex nexus with the mark %auspex-chain and the chain as the payload noun. The poke is carried by the command interface of the Grubbery agent, whose mark is %grub-cmd. A conformant peer runs Grubbery with the Auspex nexus installed. Version 1 defines no other endpoint.

Within the nexus, the chain is addressed to the grub named main.sig at the nexus root, which is the writer process of the nexus. The Grubbery message type for the delivery is the following pair.

[/ %auspex-chain]

The type is a top-level mark whose name begins with auspex-. It is not a mark beneath a path prefix. The command interface reduces a message type to a bare mark name, and a dojo poke also names a bare mark. A type of the form [/auspex %chain] cannot be addressed from another ship, and a delivery that uses it is not received. The auspex- prefix prevents a top-level name in a shared tree from colliding with a name that belongs to Grubbery.

To deliver a chain, a sending agent pokes the recipient's %grubbery agent with the mark %grub-cmd and the following noun.

:-  chan=@ta                          ::  the sender's return channel
:^  %poke
  path=<the recipient's nexus root>
  name=%'main.sig'                    ::  the writer grub
:-  mark=%auspex-chain
noun=<the chain>

The sender first subscribes to the path /client/<chan> on the same agent. The outcome of the poke arrives on that subscription as a %grub-fact that carries [%ack err=(unit tang)].

The recipient's nexus root is a path in the recipient's own tree. The reference implementation uses the following conventional installation path.

/apps/'shell.shell'/desks/'auspex.desk'/desk/data/'auspex.auspex_app'

This path is a convention and not a guarantee. The recipient chooses the name of the desk at installation, and delivery to a ship that installed the nexus at another path fails. Nothing is delivered to the wrong place in that case, because a poke to a path that does not exist is refused. It is expected that a later revision of the transport will resolve the recipient's nexus root through a published alias. Such a change affects the transport only and does not affect the message format.

Within Grubbery, the reference sender reaches the remote writer by rewriting the absolute path of the writer into the recipient's subtree, at /sys/ames/ships/<ship>/root/<path>.

8.2 Open Delivery

Delivery is open to every ship. The nexus grants the right to poke its writer to the public. A receiver MUST NOT require that the delivering ship be a participant in the thread, and MUST NOT require that it be the author of any message in the chain. The signatures are the authority, and the courier is irrelevant. This is what makes a chain portable.

The grant applies to the writer as a whole and not to a single mark. A peer that can reach the writer can also address to it the marks that are reserved for the local user. A receiver MUST refuse a locally reserved action unless the poke originates from the receiver's own ship. The receiver MUST determine the origin from the transport. It MUST NOT determine the origin from the payload.

8.3 Conformance Levels

An implementation can conform at two independent levels.

Format conformance
Sections 4 through 7. An implementation that can build the nine-field noun, hash it, sign it, and verify it conforms to the format. The test vectors of Section 11 are the test.
Transport conformance
Sections 8 and 9. A sender conforms if it can issue the %grub-cmd poke of Section 8.1 and follows the procedure of Section 9.4. A receiver conforms if it is a Grubbery nexus that accepts %auspex-chain at its writer and publishes its capabilities as required by Section 9.1.

A ship that does not run the nexus cannot receive Auspex mail under version 1. Nothing in the format depends on Grubbery. No Grubbery type is transmitted, and the payload is a bare list of messages.

8.4 Acknowledgement

The handler that receives deliveries MUST NOT terminate abnormally on any input. Every refusal is a normal return. In the reference implementation, the one procedure that can raise an error on hostile input is +thread-key, and it is invoked within an error trap.

A payload that is not a well-formed chain MUST be refused by a normal return. For this reason the wire marks of the reference implementation accept any noun, and the payload is checked against the chain type inside the handler, within an error trap. Appendix B records why a typed wire mark is unsafe on the reference transport.

As a consequence, a delivery that is refused because of a limit, a missing root, or a full store is acknowledged, and the reason is recorded locally. Only a transport failure or an undecodable payload is visible to the sender. The protocol provides no delivery receipt. A send that the network cannot deliver fails without any indication to the sender. This applies to remote delivery only. A send that the sender's own ship refuses is refused locally, where the user can see the refusal.

A sender MUST bound its wait for each recipient. The reference implementation waits 20 seconds. An unreachable recipient MUST NOT block the sender, and a nack from any peer MUST NOT cause the sender to fail. Section 9.5 defines how a sender interprets a poke that is not acknowledged.

9 Discovery

On the reference transport, a poke of a mark that the recipient does not implement is never acknowledged. The sender observes a timeout. A recipient that is offline produces the same observation, and so does a recipient that runs an incompatible version. The three conditions cannot be distinguished, and none of them tells the sender what to do.

Each nexus therefore publishes the versions it speaks, and a sender reads that publication before it pokes.

9.1 Published Capabilities

Every nexus MUST publish one noun of the following type.

+$  proto-caps
  $:  max-blob=@ud
      max-attach=@ud
      max-chain=@ud
      max-body=@ud
      max-subj=@ud
      max-to=@ud
      max-depth=@ud
      max-signers=@ud
      max-mime=@ud
      max-name=@ud
  ==
::
+$  proto  [%auspex versions=(list @ud) marks=(list @tas) caps=proto-caps]

As a noun, this is [%auspex [versions [marks caps]]], where caps is a right-nested ten-tuple of atoms in the order shown. A version 1 nexus publishes the following value.

[%auspex ~[1] ~[%auspex-chain] [262.144 16 1.000 100.000 1.000 100 64 128 128 256]]

The following requirements apply.

The mark under which the reference implementation stores this noun accepts any noun. A typed mark would validate the stored value against the current type on every read, and adding a field to $proto would then make the stored publication unreadable.

9.2 Retrieval

The noun is published in the namespace of the hosting agent at the following path.

++  proto-spur  ^-(path /auspex/proto)

A reader requests the following path.

++  proto-keen-path
  |=  [agent=@ta case=@ud]
  ^-  path
  %+  weld  `path`[%g %x (scot %ud case) agent %$ %'1' ~]
  proto-spur

The request path has the form /g/x/<case>/<agent>//1/auspex/proto. Each segment has the meaning given in Section 7.4. The requirement concerning the empty segment applies here also. The mark of the response MUST be %auspex-proto. A reader MUST discard any other response.

This is the same permissionless read by which attachment bytes are retrieved, and it is used for the same reason. A keen is the only channel that answers a peer that holds no grant.

A reader SHOULD request cases 1 through 8 in turn, with a deadline of 4 seconds for each, and SHOULD accept the first case that answers. This range is wider than the range used for blobs, and the deadline is shorter. A blob never changes, so its binding moves only when it is restricted. A capability publication moves by one case whenever a ship changes its versions or its limits, which is an ordinary event for a deployed protocol. A namespace read is answered from a cache or by the publisher's kernel, with no application involved. A read that is slow is therefore unlikely to arrive at all. A complete miss costs 32 seconds, which is close to the 30 seconds that a complete miss of a blob costs. That total delays queued mail on first contact, and the total was the quantity held fixed when these numbers were chosen.

9.3 Publication and Republication

A publisher MUST NOT publish unconditionally. Publishing to a bound path binds the next case number. A publisher that published on every deployment would move the binding beyond the range that readers probe. The publication would then be unreadable by every peer, permanently, and no error would be reported.

A publisher reads back the value that is currently bound, through the same request that a peer would issue, and then acts as follows.

Bound value Action
Nothing is bound. Publish. The value binds at case 1.
A value is bound and equals the value to be published. Do nothing. A deployment that changes nothing consumes no case.
A value is bound and differs from the value to be published. Withdraw the bound value, then publish. A reader accepts the first case that answers, counting up from 1. If the old value were left bound at a lower case, it would answer every reader, and the new value would never be read.
A value is bound and cannot be read. Do nothing, and report the condition to the operator. Publishing after a failed read consumes a case and gains no information.

The result is one new case for each change of protocol capabilities and not one for each deployment. The period during which the published limits and the enforced limits can differ is one deployment.

9.4 Sender Procedure

A sender MUST NOT poke a recipient for which it has no discovery result. If a sender transmitted version 1 while its discovery read was still outstanding, outcome 2 below could never occur on first contact. First contact is the send that is most likely to reach a ship that runs another version. The chain waits until the result arrives.

A sender obtains the recipient's $proto and then proceeds according to the first of the following outcomes that applies.

  1. The recipient publishes a version that the sender also implements. The sender pokes the mark for the highest common version. It MUST select the highest common version and not the first one listed. The recipient chooses the order of its list, and a sender that took the first common entry would be bound by that order.

  2. The recipient publishes no version that the sender implements. The sender MUST NOT poke. The send to this recipient fails with a message of the following form.

    no common protocol version: ~ship speaks <their versions>, this ship speaks <ours>
  3. The send exceeds a limit that the recipient publishes. The sender MUST NOT poke. The error message names the recipient's number, as in the following example.

    ~ship accepts at most N attachments

    The same applies to the size of an attachment, the length of the chain, the number of recipients, the subject, the body, the body media type, the depth, and the number of distinct signers. The check MUST use the limits that the recipient published. It MUST NOT use the sender's own limits.

  4. The discovery read returned nothing usable. This covers a timeout, an absent publication, a response with the wrong mark, and a noun that is not a well-formed $proto. The sender makes one attempt under version 1, because an absent publication denotes version 1 (Section 9.8). The sender SHOULD report the condition before the poke and not after a failure, with a message of the following form.

    ~ship has not answered discovery; sent as version 1

    A sender MUST reach this outcome only from the empty result of a discovery read that it actually performed. It MUST NOT reach this outcome because a cache entry is absent. The two conditions are different findings, and a sender that conflates them reports a failure that it did not observe.

The discovery read MUST NOT be performed on the process that serializes the ship's writes. A keen is a network round trip, and a keen that times out may still deliver a late response to the process that issued it. The reference implementation consults its cache on the writer. For a recipient with no fresh result, it queues the chain and starts a short-lived process. That process performs the read, applies the four outcomes above, and then pokes or refuses.

Two sends to the same unknown recipient that are issued before the discovery result arrives MUST both be delivered, in the order in which they were written. A queue that released its contents in reverse order would deliver a reply before the message it answers. Every recipient files by prev in any case, so nothing would report the error.

Two of the published limits cannot be checked before a message is signed. A sender that screens a message at composition works from the single message, while max-chain and max-depth describe the whole path that the message joins. These two limits can be checked only at the time of sending. The limit max-name is published for completeness. The receiver enforces it at its boundary, and a sender need not screen for it. The limit max-threads is a property of the receiver's store, and no chain can be measured against it.

9.5 Unacknowledged Pokes

A poke that is not acknowledged is not a failed send, and a sender MUST NOT report it as one. On the reference transport, the acknowledgement of a delivery poke does not return to the sending process at all. The sender's deadline therefore expires on every successful send. In repeated measurement between two live ships, every send between the ships timed out at the sender. Every one of them also arrived, was verified, and was stored at the receiver.

A sender MUST NOT treat a send timeout as evidence about the recipient. A sender MUST NOT discard its cached discovery result because of a timeout. A sender SHOULD NOT present a timeout to the user. It is sufficient to record the timeout for an operator, in a form such as the following.

~ship did not ack within 20s; it may still arrive

Discovery is the indication that a peer is alive, and the acknowledgement is not. A recipient that answered discovery did so recently, over a channel that returns a response. If a recipient did not answer discovery, the sender reported that fact before it poked, under outcome 4. In neither case does the timeout add information.

An explicit nack is different in kind. It is a statement by the recipient. A sender reports a nack, and a sender MUST discard its cached discovery result for a recipient that nacks, so that the next send reads the publication again.

9.6 Caching

A sender SHOULD cache the discovery result for each recipient. Two lifetimes apply.

Record Lifetime Reason
A record that permits sending. This is a record with a common version, or a recorded absence of a publication. 1 day The send itself tests the record. A nack discards the record, so an incorrect record is corrected at its first use.
A record that refuses sending, because no version is shared. 1 hour Nothing tests the record, because no poke is sent. A record that is never tested is given a shorter lifetime.

A sender SHOULD record a failed discovery read as such and SHOULD NOT simply leave the cache empty. A recipient that runs a build without discovery then costs one read per day and not one read per send.

A refusal under outcome 3 depends on the message as well as on the record. It cannot be derived from the record alone, and the record keeps the ordinary lifetime. An implementation SHOULD provide a means to discard a cached record on request. Discarding a record MUST NOT discard mail that is queued for the same recipient.

9.7 Per-Recipient Refusal

A refusal under Section 9.4 applies only to the recipient that it names. A refusal MUST NOT prevent delivery to the other recipients of the same message. One recipient that published a max-chain of 0 could otherwise block a send to a hundred recipients.

The to field MUST NOT be altered to remove a refused recipient. The field is signed and names the audience that the author chose. Delivery omits the refused recipient. The message does not.

9.8 Backward Compatibility

A peer that publishes no capabilities is treated as a version 1 peer. Auspex was deployed before discovery was defined, so the absence of a publication has exactly one possible meaning. The limits of such a peer are the values in Section 6.7. A send to such a peer is still checked against those limits.

This rule makes discovery an additive change. A sender that implements discovery and a receiver that does not implement it interoperate without modification, in both directions.

A receiver accepts every mark that it lists in marks and no other mark. Discovery changes nothing else about a receiver.

10 Local State

Nothing local is ever signed, and nothing signed is ever local.

Signed and transmitted Local and never transmitted
from, life, to, subj, body, body-mime, sent, prev, attachments read state, labels, archive state, drafts, filters, mailing lists, blob visibility, blob arrival time, inbox order, the record of blind copies, verdicts, the discovery cache

An implementation MUST NOT infer any item in the right-hand column from a chain. It MUST NOT serialize any such item into a message. It MUST NOT treat a disagreement between two ships about such an item as an error. Two ships may disagree about every item in the right-hand column. They cannot disagree about who signed what. This separation is what permits the unsigned structure to be frozen.

The following specific rules apply.

11 Conformance

11.1 Test Vectors

The file protocol/vectors/v1.json in [AUSPEX-VECTORS] contains deterministic test cases that a second implementation is expected to reproduce byte for byte. Every ship named in the file is a fake ship. The key pair of a fake ship is derived from its address (Section 5.6), so the cases can be reproduced anywhere, with no network and no Azimuth snapshot.

The file is a JSON object of the following form.

{ "version": 1,
  "digest_tag": "auspex",
  "caps":  { … every limit, as a number … },
  "cases": [ … ] }

Each case has the following members.

Member Meaning
name The identifier of the case.
kind One of message, chain, or cap.
unsigned The nine fields, in readable form.
jam (jam unsigned), printed as @uw.
msg_id (sham unsigned), printed as @ux.
digest (shaf %auspex (sham unsigned)), printed as @ux.
sig The signature, printed as @ux.
signer The ship whose key produced the signature. Where this differs from from, the case is a forgery.
verdict The verdict that a verifier MUST produce when it holds the fake keys of the ships listed in the case as known.

Version 1 of the file contains the following cases: root, reply, two-branch-tree, forged-copy, same-id-pair, unverifiable-life, orphan-chain, three-recipients, two-attachments, cap-max-chain, cap-max-body, cap-max-subj, cap-max-to, cap-max-mime-length, cap-mime-control-byte, cap-max-attach, cap-max-blob, cap-max-depth, and cap-max-signers.

Each case of kind cap gives the jam of the unsigned of the offending message and the length of the offending chain. Where that value would be larger than the rest of the file, the case gives a sample_recipe that states exactly how to construct the input.

The file has two further top-level members.

set_noun
The three ships of the three-recipients case with the mug of each, the jam of the set that they form, and the jam of the empty set. This member makes the encoding of Section 4.3 testable.
proto
The noun that a version 1 nexus publishes (Section 9.1), given as its jam in @uw and in @ux. The member also gives the published path, the response mark, the request path at case 1 with the empty segment written as //, the limits, the cache lifetime, and the compatibility rule of Section 9.8 as silent_peer_is_version: 1. The $proto noun is the only noun in this protocol that crosses the network without being hashed. Its bytes are therefore the contract. A field added to it, or a reordering of $proto-caps, would change those bytes without changing any identifier or digest.

11.2 Conformance Criteria

An implementation conforms to the format if it satisfies both of the following.

An implementer SHOULD compare jam first. The jam is the whole unsigned noun. A difference in jam isolates a failure to the order or the type of a field, before any hashing or cryptography is involved.

11.3 Regenerating the Vectors

The vectors are produced from the reference library by the generator gen/auspex-vectors.hoon in [AUSPEX-VECTORS]. The generator writes the JSON document into the ship's file system, and the published file is a byte-for-byte copy of that output.

The reference test tests/lib/auspex-vectors.hoon reads the published file itself. It asserts that the library reproduces every value in the file, including the descriptive strings. A test that generates its own expected values proves nothing about the file that an implementer downloads, and an earlier revision of the vectors lost two characters in manual transcription while such a test continued to pass. A change to a single character of the published file now fails the test.

12 Implementation Status

This section records the status of known implementations at the time of publication, in the manner described in [RFC7942].

There is one implementation. It is the reference implementation at https://github.com/nisfeb/auspex, and its author is the author of this document. It consists of a Grubbery nexus, a web client, a desktop client, and an extension for a third-party mail client. It implements every part of this document. At the time of publication, its test suites held 89 tests of the chain library, 48 tests of the client interface, and 15 tests of conformance to the vectors.

Two limits on the evidence are stated here because they bear on interoperability.

No independent implementation is known. A report of one is welcome at the address given in the Status of This Memo.

13 Security Considerations

The protocol assumes that any party may deliver any noun to a receiver and may read anything that a ship publishes. It assumes that the author of a message controls every field of that message, signed or not. The considerations below follow from these assumptions.

13.1 Key Reuse and Domain Separation

The key that signs Auspex messages is the ship's networking key. The same key authenticates network packets and signs attestations. A signature made with that key over bytes that an attacker chose could be presented in another context if the two contexts shared a space of preimages. The %auspex salt in the digest (Section 4.6) keeps the space of Auspex preimages disjoint from every other use of the key. An Auspex signature cannot be replayed in another context, and a signature from another context cannot be presented as an Auspex signature. The salt is a requirement and not an option for this reason.

13.2 Signed but Untrusted Fields

A valid signature establishes authorship. It does not establish that a value is safe to use. The fields discussed in Section 4.4 can carry header injection and path traversal payloads under a valid signature. Because a signed field cannot be repaired, the receiving boundary is the only place where such a value can be refused. The sent field cannot be refused at all. An implementation that uses sent for any decision other than display order gives the author of a message control over that decision.

13.3 Signature Shadowing

Two messages can share an identifier and carry different signatures, one valid and one invalid. Suppose that an implementation detected duplicates by identifier alone. A malicious forwarder could then deliver the invalid copy first. The genuine copy would later be discarded as a duplicate. The recipient would hold a %forged message attributed to an innocent ship and would never see the genuine one. The forwarder would have framed the author as a forger.

The pair [id sig] (Section 6.4) defeats this attack in merging. The same attack can be attempted at the level of stored state, by recording one verdict per identifier, and by exhausting storage. For this reason verdicts are recorded per pair, and the ranked discarding of Section 6.5 never removes a %verified copy in favour of a copy of lower rank.

13.4 Rejection as a Censorship Primitive

Suppose that the per-identifier copy bound were enforced by refusing deliveries. An attacker could deliver the maximum number of forged copies of a thread's root to a ship that had never seen the thread. This would create the thread under its genuine identifier, which is derived from content, filled with junk. When the genuine chain arrived later from any participant, the count would exceed the bound and the chain would be refused. Every send carries its whole path. Every later message in that thread would therefore also be refused, permanently, and the attack would cost a few messages with junk signatures. Section 6.5 requires discarding for this reason.

The bound on distinct identifiers in a thread is still enforced by refusal. It remains open to a related attack, which is described in Section 13.9.

13.5 Thread Identity Attacks

If thread identity were derived from the order of a chain or from sent values, a single delivery could duplicate an existing conversation under a new identifier. It could also move an established conversation to an identifier of the attacker's choice. Rule 1 of Section 6.2 prevents the second attack. A delivery that combines one message the receiver already holds with a new root message is filed in the established thread.

13.6 Resource Exhaustion

Delivery is open, and every limit in Section 6.7 therefore bounds work that an anonymous peer can impose. Three points deserve attention.

In the same way, the list bound of Section 9.1 is required to be checked before the lists are traversed, because the publication comes from an unknown party.

13.7 Open Delivery

The identity of the delivering ship has no meaning in this protocol, and an implementation MUST NOT assign it one. Two checks depend on information from the transport. The check of the origin of a locally reserved action (Section 8.2) is one, and any rate limiting applied to a source ship is the other. Both MUST use the origin that the transport reports. Neither may use any field of the payload.

13.8 False Accusation

The verdict %forged is an accusation against a named ship. The separation of %unverified from %forged in Section 5.4 ensures that the accusation is made only when a key was actually held and the signature actually failed against it. A modified life field can prevent a message from being verified. It cannot make a genuine message read as forged.

13.9 Known Limitations

The following weaknesses are known and are not addressed in version 1.

14 Privacy Considerations

This section follows the guidance of [RFC6973].

Disclosure by forwarding. A chain is designed to be forwarded, and everything in it is signed, permanent, and attributable to its author. Section 6.3 limits a send to a single path for this reason. If a sender transmitted a whole thread, a side conversation between two participants on one branch would be disclosed to a third party when a message on a different branch was forwarded. The third party would receive that side conversation with signatures attached.

Recipients. The to field is signed and is visible to every recipient, including every later recipient of a forward. A blind copy is not recorded in the message in any form, and the protocol carries no commitment to it.

Discovery. The capability publication of Section 9.1 is readable at a fixed path by any ship on the network. The read is not logged, and the publisher cannot refuse it. A reader learns that the ship runs Auspex. It also learns a version list and ten numbers, which together may identify a software build. This disclosure is accepted. A sender needs the information before its first poke to a ship with which it has no prior relationship, and a keen is the only channel that serves such a sender. Restricting the read would require a grant, and a grant presupposes the relationship that first contact lacks. The publication contains nothing that is specific to a user and nothing about mail. The same information can be inferred by any party that is willing to send the ship a message.

Attachments. A blob is published by content hash. A party that knows the hash can retrieve the bytes from any ship that publishes them. Restriction (Section 7.5) withdraws one ship's copy and cannot recall copies that other ships have already retrieved.

Metadata. This protocol does not conceal from the network the fact that two ships communicate. It relies on the confidentiality that the underlying transport provides between the two ships. A relaying courier sees the whole chain that it carries.

15 IANA Considerations

This document has no IANA actions.

16 References

16.1 Normative References

[AUSPEX-LIB]
Egan, J., "auspex-chain.hoon: the Auspex chain library", https://github.com/nisfeb/auspex/blob/master/code/lib/auspex-chain.hoon.
[AUSPEX-VECTORS]
Egan, J., "Auspex version 1 conformance vectors", https://github.com/nisfeb/auspex/blob/master/protocol/vectors/v1.json.
[HOON]
Urbit, "hoon.hoon: the Hoon standard library", https://github.com/urbit/urbit/blob/develop/pkg/arvo/sys/hoon.hoon.
[RFC2119]
Bradner, S., "Key words for use in RFCs to Indicate Requirement Levels", BCP 14, RFC 2119, DOI 10.17487/RFC2119, March 1997, https://www.rfc-editor.org/info/rfc2119.
[RFC8174]
Leiba, B., "Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words", BCP 14, RFC 8174, DOI 10.17487/RFC8174, May 2017, https://www.rfc-editor.org/info/rfc8174.

16.2 Informative References

[GROUNDWIRE]
"Groundwire", http://groundwire.io.
[GRUBBERY]
"Grubbery", the application runtime for Urbit that hosts the reference implementation of this protocol.
[RFC6973]
Cooper, A., Tschofenig, H., Aboba, B., Peterson, J., Morris, J., Hansen, M., and R. Smith, "Privacy Considerations for Internet Protocols", RFC 6973, DOI 10.17487/RFC6973, July 2013, https://www.rfc-editor.org/info/rfc6973.
[RFC7942]
Sheffer, Y. and A. Farrel, "Improving Awareness of Running Code: The Implementation Status Section", BCP 205, RFC 7942, DOI 10.17487/RFC7942, July 2016, https://www.rfc-editor.org/info/rfc7942.
[RFC8032]
Josefsson, S. and I. Liusvaara, "Edwards-Curve Digital Signature Algorithm (EdDSA)", RFC 8032, DOI 10.17487/RFC8032, January 2017, https://www.rfc-editor.org/info/rfc8032.
[URBIT]
Urbit, "Urbit developer documentation", https://docs.urbit.org.

Appendix A. Rules and Reference Tests

The table below pairs rules of this document with the tests in tests/lib/auspex-chain.hoon of the reference implementation that exercise them. It is provided so that the specification and the test suite can be compared. Tests of behaviour that is purely local to the reference client, such as search, pagination, labels, and storage layout, are omitted.

Rule Test
A signature made with a ship's key verifies against that ship's key. test-sign-verify-roundtrip
A signature does not verify against the key of a different ship. test-sign-wrong-key-fails
A signature does not verify against a different message. test-sign-wrong-message-fails
The digest is (shaf %auspex (sham unsigned)). test-digest-is-salted-sham
The digest differs from the unsalted hash and from the same noun salted for another protocol. test-digest-domain-separated
The message identifier covers each of the nine signed fields. test-msg-id-covers-every-field
A third party that has never communicated with the author verifies the author's signature in a forwarded chain. test-third-party-verifies-forwarded-chain
A modified body is %forged and not %unverified. test-tampered-body-is-forged
A modified life is %unverified and not %forged. test-tampered-life-is-unverified
A missing key is %unverified and never %forged. test-missing-key-is-unverified
One chain yields a separate verdict for each message. test-mixed-verdicts-per-message
A message signed under life 2 verifies against the life 2 key of its author. test-verifies-under-rotated-life
A verdict is recorded per pair [id sig]. test-verdict-keyed-on-id-and-sig
Merging the same chain twice changes nothing. test-merge-dedupes
Merging orders by sent, whatever the order of arrival. test-merge-orders-by-sent
Two copies with one identifier and different signatures both survive merging. test-merge-keeps-both-copies-on-sig-collision
An incoming chain that repeats a message is deduplicated against itself. test-merge-dedupes-within-new
Pruning discards the excess and does not refuse the chain. test-prune-sheds-excess-rather-than-rejecting
Pruning never discards a %verified copy, in either order of input. test-prune-never-sheds-a-verified
Pruning retains %unverified in preference to %forged. test-prune-prefers-unverified-over-forged
The full ranking is %verified, then %unverified, then %forged. test-prune-ranks-verified-then-unverified-then-forged
A definitive verdict is never overwritten. test-freeze-keeps-a-definitive-verdict
A later delivery can replace an %unverified verdict. test-freeze-upgrades-an-unverified
The thread identifier is the identifier of the root message, and every ship computes the same one. test-root-is-first-message-id
Thread identity comes from content and never from the order of the supplied list. test-thread-key-from-content-not-list-order
Thread identity never derives from sent. test-thread-key-ignores-sent
The identity of an established thread is immutable. test-thread-key-established-thread-is-immutable
A root that arrives with a modified copy of itself still resolves on first contact. test-thread-key-tolerates-a-shadowed-root
The limits on incoming data refuse and do not truncate, and one bad message condemns the chain. test-input-caps-reject-on-any-message
The capacity bound counts distinct identifiers and not copies. test-distinct-ids-counts-ids-not-copies
The depth limit measures the deepest path and not the number of messages. test-max-ancestry-is-the-deepest-path
The signer limit counts distinct pairs [ship life] and not messages. test-signer-cap-counts-keys-not-messages
The ancestry of a message is the list of identifiers from the root down to it, inclusive. test-ancestors-are-root-first
The parent table is indexed by identifier and not by signature. test-prev-map-is-keyed-by-id-not-signature
A forward carries the path and not the sibling branch. test-path-chain-omits-the-sibling-branch
A forwarded branch travels whole. test-path-chain-carries-the-whole-path
A forwarded path is a valid chain with a unique root and the same thread identifier. test-path-chain-is-a-fileable-chain
Every copy at a node on the path travels. test-path-chain-keeps-both-copies-of-a-node
An orphan is placed as its own root and is not dropped. test-orphan-is-its-own-root
A cycle of prev values terminates. test-ancestors-survives-a-cycle
+with-root adds the root only when the path lacks one. test-with-root-only-adds-when-the-root-is-missing
The content address covers the length as well as the bytes. test-blob-hash-covers-length
A blob is accepted only if its bytes hash to the requested address. test-blob-ok-rejects-wrong-bytes
A descriptor is held to the same limits as the file it describes. test-attach-ok-enforces-the-same-caps
The attachment count limit is max-attach. test-attaches-ok-caps-the-count
name and mime refuse control bytes at the boundary. test-text-ok-refuses-control-bytes
Replacing an attachment makes the message %forged. test-swapped-attachment-is-forged
The attachment limits apply per message and refuse the chain whole. test-incoming-attachment-caps
The blob paths are content-addressed, have no revision segment, and contain the empty segment. test-blob-paths-are-content-addressed
A draft is not a stored message, and the decoder refuses one. test-a-draft-is-not-a-stored-message
A filter cannot suppress a forged message. test-a-filter-cannot-hide-a-forgery
A search finds a forged message and labels it. test-search-finds-and-names-the-forged-message
A sender selects the highest common version. test-common-version-picks-the-highest
No common version is a refusal and not a fallback. test-common-version-answers-none
A peer with no publication is version 1, with the limits of version 1. test-a-peer-with-no-proto-is-version-1
versions and marks are parallel, and a publication whose lists disagree is refused. test-mark-for-follows-the-parallel-lists
The limit check before sending uses the limits of the peer. test-peer-cap-check-uses-the-peers-caps-not-ours
The published limits are the enforced limits. test-published-caps-are-the-enforced-caps
A cached discovery result expires after a day, and a record dated in the future is not fresh. test-a-peer-record-expires
A refusing record expires after an hour, and a recorded absence is not a refusal. test-a-refusing-record-expires-sooner
The capability path and its request path correspond, including the empty segment. test-proto-paths-mirror-each-other
The pending queue preserves the order of writing. test-the-pending-queue-drains-in-order
The publication check bounds its traversal before measuring, and refuses lists of unequal length. test-proto-ok-bounds-the-walk

Appendix B. Implementation Notes

This appendix is not normative. It records experience from the reference implementation that another implementer on the same platform is likely to need.

Typed wire marks. The reference implementation first declared its wire mark with the chain type, so that the runtime would validate a payload before the handler ran. Grubbery performs that validation before any nexus code runs. A failure of that validation terminates the writer process, and the restarted process consumes the next poke without handling it. A typed wire mark therefore never refused a malformed chain. It silently destroyed the valid delivery that followed, and delivery is open to every ship on the network. The behaviour was reproduced, and the correction was confirmed between two ships. An implementation whose transport treats a failure of payload validation as a failure of the process MUST NOT type its wire mark. The same reasoning applies to the mark of the capability publication (Section 9.1).

Separation of pure and effectful code. Jael answers only at the current time (Section 5.3). The reference library is therefore divided into pure procedures and a thin layer that the running nexus calls. The pure procedures take keys as arguments and perform all hashing, signing, and verification. The thin layer performs the scries. Every procedure quoted in this document belongs to the pure part. The pure part can be tested without a network.

Discovery off the write path. A keen that times out can leave a late response for the process that issued it. On a long-lived process, such responses accumulate. The reference implementation issues every discovery read and every blob retrieval from a short-lived process, and never from the writer.

Case numbers. The publication rules of Sections 7.4 and 9.3 were written after a test deployment moved a binding beyond the probed range by publishing repeatedly. No error is reported to any party when this occurs. An implementation on the same namespace should test for it explicitly.

Author's Address

Jackson Egan
Nisfeb
URI: https://github.com/nisfeb/auspex