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
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:
{
"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
}| Field | Meaning |
|---|---|
protocol | pm.session/1 |
address | The canonical address that was resolved. |
name | The portal name, from the verified bundle. |
pid | Its permanent ID. |
personaHash | The verified PERSONA hash, or null for a name without a persona. |
mode | The mode the client wants, one the ENDPOINT offers. |
direction | The direction the client wants, one the ENDPOINT allows. |
nonce | 16 random bytes, base64url: the challenge. New for every request. |
device | A label for the client, "browser" from portal.js in a page, or null. |
time | The 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.
{
"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"
}| Field | Rule |
|---|---|
protocol | pm.session/1 |
sessionId | The endpoint's id for the session. |
name | Must be the name requested. |
pid | Must be the permanent ID requested. |
nonce | Must echo the request's nonce exactly. |
personaHash | Must echo the request’s personaHash (null if it was null). |
issued | When the answer was made, ISO 8601 UTC. |
kid | The name’s current key id, <pid>#k<n>, the one in the ID record. |
signature | Ed25519, base64url, over the canonical JSON of every other field. |
| any other field | Allowed, 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:
- The endpoint answered with a 2xx status and JSON. Otherwise
endpoint_failed. protocolispm.session/1, andnonce,name,pidandpersonaHashall match the request. Otherwisesession_unverified, “The endpoint answered for a different session.”kidis thekeyIdof the verified ID record. Otherwise “The endpoint signed with a key that is not on the certificate.”- 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.
// 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.