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.
| NKIP | 4 |
|---|---|
| Title | Signed JSON Objects over Nostr Events |
| Status | Draft |
| Type | Standards Track |
| Created | 2026-10-27 |
| License | CC0-1.0 |
| Requires | NIP-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>"
}
}
manifestholds the payload, whatever kind of object it is. The name is kept for compatibility with existing deployments.signatureis either absent ornull(an unsigned envelope), or an object with exactly the four members shown.algMUST benostr-schnorr.created_atis 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.stringifydoes them: shortest round-trip number form (1e+21,1e-7,-0becomes0), 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:
- Parse the bytes as I-JSON. Duplicate members, lone surrogates,
non-finite numbers or integers beyond ±(2^53 − 1) →
invalid. - No
manifestmember →unsigned(a bare payload). - Any top-level member other than
manifestandsignature, a non-object payload, a top-level member namedmanifestinside the payload, or a member namedsignatureanywhere in the payload →invalid. signatureabsent ornull→unsigned.signaturenot exactly{alg, pubkey, created_at, sig},algnotnostr-schnorr, or a malformedpubkey,sigorcreated_at→invalid. A present but broken signature is neverunsigned.- Rebuild the event from the expected context and the canonical payload,
compute its id and verify
sig. Failure →invalid. - Signer in the consumer's trust list for this context →
verified, otherwiseuntrusted.
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.
untrustedis then treated likeinvalid. 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
untrusteduntil 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 refusesinvalid, and SHOULD refuseunsigned.
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
revisionwhenever 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:
- Out of band: a consumer software update or configuration change that replaces the pin.
- 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_pubkeyMUST be 64 lowercase hex (a BIP-340 x-only key), andidMUST equal<context>:rotate. On averifiedrotation object that meets both rules, the consumer replaces the pin for<context>withnew_pubkeyand stops accepting the old key there. A rotation signed by any other key, or with a malformednew_pubkeyorid, 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
pubkeyorsigasunsigned; - it drops members named
signatureat any depth instead of refusing them, and accepts a payload with a top-levelmanifestmember; - 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
manifesttopayloadin a v2 envelope? Every existing verifier would need the alias. - Should the rotation object also carry the first
revisionthe 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 missingsig, an upper-case key; - a member named
signatureinside the payload, and a payload with a top-levelmanifestmember; - 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 wrongidare 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, neverunsigned, and onlyalgnostr-schnorris 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_pubkeyformat.