Build on the Mesh

Sessions

pm.session/1 is the handshake between a client and a portal's ENDPOINT: the client sends a fresh challenge, and the endpoint proves it holds the name's private key by signing it.

On this page
  1. Why sessions are signed
  2. The request
  3. The signed answer
  4. What the client checks
  5. A reference endpoint

Why sessions are signed

A verified bundle proves what the registry published about a name, including where its ENDPOINT is. It does not prove that the server at that address really is the registered identity. The session handshake does: the endpoint signs a fresh challenge with the name’s private key, and the client checks that signature against the public key on the name’s certificate.

Only the owner holds that key. Jubilee never has it, so a session that verifies was answered by whoever controls the name’s key, and nobody else.

The request

After the bundle has verified and live status says active, the client sends an HTTP POST to the ENDPOINT’s url, with Content-Type: application/json:

POST https://endpoint.example.com/sessionjson
{
  "protocol": "pm.session/1",
  "address": "activate://oasis.character",
  "name": "oasis.character",
  "pid": "pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC",
  "personaHash": "sha256:f666f7b6e5c7b0a0c94366a7b55b902d2d3c1fb6d6d130922c70f7a274f00a24",
  "mode": "voice",
  "direction": "bring",
  "nonce": "jxxSOtB3QZ4Guy3EkV_oEw",
  "device": "browser",
  "time": null
}
FieldMeaning
protocolpm.session/1
addressThe canonical address that was resolved.
nameThe portal name, from the verified bundle.
pidIts permanent ID.
personaHashThe verified PERSONA hash, or null for a name without a persona.
modeThe mode the client wants, one the ENDPOINT offers.
directionThe direction the client wants, one the ENDPOINT allows.
nonce16 random bytes, base64url: the challenge. New for every request.
deviceA label for the client, "browser" from portal.js in a page, or null.
timeThe id of the chosen time frame, or null.

The signed answer

The endpoint decides whether to open the session. If it does, it answers 200 with JSON, signed with the name’s private key over the canonical JSON of every field except signature, exactly as a record is signed. Any non-2xx status means no session.

200 OKjson
{
  "protocol": "pm.session/1",
  "sessionId": "ses_01JBX3QA4D8F2H6K9M3P5R7T1V",
  "name": "oasis.character",
  "pid": "pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC",
  "nonce": "jxxSOtB3QZ4Guy3EkV_oEw",
  "personaHash": "sha256:f666f7b6e5c7b0a0c94366a7b55b902d2d3c1fb6d6d130922c70f7a274f00a24",
  "mode": "voice",
  "direction": "bring",
  "issued": "2026-10-23T12:31:02.000Z",
  "kid": "pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC#k1",
  "signature": "4n3roRNwLHyH-IekWJizNMBbaXzpez2nOu6FJ2kSs_PjFSBUVftrnbxlZmOifFdUecUJkcpYlXW2sojS7v_kDg"
}
FieldRule
protocolpm.session/1
sessionIdThe endpoint's id for the session.
nameMust be the name requested.
pidMust be the permanent ID requested.
nonceMust echo the request's nonce exactly.
personaHashMust echo the request’s personaHash (null if it was null).
issuedWhen the answer was made, ISO 8601 UTC.
kidThe name’s current key id, <pid>#k<n>, the one in the ID record.
signatureEd25519, base64url, over the canonical JSON of every other field.
any other fieldAllowed, and covered by the signature. The reference endpoint adds mode and direction.

What the client checks

portal.js refuses the session, failing closed, unless every one of these holds:

  1. The endpoint answered with a 2xx status and JSON. Otherwise endpoint_failed.
  2. protocol is pm.session/1, and nonce, name, pid and personaHash all match the request. Otherwise session_unverified, “The endpoint answered for a different session.”
  3. kid is the keyId of the verified ID record. Otherwise “The endpoint signed with a key that is not on the certificate.”
  4. The signature is readable (otherwise “The endpoint’s signature is unreadable.”), and verifies against the ID record’s public key over the canonical JSON of the answer without signature (otherwise “The endpoint’s session signature does not verify against the name’s certificate.”).

The example answer above passes all of them; that was checked while this page was built. With the standalone code, step 4 is verifyRecord(answer, nameKeyX), the same function that verifies a record.

A reference endpoint

A complete endpoint, as a Cloudflare Worker with no dependencies. During the build of this page it was run for real: it answered the example request with a session that passed every check above, answered a CORS preflight, and refused a request for another name.

endpoint.jsjs
// A pm.session/1 ENDPOINT as a Cloudflare Worker (module syntax). Configure:
//   NAME           the portal name it serves, e.g. "oasis.character"
//   PID            the name's permanent ID, "pid:..."
//   KID            the name's CURRENT key id, "<pid>#k<n>" (it changes when the key is rotated)
//   NAME_KEY_SEED  a secret: the 32-byte Ed25519 seed from your key file, base64url
//   ALLOWED_ORIGIN optional: the one web origin allowed to open sessions from a browser
// The private key never leaves this Worker's secret store. Jubilee never has it.

const PROTOCOL = "pm.session/1";
const PKCS8_PREFIX = [0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20];

function canonicalize(value) {
  if (value === null) return "null";
  switch (typeof value) {
    case "boolean":
      return value ? "true" : "false";
    case "number":
      if (!Number.isFinite(value)) throw new Error("non-finite number");
      return JSON.stringify(value);
    case "string":
      return JSON.stringify(value);
    case "object": {
      if (Array.isArray(value)) {
        return "[" + value.map((item) => {
          if (item === undefined) throw new Error("undefined inside an array");
          return canonicalize(item);
        }).join(",") + "]";
      }
      const proto = Object.getPrototypeOf(value);
      if (proto !== Object.prototype && proto !== null) throw new Error("not a plain object");
      const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
      return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalize(value[k])).join(",") + "}";
    }
    default:
      throw new Error("unsupported " + typeof value);
  }
}

function b64urlEncode(bytes) {
  let binary = "";
  for (const b of bytes) binary += String.fromCharCode(b);
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

function b64urlDecode(text) {
  const b64 = text.replace(/\s+/g, "").replace(/-/g, "+").replace(/_/g, "/").replace(/=+$/, "");
  const binary = atob(b64 + "=".repeat((4 - (b64.length % 4)) % 4));
  return Uint8Array.from(binary, (c) => c.charCodeAt(0));
}

let signingKey = null;

function nameKey(env) {
  if (!signingKey) {
    const seed = b64urlDecode(env.NAME_KEY_SEED);
    if (seed.length !== 32) throw new Error("NAME_KEY_SEED must be a 32-byte Ed25519 seed");
    const pkcs8 = new Uint8Array(48);
    pkcs8.set(PKCS8_PREFIX, 0);
    pkcs8.set(seed, 16);
    signingKey = crypto.subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, false, ["sign"]);
  }
  return signingKey;
}

function corsHeaders(env) {
  return {
    "Access-Control-Allow-Origin": env.ALLOWED_ORIGIN || "*",
    "Access-Control-Allow-Methods": "POST, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type",
    "Access-Control-Max-Age": "86400",
    "Vary": "Origin",
  };
}

function reply(body, status, env) {
  return new Response(JSON.stringify(body), {
    status,
    headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", ...corsHeaders(env) },
  });
}

export default {
  async fetch(request, env) {
    if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: corsHeaders(env) });
    if (request.method !== "POST") return reply({ code: "method_not_allowed", message: "POST a pm.session/1 request." }, 405, env);

    let req;
    try {
      req = await request.json();
    } catch {
      return reply({ code: "invalid_request", message: "The request body is not JSON." }, 400, env);
    }
    if (!req || req.protocol !== PROTOCOL || typeof req.nonce !== "string" || !req.nonce) {
      return reply({ code: "invalid_request", message: "This endpoint speaks pm.session/1." }, 400, env);
    }
    if (req.name !== env.NAME || req.pid !== env.PID) {
      return reply({ code: "unknown_identity", message: "This endpoint does not serve that identity." }, 404, env);
    }

    // Your own policy goes here: check req.mode, req.direction, req.time, req.personaHash and
    // req.device, and refuse (any non-2xx answer) if this endpoint should not open the session.

    const answer = {
      protocol: PROTOCOL,
      sessionId: "ses_" + crypto.randomUUID(),
      name: env.NAME,
      pid: env.PID,
      nonce: req.nonce,
      personaHash: req.personaHash ?? null,
      mode: req.mode ?? null,
      direction: req.direction ?? null,
      issued: new Date().toISOString(),
      kid: env.KID,
    };
    const bytes = new TextEncoder().encode(canonicalize(answer));
    const signature = new Uint8Array(await crypto.subtle.sign({ name: "Ed25519" }, await nameKey(env), bytes));
    return reply({ ...answer, signature: b64urlEncode(signature) }, 200, env);
  },
};

NAME_KEY_SEED is the 32-byte seed inside your key file. Open the file with your passphrase (see the key file format), encode the seed as base64url, and store it as a secret, for example with wrangler secret put NAME_KEY_SEED. Set KID to the name’s current key id, and change both whenever the key changes: a rotation, a transfer or a recovery.