RequestSeal.Quorum (RequestSeal v0.2.0)

Copy Markdown View Source

Explicit composite RFC 9421 verification policy.

new/1 requires :mode (:all, :any, or {:threshold, n}), :unit (:key, :principal, or {:role, shared_principal: boolean}), :slots, :unexpected, and :invalid (each :reject or :ignore). Labels never count. Every mode requires all configured required slots and at least one qualifying signature. :all requires at least one required slot; :any requires one eligible signer; thresholds count distinct qualifying units. Every signature must meet its own slot's complete policy. Each slot receives at most one signature; each label and each identity class occupies at most one slot across every counting unit. Observations of one label merge all its known identities and any unknown observation into one class. Unknown observations share a pool. A label is known if any eligible verification supplies a known identity. Required slots must be filled in every mode; an unfilled bound slot fails its binding. No coverage union is performed.

verify_quorum/3 requires replay: :not_required in every slot policy. A required replay policy returns :invalid_policy, layer :input, before any callback or store claim, including in optional or unmatched slots. Composite replay requires one claim after the complete quorum, bindings, and negotiation pass; per-label claims can consume nonqualifying candidates. Required replay is rejected until that composite replay design exists.

Slot :content must be :not_required or a Content-Digest policy (kind: :content) checked against the message's retained body. new/1 rejects representation digest policies and caller-fed digest state with :invalid_quorum, layer :input, including optional or unmatched slots. Quorum verification accepts no :representation or :digest_state option; streamed or unavailable content cannot qualify when a digest is required.

Assignment maximizes counting units, then filled slots, in canonical slot-id and candidate-label order, preferring challenge labels. Declaration and wire order cannot change the chosen set; output lists retain configured slot order. Within the configured input limits and with existing covered bytes preserved, adding an unrelated optional signature preserves a feasible quorum except:

  • unexpected: :reject rejects a new label with no selector-eligible slot.
  • invalid: :reject rejects a new eligible label that fails every slot policy.
  • A newly verified label spanning identity classes merges them and can reduce the count or reject the quorum. This includes a label observed as unknown under one slot and known under another: it bridges the shared unknown pool into the known class, reducing capacity for unknown-only labels as well.

An unfillable required slot returns :quorum_not_met before binding checks, even when a binding also fails. Once required slots can be filled, an unfillable bound slot or inconsistent binding returns :binding_unsatisfied. After a valid assignment is found, an unmet mode or threshold returns :quorum_not_met.

Slots number 1–16 and require unique atom :id, RequestSeal.Policy :policy, and Boolean :required. Optional :label and :tag selectors default to nil (any); :principal and :role default to nil. Principal counting requires a nonempty caller-bound principal in every slot. Role counting additionally requires an atom role. With shared_principal: false, one principal counts toward only one role. These counting bindings establish no attribution.

:bindings defaults to [], at most 64 entries: {:nested, outer: id, inner: id} requires the outer to cover both the inner's actual "signature-input";key="<inner-label>" and "signature";key="<inner-label>" dictionary members; {:same_parameter, name, ids} requires identical, present nonce, tag, or created parameters across the selected slots. Bindings must reference configured slots and cannot bind a nested signature to itself. :max_signatures defaults to 16 (1–64); thresholds cannot exceed this bound.

Unknown units, malformed choices, and ambiguous bindings return :invalid_quorum, layer :input, before callbacks run. Key identities stay internal. Unknown key equivalence cannot count in key-unit quorums; optional unknown identities are ignored, while a required slot with only unknown candidates rejects with :ambiguous_key_identity. Principal/role policies conservatively assign the shared unknown pool to at most one slot. Inspection omits slots.

Bindings search the complete eligible candidate pool, including alternatives with the same key. Nested bindings require both inner dictionary members; same-parameter bindings require equal, non-nil values. Negotiation searches the verified, selector-eligible pool after key-unit filtering. A challenge label can fulfill negotiation without counting as a unit; it appears in qualifying and signatures only when assigned. A tampered challenge fails.

Verification performs at most 64 × 16 label-slot attempts. Without bindings, key, principal, and shared-principal role units use polynomial matching. Other assignments use exact search with a 131,072-node budget. On exhaustion, a best-so-far assignment is returned only if it satisfies the mode, required slots, and bindings; otherwise the result is :limit, layer :input. Selector-free bound slots populated by trusted signers can exhaust that budget; the bound prevents unbounded search and never produces partial success.

Summary

Functions

Construct a bounded composite policy without invoking a callback.

Types

t()

@type t() :: %RequestSeal.Quorum{
  bindings: [term()],
  invalid: :reject | :ignore,
  max_signatures: 1..64,
  mode: :all | :any | {:threshold, 1..64},
  slots: [map()],
  unexpected: :reject | :ignore,
  unit: :key | :principal | {:role, keyword()}
}

Functions

new(attrs)

@spec new(map()) :: {:ok, t()} | {:error, RequestSeal.Error.t()}

Construct a bounded composite policy without invoking a callback.