Skip to content

Repository files navigation

CupriMark

A signed, versioned capability-negotiation library. CupriMark replaces hard != Version protocol cutovers with range negotiation, so nodes on different releases keep interoperating through a deliberate, security-aware migration window instead of partitioning on a flag day.

It is generic and agnostic: it knows nothing about your transport, wire format, or crypto suite. It supplies the catalogue, the negotiation primitive, and the exact bytes to bind into your own authenticated transcript. You define your components and wire the selected version to code paths.

Built for CupriNet as its first consumer, but usable by any protocol that versions independent layers.


The two ideas that do the work

  1. Only ordinals travel on the wire; their meaning is resolved locally. Peers exchange, per component, the compact range of ordinals they will speak — never version definitions. Each side looks up the agreed ordinal's behaviour in its own signed catalogue.

  2. Definitions are immutable; policy is not. Every version splits into an immutable part (ordinal → { bumpReason, payload }, frozen and hashed at publish) and a per-release policy (the supported range, the lifecycle status, the derived floors). Because a published ordinal is frozen forever, any two releases resolve a shared ordinal to identical behaviour — no trust in the peer required.


Concepts

Concept What it is
Catalogue Immutable, hashed, optionally-signed list of every version each component ever had. Its SHA-256 is the catalogue Id.
Ordinal The uint16 version number used to order and negotiate. How a version is encoded on the wire lives in its opaque payload.
BumpReason Why a version exists — Functionality (free) or Security (raises the floor). Immutable.
Status Where a version is in its life — Active → Deprecated → Buried. Policy; advances forward only.
Security floor (soft) Highest Security ordinal. The default minimum; an override may lower it.
Buried floor (hard) One above the highest buried ordinal. Inviolable — no override crosses it.
Override The operator escape hatch: temporarily lower the soft floor to bridge a migration. Secure by default (off), loud, and time-boxable.

Lifecycle & the staged migration it enables

1. Ship v5 (Security). New nodes' security floor = 5 → by default they refuse the v4 fleet.
2. During migration, set an override on the NEW nodes (acceptFloor = 4). They keep talking to v4.
3. Fleet mostly on v5+ → turn the override off → stragglers get BelowFloor and must upgrade.
4. v4 negligible → Bury it: delete the handler, set status = Buried. Now no config can revive it.

A buried version is three-layers-safe: not offered (excluded from the supported range), not acceptable (the effective floor clamps above it), and not implemented (the handler is deleted, so even a bypass fails closed).


Using the library

using CupriMark;

// Define a component's history. v3 was a security fix, so the soft floor is 3.
var noiseBinding = new Component("noise-binding",
[
    new ComponentVersion(1, BumpReason.Functionality, VersionStatus.Active),
    new ComponentVersion(2, BumpReason.Functionality, VersionStatus.Active),
    new ComponentVersion(3, BumpReason.Security,      VersionStatus.Active, payload: [0x01]),
    new ComponentVersion(4, BumpReason.Functionality, VersionStatus.Active),
]);

var catalogue = Catalogue.Create("cuprinet", [noiseBinding]);

// Advertise `catalogue.Component("noise-binding").Supported` on the wire ([1..4] here).
// A peer advertises theirs — say [2..3]. Negotiate locally:
var result = Negotiator.Negotiate(noiseBinding, OrdinalRange.Create(2, 3));
// => Accept(3): the highest mutually-supported ordinal at or above our floor.

// A peer stuck on [1..2] is below our security floor:
Negotiator.Negotiate(noiseBinding, OrdinalRange.Create(1, 2));            // Reject(BelowFloor)

// Bridge it during a migration with a loud, time-boxed override. The clock is a required
// argument — a defaulted clock would silently make every time-boxed override permanent.
var bridge = new FloorOverride { AcceptFloor = 2, ExpiresAtUnixSeconds = migrationDeadline };
var now    = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
Negotiator.Negotiate(noiseBinding, OrdinalRange.Create(1, 2), bridge, now);   // Accept(2)

A supported set is a promise: every ordinal in it must resolve to a defined, non-buried version at or above the buried floor — you never advertise an ordinal you would then refuse to honour. The default is the contiguous run ending at the newest advertisable version (cheap: a couple of bytes on the wire). A build that genuinely needs a gap — speaks v2 and v4 but not v3 — opts into a sparse set:

var codec = new Component("codec", versions, OrdinalSet.Of([2, 4])); // speaks {2,4}, not 3
// Negotiation selects the highest mutually-present ordinal; two peers whose spans overlap
// but share no actual version get Reject(NoCommonVersion). Contiguous stays the default and
// is byte-for-byte unchanged on the wire.

Downgrade protection is the consumer's job — CupriMark hands you the bytes

A signed catalogue authenticates definitions, not the live negotiation. To stop an active MITM rewriting the advertised ranges, bind the negotiation into your own authenticated transcript (e.g. mix it into a Noise prologue / handshake hash). Bindings are keyed by handshake role — initiator vs responder — so both peers, fed the same honest wire data, derive byte-identical blobs:

// Each side builds the binding from its own view; `isInitiator` maps (local, peer) onto the
// canonical (initiator, responder) slots. Only an accepted negotiation is bindable — a
// rejection aborts the handshake instead.
var binding = NegotiationBinding.FromLocal(
    "noise-binding",
    localRange: noiseBinding.Supported,
    peerRange:  peerRange,
    result:     result,
    isInitiator: weInitiatedTheHandshake);

// Bind ALL the components you negotiated — a partial binding leaves the rest MITM-able.
// EncodeComplete fails loudly if any negotiated component is missing; Digest gives a
// fixed-size 32-byte value to mix into a running handshake hash.
byte[] digest = TranscriptBinding.Digest(allBindings, negotiatedComponentNames);
// Feed `digest` into your handshake hash / Noise prologue. Both honest sides derive the same
// digest; tampering with either advertised range diverges it and breaks the MAC.

See TranscriptBindingScenarioTests for a worked initiator/responder/MITM example: an honest exchange yields matching transcript hashes, while a MITM rewriting an advertised range diverges them even though the negotiation itself still "accepts".

Signing is optional and dependency-free

The core library carries no crypto dependency: plug in Ed25519 via ICatalogueSigner / ICatalogueVerifier, and CupriMark hands over the exact canonical body to sign and stores the result. For a batteries-included, 100% managed option, add the CupriMark.Signing.CupriCurve companion package (Ed25519 over CupriCurve); or bridge your own stack. Consumers pin the release key before trusting any definition.

// With the companion package:
var signer = CupriCurveSigner.Generate(out byte[] seed);   // persist `seed` (the private key)
var signed = CatalogueSigning.Sign(catalogue, signer);
bool ok    = CatalogueSigning.Verify(signed, CupriCurveVerifier.Instance, pinnedKey: signer.PublicKey);

Key rotation without a flag day. Pin an ordered set of keys (current + still-trusted previous) so you can ship a new key, sign with it while the fleet still trusts the old one, then retire the old key:

var pins = new PinnedKeys(currentKey, previousKey);   // both trusted during the overlap
bool ok  = CatalogueSigning.Verify(signed, myVerifier, pins);
// roll forward: pins = pins.Rotate(nextKey);  then later:  pins = pins.Retire(previousKey);

The cuprimark CLI

The catalogue is generated at build time from a reviewed cuprimark.json manifest — never hand-assembled at runtime — and gated by a lockfile that enforces immutability.

# Build the catalogue, enforce the immutability gate against the lock, and emit both.
cuprimark build --manifest cuprimark.json --out catalogue.bin --lock cuprimark.lock

# Inspect a built catalogue.
cuprimark inspect catalogue.bin

# Dry-run the negotiation primitive against a peer's advertised range.
cuprimark negotiate --catalogue catalogue.bin --component noise-binding --peer 1-2 --accept-floor 1

The immutability gate fails the build if a previously-locked version's immutable definition changed ("ordinal N is published and immutable"). Adding a version or advancing a status is allowed and updates the lock, so the change is reviewable in the pull request. See samples/cuprimark.json.

Or skip the CLI: the MSBuild build task

Add the CupriMark.Build.Tasks package and the catalogue is generated as part of dotnet build — the immutability gate runs on every compile, and the catalogue (plus advisory/dependency companions) is embedded as a resource on your assembly:

<PackageReference Include="CupriMark.Build.Tasks" Version="..." PrivateAssets="all" />

Drop a cuprimark.json next to the project; the build reads it, enforces the gate (a violation fails the build with a reviewable error), updates cuprimark.lock in place only when it changes, and embeds catalogue.bin (load it at runtime with Catalogue.Decode(assembly.GetManifestResourceStream("CupriMark.catalogue.bin"))). Override CupriMarkManifest / CupriMarkLock / CupriMarkResourcePrefix as needed.


Software lifecycle: recommended practice

CupriMark only pays off if the whole team follows one discipline: a version, once published, is frozen forever; only policy moves. These are the practices that keep that true.

1. One manifest, generated in CI, gated by the lock

  • Keep a single cuprimark.json per project as the reviewed source of truth. Never hand-assemble a catalogue at runtime.
  • Run cuprimark build --manifest cuprimark.json --lock cuprimark.lock in CI on every PR, and commit cuprimark.lock. The immutability gate turns "someone quietly changed a shipped version" from a production incident into a failed check with a reviewable diff.
  • Treat a gate failure as a design signal, not an obstacle to silence: it means you tried to mutate history. Add a new ordinal instead.

2. Adding a version — the only safe change

To change behaviour, append a new ordinal; never edit a published one. Cutting a version is two decisions:

  • bumpReason — the decision that matters most. Ask: does an older node speaking the previous version expose a vulnerability?
    • Functionality — a feature or behaviour change with no security implication. The old versions stay freely negotiable; nobody is forced to upgrade. Use this for the overwhelming majority of bumps.
    • Security — a fix where continuing to speak the old version is unsafe. This raises the soft floor, so fresh nodes refuse everything below it by default. Reserve it for genuine security regressions; over-using it forces needless flag-day-like churn.
    • When unsure, default to Functionality. If a flaw is found in a version you already shipped as Functionality, you cannot rewrite its frozen tag — that is what the planned retroactive advisory list (below) is for. Do not try to "fix" it by editing the manifest; the gate will stop you, and correctly.
  • payload — put anything version-specific here (the on-wire tag, capability flags, a human label). It is opaque to CupriMark and immutable once published, so design it to be self-describing.

3. Status is policy — advance it deliberately, never reverse it

status is the one field that legitimately changes release to release, and only ever forward:

Move When Effect
→ Deprecated A later Security bump superseded this version. Still implemented, but only negotiable via an explicit override. Signals "on its way out."
→ Buried The version is negligible in the fleet and you have deleted its handler code. Permanently un-negotiable; the ordinal is tombstoned and can never be recycled.

Bury only after the code path is actually removed — burying is a promise that there is nothing left to run. The tombstone stays in the catalogue forever so history is auditable and the ordinal is reserved.

4. The migration playbook (no flag day)

This is the whole point — rolling out a Security bump without partitioning the network:

  1. Ship the new version (say ordinal 5, Security). New nodes' floor is now 5; by default they would refuse the v4 fleet.
  2. Bridge. Set a per-component, time-boxed override on the new nodes only: acceptFloor = 4, expiresAt a few weeks out. Old nodes need no override — their floor is already at or below the selected version.
  3. Drain. As the fleet moves to v5+, watch the sessions-below-security-floor counter you emit while an override is live. When it approaches zero, remove the override. Stragglers now get BelowFloor and are pushed to upgrade.
  4. Bury. Once v4 traffic is gone, delete the v4 handler and set status: Buried. Now no config, stale or malicious, can revive it.

5. Override hygiene

The override is an escape hatch, not a setting:

  • Off by default. A fresh deployment must be secure with no configuration.
  • Per-component, so you bridge one weak layer without lowering the floor on everything else.
  • Loud. While one is active, emit a standing warning and the below-floor session counter so it can't quietly become permanent. Wire ICupriMarkObserver.OnBelowSecurityFloorAccepted (passed to Negotiator.Negotiate) to your telemetry — it fires once whenever an override bridges an acceptance below the security floor. (CatalogueDiagnostics.Compare + OnCatalogueIdMismatch similarly surface a catalogue fork, without affecting negotiation.)
  • Time-boxed. Always set expiresAt; renewing it should be a conscious act. Remember wall-clock expiry is an operational nicety on a hostile host, not a security control — the real forcing function is burying the old version.
  • Delete it the moment the migration is done. An override that outlives its migration is technical debt with a security label.

6. Retroactive advisories — when a shipped version turns out to be unsafe

bumpReason only captures security issues known at publish time. When a flaw is later found in a version that shipped as a mere Functionality bump, its frozen tag can't be edited — and it shouldn't be, or immutability is meaningless. Instead, publish an advisory: a note in a separate, append-only, signable companion list that condemns an already-published ordinal after the fact.

// in cuprimark.json
"advisories": [
  { "component": "handshake", "ordinal": 1, "reference": "CVE-2026-0001" }
]

An advisory on ordinal N raises the effective security floor to N+1 for that component — exactly as a planned Security bump would — so negotiation refuses ≤N by default:

var advisories = AdvisoryList.Decode(File.ReadAllBytes("advisories.bin"));
Negotiator.Negotiate(component, peerRange, advisories, overridePolicy: bridge, now);

The advisory list is append-only: the build gate refuses to retract or edit a published advisory (retracting one would silently re-open a downgrade an operator was told was closed). An override can still bridge below an advisory floor during migration, down to — never past — the buried floor, and burying the condemned version remains the terminal fix.

7. Signing and key management

  • Sign the catalogue with your release key and pin that key in the consumer. Verification against the pin is what makes "same ordinal ⇒ same meaning" safe against a tampered local copy — verify before trusting any definition.
  • Keep the signing key in your release pipeline's secret store (or an HSM), never in the repo. A project that already embeds the catalogue inside an independently-signed binary may skip catalogue signing.

8. Inter-component dependency rules

Some version selections are only valid together — "v3 toll requires ≥v2 noise-binding". Declare such constraints and evaluate them after per-component negotiation; an unmet dependency fails the session closed.

// in cuprimark.json
"dependencies": [
  { "ifComponent": "toll", "ifMinOrdinal": 3, "requiresComponent": "noise-binding", "requiresMinOrdinal": 2 }
]
var deps = DependencySet.Decode(File.ReadAllBytes("dependencies.bin"));
var evaluation = deps.Evaluate(perComponentResults);   // Dictionary<string, NegotiationResult>
if (!evaluation.Satisfied)
    Reject(evaluation.Violations);                     // fail closed with a specific reason

Rules are local policy resolved on each side (nothing travels on the wire); cuprimark build validates them against the catalogue, and cuprimark check --select toll=3,noise-binding=1 evaluates a selection.

9. Don't forget the wire binding

CupriMark authenticates definitions; it does not, by itself, stop an active MITM from rewriting the live advertised ranges. For every negotiation that guards a secret, feed TranscriptBinding.Encode(...) into your channel's authenticated transcript. Negotiation that runs before your channel is authenticated (e.g. a pre-handshake cookie) is inherently unprotected — keep such components non-security-critical and document it.

Per-release checklist

  • Behaviour change expressed as a new ordinal, never an edit to a published one.
  • bumpReason chosen deliberately (Security only for real regressions).
  • cuprimark build run in CI; cuprimark.lock diff reviewed and committed.
  • Any status advance (Deprecate/Bury) intentional and forward-only; Bury only after the handler is deleted.
  • Migration overrides per-component, time-boxed, loud — and scheduled for removal.
  • Catalogue signed and the key pinned; negotiation bound into the authenticated transcript.

Malicious-negotiation posture

  • Downgrade (active MITM): not prevented by the signature alone — bind the offered ranges (both directions) and the selection into your authenticated transcript. CupriMark gives you the canonical bytes.

  • Self-downgrade (honest weak peer): bounded by your local security floor; a peer can't drag you below it unless you enabled the override.

  • Tampered local catalogue (supply chain): verify the signature against your pinned release key.

  • Resource abuse: hard ParseLimits caps on components, versions, payload size, name length, and range width when decoding untrusted bytes. Malformed input is rejected, never silently clamped.

  • Fingerprinting / anonymity: advertising your exact supported set leaks how old your build is (how far back it still speaks). For anonymity-sensitive transports (e.g. Tor mode), advertise the coarse range instead — [effectiveFloor..max], hiding the tail below the floor at no functional cost:

    var floor = Negotiator.EffectiveFloor(component, advisories?.SecurityFloorFor(component.Name) ?? 0, overridePolicy, now);
    OrdinalRange onWire = Advertisement.For(component, AdvertisementPolicy.Coarse, floor);
    // or: Advertisement.For(component, AdvertisementPolicy.Coarse, advisories, overridePolicy, now)

Layout

Path Contents
src/CupriMark The library: data model, canonical encoder + hash, negotiation, floors, override, transcript binding, signing abstraction, build/lock tooling.
src/CupriMark.Cli The cuprimark build/inspect/negotiate/advertise/check CLI.
src/CupriMark.Signing.CupriCurve Optional Ed25519 signer/verifier over CupriCurve.
src/CupriMark.Build.Tasks Optional MSBuild task: generate + gate + embed the catalogue during dotnet build.
tests/ xUnit suites (core, signing companion, build task).
samples/cuprimark.json An example manifest (components, advisories, dependencies).

See ROADMAP.md for what has shipped and what's next.

Status

Everything on the roadmap through v0.4 has shipped: the v0.1 core (data model, canonical encoding + hash, range negotiation, Active/Deprecated/Buried lifecycle, floors, override, transcript binding, lockfile gate, parse wards) plus retroactive advisories, transcript-binding hardening, key pinning & rotation, a bundled Ed25519 companion, coarse-advertisement anonymity mode, sparse/non-contiguous support, inter-component dependency rules, catalogue-id diagnostics, observability hooks, and an MSBuild build task.

License

MIT © Wixely

About

CupriNet-CupriMark: A signed, versioned capability-negotiation library.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages