Repository navigation
v0.2.0
A post-audit hardening pass over 0.1.0, a second review pass that closed gaps
the first missed, and a third pre-publish pass (below) that closes the
remaining gaps found before first release. Several fixes are breaking —
0.1.0 was never published, so this is the first shape consumers actually
build against.
Summary. This release binds the signed-blob digest to a caller-supplied
context, closing cross-server/namespace filter substitution
cryptographically (previously only distinct signing keys defended against
it) — isValidFilterContext(ctx) is now exported (from . and sign.ts) as
the one reusable predicate for "is this a valid context," used internally
by signFilterBlob/verifyFilterBlob themselves. Every thrown error is now
a TesseraError with a stable code. FilterType/FingerprintBits are
narrowed to the values actually implemented (1/16); the wire format still
reserves the rest. Conformance vectors now cover realistic/boundary sizes
(see CONFORMANCE.md), and docs are consistent on kindred (the convention)
vs. kenspeckle (the code).
Breaking, this round:
signFilterBlob/verifyFilterBlob/verifyAndParseFilterrequirecontext
— the digest formula changed; old signed blobs won't verify.- Every throw is a
TesseraError, not a bareError(messages unchanged). FilterType/FingerprintBitsnarrowed to1/16(reserved values stay
documented + rejected at runtime; only the exported TYPE changed).
Third pass — pre-publish hardening
Breaking
- Signature-context binding (§4.1/§4.3). The signed digest is now
sha256("tessera-kflt-sig:v1" ‖ 0x00 ‖ u32be(byteLen(ctx)) ‖ utf8(ctx) ‖ blob[0..64) ‖ sha256(blob[128..end)))— a fixed domain tag and a
length-prefixed, caller-suppliedcontextstring, byte-exact with no
Unicode normalisation (reusing the lone-surrogate checkcapability.ts's
serverIdalready used, now shared viasrc/text.ts).contextis
REQUIRED, non-empty, well-formed UTF-16, and ≤1024 UTF-8 bytes.
Why: before this fix,pinnedPubkeyHexalone was the only binding a
verifier had; reusing one signing key across servers/namespaces let a
relay/MITM substitute one deployment's validly-signed blob for another's,
invisibly.context(the verifier's own stable, out-of-band-known
deployment address — e.g. a kindred d-tag
kindred:members:<namespace>:<serverId>) closes this cryptographically.
A wrong context fails with the exact same generic error as a wrong
signer (VERIFY_SIGNATURE_OR_SIGNER_MISMATCH/ "signature invalid or
signer does not match") — never a distinguishable message or code.
A distinct signing key per server is now defence in depth, not the only
mitigation. Migrate: passcontexteverywhere these three functions
are called; re-sign every stored blob. FilterTypenarrowed to1;FingerprintBitsnarrowed to16. These
exported types previously advertised1|2|3and8|16|20|32— values the
code has always rejected at runtime. The wire format still reserves those
values (PROTOCOL.md §3), andparseFilterstill rejects them with their
ownTesseraErrorCodes; only the TYPE, not the runtime behaviour, changed.
Migrate: if you constructed aFilterBuildOptions/MembershipFilter
object with a non-implemented value underasa cast, TypeScript will now
catch it at compile time.
Added
TesseraError/TesseraErrorCode(src/errors.ts, exported from.,
./capability, and./nostr) — everythrowreachable from this kit is
now aTesseraErrorwith a readonly, stablecode(SCREAMING_SNAKE,
grouped by prefix:PARSE_*,CODEC_*,SIGN_*,VERIFY_*,
CAPABILITY_*,BUILD_*,TEST_*,INPUT_*— documented as a stable
contract in PROTOCOL.md §9). Messages are UNCHANGED. Security rule:
VERIFY_SIGNATURE_OR_SIGNER_MISMATCHis the one opaque code for a bad
signature, wrong signer, or wrong context — never split further.
vectors/reject.golden.v1.jsonandkeyed-member-key.golden.v1.jsonnow
assert onexpectedErrorCode, not a message substring.- Conformance vectors at realistic sizes (
CONFORMANCE.mdis new):
kflt-sizes.golden.v1.json(n=0,1,2,3,9,100,1000 — degenerate-n guard
input and the two smallest segment-length transitions, full blob hex),
kflt-retry-seed.golden.v1.json(n=829, the smallest input found needing a
seed retry to converge),kflt-large-10000.golden.v1.jsonand
kflt-large-100000.golden.v1.json(geometry + blob-hash + ≥20
present/absent samples, members regenerated from a documented deterministic
rule rather than stored inline).signed-blob.golden.v1.jsonregenerated
withcontextand a must-reject wrong-context case. - PROTOCOL.md §9 — the error-codes table (stable contract).
isValidFilterContext(ctx: unknown): boolean(sign.ts, exported from
.) — kenspeckle-adoption follow-up: the one reusable predicate for "is
this a valid signingcontext" (non-empty string, well-formed UTF-16,
≤1024 UTF-8 bytes), so a caller can validate acontextvalue up front
without hand-rolling the same three rules.signFilterBloband
verifyFilterBlobuse this SAME function internally — it is the single
source of truth, not a parallel copy of the rules their own error codes
already encode.
Docs
- The kindred
namespaceMUST NOT contain a colon (serverIdmay) —
kenspeckle-adoption follow-up, PROTOCOL.md §4.3/§6. Without this rule, two
different(namespace, serverId)pairs can produce the byte-identical
kindred:members:<namespace>:<serverId>string by shifting where the
d-tag's first colon after the prefix falls (e.g.("a", "b:c")and
("a:b", "c")both give...a:b:c), which lets a relay serve one
deployment's blob as another's when the context strings collide. - When to call
verifyFilterBlobdirectly vs.verifyAndParseFilter
(PROTOCOL.md §4.3, and both functions' JSDoc) — kenspeckle-adoption
follow-up: useverifyAndParseFilterwhen you hold a fixed pinned key;
callverifyFilterBlob(blob, context)yourself, check the returned
signerPubkeyHexagainst a key authenticated some other way (e.g. the
Nostr event author, as kenspeckle'srequireAuthorIsSignerdoes), and only
thenparseFilter, when the trusted key isn't a static pin. - kindred vs. kenspeckle, said consistently.
kindredis the wire
protocol/addressing convention name (the d-tag, the kind);kenspeckleis
the code that implements it (@forgesworn/kenspeckle). README, llms.txt,
PROTOCOL.md, and SECURITY.md no longer conflate the two (e.g. PROTOCOL.md
§6 was "zerokindreddependency" — you can't depend on a naming
convention; it's now "zerokenspeckledependency"). llms.txt's quick
start now usesverifyAndParseFilter(withcontext), matching its own
"recommended" API guidance instead of the two-call hand-rolled path. - Every
§-section cross-reference fixed. Several source comments and
properties.test.tscited PROTOCOL.md sections that never existed (§7.3,
§7.4,§7.5,§7.6,§10,§10.2,§6.2,§12.2,§15) — likely
left over from a section renumbering. Every one now points at the section
that actually documents the claim (mostly §1, §2.8, §3, §4.2, §5, §6, §7.2,
or SECURITY.md's own numbered sections where the claim lives there
instead).properties.test.tsalso no longer says "S = members" for the
accumulation formula'sS— it's a SERVER count (§7.2), a stale comment
the PROTOCOL.md fix in the prior pass didn't propagate to this test file.
Second review pass
Breaking
testWithCapabilitynow rejects an OPEN-pool capability whose
memberValuedoes not equalsubjectPubHex(M2). Previously, a validly
signed capability naming subject A'ssubjectPubHexbut a DIFFERENT
subject B'smemberValuewould test B's presence and report it as if it
were A's — the subject's signature proved only "A signed this tuple," never
thatmemberValuewas actually derived from A's own pubkey. On an open
poolmemberValueMUST equalsubjectPubHexby construction (PROTOCOL.md
§5.1), so this is now checked and a mismatch throws'capability: memberValue does not match subjectPubHex (open pool)'. This check cannot
be, and is not, applied to a keyed pool — the bearer has no salt to
recompute the binding with, so the subject's signature remains the only
assertion available there (PROTOCOL.md §5.4b). Migrate: an honestly
issued capability (issuePresenceCapability's own output, unmodified) is
unaffected; only a hand-crafted or tampered open-pool capability with a
mismatchedmemberValuenewly throws.buildMembershipFilter'sopts.salt, when supplied, must be non-empty
even-length hex. Previouslysalt: ''orsalt: 'zz'both silently set
the on-wirekeyedflag despite not being anything that could plausibly
serve as a salt. Migrate: pass a real (even-length hex) salt, or omit
saltentirely for an open pool.memberKey'ssaltHex(and everything built on it) now REJECTS an empty
salt, not just a malformed one.memberKey(pk, '')previously returned
sha256('' ‖ pk)as a valid "keyed" value distinct from the open form
pk— true bytewise, but meaningless as a boundary:sha256('' ‖ pk)is
computable by anyone who holds the bare pubkey, with no out-of-band
salt needed, so an empty salt provides none of the speed-bump a keyed pool
exists for.memberKey(pk, '')now throws'memberKey: salt must be non-empty even-length hex'. This propagates to
issuePresenceCapability({ salt: '' })(./capability), which derives
memberValueviamemberKeyand so now throws for the same reason — an
issuer can no longer mint a "keyed" capability that gives away nothing an
unkeyed one wouldn't.buildMembershipFilter'sopts.saltcheck above
now defers to the SAME shape rule (isValidSaltHex,member-key.ts)
rather than a second, independently-maintained copy of it. Migrate:
never passsalt: ''/saltHex: ''anywhere in this kit — omit the
option entirely for an open pool.
Fixed
- Fuse construction could be permanently blocked by two member keys that
hash to the same 64-bit value (M1).BinaryFuse16.buildde-duplicated
member-key STRINGS only (buildMembershipFilter's own dedup); two DISTINCT
keys colliding on the internalkeyToU64hash landed in the same three
slots on every seed, so peeling never converged andbuild()threw'fuse: construction failed to converge'on every call for as long as both keys
remained — an availability bug reachable by anyone able to find such a
collision (a ~2^32 targeted search, not a brute force of the full 2^64
space). Construction now also de-duplicates by 64-bit HASH VALUE before
peeling, matching the Lemire reference C'sbinary_fuse_sort_and_remove_dup
pre-processing step. Dropping a hash-duplicate changes nothing observable —
both colliding keys still testtrue. The existing frozen golden vector
(vectors/kflt.golden.v1.json, no collisions) is byte-identical. - An oversized blob was hashed before its size was checked (L1).
verifyFilterBlobunconditionally SHA-256'd the entire fingerprint region
before any size cap ran; a raw-HTTPS caller (not gated byparseFilter,
whose own cap only applied downstream) paid real CPU (~870 ms measured for a
200 MB blob) hashing a hostile oversized blob before it was ever rejected.
verifyFilterBlob(and soverifyAndParseFilter, which calls it first) now
rejectsblob.length > KFLT_MAX_BLOB_BYTESup front, alongside the existing
too-short check — before any hashing. serverIdaccepted an unpaired UTF-16 surrogate, colliding with the
literal replacement character (L3).utf8ToBytes(TextEncoder) silently
maps a lone surrogate to U+FFFD, so e.g."a\uD800"and"a�"
previously produced IDENTICAL UTF-8 bytes and so an identical canonical
preimage — a capability issued for one string verified when presented under
the other.serverIdis now validated to be well-formed UTF-16 (no lone
surrogates) on both issue and test. PROTOCOL.md §5.3 now also documents that
serverIdis compared BYTE-EXACTLY after UTF-8 encoding with NO Unicode
normalisation, and recommends restrictingserverIdto printable ASCII.- Several public functions leaked a raw, un-kit-shaped error on malformed
input (L4):deriveDecoysnow rejects an empty or malformed
(non-hex/odd-length)decoySeedHexinstead of silently producing
public/predictable decoys or letting@noble/hashesthrow a raw
RangeError— note this is a behaviour change even forcount <= 0:
deriveDecoys('', 0)previously returned[](thecount <= 0short
circuit ran BEFORE the seed was ever touched), and now throws, since the
seed is validated first regardless ofcount;issuePresenceCapability/testWithCapabilitynow
typeof-check every string field before use, so e.g. a hand-built
capability withsig: undefinedthrows acapability:-prefixed error
instead of a rawTypeError;serializeFilternow throws if its output
would exceedKFLT_MAX_BLOB_BYTES, checked before allocating, instead of
producing a blobparseFilter/verifyFilterBlobwould reject anyway.
Added
- Five new frozen vector files under
vectors/(v1 naming, alongside
kflt.golden.v1.json):decoy-seed.golden.v1.json(the per-epoch decoy-seed
derivation formula +deriveDecoysoutput),decoy-padding.golden.v1.json
(the same derivation wired end-to-end throughbuildMembershipFilter),
keyed-member-key.golden.v1.json(memberKey, keyed and open forms),
capability.golden.v1.json(the capability canonical preimage/digest and a
FIXED, already-valid signature that must re-verify — BIP340 verification is
fully deterministic even though signing itself uses random aux data, so this
corrects the previous claim incheck-vectors.mjsthat "signatures cannot
be frozen"),signed-blob.golden.v1.json(a fully signed KFLT blob that
must verify against a pinned pubkey), andreject.golden.v1.json(must-reject
parseFiltercases: truncated, bad magic, bad version, invalid
type/fingerprint-bits, bad geometry, oversized declared length, length
mismatch, epoch overflow, reserved flags, non-power-of-two band). All were
independently hand-checked againstnode:crypto(not just this package's
own@noble/hashesdependency) at generation time.
scripts/check-vectors.mjsnow dispatches by akindfield and checks all
six vector kinds; the original "filter" check now ALSOparseFilters the
frozen bytes directly and re-checks membership against the PARSED filter,
not only the rebuilt one.
Docs
- Fixed the dependency count: README/llms.txt/SECURITY.md said "two runtime
deps"; it is three (@noble/curves,@noble/hashes,@scure/base— the
last one pulled in only by the optional./nostrsubpath). - Documented the
./nostrsubpath (buildFilterPublication/
decodeFilterPublicationContent) in README.md and llms.txt — it was
exported but undocumented. - Fixed PROTOCOL.md §7.2's accumulation formula wording:
Sis a SERVER
COUNT (the number of filters probed), not a member count — "S-member
servers" read as if the per-test FPR scaled with a filter's member count,
which it does not (§7.1). - PROTOCOL.md now documents: the M1 hash-level dedup (§2.5), the M2
memberValue/subjectPubHex binding and its keyed-pool limit (§5.4b), the L1
reject-before-hash size cap (§4.2), the L3 byte-exact/no-normalisation
serverIdencoding (§5.3), and the L4 input-validation additions
(opts.salt, §1;deriveDecoys, §2.8;serializeFilter's size cap, §3).
First audit pass
Breaking
- Capability
saltHint→memberValue.PresenceCapability.saltHint(the
raw keyed-pool salt, carried in clear) is replaced bymemberValue: the
64-hex value the subject is present in the pool as
(memberKey(subjectPubHex, salt)for a keyed pool, orsubjectPubHexitself
for an open pool).issuePresenceCapability(p, subjectPrivHex)now takes
p.salt?: string(optional; the keyed pool's salt, omit for an open pool)
instead ofp.saltHint: string— the salt is used only to derive
memberValueand never appears on the returned token.
Migrate: replacesaltHint: mySaltwithsalt: mySaltat issue time (or
omitsaltentirely for an open pool instead of passingsaltHint: ''); read
cap.memberValueinstead ofcap.saltHintoff an issued capability. - Capability canonical preimage bumped
tessera-cap:v1:→tessera-cap:v2:.
The signed tuple is now{serverId, subjectPubHex, memberValue, expiresAt}
instead of{serverId, subjectPubHex, saltHint, expiresAt}. A v1-signed
capability will not verify against v2testWithCapability, and vice versa.
Migrate: re-issue any stored/cached capabilities. - Member keys must be exactly 64 hex chars.
buildMembershipFilternow
validates every input against/^[0-9a-f]{64}$/iand throws, naming the
offending index, on anything else (non-hex, odd-length, 66-hex, …). Previously
any string was accepted, which could silently break fuse-peel convergence on
a case-variant duplicate or build an untestable non-64-hex member.
Migrate: ensure every value passed tobuildMembershipFilteris
memberKey()output (or otherwise exactly 64 hex chars) before calling. epochmust be a non-negative safe integer.buildMembershipFilter
throws on a negative, fractional, non-finite, or too-largeepoch(a
negative epoch previously wrapped to2^64-1on the wire).
parseFiltermirrors this on read: a blob whose headerepochexceeds
Number.MAX_SAFE_INTEGERnow throws instead of silently losing precision.expiresAtmust be a non-negative safe integer in both
issuePresenceCapabilityandtestWithCapability(previously "any finite
number," which allowed a fractional value or one that can't be canonically
reproduced across a non-JS verifier, e.g.1e21).decoySeedHexmust be even-length hex of at least 16 bytes (32 hex
chars).buildMembershipFilterthrows otherwise; previously an empty or
odd-length seed was accepted (producing public/predictable decoys, or leaking
a rawRangeError).- Decoys are now re-keyed per epoch. When
decoySeedHexis supplied,
buildMembershipFilterderives the seed actually passed to the decoy
generator fromsha256("tessera-decoy:v1:" ‖ decoySeedHex ‖ epoch)instead of
usingdecoySeedHexdirectly. A rebuild of the same epoch with the same
inputs is still byte-identical, but the serialized blob for a given
(memberKeysHex, decoySeedHex)pair now differs from what 0.1.0 produced,
and differs across epochs where 0.1.0 would have produced the same decoys
every time. Migrate: do not assume decoy bytes are stable across epochs
(they no longer are, by design — see### Securitybelow); a frozen
golden-vector fixture using a fixeddecoySeedHexacross epochs will change. parseFilternow rejects reserved flag bits (2-7) and a non-power-of-two
member_count_band. Both are valuesserializeFilternever emits, so a
blob setting either is non-canonical and now throws instead of parsing.decodeFilterPublicationContent'smaxBytesis validated and clamped. A
non-safe-integer or negativemaxBytesnow throws (it previously disabled
the size cap entirely); any value aboveKFLT_MAX_BLOB_BYTESis silently
clamped down to it.
Added
verifyAndParseFilter(blob, { pinnedPubkeyHex, minEpoch? })(sign.ts,
exported from.) — the recommended combined path: pin-verifies the signer
(case-insensitive), parses, and (ifminEpochis given) rejects a filter
whose signedepochis older thanminEpoch(stale/rollback). Closes the
"forgot to pin" and "no freshness check" gaps left by using
verifyFilterBlob/parseFilterseparately.
Fixed
testWithCapabilitynow throws if the resolvednowclock value is not
Number.isFinite— an injectedNaNpreviously madeNaN > expiresAt
evaluate tofalse, silently skipping the expiry check.
Security
- B1 (HIGH): a capability no longer discloses the pool salt. The old
saltHintfield carried the keyed pool's salt in clear, so holding any one
subject's capability handed the bearer everything needed to compute
memberKey(anyPk, saltHint)for any candidate — equivalent to
salt-holder access to the whole pool, not consent to test one person.
memberValueconfines a bearer to testing only the subject named on the
token. Docs no longer describe the token as "one-time" (it is a bearer
token; nothing enforces single use) and now state plainly that it is not
bound to a specific filter beyond theserverIdstring. Docs also now state
plainly thatexpiresAtbounds only thetestWithCapabilitywrapper, not
memberValueitself: a bearer who has already seenmemberValuecan call
testMembership(filter, memberValue)directly, pastexpiresAt, for every
epoch until the pool's salt rotates — or indefinitely for an open pool, where
memberValuenever changes. Salt rotation is the only actual revocation. - B2 (MEDIUM): stable decoys enabled cross-epoch churn-diffing, which is the
opposite of the documented claim. Fuse fingerprint slots are XOR-shared, so
a decoy set reused byte-for-byte across epochs diffs 0 slots when nothing
changed and ~3 slots when exactly one member swapped — a real activity
leak. Decoys are now re-keyed per epoch (see### Breaking), which keeps a
same-epoch rebuild byte-identical while denying an attacker a stable
baseline to diff across epochs. - B3 (MEDIUM): unvalidated member keys could break construction or admit
untestable members. See### Breaking. - B4 (MEDIUM): no combined verify+parse+freshness helper, and no guidance to
use the blob's signed epoch over a Nostr tag. See### Added; PROTOCOL.md
§4.3/§6 now spell out the rollback and cross-server-substitution risks.
verifyAndParseFilteralso validates its own options:pinnedPubkeyHexmust
be 64 hex chars, and a givenminEpochmust be a non-negative safe integer —
aNaNminEpochpreviously madefilter.epoch < minEpochalwaysfalse,
silently accepting a stale blob (the sameNaN-clock footgun as B5). - B5 (LOW): a
NaNclock skipped capability expiry. See### Fixed. - B6 (LOW): a
NaN/negativemaxBytesdisabled the Nostr content size cap.
See### Breaking. - B7 (LOW): a negative/oversized epoch could corrupt on-wire freshness
comparisons. See### Breaking. - B8 (LOW):
expiresAt's JS-number stringification wasn't cross-language
canonical. See### Breaking. - B9 (LOW):
parseFilteraccepted non-canonical reserved flags / bands.
See### Breaking. - B10 (LOW): an empty/short/odd-length
decoySeedHexproduced predictable
decoys or leaked a raw error. See### Breaking.
Reproducible build: byte-identical output verified across two independent CI runners.
Artefact integrity
file: forgesworn-tessera-kit-0.2.0.tgz
size: 262963 bytes
sha256: 39c829d0f01c565ce5741df4115fb50220de5a5e31970c74af8cb0e4d1156d2f
sha512-f+lAa8b2Quyx9VqBjT7qJYdgyqOBNnW3tAG5KYeV8wcN+xbbYBENFIpioWyDifA033Vu57mAHQdXP0zd8yJPdg==
Verify against the registry tarball:
curl -sLO https://registry.npmjs.org/@forgesworn/tessera-kit/-/tessera-kit-0.2.0.tgz
shasum -a 256 tessera-kit-0.2.0.tgz