Repository navigation
Releases: forgesworn/tessera-kit
Release list
v0.2.1
Purely additive since 0.2.0 — no existing exported function, type, error
code, wire format, vector, or default behaviour changes. Existing tests and
vectors pass unmodified.
Added
describeFilter(f)(.) — a plain, readonly public view of a
MembershipFilter's metadata (fingerprintBits,filterType,keyed,
padded,epoch,memberCountBand,segmentLength,segmentCount,
arrayLength,byteLength,theoreticalFalsePositiveRate), and the
FilterDescriptiontype. Reveals nothing beyond what the serializedKFLT
header already discloses (PROTOCOL.md §10).testMany(f, valuesHex)(.) — test many values against a filter in one
call, with the exact same validation and semantics as calling
testMembershipon each value (PROTOCOL.md §10).verifyAndParseFilter'sopts.strictlyNewerThan— an opt-in, stricter
sibling ofminEpochthat rejects a parsed filter whoseepochis not
strictly greater than the given value, closing the same-epoch-replay gap
minEpoch's non-strict<comparison leaves open for a caller who opts in
(PROTOCOL.md §4.3).minEpoch's own behaviour is unchanged.- New
TesseraErrorCodevalues:TEST_VALUES_TYPE(testMany's
non-arrayvaluesHex),VERIFY_STRICTLY_NEWER_THAN_INVALIDand
VERIFY_EPOCH_NOT_NEWER(opts.strictlyNewerThan's validation and
freshness-check failures). - PROTOCOL.md §10 ("Introspection & bulk-test helpers") and §11 ("Evolution /
versioning") — new sections;CONFORMANCE.mdlinks §11. - Dev tooling:
fast-checkproperty tests (build→serialize→parse round-trip
and byte-identical re-serialization over random member sets sized 0–2000;
testManyvs. the equivalenttestMembershiploop) and a structure-aware
fast-check fuzz test forparseFilterthat mutates individual header
fields (boundary + random values), fingerprint bytes, and geometry-driven
resizes, asserting every failure is aTesseraError;@vitest/coverage-v8
and atest:coveragescript (reporting only, no CI-failing thresholds); a
benchscript (scripts/bench.mjs, not run in CI ornpm test) timing
build/test/testMany/parse/sign/verify at 1k/100k/1M members. - PROTOCOL.md corrections from an independent review of this pass: §11's
"how a verifier rejects an unknown version" now distinguishesparseFilter
(rejects atPARSE_UNSUPPORTED_VERSION, after bounds/magic) from
verifyAndParseFilter(rejects almost all such blobs earlier, at
VERIFY_SIGNATURE_OR_SIGNER_MISMATCH, sinceverifyFilterBlobnever
inspectsformat_version); §4.3 now states plainly that this kit does not
enforce "one content per epoch," and gives usage guidance for
strictlyNewerThan(an ordinary re-fetch of the current epoch throws
VERIFY_EPOCH_NOT_NEWER— treat that as "no update," or useminEpoch
for re-fetches instead); §9 and theTesseraErrorCodeJSDoc now state the
minor-vs-major stability policy explicitly (existing codes are fixed within
a major version, new codes may be added in a minor one — don't write an
exhaustiveswitch/Recordover the union without a fallback). - CI now runs the test matrix on Node 22 and 24 (was 22 only).
Reproducible build: byte-identical output verified across two independent CI runners.
Artefact integrity
file: forgesworn-tessera-kit-0.2.1.tgz
size: 276647 bytes
sha256: 70bd67be74d0ebe6f698ffa7d23c9df47ca312e73aaecf5c73cdb6379f555a6e
sha512-SLeid4PSZhuxGLbvbnP2eQVZxcvsdwqenzSsJh9krFGtu38F/rP4JuWHdmS8jaZ8ybvgtbJwQti0rWiR7P56Ig==
Verify against the registry tarball:
curl -sLO https://registry.npmjs.org/@forgesworn/tessera-kit/-/tessera-kit-0.2.1.tgz
shasum -a 256 tessera-kit-0.2.1.tgzv0.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 S...