statement.gno
5.50 Kb · 155 lines
1package wesh
2
3import (
4 "crypto/ed25519"
5 "crypto/sha256"
6 "encoding/hex"
7 "errors"
8 "strconv"
9 "strings"
10)
11
12// Statement kinds. A signature is only ever valid for the kind it was made
13// under, so a binding signature can never be replayed as a device statement.
14const (
15 kindBind = "gno.wesh/v0:bind"
16 kindDevice = "gno.wesh/v0:device"
17)
18
19// Device sigchain operations.
20const (
21 OpAdd = "add"
22 OpRevoke = "revoke"
23)
24
25var (
26 ErrBadOp = errors.New("wesh: operation must be add or revoke")
27 ErrBadSignature = errors.New("wesh: signature does not verify under the account key")
28 ErrBadSeq = errors.New("wesh: sequence number must not be negative")
29 ErrBadPrevLen = errors.New("wesh: previous digest must be 32 bytes")
30)
31
32// DigestLen is the length of a statement digest (SHA-256).
33const DigestLen = 32
34
35// ValidOp reports whether op is a known sigchain operation.
36func ValidOp(op string) bool { return op == OpAdd || op == OpRevoke }
37
38// BindStatement returns the canonical text an account signs, with its Wesh
39// account private key, to claim a directory entry.
40//
41// The signature is what makes a directory entry meaningful. Without it anyone
42// could publish anyone else's account key next to a seed of their choosing and
43// harvest the contact requests that followed. With it, a directory entry is a
44// statement by the account itself, verifiable by anyone, replayable nowhere:
45//
46// - the kind prefix stops a signature made for one purpose being reused for
47// another;
48// - chainID stops a binding signed for a testnet being replayed on mainnet;
49// - gnoAddr binds the Wesh identity to exactly one gno account, so a leaked
50// signature cannot be used to claim the identity from a different address;
51// - revision is monotonic, so an old, superseded binding cannot be replayed
52// to roll a rotation back.
53//
54// payload is the seed for a publicly published identity, or the seed
55// commitment from [SeedCommitment] for one that keeps its rendezvous point off
56// chain. The two are indistinguishable on the wire, which is deliberate: an
57// observer cannot tell a committed identity from a published one without the
58// realm's own mode flag.
59//
60// Fields are newline-framed and hex-encoded, never concatenated raw: with
61// plain concatenation a different split of the same bytes would produce the
62// same statement.
63func BindStatement(chainID, gnoAddr string, accountPK, payload []byte, revision int) string {
64 var b strings.Builder
65 b.WriteString(kindBind)
66 b.WriteString("\n")
67 b.WriteString(chainID)
68 b.WriteString("\n")
69 b.WriteString(gnoAddr)
70 b.WriteString("\n")
71 b.WriteString(hex.EncodeToString(accountPK))
72 b.WriteString("\n")
73 b.WriteString(hex.EncodeToString(payload))
74 b.WriteString("\n")
75 b.WriteString(strconv.Itoa(revision))
76 return b.String()
77}
78
79// DeviceStatement returns the canonical text an account signs to append one
80// entry to its device sigchain.
81//
82// This is the piece the Wesh protocol has no answer for. Its own documentation
83// says a device, once linked, can never be revoked: the account metadata log is
84// append-only and no authority can mark an entry void. A chain cannot undo
85// that (the ratchets are already out there and forward secrecy is a local
86// property), but it can host the record that was missing, so a revocation
87// becomes publicly visible, ordered, and attributable to the account key.
88//
89// prev is the digest of the preceding statement, or [GenesisDigest] for seq 0.
90// Chaining each entry to its predecessor means the log cannot be reordered or
91// have an entry quietly dropped: any gap changes every digest after it.
92func DeviceStatement(chainID string, accountPK []byte, seq int, prev []byte, op string, devicePK []byte) string {
93 var b strings.Builder
94 b.WriteString(kindDevice)
95 b.WriteString("\n")
96 b.WriteString(chainID)
97 b.WriteString("\n")
98 b.WriteString(hex.EncodeToString(accountPK))
99 b.WriteString("\n")
100 b.WriteString(strconv.Itoa(seq))
101 b.WriteString("\n")
102 b.WriteString(hex.EncodeToString(prev))
103 b.WriteString("\n")
104 b.WriteString(op)
105 b.WriteString("\n")
106 b.WriteString(hex.EncodeToString(devicePK))
107 return b.String()
108}
109
110// StatementDigest returns SHA-256 of a canonical statement. It is the value
111// the next sigchain entry chains to.
112func StatementDigest(statement string) []byte {
113 sum := sha256.Sum256([]byte(statement))
114 return sum[:]
115}
116
117// GenesisDigest is the all-zero digest that the first sigchain entry chains to.
118func GenesisDigest() []byte { return make([]byte, DigestLen) }
119
120// VerifyBind checks a binding signature against the account key.
121func VerifyBind(chainID, gnoAddr string, accountPK, payload []byte, revision int, sig []byte) error {
122 if len(accountPK) != AccountPKLen {
123 return ErrBadAccountPKLen
124 }
125 stmt := BindStatement(chainID, gnoAddr, accountPK, payload, revision)
126 if !ed25519.Verify(accountPK, []byte(stmt), sig) {
127 return ErrBadSignature
128 }
129 return nil
130}
131
132// VerifyDevice checks a sigchain entry's shape and its signature, and returns
133// the digest the next entry must chain to.
134func VerifyDevice(chainID string, accountPK []byte, seq int, prev []byte, op string, devicePK, sig []byte) ([]byte, error) {
135 if len(accountPK) != AccountPKLen {
136 return nil, ErrBadAccountPKLen
137 }
138 if len(devicePK) != DevicePKLen {
139 return nil, ErrBadDevicePKLen
140 }
141 if seq < 0 {
142 return nil, ErrBadSeq
143 }
144 if len(prev) != DigestLen {
145 return nil, ErrBadPrevLen
146 }
147 if !ValidOp(op) {
148 return nil, ErrBadOp
149 }
150 stmt := DeviceStatement(chainID, accountPK, seq, prev, op, devicePK)
151 if !ed25519.Verify(accountPK, []byte(stmt), sig) {
152 return nil, ErrBadSignature
153 }
154 return StatementDigest(stmt), nil
155}