====== Protocol ======
This document is still a **draft**, and is subject to change at any time!
The PawSD protocol is designed around a simple request-response architecture that allows it to work over a variety of underlying [[transports]]. All a transport has to provide is a way for a client to send some bytes to a server and for the server to send some bytes back in response. It is imperative to choose transports that provide some form of data integrity, i.e. preventing malicious third parties from modifying data in-transit between the client and server.
Unless otherwise specified, the protocol uses big-endian byte and bit order, and no padding.
Within this document, integers are described in a shorthand format: a letter "u" for unsigned or "s" for signed, followed by their length in bits. "Enums" are unsigned integers that take one of a defined set of values. Arrays are indicated by prefixing their item type with the number of items in square brackets, or an underscore if it's variable: ''[16]u8'' means 16 arbitrary bytes and ''[_]record'' means a number of records specified by a previous field.
===== Requests and responses =====
A request is structured as follows:
^ Field ^ Length ^ Type ^
| request magic | 8 bytes | constant ASCII string "PawRqust" |
| protocol version | 2 bytes | u16 (current version is 1) |
| request verb | 2 bytes | enum |
| request payload | varies | //according to verb// |
And a response looks like this:
^ Field ^ Length ^ Type ^
| response magic | 8 bytes | constant ASCII string "PawRspns" |
| protocol version | 2 bytes | u16 (current version is 1) |
| status code | 2 bytes | enum |
| response payload | varies | //according to status code// |
For all status codes except "OK", the response payload is a string (2 byte length prefix, UTF-8, no null terminator) describing the error in a manner useful to client developers. Friendly user interfaces are expected to provide their own localised descriptions of errors, but may choose to also show the server's description.
==== Status codes ====
The currently defined status codes for responses are (in hexadecimal):
* 0x0200: OK
* 0x0400: generic client error
* 0x0401–0x0403: //TODO//
* 0x0404: resource not found
* 0x0500: generic server error
* 0x0501: not implemented
* 0x0503: service temporarily unavailable
* 0x0505: unknown verb
===== Zones, services and records =====
Zones are purely logical and are not literally sent over the wire. However, services are identified by the combination of their containing zone's public key (and its type/cryptosystem) along with their index within the zone (starting from 0 for the "metadata service" containing info about the zone itself).
Services are transmitted as follows:
^ Field ^ Length ^ Type ^
| signature length | 2 bytes | u16 |
| signature | varies | [_]u8 |
| index | 2 bytes | u16 |
| flags | 4 bytes | bitfield |
| record count | 2 bytes | u16 |
| records | varies | [_]record |
The signature is calculated from the concatenation of the index, flags, record count, and records fields (i.e. the entire service in wire format except for the signature itself). The signature algorithm is not given in the service itself but will be specified as part of the service's identifier, such as when requesting it.
The "flags" bitfield currently only contains one flag, identified by the presence or absence of the least significant bit (1), which, if set, indicates that another service follows this one in the zone (and therefore if unset indicates that this service is the final service in the zone). In future, more flags may be added without incrementing the protocol version unless it is necessary for clients to understand them.
Records are just a list of tag-value pairs:
^ Field ^ Length ^ Type ^
| tag count | 2 bytes | u16 |
| tag1 | 16 bytes | [2]u64 |
| value1 length | 4 bytes | u32 |
| value1 | varies | [_]u8 |
| //tag2, etc// | | |
==== Signature algorithms =====
The only currently defined signature algorithm (or "public key type") is Ed25519 (identified on the wire by u16 "25519"). All implementations are required to support it, and shouldn't implement any other algorithms except for testing purposes. This is the "pure" variant usually called ''ed25519'' in crypto libraries, not "pre-hashed" (''ed25519ph'').
===== Verb 0: Echo =====
This is intended for troubleshooting purposes only. The server simply returns the same data back to the client.
Request and response payloads (identical):
^ Field ^ Length ^ Type ^
| data | 16 bytes | [16]u8 |
===== Verb 1: Fetch service =====
The client asks the server for a particular service. Even if the service exists, the server may not have it, in which case it may respond "resource not found". If the server is acting as an authoritative daemon this is likely what it should do; however, caching daemons might instead wish to forward the request onto other servers in an attempt to find the service, then return their eventual response back to the original client, while caching it in the process.
Request payload:
^ Field ^ Length ^ Type ^
| public key type | 2 bytes | enum |
| public key length | 2 bytes | u16 |
| public key | varies | [_]u8 |
| service index | 2 bytes | u16 |
Design note: although the length of the public key is implicitly given by the key type field (e.g. an ed25519 key will always be 32 bytes long), we still explicitly state its length, so that implementations that don't support a given key type can still parse the request properly, even if just to show a more useful error message.
Response payload:
^ Field ^ Length ^ Type ^
| service | varies | service |
===== Verb 2: Query services =====
The client sends a structured search query to the server, which it performs recursively (up to the specified maximum depth, or until its own depth limit) against all zones it has cached locally. Generally the server shouldn't perform lookups as part of this process unless it can be sure they will complete quickly. The list of matching services returned to the client may therefore be incomplete.
Request payload:
^ Field ^ Length ^ Type ^
| starting zone | varies >4 | key type, length, key (as in first three fields of verb 1) |
| max recursion depth | 1 byte | u8 |
| query | varies | service-condition (see below) |
Recursion starts at the "starting zone" and continues by following edge pointer records (found on service 0 of each zone). If the max recursion depth is 0, it won't follow any edges, and will just check the starting zone itself. At each zone, the query is evaluated against each service of the zone, and matching services (for which the overall result of the query condition is true) are included in the response.
The query itself is a tree-like expression, representing a logical combination of comparisons against properties of the service or its records. The **VERY NON-FINAL AND SUBJECT TO CHANGE** encoding of query expressions is defined according to the following pseudo-ABNF:
service-condition = service-logical / service-comparative
/ all-records / any-records
;; boolean operators (T is service, record, or tag)
T-logical = T-not / T-and / T-or
T-not = %x00 T-condition
T-and = %x01 T-condition T-condition
T-or = %x02 T-condition T-condition
;; numeric operators (=, <, >) (T as above)
T-comparative = T-equals / T-lt / T-gt
;; for all T-lt and T-gt, the specified value is the
;; RHS and the value in the T is the LHS
u8 = OCTET
u16 = u8 u8
u32 = u16 u16
u64 = u32 u32
tag = u64 u64
string = u32 *(u8) ;; length prefixed string, *=u32
;; returns true iff service index = 'u16'
service-equals = %x03 service-index
service-index = u16
;; comparisons for the number of records in a service
service-lt = %x04 record-count
service-gt = %x05 record-count
record-count = u16
;; both map a condition over all elements of the parent
;; returns true iff all matched
all-Ts = %x06 T-condition
;; returns false iff none matched
any-Ts = %x07 T-condition
record-condition = record-logical / record-comparative
/ all-tags / any-tags
record-lt = %x08 tag-count
record-gt = %x09 tag-count
tag-count = u16
tag-condition = tag-logical / tag-comparative
tag-equals = %x0A tag ;; tag ID = ?
/ %x0B string ;; tag value = ?
/ %x0C string ;; tag value substring match
/ %x0D string ;; tag value fuzzy match
;; compare tag value as an unsigned int
tag-lt = %x0E tag-xt
tag-gt = %x0F tag-xt
tag-xt = %x01 u8 / %x02 u16 / %x04 u32 / %x08 u64 / %x10 u64 u64
The response is a list of matching services grouped by zone:
^ Field ^ Length ^ Type ^
| zone count | 2 bytes | u16 |
| zone1 service count | 2 bytes | u16 |
| zone1 service indices | varies | [_]u16 |
| //zone2 service count, etc.// | | |
==== Examples ====
To fuzzy-search for the string "winter", you would:
* construct a logical query something like ''match any record where [match any tag where [the value fuzzy-matches "winter"]]''
* in a lisp-like syntax you might write that as ''(match-any (match-any (tag-eq 'fuzzy "winter")))''
* encode it accordingly:
* the top-level "parent" is the service, whose elements are records
* so to match against any record, use ''07 '' (nonterminal "any-Ts")
* inside that condition, the parent is a record, whose elements are tags
* so we use the same again to match against any tag
* inside the sub-condition we want to fuzzy match the tag's value: ''0D '' (part of nonterminal "tag-equals")
* the string itself will just be length-prefixed UTF-8: ''00000006 77696e746572'' in hex
* which results in the final encoded query ''07 07 0D 00000006 77696e746572'' in hex
TODO: other verbs!
===== Verb 4096 (0x1000): Start authentication =====
The client briefly identifies itself using a "client ID", which is an arbitrary byte sequence that needs to uniquely but persistently identify the client. For example, it could be some hash of the concatenation of: the client's implementation name, a random number saved on the client's storage, and some information about the server such as its IP address (to prevent cross-server tracking). The client ID allows the server to keep track of the authentication session across subsequent requests. The client also asks for access to a particular set of scopes.
Request payload:
^ Field ^ Length ^ Type ^
| client ID | 16 bytes | [16]u8 |
| scopes | ??? | //TODO// |
The server then returns either a "challenge set", or an auth token for the given scopes. The response payload therefore is:
^ Field ^ Length ^ Type ^
| completed? | 1 byte | boolean |
| token or challenges | varies | [32]u8 or challenges |
If "completed?" is false (0), the "token or challenges" field will be:
^ Field ^ Length ^ Type ^
| conjunction | 1 byte | enum (0 = or, 1 = and) |
| challenge count | 1 byte | u8 (≥ 1) |
| challenge1 ID | 16 bytes | [2]u64 |
| challenge1 data length | 4 bytes | u32 |
| challenge1 data | varies | [_]u8 |
| //challenge2 ID, etc// | | |
If the conjunction is "or", the client may choose any one of the contained challenges to solve; if it's "and", the client has to solve all of them. If the challenge count is 1, the conjunction is irrelevant. Consequently the client needs to send its challenge response(s) using the //Continue authentication// verb as follows.
==== Challenges ====
The structure of the "challenge data" and "response" fields are defined by each individual challenge. There is a [[challenges|non-exhaustive list of known challenges]].
===== Verb 4097 (0x1001): Continue authentication =====
The client sends one or multiple responses to challenges previously given by the server. (Note confusing terminology: in this case the "responses" are responses in the sense of a challenge-response exchange, but they are sent as part of the request payload in the protocol.)
Request payload:
^ Field ^ Length ^ Type ^
| client ID | 16 bytes | [16]u8 |
| response count | 1 byte | u8 |
| challenge1 ID | 16 bytes | [2]u64 |
| response1 length | 4 bytes | u32 |
| response1 | varies | [_]u8 |
| //challenge2 ID, etc// | | |
The response payload is identical to the //Start authentication// verb.
===== Verb 4098 (0x1002): Fetch contact =====
The client sends a zone's public key to be looked up in the authenticated user's contact list. ("Contacts" are PawSD's way of storing local names and trust levels, roughly analogous to a phone's contact list (hence the name), browser bookmarks, etc.)
Request payload:
^ Field ^ Length ^ Type ^
| auth token | 32 bytes | [32]u8 |
| public key type | 2 bytes | enum |
| public key length | 2 bytes | u16 |
| public key | varies | [_]u8 |
The server will either respond "resource not found" if that zone is not in the contact list, or otherwise:
^ Field ^ Length ^ Type ^
| local name length | 2 bytes | u16 |
| local name | varies | UTF-8 string |
| trust level | 1 byte | enum |
| introducer zone | varies | key type, length, key OR constant 0 (u16) |
The trust level can be one of the following values:
* 0 = **distrusted**: the user has explicitly marked the zone as evil
* 1 = **neutral**: default value in the absence of any other indication of trust
* 2 = **introduced**: implicitly trusted due to being pointed to by another zone's edge record, where that other zone' trust level is either introduced or trusted
* 3 = **trusted**: the user has explicitly marked the zone as trusted (e.g. due to in-person verification)
The introducer zone field may be set to the public key of a zone that points to the requested zone, as an explanation for the trust level being "introduced". It may also be set even if the trust level is something else. If the key type is instead set to 0 this indicates that no length or key data will follow, representing the lack of any introducer.
===== Verb 4099 (0x1003): Put contact =====
The client sends a zone's public key, local name, trust level and optionally an introducer zone, to be stored by the server in the authenticated user's contacts. If the zone in question was already in the contacts, its entry will be modified to the new information.
Request payload:
^ Field ^ Length ^ Type ^
| auth token | 32 bytes | [32]u8 |
| public key type | 2 bytes | enum |
| public key length | 2 bytes | u16 |
| public key | varies | [_]u8 |
| local name length | 2 bytes | u16 |
| local name | varies | UTF-8 string |
| trust level | 1 byte | enum |
| introducer zone | varies | key type, length, key OR constant 0 (u16) |
Response payload:
^ Field ^ Length ^ Type |
| updated? | 1 byte | boolean |
"updated?" being true (1) indicates that the contact entry already existed before this request was processed; false (0) means it was a new addition.
===== Verb 4100 (0x1004): Remove contact =====
The client sends a zone's public key, to be removed from the authenticated user's contact list.
Request payload:
^ Field ^ Length ^ Type ^
| auth token | 32 bytes | [32]u8 |
| public key type | 2 bytes | enum |
| public key length | 2 bytes | u16 |
| public key | varies | [_]u8 |
Response payload is empty. A successful status code indicates that the contact was deleted, whereas if the contact didn't exist in the first place, the status should be "resource not found".