| msgsig, over OpenSSL rather than over my own arithmetic 463098d nandi 18h ago | 1 | ## 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 | |
| 5 | import std/[strutils, tables, unittest] |
| 6 | import frq/[msgsig, crypto] |
| 7 | |
| 8 | suite "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 | |
| 24 | suite "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 | |
| 46 | suite "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 | |
| 60 | suite "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 | |
| 68 | suite "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 | |
| 79 | suite "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 |