Repository navigation
Releases: Wixely/CupriNet
Release list
v0.6.2
v0.6.1 — the WebRTC message-size check stops being inert
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
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
0today. The negotiated value lives onSctpAssociation, andWebRtcChanneldoes 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.mdcalled 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
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 sitesA 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
MonikerandWebRtcEndpointalready use without one. design/shrines.mdhad 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
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 portA 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
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.MaxChannelPayloadBytesdrops 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 fromConduitSession.MaxPayloadBytesrather 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
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
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
conduitCupriMark 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
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
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