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>
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 @@ | ||
| 5 | 5 | ## say back — so it answers with lines and the caller writes them. |
| 6 | 6 | |
| 7 | 7 | import std/[sets, strutils] |
| 8 | -import frq/[ircparse, atproto] | |
| 8 | +import frq/[ircparse, atproto, msgsig] | |
| 9 | 9 | |
| 10 | 10 | const |
| 11 | 11 | saslChunk* = 100_000 |
| @@ -102,8 +102,14 @@ proc step*(session: Session, caps: HashSet[string], m: IrcLine): Step = | ||
| 102 | 102 | result.send = saslLines(saslResponse(session, nonceOf(challenge))) |
| 103 | 103 | |
| 104 | 104 | 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"] | |
| 107 | 113 | |
| 108 | 114 | of "904", "905", "906": |
| 109 | 115 | # 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 | const | 10 | const |
| 11 | saslChunk* = 100_000 | 11 | 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 @@ | ||
| 14 | 14 | import std/[json, options, strutils, tables] |
| 15 | 15 | import std/sets |
| 16 | 16 | import frq/[cells, model, rooms, reactions, trace, ircparse, clock, |
| 17 | - atproto, handshake, textruns, members] | |
| 17 | + atproto, handshake, textruns, members, msgsig] | |
| 18 | 18 | import frq/conn as tr |
| 19 | 19 | |
| 20 | 20 | proc split2(id: string): (string, string) = |
| @@ -166,6 +166,9 @@ proc dispatch*(event: JsonNode) = | ||
| 166 | 166 | of "connect": connectNow() |
| 167 | 167 | |
| 168 | 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 | 172 | tr.close() |
| 170 | 173 | app.connecting = false |
| 171 | 174 | app.screen = scConnect |
| @@ -264,8 +267,16 @@ proc dispatch*(event: JsonNode) = | ||
| 264 | 267 | if mid.len > 0 and emoji.len > 0: |
| 265 | 268 | let m = app.currentRoom.messageById(mid) |
| 266 | 269 | 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) | |
| 269 | 280 | app.rooms.updateReaction(app.current, mid, emoji, app.formNick, on) |
| 270 | 281 | |
| 271 | 282 | of "goto": |
| @@ -14,7 +14,7 @@ | |||
| 14 | import std/[json, options, strutils, tables] | 14 | import std/[json, options, strutils, tables] |
| 15 | import std/sets | 15 | 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 tr | 18 | 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 = false | 173 | app.connecting = false |
| 171 | app.screen = scConnect | 174 | 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: true | 269 | 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 | ||