commitreveal.gno
4.75 Kb · 145 lines
1// Package commitreveal implements the commit-then-reveal scheme as a pure,
2// reusable package.
3//
4// The problem it solves: anything submitted to a chain is public before it is
5// executed, so a naive sealed-bid auction or simultaneous-move game lets the
6// last player read everyone else's move and win for free. Commit-reveal splits
7// the action in two — first publish H(value || salt), later publish the value
8// and salt. The commitment binds you to a choice without disclosing it.
9//
10// The SALT is not optional and this package refuses to let a caller skip it.
11// Without one, a commitment over a small domain is trivially brute-forced: a
12// rock-paper-scissors move has three possible hashes, so hashing all three
13// breaks the scheme entirely. MinSaltLen is enforced at commit time rather than
14// left as advice in a comment.
15//
16// Verification is CONSTANT-TIME over the digest. A short-circuiting comparison
17// leaks, through timing, how many leading bytes of a guess were right, which is
18// enough to reconstruct a commitment byte by byte.
19//
20// This package computes and checks commitments; it stores nothing and knows
21// nothing about phases or deadlines. The realm owns that.
22//
23// A live demo of this package is at
24// [r/moul/x/daily/commitrevealdemo](/r/moul/x/daily/commitrevealdemo/v0).
25package commitreveal
26
27import (
28 "crypto/sha256"
29 "encoding/hex"
30 "errors"
31)
32
33// MinSaltLen is the shortest salt accepted. Short salts make a small-domain
34// commitment brute-forceable, which defeats the whole scheme.
35const MinSaltLen = 16
36
37// MaxValueLen bounds the committed value so gas stays predictable.
38const MaxValueLen = 4096
39
40var (
41 ErrShortSalt = errors.New("commitreveal: salt is shorter than MinSaltLen")
42 ErrLongValue = errors.New("commitreveal: value exceeds MaxValueLen")
43 ErrMismatch = errors.New("commitreveal: reveal does not match the commitment")
44 ErrBadHexDigest = errors.New("commitreveal: commitment is not a valid hex digest")
45)
46
47// DigestLen is the length in bytes of a commitment digest (SHA-256).
48const DigestLen = 32
49
50// Commit returns the hex-encoded commitment for value and salt.
51//
52// The salt is length-prefixed rather than simply concatenated: with plain
53// concatenation, ("ab","cd") and ("a","bcd") hash identically, so one
54// commitment could be opened two different ways.
55func Commit(value, salt string) (string, error) {
56 if len(salt) < MinSaltLen {
57 return "", ErrShortSalt
58 }
59 if len(value) > MaxValueLen {
60 return "", ErrLongValue
61 }
62 return hex.EncodeToString(digest(value, salt)), nil
63}
64
65// MustCommit is Commit, panicking on invalid input. For tests and for callers
66// that have already validated.
67func MustCommit(value, salt string) string {
68 c, err := Commit(value, salt)
69 if err != nil {
70 panic(err.Error())
71 }
72 return c
73}
74
75// Verify reports whether value and salt open the given commitment. The digest
76// comparison is constant-time.
77func Verify(commitment, value, salt string) bool {
78 if len(salt) < MinSaltLen || len(value) > MaxValueLen {
79 return false
80 }
81 want, err := hex.DecodeString(commitment)
82 if err != nil || len(want) != DigestLen {
83 return false
84 }
85 return equalConstantTime(want, digest(value, salt))
86}
87
88// Open verifies and reports why it failed, for callers wanting a reason rather
89// than a bool.
90func Open(commitment, value, salt string) error {
91 if len(salt) < MinSaltLen {
92 return ErrShortSalt
93 }
94 if len(value) > MaxValueLen {
95 return ErrLongValue
96 }
97 want, err := hex.DecodeString(commitment)
98 if err != nil || len(want) != DigestLen {
99 return ErrBadHexDigest
100 }
101 if !equalConstantTime(want, digest(value, salt)) {
102 return ErrMismatch
103 }
104 return nil
105}
106
107// ValidCommitment reports whether s is well-formed as a commitment: hex, and
108// exactly DigestLen bytes. It says nothing about what it commits to.
109func ValidCommitment(s string) bool {
110 b, err := hex.DecodeString(s)
111 return err == nil && len(b) == DigestLen
112}
113
114// digest computes SHA-256 over a length-prefixed encoding of value and salt.
115// gno's crypto/sha256 exposes only Sum256, so the message is assembled first
116// rather than streamed through a hash.Hash.
117func digest(value, salt string) []byte {
118 buf := make([]byte, 0, 8+len(value)+len(salt))
119 buf = append(buf, lengthPrefix(len(value))...)
120 buf = append(buf, value...)
121 buf = append(buf, lengthPrefix(len(salt))...)
122 buf = append(buf, salt...)
123 sum := sha256.Sum256(buf)
124 return sum[:]
125}
126
127// lengthPrefix encodes n as 4 big-endian bytes.
128func lengthPrefix(n int) []byte {
129 return []byte{
130 byte(n >> 24), byte(n >> 16), byte(n >> 8), byte(n),
131 }
132}
133
134// equalConstantTime compares two byte slices without short-circuiting, so the
135// time taken does not reveal how many leading bytes matched.
136func equalConstantTime(a, b []byte) bool {
137 if len(a) != len(b) {
138 return false
139 }
140 var diff byte
141 for i := range a {
142 diff |= a[i] ^ b[i]
143 }
144 return diff == 0
145}