NKIPs

NKIP-0004 · Draft

Signed JSON Objects over Nostr Events

Abstract. A publisher signs any JSON object (an app manifest, a catalog, a price list, a policy) with a Nostr key, and ships the object and the signature together in a small envelope. Any Nostr library can check the signature, because what is signed is an ordinary Nostr event whose content is the object's canonical form.

NKIP4
TitleSigned JSON Objects over Nostr Events
StatusDraft
TypeStandards Track
Created2026-10-27
LicenseCC0-1.0
RequiresNIP-01, NIP-78, BIP-340, RFC 7493, RFC 8785

Files: envelope.schema.json · vectors/v1.json

Contents

Motivation

Wallets and app platforms fetch configuration over the network: app manifests, catalogs of installable apps, fee schedules, lists of supported assets. TLS proves which server answered, not who wrote the data, so a compromised host, CDN or mirror can change it. Signing the data fixes that, but each project invents its own canonical form and signature format, and most need a new crypto library.

Nostr clients already carry BIP-340 Schnorr keys and an event verifier. This NKIP signs a JSON object as a Nostr event without changing either.

Goals:

  • G1 A consumer can tell, from the object alone, which key signed it and that nothing was changed since, whichever host or mirror served it.
  • G2 A signature made for one purpose (context) can't be used for another.
  • G3 Any NIP-01 event verifier checks the signature; no new crypto.
  • G4 An old but validly signed object can't replace a newer one.
  • G5 A publisher can move to a new key without consumers losing trust.

Non-goals:

  • Encryption or confidentiality: signed objects are public.
  • Deciding which keys deserve trust. This NKIP defines how a consumer applies its trust list, not who belongs on it.
  • Publishing to relays. The signed event is valid Nostr and MAY be relayed, but transport is out of scope.

Roles

Role Does
Publisher Holds a secp256k1 key. Builds the payload, signs it under a context, ships the envelope.
Consumer Fetches envelopes, verifies them under the context it expects, classifies the signer against its trust list, applies its policy for each verdict.
Host / mirror Serves envelopes. Trusted for nothing: an envelope verifies the same from any host.

Specification

The key words MUST, SHOULD and MAY are used as in RFC 2119.

Payload

The payload is a JSON object that MUST be I-JSON (RFC 7493):

  • UTF-8, no duplicate member names, no lone surrogates in any string;
  • numbers are finite IEEE 754 doubles; integers stay within ±(2^53 − 1).

It MUST NOT contain a member named signature at any depth, and MUST NOT have a top-level member named manifest.

A payload that replaces earlier versions of itself (a catalog, a list) MUST carry an integer revision (see Replay). A payload SHOULD carry an id member naming its context (the d value, below), so the object says what it is when read on its own.

Envelope

{
  "manifest": { "...": "the payload" },
  "signature": {
    "alg": "nostr-schnorr",
    "pubkey": "<64 lowercase hex: BIP-340 x-only public key>",
    "created_at": 1782561600,
    "sig": "<128 lowercase hex: BIP-340 signature>"
  }
}
  • manifest holds the payload, whatever kind of object it is. The name is kept for compatibility with existing deployments.
  • signature is either absent or null (an unsigned envelope), or an object with exactly the four members shown.
  • alg MUST be nostr-schnorr.
  • created_at is an integer from 0 to 2^53 − 1: the signing time in Unix seconds, chosen by the publisher. It is part of what is signed, but it is not a freshness signal (see Replay).
  • An envelope MUST NOT have any other top-level member.

A consumer reading a top-level object that has no manifest member treats it as a bare payload (verdict unsigned). Envelope rules, including the ban on unknown top-level members, apply only once manifest is present: a bare payload is just a payload, whatever its members. The ban on a top-level manifest member inside the payload is what keeps the two readings apart.

Context (d tag)

Every signature is made under a context: a string that the consumer supplies from what it is doing. It is never read from the envelope. It becomes the event's d tag. Examples: example.org:catalog for a catalog, example.org:app:<id> for one app's manifest.

  • The consumer MUST verify under the context it expects for that use. An envelope signed under another context MUST fail.
  • A context MUST NOT contain control characters (U+0000–U+001F, U+007F).
  • A context SHOULD start with a DNS name the publisher's project controls, followed by :. That keeps it apart from other projects and from ordinary NIP-78 app data (see Security considerations).

Canonical form

The payload's canonical form is its RFC 8785 (JSON Canonicalization Scheme) serialization:

  • object members sorted by name, comparing UTF-16 code units;
  • no whitespace;
  • strings and numbers serialized as ECMAScript JSON.stringify does them: shortest round-trip number form (1e+21, 1e-7, -0 becomes 0), and \b \f \n \r \t \" \\ escapes, with other C0 controls as lowercase \u00xx;
  • array order kept.

An ECMAScript implementation needs no library: sort keys with the default sort(), recurse, and emit every scalar with JSON.stringify. Since the payload contains no member named signature, dropping that name (as existing deployments do) leaves the canonical form unchanged.

Signed event

The signature is a NIP-01 event signature over this event:

{
  "kind": 30078,
  "pubkey": <signature.pubkey>,
  "created_at": <signature.created_at>,
  "tags": [["d", <context>]],
  "content": <canonical form of the payload>
}

The event id is SHA-256 over the NIP-01 serialization [0,<pubkey>,<created_at>,30078,[["d",<context>]],<content>], and sig is the BIP-340 signature over that id. Exactly one tag, the d tag; no other tags. Kind 30078 is NIP-78 application-specific data, so the event is a well-formed, addressable Nostr event (see Security considerations).

Verifying

A consumer MUST, in this order:

  1. Parse the bytes as I-JSON. Duplicate members, lone surrogates, non-finite numbers or integers beyond ±(2^53 − 1) → invalid.
  2. No manifest member → unsigned (a bare payload).
  3. Any top-level member other than manifest and signature, a non-object payload, a top-level member named manifest inside the payload, or a member named signature anywhere in the payload → invalid.
  4. signature absent or null → unsigned.
  5. signature not exactly {alg, pubkey, created_at, sig}, alg not nostr-schnorr, or a malformed pubkey, sig or created_at → invalid. A present but broken signature is never unsigned.
  6. Rebuild the event from the expected context and the canonical payload, compute its id and verify sig. Failure → invalid.
  7. Signer in the consumer's trust list for this context → verified, otherwise untrusted.

Verdicts

Verdict Meaning Consumer MUST
verified Valid, signed by a key the consumer trusts for this context Use it, subject to the replay gate
untrusted Valid, signed by some other key Show it as untrusted; apply the context's policy (below)
invalid Malformed or the signature fails Refuse it: don't apply it, don't cache it, keep the previous copy
unsigned No signature Never present it as signed; apply the context's policy for unsigned data

A consumer MUST NOT turn an invalid result into unsigned. In particular it must not strip a broken signature and carry on.

Trust lists and pinning

A trust list maps each context, or a namespace of contexts, to the keys the consumer trusts there. A key trusted for one context MUST NOT become trusted for another unless the user or the consumer's configuration says so.

Each context gets one of two policies. The choice is the consumer's, and it SHOULD be documented:

  • Pinned. The consumer ships with, or is configured with, the keys for the context. untrusted is then treated like invalid. Use this for objects that carry authority on their own: prices, fees, addresses, permission grants, update pointers.
  • Open. Any valid signature is accepted and shown as untrusted until the user trusts the key. This is allowed only when the object grants no authority by itself: for example, a catalog whose entries only point at separately signed objects, which are verified on their own and installed only with the user's consent. An open policy still refuses invalid, and SHOULD refuse unsigned.

Under an open policy, anyone who can serve the object can replace it with one signed by their own key. The defence is that the object can't do anything on its own, not the signature. A consumer that later relies on such an object for more MUST switch the context to pinned.

Replay (revision gate)

A signature has no expiry, and created_at is chosen by the signer, so it can't say which of two objects is newer. For a payload that replaces earlier versions:

  • The publisher MUST increase revision whenever the payload's meaning changes, and MUST NOT reset it, including across a key rotation. It SHOULD keep the bytes stable for an unchanged revision: re-signing at the same revision is fine, but don't put a fresh timestamp in the payload.
  • The consumer keeps the highest revision it has applied for each context, and then:
  • applies a strictly higher revision;
  • keeps its cached copy for an equal revision, and MAY report it as a publisher error when the canonical bytes differ;
  • refuses a lower revision.

This gate runs after verification, and only on verified objects, or untrusted ones under an open policy.

Revision floor. A consumer with no stored revision for a context (a fresh install, cleared storage) would accept any validly signed revision, so a host could serve it an old one, for example a catalog that still lists a pulled app. For pinned contexts the consumer MUST start from a revision floor shipped with it or set in its configuration, and refuse anything below it. Updates to the consumer raise the floor. Open contexts SHOULD do the same.

Key rotation

A publisher moves a context to a new key in one of two ways:

  1. Out of band: a consumer software update or configuration change that replaces the pin.
  2. Rotation object (MAY): a payload {"id": "<context>:rotate", "new_pubkey": "<hex>", "revision": <n>}, signed by the currently pinned key under the context <context>:rotate, and subject to its own revision gate. new_pubkey MUST be 64 lowercase hex (a BIP-340 x-only key), and id MUST equal <context>:rotate. On a verified rotation object that meets both rules, the consumer replaces the pin for <context> with new_pubkey and stops accepting the old key there. A rotation signed by any other key, or with a malformed new_pubkey or id, is ignored and the pin is kept.

A stolen key can rotate to the thief's key. Recovery from that is out of band, which is one reason objects that carry authority stay pinned.

Requirements

ID Level Goal Requirement Acceptance check
R1 MUST G1 The canonical form is RFC 8785 over an I-JSON payload Test vectors: canonical bytes and SHA-256, key-order and number stress case
R2 MUST G1, G3 The signature is a BIP-340 signature over the kind 30078 event with one d tag and the canonical payload as content Test vectors: event id and signatures verify with an independent verifier
R3 MUST G2 Verification uses the context the consumer expects, never one read from the envelope Test vector: wrong context → invalid
R4 MUST G1 Duplicate members, lone surrogates, unsafe integers, a member named signature anywhere in the payload, a top-level member named manifest in the payload, unknown envelope members, unknown alg and malformed signatures are invalid Test vectors: invalid and text cases
R5 MUST G1 invalid is refused and never downgraded to unsigned Test vector: signature object without sig
R6 MUST G1 Trust is per context; untrusted is refused under a pinned policy Code review
R7 MUST G4 Revision gate: higher applies, equal keeps the cache, lower is refused; pinned contexts start from a shipped revision floor Test vectors: replay cases
R8 SHOULD G5 Rotation by a rotation object signed by the pinned key (new_pubkey 64 lowercase hex, id = <context>:rotate), or out of band Test vectors: rotation cases
R9 SHOULD G2 Contexts start with a DNS name the publisher controls Review
R10 MUST before leaving Draft — Schema, vectors and a checker in CI CI on this repo

Security considerations

Threat Likelihood Control
Host, CDN or mirror changes the data High Signature over the canonical payload (R1, R2); the host is trusted for nothing
A signature for one purpose reused for another (a submission replayed as a manifest) Medium The context is supplied by the consumer and signed as the d tag (R3)
An old valid object replaces a newer one Medium Revision gate (R7); created_at is never used for ordering
Rollback on a fresh install: no stored revision yet, so a host serves an old signed object Medium Shipped revision floor for pinned contexts (R7)
Parser differentials: duplicate members, unsafe integers or lone surrogates read differently by two parsers, so the reviewer's tool and the consumer see different data under one signature Medium I-JSON is required, and violations are invalid (R4)
Unsigned data added beside the signed data (a member named signature that canonicalization drops, or extra envelope members) Medium Both are invalid (R4)
Signature stripping: an attacker removes or breaks the signature hoping for the unsigned path Medium A broken signature is invalid (R5). Removing it entirely gives unsigned, which a pinned context refuses and which is never shown as signed
Open-policy objects replaced by an attacker's own signed copy High (for open contexts) By design only objects without authority of their own are open; they show as untrusted, and their targets are verified separately. Anything that carries authority is pinned
Cross-protocol use of a Nostr key: the envelope is a valid NIP-78 event, so a publisher's key that also signs everyday Nostr app data could be asked, by any app holding a signing interface, to sign a kind 30078 event under someone else's context Medium Contexts are namespaced by DNS name (R9). Publishers SHOULD use a dedicated key for publishing, not their everyday identity key. Signing interfaces SHOULD show the d tag of a kind 30078 request
The envelope is published to relays as an addressable event Low Harmless: it is public anyway. A relay copy replaces the publisher's other kind 30078 event with the same d tag, which is one more reason contexts don't overlap with app data
Stolen publisher key Low Key rotation; consumers keep their pin until a rotation object or an update. Recovery from a rotation done by the thief is out of band
Malicious content in a valid object (spoofed names, oversized strings) Medium The signature proves who wrote it, not that it is safe to display. Consumers keep validating and limiting every field

Compatibility

The envelope, event template and canonical function come from an existing deployment (a remote app catalog plus signed app manifests). Its signatures already conform: the canonical function there is RFC 8785 for every I-JSON payload without a signature member, which is all data published so far. Its verifier is more lenient than this NKIP in ways that change verdicts only for crafted input:

  • it accepts any alg;
  • it reports a signature object missing pubkey or sig as unsigned;
  • it drops members named signature at any depth instead of refusing them, and accepts a payload with a top-level manifest member;
  • it accepts duplicate members (last wins), lone surrogates and unsafe integers;
  • it ignores unknown envelope members.

current_practice in the vectors records its answer for each case. Consumers following this NKIP refuse all of these.

Implementations

Add yours with a pull request. Projects by this NKIP's authors are marked.

Project Role Status
Ripley Terminal (authors' project) Publisher and consumer (app catalog, app manifests) The deployment this NKIP is taken from. Its verifier is more lenient on crafted input (see Compatibility)

Open questions

  • Rename manifest to payload in a v2 envelope? Every existing verifier would need the alias.
  • Should the rotation object also carry the first revision the new key may sign, to stop a stolen old key racing the rotation?
  • A detached form (the payload served alone, the signature in an HTTP header) for objects too large to wrap.

Test vectors

nkip-0004/vectors/v1.json, checked by python3 tools/nkip0004.py check nkips/nkip-0004/vectors/v1.json. The checker uses only the Python standard library and is written independently of the signer. It contains:

  • four valid envelopes: a catalog trusted and untrusted, a canonicalization stress payload, and a rotation object. Each comes with the canonical bytes, their SHA-256 and the full event. They were signed by the signer of the deployment this NKIP is taken from;
  • eleven invalid envelopes:
  • a tampered field, a wrong context, a swapped key, a changed created_at, a non-canonical serialization, another event kind;
  • an unknown alg, a missing sig, an upper-case key;
  • a member named signature inside the payload, and a payload with a top-level manifest member;
  • three invalid JSON texts: a duplicate member, a lone surrogate, and an integer beyond 2^53 − 1;
  • three unsigned cases, four revision-gate cases (an equal revision with different bytes keeps the cache and is reported) and five rotation cases (one accepted; an upper-case or short new_pubkey, a signer that isn't pinned and a wrong id are ignored).

The private keys are sha256("NKIP-0004 test key 1") and sha256("NKIP-0004 test key 2"). Test use only. BIP-340 signatures here use random auxiliary data, so implementations check that they verify, not that they reproduce them.

Changelog

  • 2026-10-27: Revision floor for fresh installs; Implementations section.
  • 2026-10-27: Draft. Envelope, kind 30078 template and canonical form from an existing deployment, unchanged, so its published objects conform. Made explicit or tightened:
  • RFC 8785 over I-JSON as the canonical form;
  • the payload may not contain a member named signature;
  • broken signatures are invalid, never unsigned, and only alg nostr-schnorr is accepted;
  • trust is per context, and the pinned and open policies are explicit;
  • the revision gate keeps the cached copy for an equal revision;
  • an optional rotation object with a strict new_pubkey format.

Markdown source · History · Comment