mirror of
https://github.com/luxfi/threshold.git
synced 2026-07-27 04:01:59 +00:00
quorum: make Policy self-describing on the wire (3-of-5)
A quorum written as `"threshold": 3` is ambiguous — it reads as either the
signer count or the polynomial degree, and the two differ by one. That
ambiguity is what produced degree-0 (1-of-n) custody keys from configs that
said 3-of-5.
Policy now implements TextMarshaler/TextUnmarshaler over the operator form, so
any config, genesis blob or API payload that carries a quorum carries it in the
one representation that cannot be misread. Decode of an absent or undeployable
policy is an error rather than a zero value: {0,0} would reach cmp.Keygen as
degree -1, and a wrong-degree key that already holds funds has no recovery path
short of a resharing ceremony.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
@@ -101,6 +101,33 @@ func FromDegree(t, n int) (Policy, error) {
|
||||
return New(t+1, n)
|
||||
}
|
||||
|
||||
// MarshalText renders the policy in operator form so that any config, genesis
|
||||
// blob or API payload carrying a Policy is self-describing: "3-of-5" cannot be
|
||||
// misread as a degree, whereas a bare `"threshold": 3` can and has been.
|
||||
//
|
||||
// Policy implements encoding.TextMarshaler/TextUnmarshaler rather than a JSON
|
||||
// codec so the same single representation works for JSON, YAML, TOML, flags and
|
||||
// map keys — one form everywhere.
|
||||
func (p Policy) MarshalText() ([]byte, error) {
|
||||
if !p.Valid() {
|
||||
return nil, fmt.Errorf("quorum: refusing to encode invalid policy %d-of-%d", p.K, p.N)
|
||||
}
|
||||
return []byte(p.String()), nil
|
||||
}
|
||||
|
||||
// UnmarshalText reads the operator form. An absent or malformed policy is an
|
||||
// error, never a silent zero value: a Policy that decoded to {0,0} would be
|
||||
// indistinguishable from "unset" at the keygen boundary, and the failure mode
|
||||
// of guessing there is an unrecoverable wrong-degree key.
|
||||
func (p *Policy) UnmarshalText(text []byte) error {
|
||||
parsed, err := Parse(string(text))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*p = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse reads the operator form, "3-of-5". It accepts surrounding space and
|
||||
// an "of" separator with or without hyphens ("3 of 5"), because those are the
|
||||
// forms that show up in runbooks and dashboards.
|
||||
|
||||
@@ -48,7 +48,10 @@ func TestPolicy3of5(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewRejectsUndeployablePolicies(t *testing.T) {
|
||||
for _, tc := range []struct{ name string; k, n int }{
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
k, n int
|
||||
}{
|
||||
{"k exceeds n", 6, 5},
|
||||
{"k=1 is not a threshold policy", 1, 5},
|
||||
{"k=0", 0, 5},
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package quorum_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/threshold/pkg/quorum"
|
||||
)
|
||||
|
||||
// A Policy in a config file must round-trip through the operator form. This is
|
||||
// the property that lets genesis and node config state a quorum in a way that
|
||||
// cannot be misread as a polynomial degree.
|
||||
func TestPolicyJSONRoundTrip(t *testing.T) {
|
||||
type cfg struct {
|
||||
Policy quorum.Policy `json:"policy"`
|
||||
}
|
||||
|
||||
in := cfg{Policy: quorum.MustNew(3, 5)}
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if got, want := string(b), `{"policy":"3-of-5"}`; got != want {
|
||||
t.Fatalf("marshal = %s, want %s", got, want)
|
||||
}
|
||||
|
||||
var out cfg
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.Policy != in.Policy {
|
||||
t.Fatalf("round-trip = %s, want %s", out.Policy, in.Policy)
|
||||
}
|
||||
if out.Policy.Degree() != 2 {
|
||||
t.Fatalf("decoded 3-of-5 has degree %d, want 2", out.Policy.Degree())
|
||||
}
|
||||
}
|
||||
|
||||
// An undeployable or unparseable policy must fail loudly at decode. A Policy
|
||||
// that silently decoded to the zero value would reach cmp.Keygen as degree -1.
|
||||
func TestPolicyUnmarshalRejectsGarbage(t *testing.T) {
|
||||
for _, in := range []string{
|
||||
`{"policy":"6-of-5"}`, // unsatisfiable
|
||||
`{"policy":"1-of-5"}`, // not a threshold policy
|
||||
`{"policy":"3/5"}`, // wrong separator
|
||||
`{"policy":""}`, // empty
|
||||
`{"policy":"0-of-0"}`, // zero
|
||||
} {
|
||||
var out struct {
|
||||
Policy quorum.Policy `json:"policy"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(in), &out); err == nil {
|
||||
t.Errorf("Unmarshal(%s) succeeded as %s, want error", in, out.Policy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Encoding an invalid policy must fail rather than emit a string that would
|
||||
// decode back to something different (or fail asymmetrically).
|
||||
func TestPolicyMarshalRejectsInvalid(t *testing.T) {
|
||||
if _, err := (quorum.Policy{K: 0, N: 0}).MarshalText(); err == nil {
|
||||
t.Fatal("marshalling the zero policy succeeded, want error")
|
||||
}
|
||||
if _, err := (quorum.Policy{K: 9, N: 5}).MarshalText(); err == nil {
|
||||
t.Fatal("marshalling 9-of-5 succeeded, want error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user