Skip to content

Releases: Wixely/CupriNet

v0.6.2

Choose a tag to compare

@github-actions github-actions released this 29 Aug 12:46
v0.6.2: CI-only — release job deletes its run's artifacts once the re…

v0.6.1 — the WebRTC message-size check stops being inert

Choose a tag to compare

@github-actions github-actions released this 29 Aug 07:33

0.6.0 added a connect-time check that refuses a transport too small to carry a rite's frames. On the WebRTC path it did nothing: the adapter reported 0 — "unknown" — because WebRtcChannel did not expose the SCTP association's figure. CupriWebRTC 0.4.0 exposes it, so the adapter now reports a real number and the check applies.

What changed

CupriWebRTC reference 0.3.1 → 0.4.0
WebRtcDataChannel.MaxMessageBytes 0 → the channel's effective figure
CupriWebRtcTransport(…) new optional peerMaxMessageBytes

Read the number as "what we will send", not "what the pair agreed"

The peer's half is an SDP attribute, and this path has no signalling by design — a browser synthesises its answer from the published static parameters, so its offer never reaches the node. There is usually nobody to supply peerMaxMessageBytes from; set it only when you know the peers you serve are limited (a stack or middlebox at the 64 KiB interoperable floor). Unset, this stack's own 256 KiB stands, which is what every measured pairing agrees to.

So a browser that negotiated below the rites' requirement is still not caught on the .NET side. It is caught at the browser end, which can read pc.sctp.maxMessageSize. Between the two ends the case is covered; neither covers it alone.

That limitation is stated on the adapter, on the transport parameter, and in transports-and-limits.md rather than left implicit — a check that appears to prove more than it does is worse than no check.

One asymmetry worth knowing

A peer's advertised figure caps what this endpoint sends it, and never what it accepts. The receive limit was published in the endpoint's static parameters before any peer arrived, so one peer's low advertisement must not retroactively narrow the promise every other peer read. That is CupriWebRTC's decision and it is the right one.

Also

IDataChannel.MaxMessageBytes previously said "as negotiated with the peer" — too strong for an interface an implementation may satisfy with no negotiation at all. It now asks for the most a transport can honestly promise, with 0 for unknown.

466 unit tests green. No API break; additive only.

v0.6.0 — the Shrine surface reviewed, and two consumer bugs closed

Choose a tag to compare

@github-actions github-actions released this 29 Aug 07:01

The standing security review was dated 2026-07-20 and scoped to "the Rites (Epistle/Conduit/Reliquary)" — it predated Signet, the Pilgrimage, the site rites, the listener and multi-Shrine entirely. This release is that review's findings, plus the two issues raised against 0.5.0.

⚠️ Breaking for IDataChannel adapters

IDataChannel gains int MaxMessageBytes. Return 0 for unknown or unbounded and nothing changes. Only affects code implementing that interface — an IVessel consumer is unaffected.

A Shrine can no longer be held open by saying nothing · High

The serious finding. A visit slot was taken before the accept and released only when the whole visit ended, and nothing ended a visit but the peer — no idle deadline anywhere. One address could complete MaxConcurrentPilgrimages handshakes, go quiet, and hold every slot for as long as it liked. Cost to the attacker: that many Toll solves. Cost to the site: total unavailability, with no detection and no recovery.

New option Default What it bounds
PilgrimageIdleTimeout 5 min A visit quiet in both directions
MaxPilgrimagesPerAddress 8 Concurrent visits per source, checked before the handshake

Both directions matters: inbound-only would have been the obvious implementation and would have hung up on the Auspice, where a Pilgrim attends a feed and then only listens. There's a test asserting a feed keeps its visit alive well past the deadline.

The overlay path has held a global cap and a per-peer budget since the first review; the Shrine listener was modelled on that loop's shape without carrying over its Wards.

The subnet fence is containment, and now holds everywhere

AllowedSubnets documents itself as "subnets this node is allowed to connect to and accept from", and beacon dialling was already fenced. The Shrine paths were not. Both AcceptPilgrimageOverVesselAsync overloads and PilgrimageOverVesselAsync now apply it, so an outbound visit is contained too. A vessel with no IP endpoint still passes — nothing to filter on, as with an onion or hostname beacon.

Closing a session never throws · #5

DisposeAsync wasn't idempotent, and a caller reached that without disposing twice: the far side tears the session down, and their single explicit dispose lands on something already gone.

Fixed in five types, not the two reported — ArcanumSession had the identical one-line body and the same bug on the channel path, and Vessel and NoiseVessel were no better.

A transport too small for the rites says so at connect · #6

A rite's ceiling is a constant of this library; what a DataChannel carries is negotiated per association. Nothing reconciled them — DataChannelVessel emits one message per frame and never fragments — so a peer negotiating below 192 KiB would have a legal frame refused on the wire.

RiteTransport.EnsureCarriesRiteFrames now refuses such a transport when you connect, naming both numbers. Same bargain as every other ceiling: fail at the rite with a reason, never deep inside a transport. Byte-stream vessels set their own frame size and aren't subject to it.

The CupriWebRTC adapter reports 0 today. The negotiated value lives on SctpAssociation, and WebRtcChannel does not expose it, so the adapter cannot read what the two ends agreed. Reporting 0 is the honest answer rather than a guess — refusing on a number we can't see would break every pairing that currently works. Once CupriWebRTC surfaces it, returning it from the adapter is the whole of what's left.

Also

  • A node hosting several Shrines publishes them with one link each (Intone(..., signet)), rather than one link naming them all — which would tell anyone holding it that those sites are co-hosted.
  • ResolveShrine's loop always runs to the end; returning on first match let a visitor time how many sites an endpoint hosts.
  • Correction: transports-and-limits.md called the WebRTC 256 KiB "fixed". It is what this stack offers; the effective limit is the negotiated minimum, and the interoperable floor is 64 KiB. That mattered because the surrounding advice was given on the strength of it.

466 unit tests green.

v0.5.0 — several Shrines on one endpoint

Choose a tag to compare

@github-actions github-actions released this 29 Aug 05:50

One node can now host any number of Shrines behind a single listener, and a visitor names which one it wants without that name ever crossing the wire in the clear.

What's new

node.HostShrine(alpha, alphaHandler);   // a second Signet adds a site
node.HostShrine(beta,  betaHandler);    // the same one again replaces it
node.ListenForPilgrims();               // one port, both sites

A visitor does nothing differently — it pins the address it wants, and the endpoint answers as that Signet or not at all. Browser DataChannels get the same treatment, so one node serves several sites to the web.

API Change
CupriNode.HostShrine(...) Adds a site when given a new Signet; replaces when given the same one again. Previously it always replaced.
CupriNode.ShrineAddresses New. Every cupri1… address hosted, in order. ShrineAddress still returns the first.
CupriNode.AcceptPilgrimageOverVesselAsync(vessel, ct) New overload. Serves whatever the node hosts, resolving the target per visit.
NoiseConjunction.AcceptAsync(..., resolveIdentity, ct) New overload. Chooses which identity to present from the requested target.
NoiseConjunction.ShrineTargetTag(suite, handshakeHash, sigil) New. Computes the blinded target.

Upgrade impact: none

Additive only — no existing signature changed. The target is a trailing binding field, the same backward-compatible pattern Moniker and WebRtcEndpoint use, so:

  • an older Pilgrim sends no target and is served the first Shrine hosted — the only one it could have meant;
  • an older host ignores the field entirely.

A node hosting exactly one Shrine behaves precisely as it did in 0.4.0.

Why it's shaped this way

This was on the roadmap next to Noise NK, on the assumption that an endpoint must choose which key to present during the handshake and therefore needed a new handshake pattern. It needed none. The Noise static key is generated fresh per connection and is unrelated to any Signet; identity is proved afterwards in the binding exchange — inside the encrypted transport, which the responder already reads before answering. The selector had a natural home all along.

Blinded, not named

The visitor sends

HKDF(ikm: target Sigil, salt: Noise handshake hash, info: "cuprinet:shrine-target:v1")

and the host recomputes it for each Signet it hosts, comparing in fixed time. Because a visitor must name its target before the host has proved it holds anything, two properties matter:

  • A host that does not serve the site learns only "not one of mine." Probing an endpoint to see what it hosts does not tell that endpoint what you were looking for.
  • The value differs every visit, since the handshake hash salts it — so it is no use as a handle for linking one visit to the next.

It is not a secret; anyone holding the address can compute it for a session whose handshake hash they know. It hides the target from parties who do not already know it, which is exactly the set that should not learn it.

A target that matches nothing

Refused in words, inside the encrypted channel — never as silence, and never by presenting a different site the endpoint happens to host. Serving the wrong site would be the genuinely dangerous failure here, so it has a test of its own.

Notes

  • No CupriMark bump: the trailing-field pattern is the one Moniker and WebRtcEndpoint already use without one.
  • design/shrines.md had predicted a post-Noise session-kind marker would be needed for this. It was not, and that note now says so.
  • Full design: design/shrines.md → Several Shrines on one endpoint.

453 unit tests green.

v0.4.0 — a Shrine listens on a port of its own

Choose a tag to compare

@github-actions github-actions released this 29 Aug 05:32

Reaching a Shrine over TCP no longer requires the caller to write an accept loop, and mis-addressing one no longer looks like a broken rite.

What's new

node.HostShrine(signet, handler, feeds, relics, conduit);
var endpoint = node.ListenForPilgrims();   // 0 → an OS-assigned port

A Pilgrim dials it and pins the address. Nothing else:

var vessel = await TcpVessel.ConnectAsync(host, port, cancellationToken: ct);
await using var shrine = await Pilgrimage.OverVesselAsync(vessel, signetSigil, network, suite, enableToll, ct);
API Change
CupriNode.ListenForPilgrims(port = 0) New. Binds a port serving the hosted Shrine; returns the bound endpoint.
CupriNode.ShrineEndPoint New. The endpoint being served, or null.
CupriNodeOptions.MaxConcurrentPilgrimages New, default 256.

Additive only — no existing signature changed. The listener is closed when the node is disposed.

The problem it removes

A Shrine was reachable two ways: a browser DataChannel, which a node routes in by itself, and a vessel the caller owns. Over TCP only the second applied, so every consumer wrote the same VesselListener loop — and anyone who guessed instead found the node's L1 port, which does not serve Shrines and did not say so.

That port answers with the node's own Sigil. So pinning the site's Signet fails correctly, but pinning the node's Sigil succeeds — into a healthy session with no Shrine behind it, where every rite answers with a closed stream. It reads as a broken rite and is not one.

On the Shrine port that mistake cannot complete, because the host has no Sigil to offer there: the failure is loud, at the handshake. And a node hosting no Shrine never opens the port, so dialling it is refused outright rather than answered with nothing.

Why a separate port, not the L1 one

The identity to present must be chosen before the Noise handshake begins, so a shared port would need the client to signal "I am here for the site" in cleartext. That is the same pre-Noise selector multi-Shrine hosting refuses, so that the network never learns which site a connection is for. A dedicated port needs no selector at all.

Visits are bounded, not handshakes

MaxConcurrentPilgrimages holds a slot for the whole visit rather than just the handshake, because a visitor may keep a page fetch, a live feed and a raw session open for as long as it likes — that, not handshake capacity, is what a site spends. (The L1 loop frees its slot after the handshake, which is right for what it protects.) The slot is taken before the accept, so a flood queues in the kernel backlog instead of stalling the loop, and the subnet fence applies as it does on the L1 path.

Still supported

The caller-owned vessel path is not deprecated. It remains the entry for Tor, for tests, and for a WASM client that cannot bind a socket at all.

Full design: design/shrines.md → How a Pilgrim actually reaches a Shrine.

447 unit tests green.

v0.3.7 — a reachable channel ceiling, and delivery written down

Choose a tag to compare

@github-actions github-actions released this 29 Aug 05:20

Two consumer questions, both of which turned out to have answers worse than the questions assumed.

The Arcanum conduit ceiling could not be reached

ConduitCodec.MaxChannelPayloadBytes was FrameCodec.DefaultMaxFrameSize exactly. A payload at that size encodes to 17 bytes more than a frame may carry, and with the Veil's nonce and tag and the vessel header it reached 16,777,266 against a 16,777,216 limit.

So the documented maximum was unsendable — and the refusal came from FrameCodec, at the transport, quoting frame bytes rather than the payload number the caller chose. That was the single place the library broke its own rule that a ceiling fails at the rite, with a reason.

Now: a frame less 64 KiB of headroom, for precisely the reason the Pilgrimage ceiling sits 64 KiB under the browser's 256 KiB — a payload is not what goes on the wire.

⚠️ Behaviour change. MaxChannelPayloadBytes drops from 16 MiB to 16 MiB − 64 KiB. Nothing that previously succeeded now fails: payloads in that band could never be sent. Read the figure from ConduitSession.MaxPayloadBytes rather than hard-coding it and the change is invisible.

Conduit delivery is ordered but not guaranteed

Now stated, because it was not written down anywhere:

  • Frames arrive in order, never torn or duplicated.
  • They are not retried. Only the Epistle has the Vigil.
  • A receiver that stops draining fills its per-stream queue, and past that Ward further frames are dropped silently — the queue write reports success, and nothing in a frame reveals a gap.

An Epistle lost that way returns on the next retry; a conduit frame is gone and neither end finds out. Reachable only by a receiver that lets a thousand frames queue, so the shape that cannot lose anything is to take each frame and come straight back rather than processing inline. A protocol that must detect loss rather than avoid it carries its own sequence number.

Documented on ConduitFrame, on ReceiveAsync, and as its own section in design/transports-and-limits.md.

Also

design/transports-and-limits.md now says what "any transport" does and does not mean for a Shrine, and carries the authoritative account of where the send-concurrency guarantee comes from — NoiseVessel on every authenticated path, per-rite locks for bare vessels, IVessel owing nothing.

440 unit tests green.

v0.3.6 — a sealed Conduit is the reason, then end of stream

Choose a tag to compare

@github-actions github-actions released this 27 Aug 20:29

A refinement of the Conduit rite 0.3.5 shipped, driven by the first consumer's questions.

The seal is now terminal for the reader

As shipped in 0.3.5, a sealed frame was followed by a read that blocked forever: the host holds the visit open for the other rites — that is the drain which stops a sealed conduit taking the Oracle down with it — so no close was ever coming. A consumer writing the natural rule, "translate Sealed to a reason, then expect null", would have hung.

ReceiveAsync now latches: the sealed frame is yielded once carrying its SealReason, and every read after it returns null. Write exactly that rule. It is the contract the Auspice already had, where AttendAllAsync breaks on a Sealed frame.

One deliberate asymmetry. The latch is on the Pilgrimage wire only. The Arcanum conduit predates the reserved flag bit, so an application on a channel may already be using the top flag for its own purposes; reading it as a seal there would end that conduit silently — a flag day, which is what CupriMark exists to avoid. On a channel the bit stays the application's, and a test pins it.

Also in this release

Change Why
ConduitFrame.ApplicationFlags Returns flags with every rite-reserved bit cleared, so a consumer writes one expression that stays correct if a second bit is ever reserved — instead of masking against Sealed and diverging silently later.
ReceiveAsync single-reader documented Concurrent readers corrupt nothing, but each frame goes to exactly one of them, so ordering is meaningful only to one.
IConduitHandler ProtocolId convention The rite never dispatches on it and there is no registry, so a handler should seal an id it does not know rather than ignore the frame. Left unsaid, every consumer would invent a different failure for the same situation.

438 unit tests green.

v0.3.5 — the Conduit over the Shrine path

Choose a tag to compare

@github-actions github-actions released this 27 Aug 19:33

A Pilgrim could fetch a page (Oracle), attend a live feed (Auspice) and pull verified bulk content (Relic), but never simply hold a session. The Conduit — the raw duplex rite, framed and sealed but otherwise uninterpreted — is now reachable from a Pilgrimage.

Closes #3.

What's new

// Host
node.HostShrine(signet, handler, feeds, relics, conduit: new DelegateConduitHandler(async (session, token) =>
{
    while (await session.ReceiveAsync(token) is { } frame && !frame.IsSealed)
        await session.SendAsync(Handle(frame), token);
}));

// Pilgrim
await shrine.Conduits.SendAsync(new ConduitFrame { ProtocolId = 7, SchemaVersion = 1, Flags = 0, Payload = bytes }, ct);
var reply = await shrine.Conduits.ReceiveAsync(ct);

IConduitHandler, DelegateConduitHandler, ConduitHost.ServeAsync, a HostShrine overload, the matching AcceptPilgrimageOverVesselAsync, and ShrineSession.Conduits — the last in CupriNet.Shrine, so it reaches a WASM build. One conduit on stream 4, with ProtocolId as the discriminator.

This is the seam an existing framed protocol lands on when it moves to L2: the transport below it changes, the protocol above it does not. Without it, such a protocol had to be re-expressed as Oracle consults plus Auspice topics — a second implementation of its own semantics, kept in sync forever.

Keyless and author-less, deliberately

The Pilgrimage constructor takes no session key: the Noise vessel is already the confidentiality and authenticity boundary there, as it is for the Oracle, Auspice and Relic, so no key is plumbed out of the handshake. It takes no author identity either — authenticated authorship belongs in an Arcanum channel where members are durable; on a visit the Pilgrim is meant to be unlinkable, and signing frames under a lasting key would quietly undo that.

What the Shrine path adds, because a browser is on it

  • the 192 KiB payload ceiling both ways, so an oversized frame fails at the rite naming the Relic, not inside an SCTP association that rejects it. The Arcanum conduit keeps the 16 MiB a Vessel frame allows, and its wire is unchanged byte for byte;
  • padding on by default — a duplex session leaks size and timing at least as richly as a live feed;
  • a sealed frame when no conduit is hosted or a handler throws, so a peer is told rather than left waiting;
  • the conduit CupriMark component, on Pilgrimage frames only.

One bad rite still never takes the Shrine down: ConduitHost drains after sealing rather than returning, since the accept loop ends a visit when any rite's loop ends.

434 unit tests green.

v0.3.4

Choose a tag to compare

@github-actions github-actions released this 23 Aug 18:18
v0.3.4 — the Relic rite, the Oracle body guard, and the Tor package

The Shrine path is protocol-complete. Bulk content past the message
ceiling (an app blob, an asset pack) travels as a named **relic**: a
Reliquary transfer carried over the Pilgrimage on a stream of its own, its
manifest first, then each chunk as one request/response under the same
192 KiB frame ceiling the Auspice enforces - every chunk verified against
the manifest as it arrives and the whole file before the bytes are
returned, so a client proves a blob's integrity BEFORE executing it.
Hosts name relics through IRelicSource (StaticRelicSource proves manifest
fit at composition time); the Pilgrim end (ShrineSession.FetchRelicAsync)
lives in the node-free client stack, so it runs in a browser WASM build.
Frames carry the new `relic` CupriMark component.

The Oracle now guards its own bodies against the same ceiling, both ways:
an oversized handler response becomes a clear 500 naming the Relic rite
(the visit survives), and StaticFileOracleHandler refuses an over-ceiling
file before reading it - fail at the rite, with a reason, never at the
transport.

CupriNet.Tor is packed and pushed alongside CupriNet.WebRtc, so a
downstream can offer an onion transport as a package reference instead of
transcribing the binding. Closes #2.

v0.3.3

Choose a tag to compare

@github-actions github-actions released this 23 Aug 14:30
v0.3.3 — node-free Pilgrim entry

New CupriNet.Shrine package: `Pilgrimage.OverVesselAsync` runs the Pilgrim
half of a Shrine visit (Toll, Noise pinning the Signet, then the Oracle and
Auspice rites over one vessel) without a `CupriNode`, and without referencing
anything that binds a socket — so it works from a browser WebAssembly build,
a fetch-only CLI, a test with no overlay, or an embedded host with no listener.

`CupriNode.PilgrimageOverVesselAsync` is unchanged and delegates to it.
`ShrineSession` moved to the new package keeping namespace CupriNet.Hosting,
with a TypeForwardedTo from CupriNet.Hosting, so both source and binary
references built against 0.3.2 keep resolving.

Closes #1