nano133

Build · Guides

Sign in with Nano

Sign in with a wallet signature or a passkey. No payment, no password, no email. Your server checks one signature and holds no key.

Sign inSigned in asnano_1pju…edhe
  • No password
  • No email
  • Your server holds no key
  • 30 minutes
  • Developer
  • Moves no Nano
  • Checked 25 October 2026
How we checked it

No node needed. Test script with real signatures: passed. A second test shows it acts the same as the sign-in code of nano133.com.

About this guide

Let a person sign in to your site with a Nano account. The person signs a short text with a key that stays on their own device, and your server checks the signature. There is no payment, no password and no email, and your server holds no key. Signing in by a small payment is gone: nano133.com ended it on 2026-10-09, and this guide does not teach it.

The rules of this guide

  1. Step 1

    The signed text always names the domain of the site. Never sign or accept a text that does not.

  2. Step 2

    Never change the texts or the version byte of the passkey scheme after one person has made a purse. Add a new version instead. Keep the scheme in one file and test it with a fixed vector (the example below has one).

  3. Step 3

    A seed never leaves the person's browser. Never post it, store it, log it or put it in a URL.

  4. Step 4

    Check the whole address (shape and checksum) before anything else. Refuse weak keys and the second form of a signature. Build the signed text on the server from your own domain.

  5. Step 5

    Count a sign-in once, by the hash of the signed text, in a write that fails when the key exists.

  6. Step 6

    Make a new session at every sign-in. Learn who is signed in only from the session. Check the origin on every route that changes something, and rate-limit the sign-in route.

  7. Other ways

    A wallet app's request can be sent to someone else, who approves it by mistake. Do not take such a sign-in as proof for an action that cannot be undone. nano133.com marks a sign-in made this way, and the page that deletes a person's data shows how the person signed in (“signed in with NanChat 3 minutes ago”) before it goes on.

How it works

Four parts, one after the other. The account needs no XNO, so a new person with an empty purse can sign in.

on the person's deviceon your server1ӾBrowserholds the purse2Signed textdomain, account, time3Server checkchecks the signature4nano_1pju…edheSessiona new cookie
on the person's deviceon your server1ӾBrowserholds the purse2Signed textdomain, account, time3Server checkchecks the signature4nano_1pju…edheSessiona new cookie
The four parts in words
  • The browser holds a purse. A purse is account 0 of a seed. The seed stays on the person's device. A passkey (Face ID, Touch ID, a PIN) can make the seed, so the person has nothing to write down and nothing to type.
  • The person signs a text. The text names your domain, the account and the time.
  • Your server checks the signature. It checks the account, the time, the key and the signature, and it counts each sign-in once.
  • Your server makes a session. After that the person is signed in as that account, like on any site.
Warning

What a signature proves, and what it does not. A valid signature proves one thing: the person holds the key of that account, right now. It says nothing else. You get no name and no email. If you need either, ask for it after the sign-in.

What you need

The four things you need, and the versions we ran
  • Your domain, exactly as people see it (example.com). The signed text names it.
  • A database with a unique key, for the sign-ins that were used and for the sessions.
  • A browser with passkeys that support the PRF extension, for the passkey way. A call tells you if a browser can: the package's passkeySupport() answers “yes”, “no” or “unknown”.
  • Node.js 20 or newer and two packages to run the example: nanocurrency-web and @noble/curves.

The checks in this guide ran with nanocurrency-web 2.0.0 and @noble/curves 2.4.0, on Node.js 20.18.3, 22.21.1 and 24.13.0. They fail on Node.js 18, which has no crypto.subtle as a global.

Step 1Decide what is signed

Sign a short text that names your domain, the account and the time.

// "example.com: sign in as nano_… at 1790000000000"   (the time is in milliseconds)
const signinMessage = (domain, address, at) => `${domain}: sign in as ${address} at ${at}`;
Why these three things

A person signs a text, not a request. Make the text the same in the browser and on the server, and put three things in it:

  • Your domain, so a signature made for one site can never sign in to another.
  • The account, so the text says whose signature it is.
  • The time, so an old signature stops working (step 5).
Rule

The signed text always names the domain of the site. Never sign or accept a text that does not.

Check

run node signin.mjs message example.com <an address> 1790000000000: it prints the text above. Another domain, or another time, prints another text.

Step 2Make a purse from a passkey

Make the person's purse from a passkey, with a scheme that you choose once and never change.

seed    = HKDF-SHA-256(key = the 32 bytes, salt = scheme.salt, info = scheme.info, 32 bytes)
purse   = account 0 of that seed
How a passkey makes the seed

A passkey belongs to one domain, so each site gets its own account for one person. When the person uses their passkey, the browser can ask it for 32 secret bytes (its PRF answer). Those bytes become the seed of the purse, as the lines above show.

const SCHEME = { input: "example.com purse v1", salt: "example.com", info: "example.com purse seed v1", versionByte: 1 };
Why the scheme never changes

The scheme is three texts and a number that you choose one time, and then never change (the line above is an example).

A person's account, and any XNO in it, lies at the address that these exact texts give. Change one character and every person gets another account. The XNO stays in the old account, and the old texts open it again. A new scheme is a new versionByte with new texts, never an edit of the old ones. The package has no default scheme on purpose: a default could change under you.

Rule

Never change the texts or the version byte of the passkey scheme after one person has made a purse. Add a new version instead. Keep the scheme in one file and test it with a fixed vector (the example below has one).

import { firstUse, makePasskey, openPasskey } from "@nano133/signin/passkey";

const setup = { rpId: "example.com", rpName: "Example", scheme: SCHEME };
const made = await makePasskey(setup);          // remember made.credentialId now
const { seed } = await firstUse(setup, made);   // the seed comes from a USE of the passkey, never from the answer at create
// later visits: const { seed } = await openPasskey(setup);
The passkey calls, and three things to know

The calls to the browser's passkey (navigator.credentials) come from the package. Use them as they are (the lines above show how).

  • The seed comes from a use of the passkey, not from the answer at create. Nothing in the passkey standard says that a provider gives the same value at both.
  • The answer is strict. The code takes a buffer or a list of exactly 32 bytes, and never 32 equal bytes. A faulty provider that gives a blank answer would give every person the same purse.
  • Show the seed as a spare key before any XNO goes in. A lost passkey is a lost purse, unless the person kept the seed.
// purse.mjs: the passkey's answer becomes a seed, and the seed becomes the purse
const seed = await seedFromPrf(prf, SCHEME);   // 64 hex characters; it never leaves the browser
const address = purseAddress(seed);            // the account that signs in
Check

run node signin.mjs passkey-address 1,2,3,…,32 (the numbers 1 to 32): it prints nano_116an8efpjddh38hrkpjgbrbbrmu6brcrxbqkg63gmuj3aziejhac7hy9bfy, every time. A short answer, or 32 equal bytes, makes no purse.

Step 3Sign the text

Sign the text in the browser, and post three values. Never post the seed.

const body = signinBody("example.com", seed);
// { address: "nano_…", at: 1790000000000, signature: "…128 hex characters…" }
await fetch("/signin", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
What is signed, and what is not posted

The browser signs the text with the purse and posts three values. The signature is over the hex of the text's UTF-8 bytes (the lines above).

The key is made from the seed for the moment it signs. The seed is not in the body, in the address or in the signature, and your server never gets it.

Rule

A seed never leaves the person's browser. Never post it, store it, log it or put it in a URL.

Check

run PURSE_SEED=<a throwaway seed> node signin.mjs sign example.com: it prints address, at and signature, and nothing else. The same seed, text and time give the same signature.

Step 4Check the signature, strictly

Check the posted signature on your server, in this order, and give each failure its own answer.

if (!validAddress(address))                     throw new SigninError(400, "that is not a whole Nano address");
if (Math.abs(now - at) > 5 * 60_000)            throw new SigninError(400, "that sign-in is too old; try again");
if (!/^[0-9A-F]{128}$/i.test(signature))        throw new SigninError(400, "that is not a signature");
const text = signinMessage(DOMAIN, address, at); // build it; never take the text from the request
const why = whyNotSigned(address, text, signature); // weak key, second form, or a signature that does not match
if (why) throw new SigninError(401, why);
The six checks, in words

Your server reads the body and decides. Check these in this order. Each one has its own answer, so a log shows which one failed:

  1. The address is whole: its shape and its checksum (the last 8 characters). The wallet library reads the key from the first 52 characters and does not look at the checksum, so check it yourself. Otherwise one key can sign under many spellings of one account.
  2. The time is close: not more than 5 minutes before or after your clock.
  3. The signature has the right shape: 128 hex characters.
  4. The key is not a weak key. A few public keys have a small order, and nobody holds a key for them. The burn address is one. Refuse them before you read the signature.
  5. The signature has its one right form. The second half of a signature must be below the group order. The same signature can be written in a second form, and a record of used sign-ins keyed on the signature's bytes would not see that they are one.
  6. The signature is valid for the text that you build yourself from your domain, the address and the time.

Your server builds the text itself. It never signs or checks a text that the request brought.

Rule

Check the whole address (shape and checksum) before anything else. Refuse weak keys and the second form of a signature. Build the signed text on the server from your own domain.

Check

sign for example.com and check it for example.com: signed in as nano_…. Check the same body for other.example, or with another account in address: the signature does not match. An address with a wrong checksum, the burn address and the second form of the signature are each refused with their own answer.

Step 5Count each sign-in once

Record each accepted sign-in once, under a unique key.

const key = sha256(`${ONCE_LABEL}|${text}`);   // your own label; never change it
// In a database: insert a row with this key, and the account on the row. A duplicate key means “already used”.
//   SQL:       insert into signins (key, address, delete_at) values (…)  -- a unique key; refuse on a conflict
//   Firestore: in a transaction, read the document and write it only when it is missing (the kit does this)
Why once, and how to write it

A signature that was seen once can be posted again by anyone who saw it. So record every accepted sign-in with a unique key, and refuse a key that is already there. The key is the hash of the signed text, not of the signature, because one signature can have two forms (the lines above).

  • Keep the account on the row. A “delete my data” then finds the person's rows.
  • Give each row a deleteAt about 10 minutes ahead (twice the window), and add a time-to-live policy on it. After the window, the time check refuses the sign-in anyway.
  • The write must be atomic. Two requests at the same moment must not both pass.
Rule

Count a sign-in once, by the hash of the signed text, in a write that fails when the key exists.

Check

post the same body twice: the first signs in, the second answers that sign-in was already used. A body that is 6 minutes old, or 6 minutes ahead, answers too old.

Step 6Make the session

Make a new session at every sign-in, and keep only the hash of its token.

const token = randomBytes(32).toString("hex");           // a new one at every sign-in
store(sha256(token), { address, expiresAt });             // the server keeps only the hash
Set-Cookie: session=<token>; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=2592000
The session, in five points

When the check passes, make a new session, whatever the browser sent before (the lines above).

  • Keep only the hash of the token. A reader of your database then cannot use a session.
  • Show “Signed in as nano_…”, so a person can see which account they are in.
  • Learn who is signed in from the session only. Never take an account from a request parameter.
  • Rate-limit the sign-in route for each visitor. A check costs your server a signature check.
  • Refuse a sign-in that another site sent. A page on another site can make a visitor's browser post to your route and sign that browser in to an account of the attacker's choice (login CSRF). A browser marks such a request with Sec-Fetch-Site and Origin. Allow same-origin and none, and an Origin that is exactly your site. A request with neither header comes from a server or an old client, which cannot carry a visitor's cookie for another site.
Rule

Make a new session at every sign-in. Learn who is signed in only from the session. Check the origin on every route that changes something, and rate-limit the sign-in route.

Check

sign in twice: two different cookies, each with HttpOnly, Secure and SameSite=Lax. whois with a cookie answers the account; with the stored hash, or with a made-up value, it answers nobody. A request with an Origin that is another site, null, or a Sec-Fetch-Site of cross-site or same-site is refused.

Other ways: Nautilus, NanChat and Berry Wallet

The three wallet apps

A wallet that cannot make a purse in your page can still sign a person in. The package @nano133/signin has the server side of three wallet apps: Nautilus, NanChat and Berry Wallet. In each, the browser starts a request, the person approves it in the app, and the browser asks until the app has answered. All three come to the same check: a signature by the account, once. Only the browser that started the request is signed in by it.

Read the package's README for these. One rule matters more than the code:

Rule

A wallet app's request can be sent to someone else, who approves it by mistake. Do not take such a sign-in as proof for an action that cannot be undone. nano133.com marks a sign-in made this way, and the page that deletes a person's data shows how the person signed in (“signed in with NanChat 3 minutes ago”) before it goes on.

Do not build a sign-in by payment. nano133.com ended it on 2026-10-09.

Run the whole thing

The two files below need two packages. They do the steps 1 to 6 with real signatures, and need no Nano node and no money. Make a throwaway seed, sign, and check:

npm install nanocurrency-web @noble/curves
export PURSE_SEED=$(openssl rand -hex 32)                         # a throwaway: it makes a test purse
node signin.mjs sign example.com > body.json
node signin.mjs check example.com ./records "$(cat body.json)"    # signed in as nano_…
node signin.mjs check example.com ./records "$(cat body.json)"    # refused (409): that sign-in was already used
purse.mjs69 lines
purse.mjsDownload
// The browser half of "Sign in with Nano": a purse, and the signature that signs a person in.
// From the nano133.com guide "Sign in with Nano". MIT license.
// One package (npm install nanocurrency-web). It needs no Node module, so a bundler can put it in your page.
// Use it in the browser. If you run it on a server, never let a seed leave the person's device.
//
// A purse is account 0 of a seed. The seed stays in the browser. The server never gets it.

import nano from "nanocurrency-web";

const { wallet, tools } = nano;

/** Step 1. The text that is signed. It names your domain, the account and the time (in ms). */
export const signinMessage = (domain, address, at) => `${domain}: sign in as ${address} at ${at}`;

const hexOf = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");

/** The address of a seed's account 0. */
export const purseAddress = (seed) => wallet.fromLegacySeed(seed).accounts[0].address;

/** Step 3. Sign a text with account 0 of the seed. The signature is over the hex of the text's UTF-8 bytes. */
export function signText(seed, text) {
  const account = wallet.fromLegacySeed(seed).accounts[0];
  return tools.sign(account.privateKey, hexOf(new TextEncoder().encode(text)));
}

/** Step 3. What the browser posts to sign in: { address, at, signature }. */
export function signinBody(domain, seed, at = Date.now()) {
  const address = purseAddress(seed);
  return { address, at, signature: signText(seed, signinMessage(domain, address, at)) };
}

// ---- Step 2. A seed from a passkey ----------------------------------------------------------------------------
// A passkey gives the page 32 secret bytes (its PRF answer). They become the seed of the purse. The server has no
// part in this: it keeps no passkey and checks no passkey. The proof of the account is the signature of step 3.
//
// THE SCHEME IS FROZEN. A person's account, and any XNO in it, lies at the address that these exact texts give.
// Choose them one time, test them with fixed vectors, and never edit one character. A new scheme is a new
// versionByte with new texts, never an edit of the old ones. Each domain makes its own account for a person.

/** The bytes the page asks the passkey to evaluate. */
export const prfInput = (scheme) => new TextEncoder().encode(scheme.input);

/**
 * The passkey's answer as 32 bytes, or null. It is strict on purpose: only a buffer or a list of exactly 32 bytes, and
 * never 32 equal bytes (a faulty provider that gives a blank answer would give everyone one purse).
 */
export function prfBuffer(value) {
  if (value === null || typeof value !== "object") return null;
  let bytes;
  if (ArrayBuffer.isView(value)) bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
  else if (value instanceof ArrayBuffer) bytes = new Uint8Array(value);
  else if (Array.isArray(value) && value.length === 32 && value.every((n, i) => Object.hasOwn(value, i) && Number.isInteger(n) && n >= 0 && n <= 255 && !Object.is(n, -0))) bytes = Uint8Array.from(value);
  else return null;
  if (bytes.length !== 32 || bytes.every((b) => b === bytes[0])) return null;
  return Uint8Array.from(bytes);
}

/** The purse's seed (64 upper-case hex characters) from a passkey's PRF answer. HKDF-SHA-256 under your frozen scheme. */
export async function seedFromPrf(prf, scheme) {
  const ikm = prfBuffer(prf);
  if (!ikm) throw new Error("the passkey gave no usable answer");
  const key = await globalThis.crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]);
  const utf8 = (s) => new TextEncoder().encode(s);
  const bits = await globalThis.crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: utf8(scheme.salt), info: utf8(scheme.info) }, key, 256);
  return hexOf(new Uint8Array(bits)).toUpperCase();
}

/** An example scheme for the domain example.com. Write your own, once, with your own domain. Never change it afterwards. */
export const EXAMPLE_SCHEME = { input: "example.com purse v1", salt: "example.com", info: "example.com purse seed v1", versionByte: 1 };
signin.mjs188 lines
signin.mjsDownload
// The server half of "Sign in with Nano": check a signature, count it once, and make a session.
// From the nano133.com guide "Sign in with Nano". MIT license.
// Node.js 20 or newer, two packages (npm install nanocurrency-web @noble/curves). It needs no node and no Nano.
// Your server holds no key and no seed. It checks a signature that the person's browser made (purse.mjs).
//
//   node signin.mjs message <domain> <address> <time in ms>
//       prints the text that a person signs.
//
//   PURSE_SEED=<64 hex characters> node signin.mjs sign <domain> [time in ms]
//       what the browser posts: { address, at, signature }. The seed is a throwaway: it makes a test purse.
//
//   node signin.mjs check <domain> <folder> '<the posted JSON>'
//       checks it as the server does, counts it once, makes a session, and prints who signed in.
//       The folder holds the demo's records: one file for each sign-in, one for each session.
//
//   node signin.mjs whois <folder> '<the Cookie header>'
//       prints who a session cookie belongs to.
//
//   node signin.mjs passkey-address <32 byte values, 0 to 255, with commas>
//       the address that a passkey's answer gives under the example scheme of purse.mjs (it prints no seed).
//
//   node signin.mjs origin <domain> '<Origin header or ->' '<Sec-Fetch-Site header or ->'
//       says whether a request that changes something comes from your own pages.
//
// In a shop, the records are rows in your database (steps 5 and 6), never files.

import { createHash, randomBytes } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import nano from "nanocurrency-web";
import { ed25519 } from "@noble/curves/ed25519.js";
import { EXAMPLE_SCHEME, purseAddress, seedFromPrf, signinBody, signinMessage } from "./purse.mjs";

const { tools } = nano;

const WINDOW_MS = 5 * 60_000; // a sign-in is good for 5 minutes, before or after the time it names
const ONCE_LABEL = "example.com sign-in once v1"; // your own label; never change it
const SESSION_DAYS = 30;
const COOKIE = "session";

/** A refusal. `status` is the HTTP status to answer with. */
export class SigninError extends Error {
  constructor(status, message) {
    super(message);
    this.status = status;
  }
}

// ---- Step 4. Check the signature, strictly ----------------------------------------------------------------------

const ADDRESS = /^nano_[13][13456789abcdefghijkmnopqrstuwxyz]{59}$/;
const SIGNATURE = /^[0-9A-F]{128}$/i;
/** The order of the Ed25519 group: the second half of a signature must be below it. */
const L = 2n ** 252n + 27742317777372353535851937790883648493n;
const littleEndian = (hex) => BigInt(`0x${Buffer.from(hex, "hex").reverse().toString("hex") || "0"}`);

/** A whole address: its shape AND its checksum (the last 8 characters). */
export const validAddress = (a) => typeof a === "string" && ADDRESS.test(a) && tools.validateAddress(a);

/** A few public keys have a small order: forgeries exist for them, and nobody holds a key for them. The burn address is one. */
function strongKey(publicKey) {
  try {
    return !ed25519.Point.fromHex(publicKey.toLowerCase()).isSmallOrder();
  } catch {
    return false; // not a point on the curve at all
  }
}

/** Does this account's key sign this text? It answers a reason when not, and null when it does. */
export function whyNotSigned(address, text, signature) {
  const publicKey = tools.addressToPublicKey(address);
  if (!strongKey(publicKey)) return "this account's key is not allowed";
  // The second half of a signature has one right form. A second form of the same signature would count as a new sign-in.
  if (littleEndian(signature.slice(64)) >= L) return "the signature is in a form that is not allowed";
  return tools.verify(publicKey, signature, Buffer.from(text, "utf8").toString("hex")) ? null : "the signature does not match";
}

// ---- Step 5. Count each sign-in once ----------------------------------------------------------------------------

/** The key of one signed text. Not the signature's bytes: one signature can have two forms. */
export const onceKey = (text) => createHash("sha256").update(`${ONCE_LABEL}|${text}`).digest("hex");

/**
 * Records a key and answers false when it was already there. In a database this is one row with a unique key.
 * Here a file is made with the flag "wx", which fails when it exists: the same guarantee, made by the file system.
 */
export function useOnce(folder, key, address) {
  mkdirSync(join(folder, "once"), { recursive: true });
  try {
    writeFileSync(join(folder, "once", key), JSON.stringify({ address, at: Date.now() }), { flag: "wx" });
    return true;
  } catch (e) {
    if (e.code === "EEXIST") return false;
    throw e;
  }
}

/** Steps 4 and 5. Answers the address that signed in, or throws a SigninError. */
export function checkSignin({ domain, folder, now = Date.now() }, body) {
  if (!body || typeof body !== "object") throw new SigninError(400, "send { address, at, signature }");
  const { address, at, signature } = body;
  if (!validAddress(address)) throw new SigninError(400, "that is not a whole Nano address");
  if (!Number.isFinite(at) || Math.abs(now - at) > WINDOW_MS) throw new SigninError(400, "that sign-in is too old; try again");
  if (typeof signature !== "string" || !SIGNATURE.test(signature)) throw new SigninError(400, "that is not a signature");
  const text = signinMessage(domain, address, at);
  const why = whyNotSigned(address, text, signature);
  if (why) throw new SigninError(401, why);
  if (!useOnce(folder, onceKey(text), address)) throw new SigninError(409, "that sign-in was already used");
  return address;
}

// ---- Step 6. Make the session -----------------------------------------------------------------------------------

const sha = (s) => createHash("sha256").update(s).digest("hex");

/** A new session for an account: a random token for the cookie, and only its hash on the server. */
export function newSession(folder, address, now = Date.now()) {
  const token = randomBytes(32).toString("hex"); // a new one at every sign-in, never one that the browser sent
  mkdirSync(join(folder, "sessions"), { recursive: true });
  writeFileSync(join(folder, "sessions", sha(token)), JSON.stringify({ address, expiresAt: now + SESSION_DAYS * 86_400_000 }));
  const maxAge = SESSION_DAYS * 86_400;
  return { token, cookie: `${COOKIE}=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}` };
}

/** Who a Cookie header belongs to, or null. The only way server code learns who is signed in. */
export function signedIn(folder, cookieHeader, now = Date.now()) {
  const token = String(cookieHeader ?? "").split(";").map((c) => c.trim()).find((c) => c.startsWith(`${COOKIE}=`))?.slice(COOKIE.length + 1);
  if (!token || !/^[0-9a-f]{64}$/.test(token)) return null;
  try {
    const row = JSON.parse(readFileSync(join(folder, "sessions", sha(token)), "utf8"));
    return row.expiresAt > now ? row.address : null;
  } catch {
    return null;
  }
}

/**
 * Refuses a request that another site's page sent (login CSRF: a page elsewhere signing a visitor's browser in to
 * someone else's account). A browser marks such a request with Sec-Fetch-Site and Origin. A request with neither
 * header comes from a server or an old client, which cannot carry a visitor's cookie for another site.
 */
export function sameOrigin({ domain, origin = null, secFetchSite = null }) {
  if (secFetchSite !== null && secFetchSite !== "same-origin" && secFetchSite !== "none") return false; // "same-site" too
  if (origin !== null && origin !== `https://${domain}`) return false; // "null" comes from sandboxed frames: never yours
  return true;
}

// ---- The command line -------------------------------------------------------------------------------------------

async function main(args) {
  const [command, ...rest] = args;
  if (command === "message") {
    const [domain, address, at] = rest;
    console.log(signinMessage(domain, address, Number(at)));
  } else if (command === "sign") {
    const [domain, at] = rest;
    if (!/^[0-9A-F]{64}$/i.test(process.env.PURSE_SEED ?? "")) throw new Error("Set PURSE_SEED to a throwaway seed: 64 hex characters (openssl rand -hex 32).");
    console.log(JSON.stringify(signinBody(domain, process.env.PURSE_SEED, at ? Number(at) : Date.now())));
  } else if (command === "check") {
    const [domain, folder, json] = rest;
    const address = checkSignin({ domain, folder }, JSON.parse(json));
    const { cookie } = newSession(folder, address);
    console.log(`signed in as ${address}`);
    console.log(`Set-Cookie: ${cookie}`);
  } else if (command === "passkey-address") {
    // The address that a passkey's 32 bytes give under the example scheme. It prints no seed.
    console.log(purseAddress(await seedFromPrf(rest[0].split(",").map(Number), EXAMPLE_SCHEME)));
  } else if (command === "whois") {
    const [folder, cookie] = rest;
    console.log(signedIn(folder, cookie) ?? "nobody");
  } else if (command === "origin") {
    const [domain, origin, site] = rest;
    console.log(sameOrigin({ domain, origin: origin === "-" ? null : origin, secFetchSite: site === "-" ? null : site }) ? "same site" : "refused");
  } else {
    throw new Error("Commands: message, sign, check, whois, origin, passkey-address. Read the top of this file.");
  }
}

// Run the command line only when this file is started by itself, not when your server imports it.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  try {
    await main(process.argv.slice(2));
  } catch (e) {
    console.error(e instanceof SigninError ? `refused (${e.status}): ${e.message}` : (e?.message ?? e));
    process.exit(1);
  }
}
Warning

The example keeps its records in files, one for each sign-in and each session. In your site, steps 5 and 6 belong in your database. Checked so far: the server checks, the once-only count and the session, with real signatures that the example purse makes (run node test.mjs; no node is needed). Not checked: the passkey calls in a browser (they come from the package, which nano133.com runs), and a database in place of the files.

Or use the kit

Sign in with Nano packages these steps as @nano133/signin: checkSignatureSignin for the signature route, signinMessage and signinBody for the text and the body, the passkey calls, and the server side of Nautilus, NanChat and Berry Wallet, with stores for Firestore or your own database. The checks are the same as in this guide. Install it with npm install @nano133/signin.

What breaks, and what it costs

  • The passkey scheme changes after people made purses
    Loss in Nano
    Every person gets another account. The XNO stays in the old purse, and the passkey opens it again only with the old texts
    Check
    Step 2: freeze the scheme, keep it in one file, test a fixed vector
  • A person loses the passkey
    Loss in Nano
    The purse and every XNO in it, unless the person kept the seed
    Check
    Step 2: show the seed as a spare key before any XNO goes in
  • A seed reaches your server, a log or a URL
    Loss in Nano
    Everything the purse holds
    Check
    Step 3: the body holds no seed, and you never log a seed
  • The signed text does not name your domain
    Loss in Nano
    None in Nano. A signature made for another site signs in to yours
    Check
    Step 1: the text names the domain; the server builds it
  • An address is taken without its checksum
    Loss in Nano
    None in Nano. One key appears as many accounts, and the records split
    Check
    Step 4: the address is whole before anything else
  • A weak key, or the second form of a signature, is accepted
    Loss in Nano
    None in Nano. Someone signs in to an account that nobody holds, or one sign-in counts twice
    Check
    Step 4: refuse both, each with its own answer
  • A sign-in is accepted twice
    Loss in Nano
    Anyone who saw a sign-in can use it again for 5 minutes and be signed in as that person
    Check
    Step 5: one unique key for each signed text
  • A page on another site posts a sign-in (login CSRF)
    Loss in Nano
    None in Nano. A person is signed in to an account that is not theirs, and may pay it
    Check
    Step 6: check Origin and Sec-Fetch-Site
  • A wallet app's request is approved by the wrong person
    Loss in Nano
    Someone else's browser is signed in as that person
    Check
    The wallet apps section: never use such a sign-in as proof for an action that cannot be undone

Sources

Written by nano133.com from code that runs on this site. Code: MIT license.