Nostr WoT

Flujo de firma y aprobación

Conecta aprobación, desbloqueo y firma, incluidos lotes y cuentas remotas.

Esta referencia detallada de la API se mantiene en inglés y sigue el código del SDK enlazado. Los ejemplos con variables de aplicación requieren tu firmante, claves, almacenamiento o interfaz.

Use

ts
import { SignerCore } from '@nostr-wot/identity/signing';

const core = new SignerCore({
  vault,        // @nostr-wot/identity/vault
  permissions,  // @nostr-wot/identity/permissions
  identity,     // your source of truth for the active account, locked or not
  approval,     // your prompt: present(request, account) and cancel(origin, requestId, reason)
  activity,     // your log: record(entry)
});

const signed = await core.handle({
  id: 'req_1',
  origin: { kind: 'web', identifier: 'example.com' },
  method: 'signEvent',
  params: { event: { kind: 1, content: 'hello', tags: [] } },
  receivedAt: Date.now(),
});

handle resolves with the method's result and rejects with a SignerError carrying a stable code for every refusal. A refusal is never a null or an empty result, and its message is always fixed text: whatever a port, a store, the vault or a cipher threw is given to the logger and to the activity entry's reason, never to the caller. SignerError carries wireVisible: true, which is what @nostr-wot/identity/bunker checks before forwarding a message.

identity is required. The permission check needs the account and runs before lock state is consulted, and a locked vault cannot name its account, so the host has to. Keep the active account id outside the vault, as the extension does.

Whenever the active account changes, call core.onActiveAccountChanged(previousId, nextId). It rejects everything queued for the previous account and clears the getPublicKey cooldown, so a caller cannot receive the new account's identity from a prompt shown for the old one.

When the host stops trusting a caller (a remote client revoked, a paired device removed, a site forgotten), call core.revokeOrigin(originKey, reason?). It clears that origin's getPublicKey cooldown, which would otherwise admit a revoked client's next connect without a prompt for up to a minute, and rejects everything the origin has queued, prompts on screen included, and stops a batch already executing between items — the items it had already signed come back signed, the rest as rejected carrying the reason. The key is canonicalised as the boundary canonicalises it, and every spelling of a site is covered: example.com revokes https://example.com and http://example.com:8080, and the other way round. It is onActiveAccountChanged scoped to an origin. It is not a deny: store a permission for that. core.clearCooldown(originKey) is the narrow form, for re-asking without cutting the caller off.

Params per method

Methodparams
getPublicKey, getRelaysnone
signEvent{ event: { kind, content, tags, created_at?, pubkey? } }
nip04Encrypt{ pubkey, plaintext }
nip44Encrypt{ pubkey, plaintext, opts?: { scheme: 'pq', recipientKemKey } }
nip04Decrypt, nip44Decrypt{ pubkey, ciphertext }
signPqAttestationnone

Everything is validated by validateRequest at the boundary and nowhere else: an integer kind, a string content, an array of string arrays as tags, pubkeys as 64 lowercase hex characters, and the extension's size limits, applied before anything large is copied or serialised. What comes out is a frozen deep copy: the prompt shows it and the signer signs it, so the two cannot drift.

Origins are canonical. A web identifier is either an http(s) origin, which is what a browser's location.origin is and what @nostr-wot/identity/permissions keys on — canonicalised by that package's own canonicalHttpOrigin (scheme and host lowercased, a default port dropped, credentials or a path refused; never the host's URL, which on React Native folds nothing), with http:// and https:// as different keys and the legacy bare-hostname rule read underneath — or a bare hostname, lower-cased with trailing dots removed. Anything else with a : is refused, so a web caller cannot spell another transport's namespace; that includes a bare localhost:3000 or [::1], which arrive as http://localhost:3000 and http://[::1]:8080 anyway. A nip46 identifier is a lowercase hex pubkey.

The envelope is bounded too, before anything is copied: id at 256 characters, origin.identifier at 512, origin.displayName at 128 and origin.icon at 2048. All four are written verbatim into every activity entry, denied requests included, so an unbounded one would let any connected page fill the user's storage. An opaque origin (location.origin === 'null': a sandboxed iframe, a data: page, Chrome's file://) is refused as a web identifier, because every such page reports the same string and one remembered allow would cover all of them.

Batches

One request carrying many items, one approval, many signatures. On iOS every signature costs a user gesture that nothing can suppress, so ten reactions and two zaps are twelve prompts, roughly a minute of the user's attention; one batch is one prompt.

ts
const result = await core.handleBatch({
  id: 'batch_1',
  origin: { kind: 'web', identifier: 'example.com' },
  items: [
    { id: 'a', method: 'signEvent', params: { event: { kind: 7, content: '+', tags: [['e', id]] } } },
    { id: 'b', method: 'signEvent', params: { event: { kind: 9734, content: '', tags } } },
    { id: 'c', method: 'nip44Encrypt', params: { pubkey, plaintext } },
  ],
  receivedAt: Date.now(),
});
for (const item of result.items) {
  if (item.ok) publish(item.result);
  else console.log(item.id, item.code, item.message);
}

A batch is a first-class request, not a loop over single ones, and it runs the same pipeline in the same order. What differs is deliberate, and each rule is written on #runBatch:

  • Every item is shown. The ApprovalPort gets presentBatch(batch, account) with the whole frozen batch: every item, full content, every tag. It is answered once, for the whole batch: there is deliberately no per-item exclusion, and the assessment that left it out is written on ApprovalDecision in types.ts — say the all-or-nothing rule before the list. A host that does not implement it cannot show a batch, so a batch that needs a prompt is refused as unsupported; a batch every item of which is already allowed still signs. Same for UnlockPort.requestUnlockBatch. A signPqAttestation item is built before the prompt and shown as the signEvent it is, for the reason above, which is also why a batch carrying one unlocks first and refuses outright when the account cannot produce it.
  • Permissions are per item. A batch of kinds 1, 7 and 1059 reads the rule for each, before lock state. A deny on any item refuses the whole batch as permission_denied before any prompt: a stored deny is the user's standing answer, and neither re-asking it nor hiding it from the prompt is acceptable. Allowed items are still shown; remember persists only the rules that were unset.
  • Partial failure is per item, and the result says which. handleBatch resolves with a BatchResult whose items carry { id, ok: true, result } or { id, ok: false, code, message }, in order. Each item is signed in its own withPrivkey scope, so eight of ten sign and the vault locks gives eight signatures and two vault_locked outcomes; one undecryptable message is one operation_failed, not a failed batch. A refusal of the batch as a whole rejects with a SignerError like handle does, but only when it is decided before execution begins: malformed, denied, rejected by the user, timed out, the account switched while the prompt was open, the vault locked with no way to open it. Nothing had been computed then, so there is nothing to report per item.
  • A switch or a revocation partway through is reported per item too. Once the first item has been attempted the answer is always a BatchResult. A revocation after item 8 of 10 gives eight signatures and two { ok: false, code: 'rejected' } outcomes carrying the host's revocation reason; an account switch gives eight and two account_switched. The eight were computed under an account and a permission valid at the moment each ran, with the key pinned to the account the prompt showed, and withholding them would not un-sign them — it would only tell the caller that nothing happened when eight things did, and write ten denials into the activity log that never occurred. The distinct codes are what let a caller tell a batch cut off from outside apart from items that failed on their own, and tell it not to retry a revoked origin. No further signature is computed after the stop; the reasoning, and the defensible opposite, are written out on #runBatch in core.ts.
  • The host gets a turn between items. Each gap between two items is a real macrotask, not an await Promise.resolve(), so a pending timer, a frame and a gesture can run in it. Without it a 64-item post-quantum batch was 1.4 seconds of unbroken CPU during which a 1 ms timer did not fire once; see the table under Post-quantum for what it costs and buys.
  • A batch is one queued request. It counts once toward MAX_PENDING_PER_ORIGIN, because that cap bounds prompts and a batch is one prompt. Its size is bounded at the boundary instead: MAX_BATCH_ITEMS (64) and MAX_BATCH_BYTES, which equals MAX_EVENT_BYTES on purpose so a batch can hold no more than one request can. Both bite before any item is walked or copied.
  • Only key methods batch. getPublicKey and getRelays have their own consent model and are refused as items; a transport answers them through handle. A remote (NIP-46) account cannot batch: the bunker runs its own approval per request.

Every item lands in the activity log under its own id with batchId set, whatever happened. onActiveAccountChanged rejects a queued batch as it rejects a single request, and the port's cancel names the batch id.

Remote signers

A remote (nip46) account's signEvent goes out through RemoteSignerPort.execute, and what comes back is verified before the caller sees it: the author is the account the request was resolved for, the kind, content and tags are the ones the request named, a stated created_at came back unchanged (an unstated one is the remote's to choose, as it is locally), and the signature verifies. Only the seven fields a signature covers are returned, so a decorated reply cannot smuggle unverified data into an object a caller treats as verified. A bunker is a separate process reached over relays; unverified, it can return a signature over a different event than the one asked for, which the caller then publishes as the account's own words.

The refusals are named separately, because they are different things for a host to report: author_mismatch for another key's signature, and operation_failed with Remote signer changed the approved event, ... did not return an event, ... did not return a signed event or ... returned an invalid signature.

One trap worth knowing if you write your own verification: nostr-tools memoises verification on a module-private symbol that finalizeEvent stamps, and an object spread copies own symbol properties. A port that uses nostr-tools itself can sign one event, spread it with a different id, and hand back something verifyEvent returns true for. Verification here runs on a freshly built plain object, which carries no such stamp.

Optional ports

PortWithout it
unlockA request that needs the key while the vault is locked is refused after the permission gate. Its requestUnlockBatch is optional; without it a batch that finds the vault locked is refused.
remoteA NIP-46 account cannot sign or encrypt.
relaysgetRelays answers {}.
loggerA failing activity log is silent. It never fails the request.

Queue

MAX_PENDING_PER_ORIGIN (5) caps the prompts one origin may have open; unlock markers and in-flight remote work do not count toward it, and are bounded by the in-flight caps instead. REQUEST_TIMEOUT_MS (120 s) rejects anything unanswered. core.pending() lists what is waiting, for a badge or a list; core.cancel(origin, id) settles one from the host's side, and the port's cancel(origin, id, reason) is told the origin for the same reason, scoped to the origin because NIP-46 request ids are chosen by the client.

Authentication requests

The signing boundary parses kind 22242 and 27235 with @nostr-wot/identity/permissions before asking. Malformed or ambiguous tags, stale timestamps and insecure destinations fail before signing. By default, authentication reaches the local approval port, including remote accounts and callers with a broad event-kind allow. Explicit destination grants and host self-authentication are described below. The third present(request, account, context) argument contains the parsed protocol, exact URL, method and cross-origin status; the approval must explicitly return { allow: true, authenticationScope: 'once' }. A generic allow is insufficient. Authentication items must be sent individually rather than in a batch.

The core uses the transport-attested web origin. A web host that resolves it itself provides authentication.originFor(request, account). Never derive this value from event tags. authentication.assertAllowed lets the host enforce connected-site state, frame restrictions, privileged endpoint policy and destination-grant revocation. It runs before approval and again before and after signing, along with timestamp, permission and account checks. Hosts implementing remembered grants or opt-in backend automation can use AuthenticationGrants in their approval adapter and must revalidate that authorization in assertAllowed; the core does not silently turn kind rules into destination grants.

Requesters that are not web pages

A phone signer's callers are a NIP-46 client (a pubkey its pairing proved), an Android package (the name the binder reported), a LAN peer (a paired device id) and the app itself. None of those is a web origin, and an originFor that maps them to one would be inventing an identity. Such a host configures authentication.requesterFor(request, account) instead, returning the typed AuthenticationRequester its transport established:

ts
import { requesterOf, type AuthenticationPolicy } from '@nostr-wot/identity/signing';

const authentication: AuthenticationPolicy = {
  // Right only when every transport establishes identity before building the request.
  requesterFor: async (request) => requesterOf(request.origin),
  grants: permissions.authentication,
  self: { requesters: [{ kind: 'local', id: 'my-app' }] },
};

originFor and requesterFor are exclusive; configuring both throws. A non-web requester with neither configured is refused as invalid_request, as before. For a non-web requester the parser refuses an origin or client-origin tag outright (there is no string such a tag could truthfully hold), treats the request as cross-origin for both protocols, and refuses legacy login. The grant key is authenticationRequesterKey(requester): the canonical origin for a page, kind:identifier otherwise, which is exactly what permissionOrigin spells for the same request, and which can never collide with a web origin in either direction. The approval context carries it as context.requester.

Stored consent is an opt-in. With authentication.grants (an AuthenticationGrantsPort; Permissions.authentication satisfies it, as does a host store over the same records) the core reads the store before every authentication prompt and writes it after an approval. A stored deny refuses as permission_denied without a prompt, for every requester kind. A stored allow skips the prompt, and the prompt may answer authenticationScope: 'site' or 'connected-sites' (relays only), for a requester whose kind is in authentication.rememberFor, whose default is every kind but web. The scopes the core will accept are handed to the prompt as context.scopes; anything else refuses the request. A remembered refusal (allow: false, remember: true) is stored as a destination-scoped deny for that requester, not as the cascade's kind deny. The grant is written after every revalidation, immediately before the key is used, and the store is re-read after the unlock and after the signature: a deny recorded in the meantime refuses, and a request that rode a stored allow is refused (permission_denied, "Authentication permission revoked") once that allow is gone. A store that fails to record an approval refuses the signature as internal; once writes nothing and does not depend on it.

A web requester keeps the extension's behaviour under every configuration: every request prompts, only once is accepted, and a stored allow is set aside. A host adds 'web' to rememberFor on purpose if it wants the core, rather than its approval adapter, to hold a page's grants.

The host's own relay login. authentication.self.requesters names the requesters that are the host app, a local label only in-app code can spell; a web requester is refused at construction. A NIP-42 event from one of them, sent through core.handleSelf(request), is signed with no destination prompt and no grant written, because the question "may this caller log you in to this relay" has nobody else in it. Everything else still runs: the cascade's deny, a stored deny grant, assertAllowed, the unlock, every revalidation, and an activity entry with authentication.consent: 'self'. NIP-98 is refused on this path (permission_denied): an HTTP token is a bearer credential, and a host flow that needs one signs it through its own explicit path. The same requester arriving through handle with an authentication event is refused as permission_denied, so a transport that forged the host's origin gets nothing; an ordinary signature from that origin through handle is unchanged. handleSelf is for the host's own code and must not be handed to a transport.

Every activity entry for an authentication event that parsed carries authentication: { protocol, destination, method?, requester, consent? }, where consent is the scope the user answered, grant for a stored allow, or self.

Legacy website login compatibility is off by default. Hosts can set exact authentication.legacyLoginOrigins; only the narrow domain/challenge format is accepted. The approval context identifies legacy-login, which the host must display as a nonstandard authentication warning with guidance to contact the client developers. It always requires one-time consent and never persists a blanket allow or denial. This matches the extension's src/domain/signing/authentication.ts and src/services/signing/signer.ts boundaries without embedding a particular client's identity in the library.

Referencia de API