@forgesworn/kenspeckle
v0.2.0
Published
Verified relationships across three social distances — kin (family), kith (mutually verified), ken (one-way recognised) — with handshake, bond ceremony, ken trust-store, and local presence discovery.
Maintainers
Readme
@forgesworn/kenspeckle
Verified relationships across three social distances — kin (family), kith (mutually verified), ken (one-way recognised) — with the handshake, bond ceremony, ken trust-store, local presence discovery, and invite flows. Protocol-neutral, pure functions, no storage, no graph traversal.
The problem
An app wants to hold a user's relationships — family, people they've met and verified, public keys they recognise — without becoming a social network: no feed, no follower count, no central social graph, no shadow profiles. It also wants two ceremonies most contact systems lack: a bond (two people mutually verify each other out-of-band by speaking short rotating words from a shared secret) and a live key-control challenge (a public figure proves an in-game account really holds the key you pinned).
kenspeckle is those primitives as plain data + pure functions. It does not store
anything, render anything, or traverse a graph (those are explicit non-goals). It
holds relationships, derives ceremony words, pins and challenges recognised keys,
and tests a user's own contacts against a server's published presence filter
(via the sibling @forgesworn/tessera-kit) —
locally, never uploading contacts.
Naming. kenspeckle (Scots: easily recognised, known by sight) was developed as
@forgesworn/kindredand renamed before first publish — "Kindred" is now the ForgeSworn app-suite brand. The wire protocol keeps the historicalkindrednaming, frozen:'kindred:bond','kindred:members:','kindred-bond', kinds 30444/30445, and theKINDRED_*/KindredEntry/KindredTieridentifiers are unchanged — renaming them would change every deployed bond word and published filter.
Honest scope (read SECURITY.md): persona-scoping is a consumer obligation kenspeckle assists but cannot guarantee (no storage). NIP-05 is a DNS/TLS/HTTP TOFU anchor, not key-continuity.
attributeSignaturealone is replayable — impersonation resistance needsverifyKeyControlagainst a fresh nonce. Private annotations are never serialised. A forged presence filter is a doxxing primitive — verify it against a pinned server key.
Install
npm i @forgesworn/kenspeckleESM-only, Node ≥ 22. nostr-tools is a peer dependency (so you and kenspeckle
share one event type) — install it alongside.
The @forgesworn/tessera-kit sibling dependency
kenspeckle depends on @forgesworn/tessera-kit
(^0.2.1, from npm), which provides the signed membership filters that discovery
verifies. A scripts/check-publishable-deps.mjs guard runs in prepack and
refuses to publish while any dependencies / optionalDependencies /
peerDependencies entry is a git/file/http(s) spec, so kenspeckle can only ship
against registry versions.
Quick start
kith — handshake → ECDH bond → spoken-word ceremony
Two people exchange a handshake, derive the byte-exact shared secret, then read each other short rotating words. The word I speak differs from the word I expect (directional — the listener can't parrot it back).
import { buildHandshakePayload, parseHandshakePayload } from '@forgesworn/kenspeckle/handshake'
import { deriveBondSecret, bondWords, verifyBondWord } from '@forgesworn/kenspeckle/bond'
// (1) I present my persona + a fresh nonce over QR/NFC/relay; they parse it.
const blob = buildHandshakePayload({ pubkey: myPubHex, nonce: my16ByteHexNonce })
const theirs = parseHandshakePayload(receivedBytes) // validates; displayName is VERBATIM
// (2) Each side derives the SAME ECDH secret. (myPriv × their x-only pubkey, hash x.)
const secret = deriveBondSecret(myPrivHex, theirs.pubkey)
// (3) Read words for an agreed counter. I say `mine`; I expect to hear `theirs`.
const { mine } = bondWords(secret, myPubHex, theirs.pubkey, counter)
speakAloud(mine)
// (4) Verify what THEY spoke (constant-time compare under the hood).
const result = verifyBondWord(secret, myPubHex, theirs.pubkey, counter, whatTheySaid)
if (result.ok) { /* bonded — persist a KithEntry, encrypt sharedSecret at rest */ }Migrating from signet-app's
signet-me? By DEFAULTbondWordsuses namespace'kindred:bond', so its words differ fromsignet-me's ('signet:me') — a naive migration changes the words. To cross-verify with a peer who hasn't migrated yet, reproducesignet-me's words by passingbondWords(secret, myPub, theirPub, counter, { namespace: 'signet:me' })andverifyBondWord(…, { namespace: 'signet:me', tolerance: 1 })(each seat passes its OWN pubkey first). Both peers must either upgrade together or pass this signet-me namespace.
ken — pin a public figure, then prove LIVE control
attributeSignature ("did the pinned key sign this old event?") is replayable.
To bind an in-game account to the real key, issue a fresh nonce and verify the
claimant signs it.
import { pinKenFromNip05, buildKeyControlChallenge, verifyKeyControl } from '@forgesworn/kenspeckle/ken'
// Pin via NIP-05 — refuses to pin unless the name resolves (TOFU; HTTPS-only fetch).
const entry = await pinKenFromNip05('[email protected]', myGamingPersonaPubHex, fetch)
// Live challenge: the claimant signs an event whose `content` IS the nonce.
const { nonce } = buildKeyControlChallenge()
const signed = await askClaimantToSign(nonce) // their client builds + signs it
const proof = verifyKeyControl(entry, nonce, signed)
if (proof.ok) { /* the account in front of me holds the pinned key, live (no replay) */ }discovery — "which of my contacts are here?" (locally)
A server publishes a signed, non-enumerable presence filter (@forgesworn/tessera-kit). The
client parses it, verifies it against a pinned server key, then tests its own
contacts — scoped to one persona.
// `parseFilter` is re-exported from `./discovery` (it delegates to `@forgesworn/tessera-kit`
// internally) so a consumer importing only kenspeckle's discovery subpath never has to reach past
// it into tessera-kit directly.
import { discoverPresent, parseFilter, parseFilterPublication } from '@forgesworn/kenspeckle/discovery'
// Pull the kind-30444 publication for the (namespace, serverId) YOU chose to fetch — as of 0.2.0
// this REQUIRES opts, since @forgesworn/tessera-kit now binds the in-blob signature to a `context`
// string built ONLY from opts (kindred: `kindred:members:<namespace>:<serverId>`), never from the
// event's own d-tag — that is what stops a relay from relabelling a substituted blob. Verifies BOTH
// the Nostr event sig and the in-blob Schnorr sig (bound to that context), that the event's own
// d-tag equals it, and — default true — that the event's signer IS the in-blob signer
// (`requireAuthorIsSigner`). Returns null on any failure.
const pub = parseFilterPublication(rawWireEvent, {
namespace: 'com.example.game',
serverId: 'play.example.com',
}) // pass the RAW event — not a spread-mutated one
if (!pub) throw new Error('untrusted publication')
// Provenance gate: the IN-BLOB signer must be a key you pinned out-of-band.
if (pub.signerPubkeyHex !== PINNED_SERVER_PUBKEY) throw new Error('unpinned server')
// Test MY contacts, scoped to one persona (throws on mixed-owner input — anti-correlation; also
// throws if a salt is passed to an OPEN filter, or omitted for a KEYED one — either mismatch
// silently matches nothing otherwise).
const filter = parseFilter(pub.blob)
const present = discoverPresent(filter, myEntries, myGamingPersonaPubHex /*, salt if keyed */)
// `present` is a CANDIDATE list — confirm-on-connect with a ken key-control challenge before acting.API by subpath
The . entry carries the model + local ops; the protocol ceremonies are separate
subpaths (so a consumer that only needs ./bond doesn't pull the discovery /
@forgesworn/tessera-kit graph).
. (model + local ops)
| Export | Purpose |
|--------|---------|
| types KindredTier, KindredEntry (KinEntry/KithEntry/KenEntry), MutualEntry, KenProvenance, KenRotation, PrivateAnnotations, WireEntry, SyncEntry, DistributiveOmit | the relationship model — KenEntry.corroborations? records additional independent channels agreeing (provenance stays the required, singular primary). WireEntry/SyncEntry are built with DistributiveOmit so narrowing on tier still sees tier-specific fields (sharedSecret, provenance, relationship, …) |
| EventTemplate, NostrEvent, NostrFilter | re-exported nostr-tools aliases — one canonical event type |
| hasSharedSecret(entry) | type predicate — narrows to the kin/kith arms that carry a sharedSecret |
| scopeToPersona(entry, personaPubkeyHex) / assertOwnedPersona(personaPubkeyHex, myLeaves) | persona scoping (the latter throws unless owned) |
| searchEntries(entries, query) | local search over name / pubkey / annotations (searchable locally, never serialised) |
| linkForRecall(entries, ids) / unlink(entries, pubkey) | private "these pubkeys are one human" grouping (mutates in place) |
| toWire(e) / serializeEntry(e) | true wire-safe view + byte-stable canonical JSON — strips both annotations and sharedSecret (0.2.0: previously only annotations). Safe to hand to any third party. A kin/kith WireEntry therefore does not round-trip through parseEntry — the secret's absence is intentional, not a bug |
| toSyncForm(e) / serializeEntryForSync(e) | KEEPS sharedSecret (strips only annotations) — for the ONE legitimate case that needs the secret to travel: syncing a roster across the user's OWN devices over an already-private/authenticated transport. Never publish this output |
| parseEntry(s) | hardened parse; reconstructs serializeEntryForSync's output (requires sharedSecret for kin/kith either way) |
| exportEntriesEncrypted(entries, key) / importEntries(blob, key) | XChaCha20-Poly1305 self-backup — includes annotations (the user's own sealed copy, not a graph disclosure). Backups now carry a KSBK version header; older (pre-header) backups are still readable by importEntries |
| toGrantView(entry) / buildGrantEnvelope(scope, views, publishedAt, opts?) / parseGrantEnvelope(s) | companion data-rail envelope: a secret-stripped "read & pick" contact view (GrantContactView — structurally omits sharedSecret/annotations/bondAssertion/provenance) a companion app can be granted, scoped by tier/persona (GrantScope) |
./companion-rail
Pure wire ownership shared by Signet producers and companion consumers:
buildPairingUri / parsePairingRequest, buildPairingAck /
parsePairingAck, applyCompanionSnapshot, the pairing/snapshot types, and
the frozen ACK_KIND, SNAPSHOT_KIND, and SNAPSHOT_D_TAG constants. Relay
subscriptions, encryption, device keys, clocks, storage, retry policy and UI
remain in the consuming app.
Return rail — WireKen, buildReturnEnvelope / parseReturnEnvelope,
landReturnedKen, and the RETURN_D_TAG / RETURN_ADDITIONS_CAP /
RETURN_CORROBORATIONS_CAP constants. A companion app holds no identity keys, so
it can never mint a kith/kin bond — it can only propose a ken. It can,
however, carry the provenance it claims (claimedProvenance /
claimedCorroborations): the companion is often where the evidence actually is,
and scanning someone's code while standing next to them is real in-person
provenance. landReturnedKen preserves that claim as a corroboration
namespaced companion:<appName>:<locator> (with confirmedAt clamped to now),
while the primary provenance stays { source:'manual',
locator:'companion:<appName>' } — what Signet can attest itself. A claim is
preserved, never promoted: no app but Signet is a source of identity truth.
./handshake
buildHandshakePayload(p) → bytes; parseHandshakePayload(blob) (8192-byte cap,
all fields validated, displayName returned verbatim). Type HandshakePayload.
./bond
deriveBondSecret(myPrivHex, theirPubHex) (byte-exact ECDH); bondWords(secret,
aPub, bPub, counter, opts?) → { mine, theirs }; verifyBondWord(…, spoken, opts?)
→ { ok }; buildBondAttestation({ subjectPubHex, summary? }) → kind-31000
EventTemplate (type:'kindred-bond').
Retracting a bond: buildBondRevocation({ subjectPubHex, reason? }) is now the
primary way to retract — it emits a kind-31000 ["status","revoked"] event
(nostr-attestations createRevocation) at the SAME addressable slot, so it
replaces the attestation and verifyBondAttestation rejects it as revoked. Sign
it with the key that signed the attestation.
retractBondAssertion(assertion, { attesterPubHex, subjectPubHex }) remains as a
supplement: a kind-5 NIP-09 deletion carrying e (the attestation event id), a
(its addressable coordinate) and k (kind 31000) tags.
Const KINDRED_BOND_NAMESPACE. The optional opts
({ namespace? }, plus tolerance? on verifyBondWord)
exist for signet-me migration compatibility — { namespace: 'signet:me',
tolerance: N } reproduces signet-app's signet-me words so a
migrated contact can cross-verify with an un-migrated peer; defaults
('kindred:bond' + tolerance 0) keep the existing behaviour but
differ from signet-me's words.
deriveCeremonyCounter(nonceAHex, nonceBHex) → number — the counter for an
in-person bond ceremony: both parties derive the SAME counter from their two
handshake nonces (SHA-256, domain-tagged CEREMONY_COUNTER_TAG), symmetric in
argument order. timeCounter(nowSec, periodSec) → number — Math.floor(nowSec /
periodSec), for a later re-verification (no fresh nonces on hand); the result
must fit the uint32 counter range, otherwise it throws (it is not clamped). A
fixed counter is not recommended — see PROTOCOL.md §2.
normalizeSpokenWord(s) → string — NFKC + trim + lowercase; verifyBondWord
already applies this to spoken before comparing, so callers don't need to.
import { deriveCeremonyCounter } from '@forgesworn/kenspeckle/bond'
// Both handshake nonces are exchanged already (see the kith example above).
const counter = deriveCeremonyCounter(myNonceHex, theirs.nonce) // same value on both sides
const { mine } = bondWords(secret, myPubHex, theirs.pubkey, counter)./ken
pinKen(p) / pinKenFromNip05(nip05, ownerPubkeyHex, fetch) (refuse-on-mismatch
TOFU); buildKeyControlChallenge(); verifyKeyControl(entry, nonce, signedEvent,
opts?) (live, fail-closed; opts.expectedCreatedAt/maxAgeSec/verifierTag
are an OPT-IN, backward-compatible partial mitigation for a verifier-relay attack —
see the doc comment); attributeSignature(entry, event) (replayable — credit,
don't authenticate); resolveKen (propose-not-flip; a NIP-05 resolving to a key
already in previousPubkeys is flagged rotation.rollback: true rather than
proposed as an ordinary rotation) / acceptKenRotation(entry, opts?) (throws if
already accepted, if newPubkey equals the current pin, or — unless opts:
{ allowRevert: true } — if the rotation is a flagged rollback) / revokeKen /
dropKen (consumer deletes).
Both pinKen and addCorroboration(entry, provenance) run the SAME provenance
validation, the same MAX_CORROBORATIONS (64) cap, and reject the reserved
companion: locator prefix — a builder can no longer mint an entry the parser
would then refuse.
addCorroboration(entry, provenance) records another independent channel
agreeing this key is this person — pure, appends in observation order, never
touches the primary provenance, and deliberately does not de-duplicate (a
re-check a year later is new recency evidence). summarizeKenProvenance(entry)
reports { confirmations, claimed, distinctSources, distinctLocators, sources,
mostRecentAt, oldestAt }.
NIP-05 handling. Every entry point that can fetch a nip05 (pinKen,
pinKenFromNip05, resolveKen) validates a STRICT local@domain shape — no
port, no userinfo, no path/query/fragment, and a last label that is alphabetic or
xn--, so no IP literal in any form (127.1, 0x7f.1 included). The parser keeps
a stored nip05 that 0.1.x accepted; resolveKen treats one that fails the strict
check as unresolvable and never fetches it. The same check is exported as
validateNip05(value) from ./ken (returns the value, throws on a bad one), so an app can pre-validate input and show its own message. The fetch is
hardened: redirect:'error' (NIP-05 requires ignoring redirects), a bounded
timeout, a size-capped body read before JSON.parse, and both halves lowercased
before querying/looking up (NIP-05 names are case-insensitive).
claimed is the one to render alongside any total. A ken landed from a companion
app can show six confirmations across six distinct sources with nothing verified
first-hand — every record is a relayed claim. claimed counts the records in the
reserved companion: locator namespace, so confirmations - claimed is what was
actually confirmed first-hand. Show a total without it and you present relayed
claims as verification.
It returns facts, not a trust score — on purpose. A scalar "strength" invites
false precision and invites being used as an authorization input
(if (strength > 0.7) allow), which is a security decision this primitive cannot
underwrite; and weighting channels against each other (is one in-person worth
two nip05?) is consumer policy, not a property of the data. It is also honest
about its limits: distinctSources can understate independence (two different
websites are both web) and distinctLocators can overstate it (one operator
can serve two locators; DNS and the site behind it often share one point of
compromise). Signals, not guarantees.
./discovery
discoverPresent(filter, entries, ownerPubkey, saltHex?) (throws on mixed persona;
also throws if saltHex is given for an OPEN filter or omitted for a KEYED
one, or if saltHex is the empty string for a KEYED one (@forgesworn/tessera-kit
0.2.0 rejects an empty salt outright) — any of these would otherwise silently
match nothing, a false "no friends here"); disclosureFor(filter: MembershipFilter)
→ DiscoveryDisclosure (pass the filter parseFilter returned);
filterSignatureContext(namespace, serverId) → the kindred convention's
filter-signature context string (@forgesworn/tessera-kit 0.2.0), identical to
the d-tag value kindred:members:<namespace>:<serverId> — build it from the
(namespace, serverId) you chose to fetch, never from a received event's own
d-tag; buildFilterPublication(p) — p.blob must already be signed
(signFilterBlob) for filterSignatureContext(p.namespace, p.serverId); this
function verifies that itself and throws a clear error if it doesn't verify, so a
blob signed for the wrong deployment is caught before publishing
/ parseFilterPublication(event, opts) — opts is now REQUIRED:
{ namespace, serverId, minEpoch?, requireAuthorIsSigner? } (BREAKING as of
0.2.0; previously optional, with namespace/serverId read off the event's own
d-tag). Verifies the Nostr event sig, that the event's d-tag equals
filterSignatureContext(opts.namespace, opts.serverId), the in-blob Schnorr sig
bound to that same context (@forgesworn/tessera-kit 0.2.0 — closes
cross-server/namespace filter substitution cryptographically), and —
opts.requireAuthorIsSigner, default true — that the event's signer IS the
in-blob signer; pass { requireAuthorIsSigner: false } to opt out for a
different trust model. Returns null, never throws — including when opts is
missing or opts.namespace/opts.serverId is invalid; parseFilterPublicationResult(event, opts) → { ok: true,
value } | { ok: false, reason: FilterPublicationRejection } — the same checks as
parseFilterPublication, but naming WHICH one failed instead of collapsing to
null (parseFilterPublication is now a thin wrapper over this); aggregatorQuery(namespace); buildOptOutRequest(p,
memberPrivHex). Re-exports parseFilter from @forgesworn/tessera-kit. Consts
KINDRED_FILTER_KIND = 30444, KINDRED_OPTOUT_KIND = 30445.
./invite
buildJoinInvite(p, inviterPrivHex) → JoinInvite object;
parseJoinInvite(blob, now?) (verifies sig + expiry); verifyBondAttestation(event,
now?) → { ok, reason?, attesterPubHex?, subjectPubHex? } (single-attestation
only — no counting/graph; fails with a reason for a revoked, expired,
not-yet-active, or self-attestation). Type JoinInvite.
Invites are v2 only. Every JoinInvite carries v: 2. The signed digest is
sha256(JSON.stringify(['kenspeckle-invite', 2, namespace, serverId,
inviterPubkey, nonce, expiresAt ?? null])). nonce must be at least 16 bytes of
hex; expiresAt, when present, must be a non-negative safe integer; and a lone
UTF-16 surrogate anywhere in the invite's string fields is rejected (it would
serialise to different bytes depending on the JSON encoder / platform, breaking
the byte-exact digest).
Exact byte layouts (the ECDH construction + frozen vector, invite canonical bytes, kind/tag shapes, build/parse asymmetry) are in PROTOCOL.md.
Security
kenspeckle makes narrow, honest claims — read SECURITY.md. The short version:
- Persona-scoping is a consumer obligation kenspeckle assists (
discoverPresentenforces single-owner input) but cannot guarantee (no storage). - Private annotations are never serialised (type-level + runtime); they ARE in the user's own encrypted backup (allowed — not a graph disclosure).
- kith/kin shared secrets are never published —
toWire/serializeEntrystripsharedSecretstructurally (0.2.0; previously onlyannotationswas excluded). Cross-device sync of your OWN devices, which genuinely needs the secret to travel, uses the separately-namedtoSyncForm/serializeEntryForSyncinstead — never publish that output. - NIP-05 is TOFU, not key-continuity — a key-change is propose-only, never
auto-accept; require user confirmation. A rollback to a key already in
previousPubkeys(a compromised/reverted domain re-serving an old key) is flagged distinctly andacceptKenRotationrefuses it without an explicit{ allowRevert: true }.acceptKenRotationalso refuses an already-accepted rotation, so it can never double-append the current key into its own history. attributeSignatureis replayable — useverifyKeyControl(fresh nonce) for impersonation resistance.- Verify filter publications against a pinned key — check the Nostr sig AND the
in-blob Schnorr sig; pass the raw wire event (a spread-mutated one carries a
stale
Symbol(verified)false-green). Since 0.2.0,parseFilterPublication/parseFilterPublicationResultrequireopts: { namespace, serverId }— the in-blob signature is now bound to acontextstring (@forgesworn/tessera-kit 0.2.0) built only fromopts, never from the event's own d-tag, which is what stops a relay/MITM from relabelling a substituted blob (see @forgesworn/tessera-kit PROTOCOL.md §4.3). The event's own d-tag is separately checked to equal that same context ('address-mismatch'on a mismatch). By default (requireAuthorIsSigner) the event's signer must also equal the in-blob signer, defence-in-depth alongside — not instead of — context binding. - Discovery is opt-in + exit-able, but you cannot cryptographically verify removal from a pool you left. Real member privacy = per-context personas.
tier:'kith'is a shape, not proof a bond happened.parseEntry/importEntriesaccept any syntactically-valid 64-hexsharedSecret— kenspeckle cannot itself confirm a realderiveBondSecretceremony occurred. Authenticate/encrypt whatever channel feedsparseEntry/importEntries; an unauthenticated sync source could otherwise escalate a one-waykeninto a mutually-trusted-lookingkith.
Toolkit
kenspeckle is the relationships brick of the Forgesworn / Signet ecosystem. It composes:
@forgesworn/tessera-kit— the non-enumerable membership-presence filterkenspeckle/discoveryis a thin layer over.spoken-token— the directional verification words the bond ceremony speaks.nostr-attestations— the co-signed bond assertions (kind-31000).nsec-tree— hierarchical persona key derivation (theownerPubkeyscoping; kenspeckle needs only the type relationship).
Consumed by signet-app (the user-facing identity app): it maps its Contact
record onto KinEntry/KithEntry/ken, persists KindredEntry in IndexedDB, and
keeps tier/badge enrichment app-side. The byte-exact ECDH (PROTOCOL.md §1) is what
makes that migration lossless.
Licence
MIT.
