nandi/frqpublic Fork 0
463098d
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.

msgsig, over OpenSSL rather than over my own arithmetic

You stopped me writing SHA-512 and Ed25519 by hand, and you were right to.
Test vectors prove agreement with the standard on the inputs somebody thought
to publish; they say nothing about the timing side channels and carry bugs
that are the actual reason not to write your own. I had the first file open.

The prior work was already a hard dependency of this tree: `tools/toolchain.sh`
checks for OpenSSL, `conn.nim` dlopens it for TLS, and OpenSSL 3 does Ed25519
through EVP. So `crypto.nim` is bindings — EVP_Digest for SHA-256,
EVP_PKEY_new_raw_private_key and the one-shot EVP_DigestSign for Ed25519 —
and `std/sysrand` for the seed. Nothing new is linked: the NEEDED list is
still libc, libm and the loader, because Nim resolves OpenSSL at run time.

One-shot signing on purpose: Ed25519 hashes the message internally as part of
the scheme, and OpenSSL refuses the Update/Final pair for it.

The RFC 8032 vectors are still here and still the right test — they now check
that this drives OpenSSL correctly (the seed in the right place, the bytes out
in the right order) rather than that I implemented a curve. All four of §7.1,
including the 1023-byte one, plus SHA-256 against FIPS 180-4.

`msgsig` on top: the canonical form is the part that matters, and it is
deliberately not a JSON encoder — both ends build that string independently
and neither sends it, so a library that escaped one more character than the
other's would break every signature. There is a test asserting a newline is
NOT escaped, which is the sort of thing a well-meaning refactor breaks.

Wired in, which is the point of doing it at all: 903 mints the key and
announces it with MSGSIG while the server is still in CAP, a reaction carries
`+freeq.at/eventid` and `+freeq.at/sig`, and disconnecting forgets the key so
a reconnect signs with one the server has been told about. Without this a
signed-in reader can read and talk but cannot react — freeq answers an
unsigned mutation from an account with FAIL TAGMSG SIGNATURE_REQUIRED.

296 Nim tests, 20 Dart, 21 layout, no warnings, app still reaches #test.

Untested against a real account, for the same reason SASL is: nobody has
watched freeq accept one of these signatures. The shape is checked, the curve
is OpenSSL's, and the wire format is transcribed — but the server has not
said yes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-19T06:12:14-07:00 Browse files
463098d parent: 3975b4b
added nim/src/frq/crypto.nim +130 -0
new file mode 100644
@@ -0,0 +1,130 @@
1+## The crypto this client needs, from OpenSSL.
2+##
3+## Bindings, not implementations. The first draft of this file was going to be
4+## SHA-512 and Ed25519 written out by hand and checked against RFC 8032's test
5+## vectors — which is the classic mistake: vectors prove you agree with the
6+## standard on the inputs someone thought to publish, and say nothing about
7+## the timing side channels and carry bugs that are the actual reason not to
8+## write your own. OpenSSL is already a hard requirement of this tree (the
9+## toolchain checks for it, `conn.nim` dlopens it for TLS), so using it here
10+## costs nothing that was not already being paid.
11+##
12+## Ed25519 through the EVP interface, which is the one OpenSSL 3 supports for
13+## it: `EVP_DigestSign` in its **one-shot** form, because Ed25519 is not a
14+## prehash scheme and the Update/Final pair is refused for it.
15+
16+import std/sysrand
17+import frq/trace
18+
19+const
20+ DLLUtilName = "libcrypto.so.3"
21+ EVP_PKEY_ED25519 = 1087.cint
22+ ## NID_ED25519, from OpenSSL's obj_mac.h. A number rather than a name
23+ ## because the header is not ours to include.
24+
25+type
26+ EvpPkey = pointer
27+ EvpMdCtx = pointer
28+ EvpMd = pointer
29+
30+{.push cdecl, dynlib: DLLUtilName, importc.}
31+proc EVP_sha256(): EvpMd
32+proc EVP_Digest(data: pointer, count: csize_t, md: pointer, size: ptr cuint,
33+ typ: EvpMd, engine: pointer): cint
34+proc EVP_PKEY_new_raw_private_key(typ: cint, e: pointer, key: pointer,
35+ keylen: csize_t): EvpPkey
36+proc EVP_PKEY_get_raw_public_key(pkey: EvpPkey, pub: pointer,
37+ len: ptr csize_t): cint
38+proc EVP_PKEY_free(pkey: EvpPkey)
39+proc EVP_MD_CTX_new(): EvpMdCtx
40+proc EVP_MD_CTX_free(ctx: EvpMdCtx)
41+proc EVP_DigestSignInit(ctx: EvpMdCtx, pctx: pointer, typ: EvpMd,
42+ e: pointer, pkey: EvpPkey): cint
43+proc EVP_DigestSign(ctx: EvpMdCtx, sig: pointer, siglen: ptr csize_t,
44+ tbs: pointer, tbslen: csize_t): cint
45+{.pop.}
46+
47+type
48+ CryptoError* = object of CatchableError
49+
50+ KeyPair* = object
51+ ## An Ed25519 key. The seed **is** the private key — which is why
52+ ## `frq.msgsig` chooses the seed rather than asking for one to be made.
53+ seed*: array[32, byte]
54+ public*: array[32, byte]
55+
56+proc randomBytes*(n: int): seq[byte] =
57+ ## `n` bytes from the platform's own source of them.
58+ result = newSeq[byte](n)
59+ if n > 0 and not urandom(result):
60+ raise newException(CryptoError, "no randomness available")
61+
62+proc sha256*(data: openArray[byte]): array[32, byte] =
63+ var size: cuint
64+ let ok = EVP_Digest(if data.len > 0: unsafeAddr data[0] else: nil,
65+ data.len.csize_t, addr result[0], addr size,
66+ EVP_sha256(), nil)
67+ if ok != 1 or size != 32:
68+ raise newException(CryptoError, "sha256 failed")
69+
70+proc sha256*(s: string): array[32, byte] =
71+ sha256(s.toOpenArrayByte(0, s.high))
72+
73+func toHex*(bs: openArray[byte]): string =
74+ const digits = "0123456789abcdef"
75+ for b in bs:
76+ result.add digits[int(b shr 4)]
77+ result.add digits[int(b and 0x0F)]
78+
79+proc keyFromSeed*(seed: openArray[byte]): KeyPair =
80+ ## The key a 32-byte seed names, with its public half.
81+ if seed.len != 32:
82+ raise newException(CryptoError, "an Ed25519 seed is 32 bytes")
83+ for i in 0 ..< 32: result.seed[i] = seed[i]
84+
85+ let pkey = EVP_PKEY_new_raw_private_key(
86+ EVP_PKEY_ED25519, nil, unsafeAddr seed[0], 32)
87+ if pkey.isNil:
88+ raise newException(CryptoError, "OpenSSL would not take the seed")
89+ defer: EVP_PKEY_free(pkey)
90+
91+ var n = 32.csize_t
92+ if EVP_PKEY_get_raw_public_key(pkey, addr result.public[0], addr n) != 1 or
93+ n != 32:
94+ raise newException(CryptoError, "could not derive the public key")
95+
96+proc sign*(key: KeyPair, msg: openArray[byte]): array[64, byte] =
97+ ## Ed25519 over `msg`.
98+ ##
99+ ## One-shot `EVP_DigestSign`, not Update/Final: Ed25519 hashes the message
100+ ## internally as part of the scheme, so OpenSSL refuses the streaming form
101+ ## for it.
102+ let pkey = EVP_PKEY_new_raw_private_key(
103+ EVP_PKEY_ED25519, nil, unsafeAddr key.seed[0], 32)
104+ if pkey.isNil:
105+ raise newException(CryptoError, "OpenSSL would not take the key")
106+ defer: EVP_PKEY_free(pkey)
107+
108+ let ctx = EVP_MD_CTX_new()
109+ if ctx.isNil:
110+ raise newException(CryptoError, "no digest context")
111+ defer: EVP_MD_CTX_free(ctx)
112+
113+ if EVP_DigestSignInit(ctx, nil, nil, nil, pkey) != 1:
114+ raise newException(CryptoError, "could not start the signature")
115+
116+ var n = 64.csize_t
117+ let ok = EVP_DigestSign(ctx, addr result[0], addr n,
118+ if msg.len > 0: unsafeAddr msg[0] else: nil,
119+ msg.len.csize_t)
120+ if ok != 1 or n != 64:
121+ raise newException(CryptoError, "could not sign")
122+
123+proc sign*(key: KeyPair, s: string): array[64, byte] =
124+ sign(key, s.toOpenArrayByte(0, s.high))
125+
126+proc newKey*(): KeyPair =
127+ ## A fresh key from the platform's randomness.
128+ let seed = randomBytes(32)
129+ trace("crypto", "minted an Ed25519 key")
130+ keyFromSeed(seed)
new file mode 100644
@@ -0,0 +1,130 @@
1+## The crypto this client needs, from OpenSSL.
2+##
3+## Bindings, not implementations. The first draft of this file was going to be
4+## SHA-512 and Ed25519 written out by hand and checked against RFC 8032's test
5+## vectors — which is the classic mistake: vectors prove you agree with the
6+## standard on the inputs someone thought to publish, and say nothing about
7+## the timing side channels and carry bugs that are the actual reason not to
8+## write your own. OpenSSL is already a hard requirement of this tree (the
9+## toolchain checks for it, `conn.nim` dlopens it for TLS), so using it here
10+## costs nothing that was not already being paid.
11+##
12+## Ed25519 through the EVP interface, which is the one OpenSSL 3 supports for
13+## it: `EVP_DigestSign` in its **one-shot** form, because Ed25519 is not a
14+## prehash scheme and the Update/Final pair is refused for it.
15+
16+import std/sysrand
17+import frq/trace
18+
19+const
20+ DLLUtilName = "libcrypto.so.3"
21+ EVP_PKEY_ED25519 = 1087.cint
22+ ## NID_ED25519, from OpenSSL's obj_mac.h. A number rather than a name
23+ ## because the header is not ours to include.
24+
25+type
26+ EvpPkey = pointer
27+ EvpMdCtx = pointer
28+ EvpMd = pointer
29+
30+{.push cdecl, dynlib: DLLUtilName, importc.}
31+proc EVP_sha256(): EvpMd
32+proc EVP_Digest(data: pointer, count: csize_t, md: pointer, size: ptr cuint,
33+ typ: EvpMd, engine: pointer): cint
34+proc EVP_PKEY_new_raw_private_key(typ: cint, e: pointer, key: pointer,
35+ keylen: csize_t): EvpPkey
36+proc EVP_PKEY_get_raw_public_key(pkey: EvpPkey, pub: pointer,
37+ len: ptr csize_t): cint
38+proc EVP_PKEY_free(pkey: EvpPkey)
39+proc EVP_MD_CTX_new(): EvpMdCtx
40+proc EVP_MD_CTX_free(ctx: EvpMdCtx)
41+proc EVP_DigestSignInit(ctx: EvpMdCtx, pctx: pointer, typ: EvpMd,
42+ e: pointer, pkey: EvpPkey): cint
43+proc EVP_DigestSign(ctx: EvpMdCtx, sig: pointer, siglen: ptr csize_t,
44+ tbs: pointer, tbslen: csize_t): cint
45+{.pop.}
46+
47+type
48+ CryptoError* = object of CatchableError
49+
50+ KeyPair* = object
51+ ## An Ed25519 key. The seed **is** the private key — which is why
52+ ## `frq.msgsig` chooses the seed rather than asking for one to be made.
53+ seed*: array[32, byte]
54+ public*: array[32, byte]
55+
56+proc randomBytes*(n: int): seq[byte] =
57+ ## `n` bytes from the platform's own source of them.
58+ result = newSeq[byte](n)
59+ if n > 0 and not urandom(result):
60+ raise newException(CryptoError, "no randomness available")
61+
62+proc sha256*(data: openArray[byte]): array[32, byte] =
63+ var size: cuint
64+ let ok = EVP_Digest(if data.len > 0: unsafeAddr data[0] else: nil,
65+ data.len.csize_t, addr result[0], addr size,
66+ EVP_sha256(), nil)
67+ if ok != 1 or size != 32:
68+ raise newException(CryptoError, "sha256 failed")
69+
70+proc sha256*(s: string): array[32, byte] =
71+ sha256(s.toOpenArrayByte(0, s.high))
72+
73+func toHex*(bs: openArray[byte]): string =
74+ const digits = "0123456789abcdef"
75+ for b in bs:
76+ result.add digits[int(b shr 4)]
77+ result.add digits[int(b and 0x0F)]
78+
79+proc keyFromSeed*(seed: openArray[byte]): KeyPair =
80+ ## The key a 32-byte seed names, with its public half.
81+ if seed.len != 32:
82+ raise newException(CryptoError, "an Ed25519 seed is 32 bytes")
83+ for i in 0 ..< 32: result.seed[i] = seed[i]
84+
85+ let pkey = EVP_PKEY_new_raw_private_key(
86+ EVP_PKEY_ED25519, nil, unsafeAddr seed[0], 32)
87+ if pkey.isNil:
88+ raise newException(CryptoError, "OpenSSL would not take the seed")
89+ defer: EVP_PKEY_free(pkey)
90+
91+ var n = 32.csize_t
92+ if EVP_PKEY_get_raw_public_key(pkey, addr result.public[0], addr n) != 1 or
93+ n != 32:
94+ raise newException(CryptoError, "could not derive the public key")
95+
96+proc sign*(key: KeyPair, msg: openArray[byte]): array[64, byte] =
97+ ## Ed25519 over `msg`.
98+ ##
99+ ## One-shot `EVP_DigestSign`, not Update/Final: Ed25519 hashes the message
100+ ## internally as part of the scheme, so OpenSSL refuses the streaming form
101+ ## for it.
102+ let pkey = EVP_PKEY_new_raw_private_key(
103+ EVP_PKEY_ED25519, nil, unsafeAddr key.seed[0], 32)
104+ if pkey.isNil:
105+ raise newException(CryptoError, "OpenSSL would not take the key")
106+ defer: EVP_PKEY_free(pkey)
107+
108+ let ctx = EVP_MD_CTX_new()
109+ if ctx.isNil:
110+ raise newException(CryptoError, "no digest context")
111+ defer: EVP_MD_CTX_free(ctx)
112+
113+ if EVP_DigestSignInit(ctx, nil, nil, nil, pkey) != 1:
114+ raise newException(CryptoError, "could not start the signature")
115+
116+ var n = 64.csize_t
117+ let ok = EVP_DigestSign(ctx, addr result[0], addr n,
118+ if msg.len > 0: unsafeAddr msg[0] else: nil,
119+ msg.len.csize_t)
120+ if ok != 1 or n != 64:
121+ raise newException(CryptoError, "could not sign")
122+
123+proc sign*(key: KeyPair, s: string): array[64, byte] =
124+ sign(key, s.toOpenArrayByte(0, s.high))
125+
126+proc newKey*(): KeyPair =
127+ ## A fresh key from the platform's randomness.
128+ let seed = randomBytes(32)
129+ trace("crypto", "minted an Ed25519 key")
130+ keyFromSeed(seed)
modified nim/src/frq/handshake.nim +9 -3
@@ -5,7 +5,7 @@
55 ## say back — so it answers with lines and the caller writes them.
66
77 import std/[sets, strutils]
8-import frq/[ircparse, atproto]
8+import frq/[ircparse, atproto, msgsig]
99
1010 const
1111 saslChunk* = 100_000
@@ -102,8 +102,14 @@ proc step*(session: Session, caps: HashSet[string], m: IrcLine): Step =
102102 result.send = saslLines(saslResponse(session, nonceOf(challenge)))
103103
104104 of "903":
105- # Authenticated. Registration proceeds once CAP is ended.
106- result.send = @["CAP END"]
105+ # Authenticated. Mint the signing key now, while the server is still in
106+ # CAP — `freeq.at/msgsig` is what makes a signed-in account able to react
107+ # at all, and an unsigned mutation comes back
108+ # `FAIL TAGMSG SIGNATURE_REQUIRED`.
109+ if "freeq.at/msgsig" in caps and session.did.len > 0:
110+ result.send = @["MSGSIG " & generate(session.did), "CAP END"]
111+ else:
112+ result.send = @["CAP END"]
107113
108114 of "904", "905", "906":
109115 # Refused. End CAP anyway and carry on as a guest rather than hanging —
@@ -5,7 +5,7 @@
5 ## say back — so it answers with lines and the caller writes them.5 ## say back — so it answers with lines and the caller writes them.
6 6
7 import std/[sets, strutils]7 import std/[sets, strutils]
8-import frq/[ircparse, atproto]8+import frq/[ircparse, atproto, msgsig]
9 9
10 const10 const
11 saslChunk* = 100_00011 saslChunk* = 100_000
@@ -102,8 +102,14 @@ proc step*(session: Session, caps: HashSet[string], m: IrcLine): Step =
102 result.send = saslLines(saslResponse(session, nonceOf(challenge)))102 result.send = saslLines(saslResponse(session, nonceOf(challenge)))
103 103
104 of "903":104 of "903":
105- # Authenticated. Registration proceeds once CAP is ended.105+ # Authenticated. Mint the signing key now, while the server is still in
106- result.send = @["CAP END"]106+ # CAP — `freeq.at/msgsig` is what makes a signed-in account able to react
107+ # at all, and an unsigned mutation comes back
108+ # `FAIL TAGMSG SIGNATURE_REQUIRED`.
109+ if "freeq.at/msgsig" in caps and session.did.len > 0:
110+ result.send = @["MSGSIG " & generate(session.did), "CAP END"]
111+ else:
112+ result.send = @["CAP END"]
107 113
108 of "904", "905", "906":114 of "904", "905", "906":
109 # Refused. End CAP anyway and carry on as a guest rather than hanging —115 # Refused. End CAP anyway and carry on as a guest rather than hanging —
added nim/src/frq/msgsig.nim +173 -0
new file mode 100644
@@ -0,0 +1,173 @@
1+## Signing a mutation so freeq will accept it.
2+##
3+## From `common/frq/msgsig.cljc`. What this is for: freeq answers an unsigned
4+## reaction or edit from a signed-in account with
5+## `FAIL TAGMSG SIGNATURE_REQUIRED`, so without this a signed-in reader can
6+## read and talk but cannot react at all.
7+##
8+## The key is per-connection and per-session: minted when the server ACKs the
9+## `freeq.at/msgsig` capability, announced with `MSGSIG`, and dropped when the
10+## connection goes — so a reconnect signs with a key the server has actually
11+## been told about.
12+
13+import std/[algorithm, strutils, tables]
14+import frq/[crypto, trace]
15+
16+type
17+ Signer* = object
18+ has*: bool
19+ did*: string
20+ kid*: string ## how the server names this key
21+ key*: KeyPair
22+
23+var signer: Signer
24+
25+const b64urlAlphabet =
26+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
27+
28+proc b64url*(bs: openArray[byte]): string =
29+ ## base64url of raw bytes, unpadded — how freeq writes a key and a
30+ ## signature.
31+ ##
32+ ## Over bytes and not over a string, which is the whole reason this is not
33+ ## `atproto.b64url`: a signature run through a String comes back re-encoded
34+ ## and no longer verifies.
35+ var i = 0
36+ while i < bs.len:
37+ let n = min(3, bs.len - i)
38+ let a = int(bs[i])
39+ let b = if n > 1: int(bs[i + 1]) else: 0
40+ let c = if n > 2: int(bs[i + 2]) else: 0
41+ let v = (a shl 16) or (b shl 8) or c
42+ for k in 0 .. n:
43+ result.add b64urlAlphabet[(v shr (6 * (3 - k))) and 0x3F]
44+ i += 3
45+
46+proc forget*() =
47+ ## Drop the session key.
48+ signer = Signer()
49+ trace("msgsig", "key forgotten")
50+
51+proc generate*(did: string): string =
52+ ## Mint a key for this connection and answer with its public half, base64url
53+ ## — which is what goes out as `MSGSIG <pub>`.
54+ signer = Signer(has: true, did: did, key: newKey())
55+ signer.kid = b64url(signer.key.public)[0 ..< 16]
56+ trace("msgsig", "key for " & did & " kid=" & signer.kid)
57+ b64url(signer.key.public)
58+
59+proc publicKey*(): string =
60+ if signer.has: b64url(signer.key.public) else: ""
61+
62+proc signedIn*(): bool = signer.has
63+
64+proc jsonString(s: string): string =
65+ ## Just the two escapes the canonical form can contain. Deliberately not a
66+ ## JSON encoder: both ends build this string themselves, and a library that
67+ ## escaped one more character than the other's would break every signature.
68+ result = "\""
69+ for c in s:
70+ case c
71+ of '\\': result.add "\\\\"
72+ of '"': result.add "\\\""
73+ else: result.add c
74+ result.add "\""
75+
76+proc canonical*(fields: Table[string, string]): string =
77+ ## The bytes that get signed: a JSON object with its keys in sorted order
78+ ## and no space in it.
79+ ##
80+ ## Both ends build this from the same fields and neither sends it — a
81+ ## signature over anything else is a signature over nothing.
82+ var keys: seq[string]
83+ for k in fields.keys: keys.add k
84+ keys.sort()
85+ result = "{"
86+ for i, k in keys:
87+ if i > 0: result.add ","
88+ result.add jsonString(k) & ":" & jsonString(fields[k])
89+ result.add "}"
90+
91+proc bodyHash*(text: string): string =
92+ ## How a document names the text it covers: `sha256:` and the hash in
93+ ## lower-case hex. The signature is over the hash rather than the words, so
94+ ## a message of any length signs the same amount.
95+ "sha256:" & sha256(text).toHex
96+
97+proc signingTarget*(target, ourDid, peerDid: string): string =
98+ ## How freeq names the place a mutation happens: a channel by its lowercased
99+ ## name, a DM by both DIDs in sorted order.
100+ ##
101+ ## "" where there is no way to say it — a DM with someone whose DID we have
102+ ## not seen yet — and an unsigned mutation is better than one signed over
103+ ## the wrong thing.
104+ if target.startsWith("#") or target.startsWith("&"):
105+ target.toLowerAscii
106+ elif ourDid.len > 0 and peerDid.len > 0:
107+ if ourDid <= peerDid: "dm:" & ourDid & "," & peerDid
108+ else: "dm:" & peerDid & "," & ourDid
109+ else:
110+ ""
111+
112+const crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
113+
114+proc eventId*(nowMs: int64): string =
115+ ## A fresh id for this mutation: ten characters of the clock, then sixteen
116+ ## of chance. Sortable like the msgids the server hands out, and unguessable
117+ ## enough that two clients cannot mint the same one.
118+ var t = nowMs
119+ var head = ""
120+ while head.len < 10:
121+ head = crockford[int(t mod 32)] & head
122+ t = t div 32
123+ result = head
124+ for b in randomBytes(16):
125+ result.add crockford[int(b) mod 32]
126+
127+proc signBytes(msg: string): string =
128+ ## An Ed25519 signature over `msg`, as `ed25519:<kid>:<b64url>` — the shape
129+ ## freeq's `+freeq.at/sig` carries.
130+ if not signer.has: return ""
131+ "ed25519:" & signer.kid & ":" & b64url(sign(signer.key, msg))
132+
133+proc mutationTags*(kind, target, subject, emoji, peerDid: string,
134+ nowMs: int64): Table[string, string] =
135+ ## The two tags that make a mutation acceptable: the event id and the
136+ ## signature over it.
137+ ##
138+ ## Empty when this connection has no key — a guest signs nothing, and the
139+ ## server asks nothing of one.
140+ if not signer.has: return
141+ let venue = signingTarget(target, signer.did, peerDid)
142+ if venue.len == 0: return
143+
144+ let id = eventId(nowMs)
145+ var fields = {"from": signer.did, "kind": kind, "msgid": id,
146+ "subject": subject, "target": venue}.toTable
147+ if emoji.len > 0 and kind != "delete":
148+ fields["emoji"] = emoji
149+
150+ let sig = signBytes(canonical(fields))
151+ if sig.len == 0: return
152+ {"+freeq.at/eventid": id, "+freeq.at/sig": sig}.toTable
153+
154+proc editTags*(target, rootMsgid, text, replyTo, peerDid: string,
155+ nowMs: int64): Table[string, string] =
156+ ## The tags that make a rewrite acceptable.
157+ ##
158+ ## An edit is a *message* document rather than a mutation one — it carries a
159+ ## body — so the fields are the message's own: who, which id, where, the
160+ ## hash of the new text, and `edit` naming the message being replaced.
161+ if not signer.has: return
162+ let venue = signingTarget(target, signer.did, peerDid)
163+ if venue.len == 0: return
164+
165+ let id = eventId(nowMs)
166+ var fields = {"body": bodyHash(text), "edit": rootMsgid,
167+ "from": signer.did, "msgid": id, "target": venue}.toTable
168+ if replyTo.len > 0:
169+ fields["reply"] = replyTo
170+
171+ let sig = signBytes(canonical(fields))
172+ if sig.len == 0: return
173+ {"+freeq.at/eventid": id, "+freeq.at/sig": sig}.toTable
new file mode 100644
@@ -0,0 +1,173 @@
1+## Signing a mutation so freeq will accept it.
2+##
3+## From `common/frq/msgsig.cljc`. What this is for: freeq answers an unsigned
4+## reaction or edit from a signed-in account with
5+## `FAIL TAGMSG SIGNATURE_REQUIRED`, so without this a signed-in reader can
6+## read and talk but cannot react at all.
7+##
8+## The key is per-connection and per-session: minted when the server ACKs the
9+## `freeq.at/msgsig` capability, announced with `MSGSIG`, and dropped when the
10+## connection goes — so a reconnect signs with a key the server has actually
11+## been told about.
12+
13+import std/[algorithm, strutils, tables]
14+import frq/[crypto, trace]
15+
16+type
17+ Signer* = object
18+ has*: bool
19+ did*: string
20+ kid*: string ## how the server names this key
21+ key*: KeyPair
22+
23+var signer: Signer
24+
25+const b64urlAlphabet =
26+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
27+
28+proc b64url*(bs: openArray[byte]): string =
29+ ## base64url of raw bytes, unpadded — how freeq writes a key and a
30+ ## signature.
31+ ##
32+ ## Over bytes and not over a string, which is the whole reason this is not
33+ ## `atproto.b64url`: a signature run through a String comes back re-encoded
34+ ## and no longer verifies.
35+ var i = 0
36+ while i < bs.len:
37+ let n = min(3, bs.len - i)
38+ let a = int(bs[i])
39+ let b = if n > 1: int(bs[i + 1]) else: 0
40+ let c = if n > 2: int(bs[i + 2]) else: 0
41+ let v = (a shl 16) or (b shl 8) or c
42+ for k in 0 .. n:
43+ result.add b64urlAlphabet[(v shr (6 * (3 - k))) and 0x3F]
44+ i += 3
45+
46+proc forget*() =
47+ ## Drop the session key.
48+ signer = Signer()
49+ trace("msgsig", "key forgotten")
50+
51+proc generate*(did: string): string =
52+ ## Mint a key for this connection and answer with its public half, base64url
53+ ## — which is what goes out as `MSGSIG <pub>`.
54+ signer = Signer(has: true, did: did, key: newKey())
55+ signer.kid = b64url(signer.key.public)[0 ..< 16]
56+ trace("msgsig", "key for " & did & " kid=" & signer.kid)
57+ b64url(signer.key.public)
58+
59+proc publicKey*(): string =
60+ if signer.has: b64url(signer.key.public) else: ""
61+
62+proc signedIn*(): bool = signer.has
63+
64+proc jsonString(s: string): string =
65+ ## Just the two escapes the canonical form can contain. Deliberately not a
66+ ## JSON encoder: both ends build this string themselves, and a library that
67+ ## escaped one more character than the other's would break every signature.
68+ result = "\""
69+ for c in s:
70+ case c
71+ of '\\': result.add "\\\\"
72+ of '"': result.add "\\\""
73+ else: result.add c
74+ result.add "\""
75+
76+proc canonical*(fields: Table[string, string]): string =
77+ ## The bytes that get signed: a JSON object with its keys in sorted order
78+ ## and no space in it.
79+ ##
80+ ## Both ends build this from the same fields and neither sends it — a
81+ ## signature over anything else is a signature over nothing.
82+ var keys: seq[string]
83+ for k in fields.keys: keys.add k
84+ keys.sort()
85+ result = "{"
86+ for i, k in keys:
87+ if i > 0: result.add ","
88+ result.add jsonString(k) & ":" & jsonString(fields[k])
89+ result.add "}"
90+
91+proc bodyHash*(text: string): string =
92+ ## How a document names the text it covers: `sha256:` and the hash in
93+ ## lower-case hex. The signature is over the hash rather than the words, so
94+ ## a message of any length signs the same amount.
95+ "sha256:" & sha256(text).toHex
96+
97+proc signingTarget*(target, ourDid, peerDid: string): string =
98+ ## How freeq names the place a mutation happens: a channel by its lowercased
99+ ## name, a DM by both DIDs in sorted order.
100+ ##
101+ ## "" where there is no way to say it — a DM with someone whose DID we have
102+ ## not seen yet — and an unsigned mutation is better than one signed over
103+ ## the wrong thing.
104+ if target.startsWith("#") or target.startsWith("&"):
105+ target.toLowerAscii
106+ elif ourDid.len > 0 and peerDid.len > 0:
107+ if ourDid <= peerDid: "dm:" & ourDid & "," & peerDid
108+ else: "dm:" & peerDid & "," & ourDid
109+ else:
110+ ""
111+
112+const crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
113+
114+proc eventId*(nowMs: int64): string =
115+ ## A fresh id for this mutation: ten characters of the clock, then sixteen
116+ ## of chance. Sortable like the msgids the server hands out, and unguessable
117+ ## enough that two clients cannot mint the same one.
118+ var t = nowMs
119+ var head = ""
120+ while head.len < 10:
121+ head = crockford[int(t mod 32)] & head
122+ t = t div 32
123+ result = head
124+ for b in randomBytes(16):
125+ result.add crockford[int(b) mod 32]
126+
127+proc signBytes(msg: string): string =
128+ ## An Ed25519 signature over `msg`, as `ed25519:<kid>:<b64url>` — the shape
129+ ## freeq's `+freeq.at/sig` carries.
130+ if not signer.has: return ""
131+ "ed25519:" & signer.kid & ":" & b64url(sign(signer.key, msg))
132+
133+proc mutationTags*(kind, target, subject, emoji, peerDid: string,
134+ nowMs: int64): Table[string, string] =
135+ ## The two tags that make a mutation acceptable: the event id and the
136+ ## signature over it.
137+ ##
138+ ## Empty when this connection has no key — a guest signs nothing, and the
139+ ## server asks nothing of one.
140+ if not signer.has: return
141+ let venue = signingTarget(target, signer.did, peerDid)
142+ if venue.len == 0: return
143+
144+ let id = eventId(nowMs)
145+ var fields = {"from": signer.did, "kind": kind, "msgid": id,
146+ "subject": subject, "target": venue}.toTable
147+ if emoji.len > 0 and kind != "delete":
148+ fields["emoji"] = emoji
149+
150+ let sig = signBytes(canonical(fields))
151+ if sig.len == 0: return
152+ {"+freeq.at/eventid": id, "+freeq.at/sig": sig}.toTable
153+
154+proc editTags*(target, rootMsgid, text, replyTo, peerDid: string,
155+ nowMs: int64): Table[string, string] =
156+ ## The tags that make a rewrite acceptable.
157+ ##
158+ ## An edit is a *message* document rather than a mutation one — it carries a
159+ ## body — so the fields are the message's own: who, which id, where, the
160+ ## hash of the new text, and `edit` naming the message being replaced.
161+ if not signer.has: return
162+ let venue = signingTarget(target, signer.did, peerDid)
163+ if venue.len == 0: return
164+
165+ let id = eventId(nowMs)
166+ var fields = {"body": bodyHash(text), "edit": rootMsgid,
167+ "from": signer.did, "msgid": id, "target": venue}.toTable
168+ if replyTo.len > 0:
169+ fields["reply"] = replyTo
170+
171+ let sig = signBytes(canonical(fields))
172+ if sig.len == 0: return
173+ {"+freeq.at/eventid": id, "+freeq.at/sig": sig}.toTable
modified nim/src/frq/reducer.nim +14 -3
@@ -14,7 +14,7 @@
1414 import std/[json, options, strutils, tables]
1515 import std/sets
1616 import frq/[cells, model, rooms, reactions, trace, ircparse, clock,
17- atproto, handshake, textruns, members]
17+ atproto, handshake, textruns, members, msgsig]
1818 import frq/conn as tr
1919
2020 proc split2(id: string): (string, string) =
@@ -166,6 +166,9 @@ proc dispatch*(event: JsonNode) =
166166 of "connect": connectNow()
167167
168168 of "cancel", "disconnect":
169+ # The key goes with the connection, so a reconnect signs with one the
170+ # server has actually been told about.
171+ msgsig.forget()
169172 tr.close()
170173 app.connecting = false
171174 app.screen = scConnect
@@ -264,8 +267,16 @@ proc dispatch*(event: JsonNode) =
264267 if mid.len > 0 and emoji.len > 0:
265268 let m = app.currentRoom.messageById(mid)
266269 let on = if m.isSome: not m.get.mine(emoji, app.formNick) else: true
267- send("@+draft/react=" & emoji & ";+draft/reply=" & mid &
268- " TAGMSG " & app.current)
270+ # Signed where there is a key. freeq answers an unsigned mutation from
271+ # an account with FAIL TAGMSG SIGNATURE_REQUIRED; a guest has no key and
272+ # the server asks one for nothing.
273+ var tags = "+draft/react=" & emoji & ";+draft/reply=" & mid
274+ for k, v in mutationTags(if on: "react" else: "unreact",
275+ app.current, mid, emoji,
276+ peerDid(app.currentRoom, app.formNick),
277+ nowMs()):
278+ tags.add ";" & k & "=" & v
279+ send("@" & tags & " TAGMSG " & app.current)
269280 app.rooms.updateReaction(app.current, mid, emoji, app.formNick, on)
270281
271282 of "goto":
@@ -14,7 +14,7 @@
14 import std/[json, options, strutils, tables]14 import std/[json, options, strutils, tables]
15 import std/sets15 import std/sets
16 import frq/[cells, model, rooms, reactions, trace, ircparse, clock,16 import frq/[cells, model, rooms, reactions, trace, ircparse, clock,
17- atproto, handshake, textruns, members]17+ atproto, handshake, textruns, members, msgsig]
18 import frq/conn as tr18 import frq/conn as tr
19 19
20 proc split2(id: string): (string, string) =20 proc split2(id: string): (string, string) =
@@ -166,6 +166,9 @@ proc dispatch*(event: JsonNode) =
166 of "connect": connectNow()166 of "connect": connectNow()
167 167
168 of "cancel", "disconnect":168 of "cancel", "disconnect":
169+ # The key goes with the connection, so a reconnect signs with one the
170+ # server has actually been told about.
171+ msgsig.forget()
169 tr.close()172 tr.close()
170 app.connecting = false173 app.connecting = false
171 app.screen = scConnect174 app.screen = scConnect
@@ -264,8 +267,16 @@ proc dispatch*(event: JsonNode) =
264 if mid.len > 0 and emoji.len > 0:267 if mid.len > 0 and emoji.len > 0:
265 let m = app.currentRoom.messageById(mid)268 let m = app.currentRoom.messageById(mid)
266 let on = if m.isSome: not m.get.mine(emoji, app.formNick) else: true269 let on = if m.isSome: not m.get.mine(emoji, app.formNick) else: true
267- send("@+draft/react=" & emoji & ";+draft/reply=" & mid &270+ # Signed where there is a key. freeq answers an unsigned mutation from
268- " TAGMSG " & app.current)271+ # an account with FAIL TAGMSG SIGNATURE_REQUIRED; a guest has no key and
272+ # the server asks one for nothing.
273+ var tags = "+draft/react=" & emoji & ";+draft/reply=" & mid
274+ for k, v in mutationTags(if on: "react" else: "unreact",
275+ app.current, mid, emoji,
276+ peerDid(app.currentRoom, app.formNick),
277+ nowMs()):
278+ tags.add ";" & k & "=" & v
279+ send("@" & tags & " TAGMSG " & app.current)
269 app.rooms.updateReaction(app.current, mid, emoji, app.formNick, on)280 app.rooms.updateReaction(app.current, mid, emoji, app.formNick, on)
270 281
271 of "goto":282 of "goto":
added nim/tests/tcrypto.nim +159 -0
new file mode 100644
@@ -0,0 +1,159 @@
1+## The crypto bindings, against published vectors.
2+##
3+## These check that we are driving OpenSSL correctly — the seed in the right
4+## place, the one-shot signing form, the byte order out — not that Ed25519 is
5+## implemented correctly, which is OpenSSL's problem and not ours.
6+##
7+## Vectors are RFC 8032 §7.1 (Ed25519) and RFC 4634 / FIPS 180-4 (SHA-256).
8+
9+import std/[strutils, unittest]
10+import frq/crypto
11+
12+func hexToBytes(s: string): seq[byte] =
13+ for i in countup(0, s.len - 2, 2):
14+ result.add byte(parseHexInt(s[i .. i + 1]))
15+
16+suite "sha256":
17+ test "the empty string":
18+ check sha256("").toHex ==
19+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
20+
21+ test "abc":
22+ check sha256("abc").toHex ==
23+ "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
24+
25+ test "the 448-bit message from FIPS 180-4":
26+ check sha256("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq").toHex ==
27+ "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
28+
29+ test "a million a's":
30+ check sha256("a".repeat(1_000_000)).toHex ==
31+ "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"
32+
33+suite "Ed25519, RFC 8032 section 7.1":
34+ # TEST 1
35+ test "test 1: the empty message":
36+ let seed = hexToBytes("9d61b19deffd5a60ba844af492ec2cc4" &
37+ "4449c5697b326919703bac031cae7f60")
38+ let k = keyFromSeed(seed)
39+ check k.public.toHex ==
40+ "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"
41+ check sign(k, "").toHex ==
42+ "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155" &
43+ "5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"
44+
45+ # TEST 2
46+ test "test 2: one byte":
47+ let seed = hexToBytes("4ccd089b28ff96da9db6c346ec114e0f" &
48+ "5b8a319f35aba624da8cf6ed4fb8a6fb")
49+ let k = keyFromSeed(seed)
50+ check k.public.toHex ==
51+ "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"
52+ check sign(k, @[0x72'u8]).toHex ==
53+ "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da" &
54+ "085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"
55+
56+ # TEST 3
57+ test "test 3: two bytes":
58+ let seed = hexToBytes("c5aa8df43f9f837bedb7442f31dcb7b1" &
59+ "66d38535076f094b85ce3a2e0b4458f7")
60+ let k = keyFromSeed(seed)
61+ check k.public.toHex ==
62+ "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025"
63+ check sign(k, @[0xaf'u8, 0x82]).toHex ==
64+ "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac" &
65+ "18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a"
66+
67+ # TEST 1024 — the long one, which is where a length or a carry goes wrong.
68+ test "test 1024: a 1023-byte message":
69+ let seed = hexToBytes("f5e5767cf153319517630f226876b86c" &
70+ "8160cc583bc013744c6bf255f5cc0ee5")
71+ let k = keyFromSeed(seed)
72+ check k.public.toHex ==
73+ "278117fc144c72340f67d0f2316e8386ceffbf2b2428c9c51fef7c597f1d426e"
74+ let msg = hexToBytes(
75+ "08b8b2b733424243760fe426a4b54908" &
76+ "632110a66c2f6591eabd3345e3e4eb98" &
77+ "fa6e264bf09efe12ee50f8f54e9f77b1" &
78+ "e355f6c50544e23fb1433ddf73be84d8" &
79+ "79de7c0046dc4996d9e773f4bc9efe57" &
80+ "38829adb26c81b37c93a1b270b20329d" &
81+ "658675fc6ea534e0810a4432826bf58c" &
82+ "941efb65d57a338bbd2e26640f89ffbc" &
83+ "1a858efcb8550ee3a5e1998bd177e93a" &
84+ "7363c344fe6b199ee5d02e82d522c4fe" &
85+ "ba15452f80288a821a579116ec6dad2b" &
86+ "3b310da903401aa62100ab5d1a36553e" &
87+ "06203b33890cc9b832f79ef80560ccb9" &
88+ "a39ce767967ed628c6ad573cb116dbef" &
89+ "efd75499da96bd68a8a97b928a8bbc10" &
90+ "3b6621fcde2beca1231d206be6cd9ec7" &
91+ "aff6f6c94fcd7204ed3455c68c83f4a4" &
92+ "1da4af2b74ef5c53f1d8ac70bdcb7ed1" &
93+ "85ce81bd84359d44254d95629e9855a9" &
94+ "4a7c1958d1f8ada5d0532ed8a5aa3fb2" &
95+ "d17ba70eb6248e594e1a2297acbbb39d" &
96+ "502f1a8c6eb6f1ce22b3de1a1f40cc24" &
97+ "554119a831a9aad6079cad88425de6bd" &
98+ "e1a9187ebb6092cf67bf2b13fd65f270" &
99+ "88d78b7e883c8759d2c4f5c65adb7553" &
100+ "878ad575f9fad878e80a0c9ba63bcbcc" &
101+ "2732e69485bbc9c90bfbd62481d9089b" &
102+ "eccf80cfe2df16a2cf65bd92dd597b07" &
103+ "07e0917af48bbb75fed413d238f5555a" &
104+ "7a569d80c3414a8d0859dc65a46128ba" &
105+ "b27af87a71314f318c782b23ebfe808b" &
106+ "82b0ce26401d2e22f04d83d1255dc51a" &
107+ "ddd3b75a2b1ae0784504df543af8969b" &
108+ "e3ea7082ff7fc9888c144da2af58429e" &
109+ "c96031dbcad3dad9af0dcbaaaf268cb8" &
110+ "fcffead94f3c7ca495e056a9b47acdb7" &
111+ "51fb73e666c6c655ade8297297d07ad1" &
112+ "ba5e43f1bca32301651339e22904cc8c" &
113+ "42f58c30c04aafdb038dda0847dd988d" &
114+ "cda6f3bfd15c4b4c4525004aa06eeff8" &
115+ "ca61783aacec57fb3d1f92b0fe2fd1a8" &
116+ "5f6724517b65e614ad6808d6f6ee34df" &
117+ "f7310fdc82aebfd904b01e1dc54b2927" &
118+ "094b2db68d6f903b68401adebf5a7e08" &
119+ "d78ff4ef5d63653a65040cf9bfd4aca7" &
120+ "984a74d37145986780fc0b16ac451649" &
121+ "de6188a7dbdf191f64b5fc5e2ab47b57" &
122+ "f7f7276cd419c17a3ca8e1b939ae49e4" &
123+ "88acba6b965610b5480109c8b17b80e1" &
124+ "b7b750dfc7598d5d5011fd2dcc5600a3" &
125+ "2ef5b52a1ecc820e308aa342721aac09" &
126+ "43bf6686b64b2579376504ccc493d97e" &
127+ "6aed3fb0f9cd71a43dd497f01f17c0e2" &
128+ "cb3797aa2a2f256656168e6c496afc5f" &
129+ "b93246f6b1116398a346f1a641f3b041" &
130+ "e989f7914f90cc2c7fff357876e506b5" &
131+ "0d334ba77c225bc307ba537152f3f161" &
132+ "0e4eafe595f6d9d90d11faa933a15ef1" &
133+ "369546868a7f3a45a96768d40fd9d034" &
134+ "12c091c6315cf4fde7cb68606937380d" &
135+ "b2eaaa707b4c4185c32eddcdd306705e" &
136+ "4dc1ffc872eeee475a64dfac86aba41c" &
137+ "0618983f8741c5ef68d3a101e8a3b8ca" &
138+ "c60c905c15fc910840b94c00a0b9d0")
139+ check sign(k, msg).toHex ==
140+ "0aab4c900501b3e24d7cdf4663326a3a" &
141+ "87df5e4843b2cbdb67cbf6e460fec350" &
142+ "aa5371b1508f9f4528ecea23c436d94b" &
143+ "5e8fcd4f681e30a6ac00a9704a188a03"
144+
145+suite "the shape of a key":
146+ test "a seed that is not 32 bytes is refused":
147+ expect CryptoError:
148+ discard keyFromSeed(@[1'u8, 2, 3])
149+
150+ test "the same seed always gives the same key":
151+ let seed = newSeq[byte](32)
152+ check keyFromSeed(seed).public == keyFromSeed(seed).public
153+
154+ test "a fresh key is not the same twice":
155+ check newKey().public != newKey().public
156+
157+ test "randomBytes gives what was asked for":
158+ check randomBytes(32).len == 32
159+ check randomBytes(0).len == 0
new file mode 100644
@@ -0,0 +1,159 @@
1+## The crypto bindings, against published vectors.
2+##
3+## These check that we are driving OpenSSL correctly — the seed in the right
4+## place, the one-shot signing form, the byte order out — not that Ed25519 is
5+## implemented correctly, which is OpenSSL's problem and not ours.
6+##
7+## Vectors are RFC 8032 §7.1 (Ed25519) and RFC 4634 / FIPS 180-4 (SHA-256).
8+
9+import std/[strutils, unittest]
10+import frq/crypto
11+
12+func hexToBytes(s: string): seq[byte] =
13+ for i in countup(0, s.len - 2, 2):
14+ result.add byte(parseHexInt(s[i .. i + 1]))
15+
16+suite "sha256":
17+ test "the empty string":
18+ check sha256("").toHex ==
19+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
20+
21+ test "abc":
22+ check sha256("abc").toHex ==
23+ "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
24+
25+ test "the 448-bit message from FIPS 180-4":
26+ check sha256("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq").toHex ==
27+ "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
28+
29+ test "a million a's":
30+ check sha256("a".repeat(1_000_000)).toHex ==
31+ "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"
32+
33+suite "Ed25519, RFC 8032 section 7.1":
34+ # TEST 1
35+ test "test 1: the empty message":
36+ let seed = hexToBytes("9d61b19deffd5a60ba844af492ec2cc4" &
37+ "4449c5697b326919703bac031cae7f60")
38+ let k = keyFromSeed(seed)
39+ check k.public.toHex ==
40+ "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"
41+ check sign(k, "").toHex ==
42+ "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155" &
43+ "5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"
44+
45+ # TEST 2
46+ test "test 2: one byte":
47+ let seed = hexToBytes("4ccd089b28ff96da9db6c346ec114e0f" &
48+ "5b8a319f35aba624da8cf6ed4fb8a6fb")
49+ let k = keyFromSeed(seed)
50+ check k.public.toHex ==
51+ "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"
52+ check sign(k, @[0x72'u8]).toHex ==
53+ "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da" &
54+ "085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"
55+
56+ # TEST 3
57+ test "test 3: two bytes":
58+ let seed = hexToBytes("c5aa8df43f9f837bedb7442f31dcb7b1" &
59+ "66d38535076f094b85ce3a2e0b4458f7")
60+ let k = keyFromSeed(seed)
61+ check k.public.toHex ==
62+ "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025"
63+ check sign(k, @[0xaf'u8, 0x82]).toHex ==
64+ "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac" &
65+ "18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a"
66+
67+ # TEST 1024 — the long one, which is where a length or a carry goes wrong.
68+ test "test 1024: a 1023-byte message":
69+ let seed = hexToBytes("f5e5767cf153319517630f226876b86c" &
70+ "8160cc583bc013744c6bf255f5cc0ee5")
71+ let k = keyFromSeed(seed)
72+ check k.public.toHex ==
73+ "278117fc144c72340f67d0f2316e8386ceffbf2b2428c9c51fef7c597f1d426e"
74+ let msg = hexToBytes(
75+ "08b8b2b733424243760fe426a4b54908" &
76+ "632110a66c2f6591eabd3345e3e4eb98" &
77+ "fa6e264bf09efe12ee50f8f54e9f77b1" &
78+ "e355f6c50544e23fb1433ddf73be84d8" &
79+ "79de7c0046dc4996d9e773f4bc9efe57" &
80+ "38829adb26c81b37c93a1b270b20329d" &
81+ "658675fc6ea534e0810a4432826bf58c" &
82+ "941efb65d57a338bbd2e26640f89ffbc" &
83+ "1a858efcb8550ee3a5e1998bd177e93a" &
84+ "7363c344fe6b199ee5d02e82d522c4fe" &
85+ "ba15452f80288a821a579116ec6dad2b" &
86+ "3b310da903401aa62100ab5d1a36553e" &
87+ "06203b33890cc9b832f79ef80560ccb9" &
88+ "a39ce767967ed628c6ad573cb116dbef" &
89+ "efd75499da96bd68a8a97b928a8bbc10" &
90+ "3b6621fcde2beca1231d206be6cd9ec7" &
91+ "aff6f6c94fcd7204ed3455c68c83f4a4" &
92+ "1da4af2b74ef5c53f1d8ac70bdcb7ed1" &
93+ "85ce81bd84359d44254d95629e9855a9" &
94+ "4a7c1958d1f8ada5d0532ed8a5aa3fb2" &
95+ "d17ba70eb6248e594e1a2297acbbb39d" &
96+ "502f1a8c6eb6f1ce22b3de1a1f40cc24" &
97+ "554119a831a9aad6079cad88425de6bd" &
98+ "e1a9187ebb6092cf67bf2b13fd65f270" &
99+ "88d78b7e883c8759d2c4f5c65adb7553" &
100+ "878ad575f9fad878e80a0c9ba63bcbcc" &
101+ "2732e69485bbc9c90bfbd62481d9089b" &
102+ "eccf80cfe2df16a2cf65bd92dd597b07" &
103+ "07e0917af48bbb75fed413d238f5555a" &
104+ "7a569d80c3414a8d0859dc65a46128ba" &
105+ "b27af87a71314f318c782b23ebfe808b" &
106+ "82b0ce26401d2e22f04d83d1255dc51a" &
107+ "ddd3b75a2b1ae0784504df543af8969b" &
108+ "e3ea7082ff7fc9888c144da2af58429e" &
109+ "c96031dbcad3dad9af0dcbaaaf268cb8" &
110+ "fcffead94f3c7ca495e056a9b47acdb7" &
111+ "51fb73e666c6c655ade8297297d07ad1" &
112+ "ba5e43f1bca32301651339e22904cc8c" &
113+ "42f58c30c04aafdb038dda0847dd988d" &
114+ "cda6f3bfd15c4b4c4525004aa06eeff8" &
115+ "ca61783aacec57fb3d1f92b0fe2fd1a8" &
116+ "5f6724517b65e614ad6808d6f6ee34df" &
117+ "f7310fdc82aebfd904b01e1dc54b2927" &
118+ "094b2db68d6f903b68401adebf5a7e08" &
119+ "d78ff4ef5d63653a65040cf9bfd4aca7" &
120+ "984a74d37145986780fc0b16ac451649" &
121+ "de6188a7dbdf191f64b5fc5e2ab47b57" &
122+ "f7f7276cd419c17a3ca8e1b939ae49e4" &
123+ "88acba6b965610b5480109c8b17b80e1" &
124+ "b7b750dfc7598d5d5011fd2dcc5600a3" &
125+ "2ef5b52a1ecc820e308aa342721aac09" &
126+ "43bf6686b64b2579376504ccc493d97e" &
127+ "6aed3fb0f9cd71a43dd497f01f17c0e2" &
128+ "cb3797aa2a2f256656168e6c496afc5f" &
129+ "b93246f6b1116398a346f1a641f3b041" &
130+ "e989f7914f90cc2c7fff357876e506b5" &
131+ "0d334ba77c225bc307ba537152f3f161" &
132+ "0e4eafe595f6d9d90d11faa933a15ef1" &
133+ "369546868a7f3a45a96768d40fd9d034" &
134+ "12c091c6315cf4fde7cb68606937380d" &
135+ "b2eaaa707b4c4185c32eddcdd306705e" &
136+ "4dc1ffc872eeee475a64dfac86aba41c" &
137+ "0618983f8741c5ef68d3a101e8a3b8ca" &
138+ "c60c905c15fc910840b94c00a0b9d0")
139+ check sign(k, msg).toHex ==
140+ "0aab4c900501b3e24d7cdf4663326a3a" &
141+ "87df5e4843b2cbdb67cbf6e460fec350" &
142+ "aa5371b1508f9f4528ecea23c436d94b" &
143+ "5e8fcd4f681e30a6ac00a9704a188a03"
144+
145+suite "the shape of a key":
146+ test "a seed that is not 32 bytes is refused":
147+ expect CryptoError:
148+ discard keyFromSeed(@[1'u8, 2, 3])
149+
150+ test "the same seed always gives the same key":
151+ let seed = newSeq[byte](32)
152+ check keyFromSeed(seed).public == keyFromSeed(seed).public
153+
154+ test "a fresh key is not the same twice":
155+ check newKey().public != newKey().public
156+
157+ test "randomBytes gives what was asked for":
158+ check randomBytes(32).len == 32
159+ check randomBytes(0).len == 0
added nim/tests/tmsgsig.nim +140 -0
new file mode 100644
@@ -0,0 +1,140 @@
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
new file mode 100644
@@ -0,0 +1,140 @@
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