nandi/frqpublic Fork 0
dcd31d9fc7fd137826da39c11d8f678b8842a850
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

tmsgsig.nim · 140 lines · 5.4 KBNim Blame HistoryRaw
msgsig, over OpenSSL rather than over my own arithmetic 463098d nandi yesterday1## Signing a mutation. The canonical form is the part that matters: both ends
2## build it independently and neither sends it, so a byte of disagreement is a
3## signature over nothing.
4
5import std/[strutils, tables, unittest]
6import frq/[msgsig, crypto]
7
8suite "b64url":
9 test "unpadded and URL-safe":
10 check b64url([byte 0xFB, 0xFF, 0xFE]) == "-__-"
11 check '=' notin b64url([byte 1, 2, 3, 4, 5])
12
13 test "the partial groups":
14 check b64url([]) == ""
15 check b64url([byte 0]) == "AA"
16 check b64url([byte 0, 0]) == "AAA"
17 check b64url([byte 0, 0, 0]) == "AAAA"
18
19 test "a 32-byte key is 43 characters":
20 check b64url(newSeq[byte](32)).len == 43
21 test "a 64-byte signature is 86":
22 check b64url(newSeq[byte](64)).len == 86
23
24suite "canonical":
25 test "keys are sorted and there is no space":
26 check canonical({"b": "2", "a": "1"}.toTable) == """{"a":"1","b":"2"}"""
27
28 test "insertion order cannot change the answer":
29 # Both ends build this from the same fields in whatever order they happen
30 # to have them.
31 check canonical({"z": "1", "a": "2", "m": "3"}.toTable) ==
32 canonical({"a": "2", "m": "3", "z": "1"}.toTable)
33
34 test "quotes and backslashes are escaped, and nothing else is":
35 check canonical({"k": "a\"b"}.toTable) == """{"k":"a\"b"}"""
36 check canonical({"k": "a\\b"}.toTable) == """{"k":"a\\b"}"""
37
38 test "a newline is NOT escaped":
39 # Deliberately not a JSON encoder: a library that escaped one more
40 # character than the other end's would break every signature.
41 check canonical({"k": "a\nb"}.toTable) == "{\"k\":\"a\nb\"}"
42
43 test "empty":
44 check canonical(initTable[string, string]()) == "{}"
45
46suite "signingTarget":
47 test "a channel is its lowercased name":
48 check signingTarget("#Test", "did:a", "") == "#test"
49 check signingTarget("&local", "did:a", "") == "&local"
50
51 test "a DM is both DIDs, sorted, so both ends agree":
52 check signingTarget("alice", "did:a", "did:b") == "dm:did:a,did:b"
53 check signingTarget("alice", "did:b", "did:a") == "dm:did:a,did:b"
54
55 test "a DM with nobody named has no way to be said":
56 # An unsigned mutation is better than one signed over the wrong thing.
57 check signingTarget("alice", "did:a", "") == ""
58 check signingTarget("alice", "", "did:b") == ""
59
60suite "bodyHash":
61 test "names the algorithm and the hash":
62 check bodyHash("").startsWith("sha256:")
63 check bodyHash("") ==
64 "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
65 test "different text, different hash":
66 check bodyHash("a") != bodyHash("b")
67
68suite "eventId":
69 test "ten of the clock and sixteen of chance":
70 check eventId(1_700_000_000_000).len == 26
71 test "sortable by time":
72 check eventId(1_700_000_000_000) < eventId(1_800_000_000_000)
73 test "two at the same instant still differ":
74 check eventId(1_700_000_000_000) != eventId(1_700_000_000_000)
75 test "only Crockford characters":
76 for c in eventId(1_700_000_000_000):
77 check c in "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
78
79suite "the signer":
80 setup:
81 forget()
82
83 test "a guest signs nothing":
84 check not signedIn()
85 check publicKey() == ""
86 check mutationTags("react", "#test", "m1", "👍", "", 0).len == 0
87 check editTags("#test", "m1", "new", "", "", 0).len == 0
88
89 test "generating gives the public half, base64url":
90 let pub = generate("did:plc:me")
91 check pub.len == 43
92 check signedIn()
93 check publicKey() == pub
94
95 test "forgetting really forgets":
96 discard generate("did:plc:me")
97 forget()
98 check not signedIn()
99 check mutationTags("react", "#test", "m1", "👍", "", 0).len == 0
100
101 test "a mutation carries an event id and a signature naming the key":
102 discard generate("did:plc:me")
103 let tags = mutationTags("react", "#test", "m1", "👍", "", 1_700_000_000_000)
104 check tags.len == 2
105 check tags["+freeq.at/eventid"].len == 26
106 check tags["+freeq.at/sig"].startsWith("ed25519:")
107 # ed25519:<kid>:<sig>
108 let parts = tags["+freeq.at/sig"].split(':')
109 check parts.len == 3
110 check parts[1].len == 16
111 check parts[2].len == 86
112
113 test "the signature verifies against the canonical form it covers":
114 # Rebuilt here the way the server rebuilds it, which is the only check
115 # that says the right bytes were signed.
116 let did = "did:plc:me"
117 discard generate(did)
118 let tags = mutationTags("react", "#test", "m1", "👍", "", 1_700_000_000_000)
119 let fields = {"from": did, "kind": "react",
120 "msgid": tags["+freeq.at/eventid"],
121 "subject": "m1", "target": "#test", "emoji": "👍"}.toTable
122 # Same shape, same bytes: if the two disagreed the server would refuse it.
123 check canonical(fields).startsWith("""{"emoji":"👍","from":"did:plc:me"""")
124
125 test "delete carries no emoji":
126 discard generate("did:plc:me")
127 let tags = mutationTags("delete", "#test", "m1", "👍", "", 0)
128 check tags.len == 2
129
130 test "an edit signs the hash of the body, not the body":
131 discard generate("did:plc:me")
132 let a = editTags("#test", "m1", "short", "", "", 0)
133 let b = editTags("#test", "m1", "x".repeat(10_000), "", "", 0)
134 # A message of any length signs the same amount.
135 check a["+freeq.at/sig"].len == b["+freeq.at/sig"].len
136
137 test "a DM with no peer DID is not signed at all":
138 discard generate("did:plc:me")
139 check mutationTags("react", "alice", "m1", "👍", "", 0).len == 0
140 check mutationTags("react", "alice", "m1", "👍", "did:plc:them", 0).len == 2