Skip to content

Releases: forgesworn/tessera-kit

Release list

v0.2.1

Choose a tag to compare

@decented decented released this 26 Sep 06:55

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
    FilterDescription type. Reveals nothing beyond what the serialized KFLT
    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
    testMembership on each value (PROTOCOL.md §10).
  • verifyAndParseFilter's opts.strictlyNewerThan — an opt-in, stricter
    sibling of minEpoch that rejects a parsed filter whose epoch is 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 TesseraErrorCode values: TEST_VALUES_TYPE (testMany's
    non-array valuesHex), VERIFY_STRICTLY_NEWER_THAN_INVALID and
    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.md links §11.
  • Dev tooling: fast-check property tests (build→serialize→parse round-trip
    and byte-identical re-serialization over random member sets sized 0–2000;
    testMany vs. the equivalent testMembership loop) and a structure-aware
    fast-check fuzz test for parseFilter that mutates individual header
    fields (boundary + random values), fingerprint bytes, and geometry-driven
    resizes, asserting every failure is a TesseraError; @vitest/coverage-v8
    and a test:coverage script (reporting only, no CI-failing thresholds); a
    bench script (scripts/bench.mjs, not run in CI or npm 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 distinguishes parseFilter
    (rejects at PARSE_UNSUPPORTED_VERSION, after bounds/magic) from
    verifyAndParseFilter (rejects almost all such blobs earlier, at
    VERIFY_SIGNATURE_OR_SIGNER_MISMATCH, since verifyFilterBlob never
    inspects format_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 use minEpoch
    for re-fetches instead); §9 and the TesseraErrorCode JSDoc 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
    exhaustive switch/Record over 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.tgz

v0.2.0

Choose a tag to compare

@decented decented released this 25 Sep 19:35

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/verifyAndParseFilter require context
    — the digest formula changed; old signed blobs won't verify.
  • Every throw is a TesseraError, not a bare Error (messages unchanged).
  • FilterType/FingerprintBits narrowed to 1/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-supplied context string, byte-exact with no
    Unicode normalisation (reusing the lone-surrogate check capability.ts's
    serverId already used, now shared via src/text.ts). context is
    REQUIRED, non-empty, well-formed UTF-16, and ≤1024 UTF-8 bytes.
    Why: before this fix, pinnedPubkeyHex alone 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: pass context everywhere these three functions
    are called; re-sign every stored blob.
  • FilterType narrowed to 1; FingerprintBits narrowed to 16. These
    exported types previously advertised 1|2|3 and 8|16|20|32 — values the
    code has always rejected at runtime. The wire format still reserves those
    values (PROTOCOL.md §3), and parseFilter still rejects them with their
    own TesseraErrorCodes; only the TYPE, not the runtime behaviour, changed.
    Migrate: if you constructed a FilterBuildOptions/MembershipFilter
    object with a non-implemented value under as a cast, TypeScript will now
    catch it at compile time.

Added

  • TesseraError / TesseraErrorCode (src/errors.ts, exported from .,
    ./capability, and ./nostr) — every throw reachable from this kit is
    now a TesseraError with a readonly, stable code (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_MISMATCH is the one opaque code for a bad
    signature, wrong signer, or wrong context — never split further.
    vectors/reject.golden.v1.json and keyed-member-key.golden.v1.json now
    assert on expectedErrorCode, not a message substring.
  • Conformance vectors at realistic sizes (CONFORMANCE.md is 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.json and
    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.json regenerated
    with context and 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 signing context" (non-empty string, well-formed UTF-16,
    ≤1024 UTF-8 bytes), so a caller can validate a context value up front
    without hand-rolling the same three rules. signFilterBlob and
    verifyFilterBlob use 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 namespace MUST NOT contain a colon (serverId may) —
    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 verifyFilterBlob directly vs. verifyAndParseFilter
    (PROTOCOL.md §4.3, and both functions' JSDoc) — kenspeckle-adoption
    follow-up: use verifyAndParseFilter when you hold a fixed pinned key;
    call verifyFilterBlob(blob, context) yourself, check the returned
    signerPubkeyHex against a key authenticated some other way (e.g. the
    Nostr event author, as kenspeckle's requireAuthorIsSigner does), and only
    then parseFilter, when the trusted key isn't a static pin.
  • kindred vs. kenspeckle, said consistently. kindred is the wire
    protocol/addressing convention name (the d-tag, the kind); kenspeckle is
    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 "zero kindred dependency" — you can't depend on a naming
    convention; it's now "zero kenspeckle dependency"). llms.txt's quick
    start now uses verifyAndParseFilter (with context), 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.ts cited 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.ts also no longer says "S = members" for the
    accumulation formula's S — 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

  • testWithCapability now rejects an OPEN-pool capability whose
    memberValue does not equal subjectPubHex (M2).
    Previously, a validly
    signed capability naming subject A's subjectPubHex but a DIFFERENT
    subject B's memberValue would test B's presence and report it as if it
    were A's — the subject's signature proved only "A signed this tuple," never
    that memberValue was actually derived from A's own pubkey. On an open
    pool memberValue MUST equal subjectPubHex by 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
    mismatched memberValue newly throws.
  • buildMembershipFilter's opts.salt, when supplied, must be non-empty
    even-length hex.
    Previously salt: '' or salt: 'zz' both silently set
    the on-wire keyed flag despite not being anything that could plausibly
    serve as a salt. Migrate: pass a real (even-length hex) salt, or omit
    salt entirely for an open pool.
  • memberKey's saltHex (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
    memberValue via memberKey and 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's opts.salt check above
    now defers to the S...
Read more