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
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, withDASSIE_PUBLIC_NAMES(Environment variables). It also answers under<name>.dassiepay.netfor 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 besidedassiepay.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";reqand"@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
-
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 underdassiepay.net. -
Send the request with a fresh
Dassie-Nonce. -
Refuse an answer whose signature leaves out the nonce, or the digest of its body, or is not tagged
dassie. -
Hash the two keys in
keyidto a node ID, and refuse the answer unless it is the node you meant. -
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:
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.
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