Menu

Signed responses and names

A node signs every Open Payments answer with its node key. This page shows how your client checks that an answer came from the node it meant to reach, even when a front door passed it on.

On this page
  1. Why check
  2. The names a node answers to
  3. The dassie field
  4. What the signature covers
  5. Check an answer
  6. Answers under a name

Why check

A node without a hostname, such as the desktop app, is reached at <node ID>.dassiepay.net through the front door there, which it keeps as its relay by default. The project runs that front door. It ends the TLS connection, so it reads every request and answer, and a client that takes answers as they come trusts it as much as the node. The node's signature takes that trust away: a front door, or anyone else in the middle, cannot make up an answer the node would sign. It can still read the answers, STREAM secrets included, and it can still refuse to pass them on.

Checking is up to your client. Other Open Payments clients ignore the signature, and work as before. A node with a hostname signs its answers all the same.

Why it works this way, and why a node keeps the project's front door by default.

The names a node answers to

  • A node with a hostname answers under that name alone, such as node.example.com.
  • A node without one answers first under <node ID>.dassiepay.net, its name at the default front door, which comes from its node ID. Its owner may add names, another front door's or a domain of their own, with DASSIE_PUBLIC_NAMES (Environment variables). It also answers under <name>.dassiepay.net for each name it holds, once the network's registrars list the name for it. Where its owner added its ID name at another front door, <node ID>.<domain>, it answers under <name>.<domain> there too, at up to three front doors beside dassiepay.net. Names and addresses explains names, which run on the testnet only.

A node signs its answers under every one of these names, errors included. A node without a hostname refuses a name it does not answer to with 421 misdirected_request, unsigned, since that is no answer under one of its names; a node with a hostname answers under it whatever Host says. CORS preflights go unsigned too, and so does every answer of a node that is not set up yet.

A node ID is 39 characters of a to z and 2 to 7. A name is shorter, so no host is both.

The dassie field

A Dassie node's wallet address carries the node's ID, in a field the Open Payments schema lets other clients ignore:

{
  "id": "https://hauavng4okp6d55tghlsiqvnn6j5v6mpmktgnfq.dassiepay.net/pay/xrp",
  "assetCode": "XRP",
  "assetScale": 9,
  "authServer": "https://hauavng4okp6d55tghlsiqvnn6j5v6mpmktgnfq.dassiepay.net/gnap",
  "resourceServer": "https://hauavng4okp6d55tghlsiqvnn6j5v6mpmktgnfq.dassiepay.net",
  "dassie": { "nodeId": "hauavng4okp6d55tghlsiqvnn6j5v6mpmktgnfq" }
}

It tells you which node should sign the answers. It is a hint, not proof: it comes from whoever answered. Check it as below before you rely on it. Another server may use a field called dassie for something of its own, so read it leniently: treat a dassie in any other shape as absent, and the wallet as payable all the same.

What the signature covers

Send a fresh random value in the Dassie-Nonce header with each request. The node's answer then carries an RFC 9421 signature, labeled sig1, tagged dassie, with alg="ed25519", over:

  • "@status", the answer's status;
  • "content-digest", the SHA-512 of the body, whenever there is one;
  • "@method";req and "@target-uri";req, your request's method and URL, at the name it was answered under;
  • "dassie-nonce";req, your nonce, so that an old answer cannot pass for a new one.

Its keyid is the node's two public keys, 64 bytes in base64url: the Ed25519 key that signs, then the SLH-DSA key. The node ID is a hash of the two, so you check them against the ID, then the signature against the Ed25519 key.

Check an answer

  1. Know which node you mean. For <node ID>.dassiepay.net, it is the first label of the host, the URL you fetch. For a name, see below. A node with a hostname of its own signs as well, though there its TLS certificate already vouches for the name; Dassie's own client checks signatures only under dassiepay.net.

  2. Send the request with a fresh Dassie-Nonce.

  3. Refuse an answer whose signature leaves out the nonce, or the digest of its body, or is not tagged dassie.

  4. Hash the two keys in keyid to a node ID, and refuse the answer unless it is the node you meant.

  5. Check the body against Content-Digest, and the signature under the Ed25519 key.

In Node.js, with nothing but node:crypto, that looks like this. It is checked against a signature made by the node's own code:

check-node.mjs
import { createHash, createPublicKey, randomBytes, verify } from "node:crypto";

// A node ID hashes this, then the node's two public keys, each after its length.
const ID_PREFIX = Buffer.from("0d2b0601040183a878058a770202", "hex");
const BASE32 = "abcdefghijklmnopqrstuvwxyz234567";

/** The node ID of an Ed25519 key and an SLH-DSA key, 32 bytes each. */
export function nodeIdOf(ed25519, slhDsa) {
  const input = Buffer.concat([ID_PREFIX, Buffer.of(32), ed25519, Buffer.of(32), slhDsa]);
  const bytes = createHash("sha256").update(input).digest().subarray(0, 24);
  let id = "";
  let bits = 0;
  let value = 0;
  for (const byte of bytes) {
    value = ((value << 8) | byte) & 0xfff;
    bits += 8;
    while (bits >= 5) {
      bits -= 5;
      id += BASE32[(value >> bits) & 31];
    }
  }
  return bits > 0 ? id + BASE32[(value << (5 - bits)) & 31] : id;
}

/**
 * GETs `url` and returns the response once it is checked to come from the
 * node `nodeId`: by default the node whose ID is the first label of the host.
 */
export async function fetchFromNode(url, nodeId = new URL(url).hostname.split(".")[0]) {
  const target = new URL(url);
  const nonce = randomBytes(16).toString("base64url");
  const response = await fetch(target, { headers: { "dassie-nonce": nonce } });
  const body = Buffer.from(await response.arrayBuffer());
  const input = response.headers.get("signature-input") ?? "";
  const signature = /^sig1=:([A-Za-z0-9+/]+=*):$/.exec(response.headers.get("signature") ?? "");
  const params = /^sig1=(\(([^)]*)\)((?:;[a-z]+=(?:\d+|"[^"]*"))*))$/.exec(input);
  const covered = (body.length > 0 ? ['"@status"', '"content-digest"'] : ['"@status"'])
    .concat(['"@method";req', '"@target-uri";req', '"dassie-nonce";req'])
    .join(" ");
  if (!signature || !params || params[2] !== covered) throw new Error("not signed as a node signs");
  const fields = Object.fromEntries(
    params[3].slice(1).split(";").map((field) => field.replaceAll('"', "").split("=")),
  );
  if (fields.tag !== "dassie" || fields.alg !== "ed25519") throw new Error("not a node's signature");
  if (Math.abs(Date.now() / 1000 - Number(fields.created)) > 300) throw new Error("signed too long ago");
  const keys = Buffer.from(fields.keyid ?? "", "base64url");
  if (keys.length !== 64 || nodeIdOf(keys.subarray(0, 32), keys.subarray(32)) !== nodeId) {
    throw new Error("signed by another node");
  }
  const digest = response.headers.get("content-digest");
  const sha512 = createHash("sha512").update(body).digest("base64");
  if (body.length > 0 && digest !== `sha-512=:${sha512}:`) throw new Error("the body is not the one signed");
  const base = [
    `"@status": ${response.status}`,
    ...(body.length > 0 ? [`"content-digest": ${digest}`] : []),
    `"@method";req: GET`,
    `"@target-uri";req: ${target.origin}${target.pathname}${target.search}`,
    `"dassie-nonce";req: ${nonce}`,
    `"@signature-params": ${params[1]}`,
  ].join("\n");
  const key = createPublicKey({
    key: { kty: "OKP", crv: "Ed25519", x: keys.subarray(0, 32).toString("base64url") },
    format: "jwk",
  });
  if (!verify(null, Buffer.from(base), key, Buffer.from(signature[1], "base64"))) {
    throw new Error("the signature does not verify");
  }
  return { status: response.status, body };
}
const { status, body } = await fetchFromNode(
  "https://hauavng4okp6d55tghlsiqvnn6j5v6mpmktgnfq.dassiepay.net/pay/xrp",
);

The node sends Access-Control-Expose-Headers for Signature, Signature-Input and Content-Digest, and takes Dassie-Nonce from any origin, so a client in a browser can check too, with Web Crypto in place of node:crypto.

Dassie's own check is check_node_response in crates/dassie-payments/src/httpsig.rs. It is stricter than the sketch above where that matters to Dassie: it reads the signature fields as structured fields, and accepts a SHA-256 digest beside SHA-512.

Why it works this way

Answers under a name

A name like alice.dassiepay.net says nothing about the node by itself. The node that holds it is the one that more than two thirds of the network's registrars list for it. A Dassie node keeps its own view of those lists, and takes an answer under a name only from the holder in that view. A front door does the same before it passes a request on.

A client outside Dassie has no such view. The dassie field tells you who the holder claims to be, but it comes from whoever answered. If you need the guarantee, check the answer against a node ID you know by other means: one you kept from before, or the one your user gave you. Or pay at the node's ID name, <node ID>.dassiepay.net, whose host names the node.

Names change hands when they lapse, and the testnet's may be reset, so a name can lead to another node later. The node ID never does.

A DNS record that names the node behind an owner's own domain is not there yet, nor is an owner's domain pointed at a front door. What's next

A kopje of granite blocks against the sun, in veld grass; one dassie climbs its slab, one keeps lookout on top inside the sun, and one sleeps on the next rock; on a wide screen, a pair sits on a kopje to the left