├── .gitignore ├── COPYING ├── README.md ├── best-practices.gmi ├── make-cert.sh ├── old ├── misfin-send.sh ├── misfin_a.py ├── misfin_original.py └── spec_A.gmi ├── show-cert.sh ├── specification.gmi └── transponder ├── __init__.py ├── debug.py ├── identity.py ├── letter.py ├── misfin.py └── send.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.who 2 | *.pem 3 | *.cert 4 | __pycache__/ 5 | *.py[cod] 6 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | I don't think a protocol can be subject to copyright, and I don't want it to be 2 | anyway. Consider Misfin to be in the public domain. 3 | 4 | Below are the licenses for the contents of this repository. The documentation 5 | for Misfin here is released under the CC-BY-SA 4.0 license. The reference 6 | implementation and all related files - Python source, Bash scripts, etc. - are 7 | released under the MIT license. 8 | 9 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 10 | 11 | Attribution-ShareAlike 4.0 International 12 | 13 | ======================================================================= 14 | 15 | Creative Commons Corporation ("Creative Commons") is not a law firm and 16 | does not provide legal services or legal advice. Distribution of 17 | Creative Commons public licenses does not create a lawyer-client or 18 | other relationship. Creative Commons makes its licenses and related 19 | information available on an "as-is" basis. Creative Commons gives no 20 | warranties regarding its licenses, any material licensed under their 21 | terms and conditions, or any related information. Creative Commons 22 | disclaims all liability for damages resulting from their use to the 23 | fullest extent possible. 24 | 25 | Using Creative Commons Public Licenses 26 | 27 | Creative Commons public licenses provide a standard set of terms and 28 | conditions that creators and other rights holders may use to share 29 | original works of authorship and other material subject to copyright 30 | and certain other rights specified in the public license below. The 31 | following considerations are for informational purposes only, are not 32 | exhaustive, and do not form part of our licenses. 33 | 34 | Considerations for licensors: Our public licenses are 35 | intended for use by those authorized to give the public 36 | permission to use material in ways otherwise restricted by 37 | copyright and certain other rights. Our licenses are 38 | irrevocable. Licensors should read and understand the terms 39 | and conditions of the license they choose before applying it. 40 | Licensors should also secure all rights necessary before 41 | applying our licenses so that the public can reuse the 42 | material as expected. Licensors should clearly mark any 43 | material not subject to the license. This includes other CC- 44 | licensed material, or material used under an exception or 45 | limitation to copyright. More considerations for licensors: 46 | wiki.creativecommons.org/Considerations_for_licensors 47 | 48 | Considerations for the public: By using one of our public 49 | licenses, a licensor grants the public permission to use the 50 | licensed material under specified terms and conditions. If 51 | the licensor's permission is not necessary for any reason--for 52 | example, because of any applicable exception or limitation to 53 | copyright--then that use is not regulated by the license. Our 54 | licenses grant only permissions under copyright and certain 55 | other rights that a licensor has authority to grant. Use of 56 | the licensed material may still be restricted for other 57 | reasons, including because others have copyright or other 58 | rights in the material. A licensor may make special requests, 59 | such as asking that all changes be marked or described. 60 | Although not required by our licenses, you are encouraged to 61 | respect those requests where reasonable. More considerations 62 | for the public: 63 | wiki.creativecommons.org/Considerations_for_licensees 64 | 65 | ======================================================================= 66 | 67 | Creative Commons Attribution-ShareAlike 4.0 International Public 68 | License 69 | 70 | By exercising the Licensed Rights (defined below), You accept and agree 71 | to be bound by the terms and conditions of this Creative Commons 72 | Attribution-ShareAlike 4.0 International Public License ("Public 73 | License"). To the extent this Public License may be interpreted as a 74 | contract, You are granted the Licensed Rights in consideration of Your 75 | acceptance of these terms and conditions, and the Licensor grants You 76 | such rights in consideration of benefits the Licensor receives from 77 | making the Licensed Material available under these terms and 78 | conditions. 79 | 80 | 81 | Section 1 -- Definitions. 82 | 83 | a. Adapted Material means material subject to Copyright and Similar 84 | Rights that is derived from or based upon the Licensed Material 85 | and in which the Licensed Material is translated, altered, 86 | arranged, transformed, or otherwise modified in a manner requiring 87 | permission under the Copyright and Similar Rights held by the 88 | Licensor. For purposes of this Public License, where the Licensed 89 | Material is a musical work, performance, or sound recording, 90 | Adapted Material is always produced where the Licensed Material is 91 | synched in timed relation with a moving image. 92 | 93 | b. Adapter's License means the license You apply to Your Copyright 94 | and Similar Rights in Your contributions to Adapted Material in 95 | accordance with the terms and conditions of this Public License. 96 | 97 | c. BY-SA Compatible License means a license listed at 98 | creativecommons.org/compatiblelicenses, approved by Creative 99 | Commons as essentially the equivalent of this Public License. 100 | 101 | d. Copyright and Similar Rights means copyright and/or similar rights 102 | closely related to copyright including, without limitation, 103 | performance, broadcast, sound recording, and Sui Generis Database 104 | Rights, without regard to how the rights are labeled or 105 | categorized. For purposes of this Public License, the rights 106 | specified in Section 2(b)(1)-(2) are not Copyright and Similar 107 | Rights. 108 | 109 | e. Effective Technological Measures means those measures that, in the 110 | absence of proper authority, may not be circumvented under laws 111 | fulfilling obligations under Article 11 of the WIPO Copyright 112 | Treaty adopted on December 20, 1996, and/or similar international 113 | agreements. 114 | 115 | f. Exceptions and Limitations means fair use, fair dealing, and/or 116 | any other exception or limitation to Copyright and Similar Rights 117 | that applies to Your use of the Licensed Material. 118 | 119 | g. License Elements means the license attributes listed in the name 120 | of a Creative Commons Public License. The License Elements of this 121 | Public License are Attribution and ShareAlike. 122 | 123 | h. Licensed Material means the artistic or literary work, database, 124 | or other material to which the Licensor applied this Public 125 | License. 126 | 127 | i. Licensed Rights means the rights granted to You subject to the 128 | terms and conditions of this Public License, which are limited to 129 | all Copyright and Similar Rights that apply to Your use of the 130 | Licensed Material and that the Licensor has authority to license. 131 | 132 | j. Licensor means the individual(s) or entity(ies) granting rights 133 | under this Public License. 134 | 135 | k. Share means to provide material to the public by any means or 136 | process that requires permission under the Licensed Rights, such 137 | as reproduction, public display, public performance, distribution, 138 | dissemination, communication, or importation, and to make material 139 | available to the public including in ways that members of the 140 | public may access the material from a place and at a time 141 | individually chosen by them. 142 | 143 | l. Sui Generis Database Rights means rights other than copyright 144 | resulting from Directive 96/9/EC of the European Parliament and of 145 | the Council of 11 March 1996 on the legal protection of databases, 146 | as amended and/or succeeded, as well as other essentially 147 | equivalent rights anywhere in the world. 148 | 149 | m. You means the individual or entity exercising the Licensed Rights 150 | under this Public License. Your has a corresponding meaning. 151 | 152 | 153 | Section 2 -- Scope. 154 | 155 | a. License grant. 156 | 157 | 1. Subject to the terms and conditions of this Public License, 158 | the Licensor hereby grants You a worldwide, royalty-free, 159 | non-sublicensable, non-exclusive, irrevocable license to 160 | exercise the Licensed Rights in the Licensed Material to: 161 | 162 | a. reproduce and Share the Licensed Material, in whole or 163 | in part; and 164 | 165 | b. produce, reproduce, and Share Adapted Material. 166 | 167 | 2. Exceptions and Limitations. For the avoidance of doubt, where 168 | Exceptions and Limitations apply to Your use, this Public 169 | License does not apply, and You do not need to comply with 170 | its terms and conditions. 171 | 172 | 3. Term. The term of this Public License is specified in Section 173 | 6(a). 174 | 175 | 4. Media and formats; technical modifications allowed. The 176 | Licensor authorizes You to exercise the Licensed Rights in 177 | all media and formats whether now known or hereafter created, 178 | and to make technical modifications necessary to do so. The 179 | Licensor waives and/or agrees not to assert any right or 180 | authority to forbid You from making technical modifications 181 | necessary to exercise the Licensed Rights, including 182 | technical modifications necessary to circumvent Effective 183 | Technological Measures. For purposes of this Public License, 184 | simply making modifications authorized by this Section 2(a) 185 | (4) never produces Adapted Material. 186 | 187 | 5. Downstream recipients. 188 | 189 | a. Offer from the Licensor -- Licensed Material. Every 190 | recipient of the Licensed Material automatically 191 | receives an offer from the Licensor to exercise the 192 | Licensed Rights under the terms and conditions of this 193 | Public License. 194 | 195 | b. Additional offer from the Licensor -- Adapted Material. 196 | Every recipient of Adapted Material from You 197 | automatically receives an offer from the Licensor to 198 | exercise the Licensed Rights in the Adapted Material 199 | under the conditions of the Adapter's License You apply. 200 | 201 | c. No downstream restrictions. You may not offer or impose 202 | any additional or different terms or conditions on, or 203 | apply any Effective Technological Measures to, the 204 | Licensed Material if doing so restricts exercise of the 205 | Licensed Rights by any recipient of the Licensed 206 | Material. 207 | 208 | 6. No endorsement. Nothing in this Public License constitutes or 209 | may be construed as permission to assert or imply that You 210 | are, or that Your use of the Licensed Material is, connected 211 | with, or sponsored, endorsed, or granted official status by, 212 | the Licensor or others designated to receive attribution as 213 | provided in Section 3(a)(1)(A)(i). 214 | 215 | b. Other rights. 216 | 217 | 1. Moral rights, such as the right of integrity, are not 218 | licensed under this Public License, nor are publicity, 219 | privacy, and/or other similar personality rights; however, to 220 | the extent possible, the Licensor waives and/or agrees not to 221 | assert any such rights held by the Licensor to the limited 222 | extent necessary to allow You to exercise the Licensed 223 | Rights, but not otherwise. 224 | 225 | 2. Patent and trademark rights are not licensed under this 226 | Public License. 227 | 228 | 3. To the extent possible, the Licensor waives any right to 229 | collect royalties from You for the exercise of the Licensed 230 | Rights, whether directly or through a collecting society 231 | under any voluntary or waivable statutory or compulsory 232 | licensing scheme. In all other cases the Licensor expressly 233 | reserves any right to collect such royalties. 234 | 235 | 236 | Section 3 -- License Conditions. 237 | 238 | Your exercise of the Licensed Rights is expressly made subject to the 239 | following conditions. 240 | 241 | a. Attribution. 242 | 243 | 1. If You Share the Licensed Material (including in modified 244 | form), You must: 245 | 246 | a. retain the following if it is supplied by the Licensor 247 | with the Licensed Material: 248 | 249 | i. identification of the creator(s) of the Licensed 250 | Material and any others designated to receive 251 | attribution, in any reasonable manner requested by 252 | the Licensor (including by pseudonym if 253 | designated); 254 | 255 | ii. a copyright notice; 256 | 257 | iii. a notice that refers to this Public License; 258 | 259 | iv. a notice that refers to the disclaimer of 260 | warranties; 261 | 262 | v. a URI or hyperlink to the Licensed Material to the 263 | extent reasonably practicable; 264 | 265 | b. indicate if You modified the Licensed Material and 266 | retain an indication of any previous modifications; and 267 | 268 | c. indicate the Licensed Material is licensed under this 269 | Public License, and include the text of, or the URI or 270 | hyperlink to, this Public License. 271 | 272 | 2. You may satisfy the conditions in Section 3(a)(1) in any 273 | reasonable manner based on the medium, means, and context in 274 | which You Share the Licensed Material. For example, it may be 275 | reasonable to satisfy the conditions by providing a URI or 276 | hyperlink to a resource that includes the required 277 | information. 278 | 279 | 3. If requested by the Licensor, You must remove any of the 280 | information required by Section 3(a)(1)(A) to the extent 281 | reasonably practicable. 282 | 283 | b. ShareAlike. 284 | 285 | In addition to the conditions in Section 3(a), if You Share 286 | Adapted Material You produce, the following conditions also apply. 287 | 288 | 1. The Adapter's License You apply must be a Creative Commons 289 | license with the same License Elements, this version or 290 | later, or a BY-SA Compatible License. 291 | 292 | 2. You must include the text of, or the URI or hyperlink to, the 293 | Adapter's License You apply. You may satisfy this condition 294 | in any reasonable manner based on the medium, means, and 295 | context in which You Share Adapted Material. 296 | 297 | 3. You may not offer or impose any additional or different terms 298 | or conditions on, or apply any Effective Technological 299 | Measures to, Adapted Material that restrict exercise of the 300 | rights granted under the Adapter's License You apply. 301 | 302 | 303 | Section 4 -- Sui Generis Database Rights. 304 | 305 | Where the Licensed Rights include Sui Generis Database Rights that 306 | apply to Your use of the Licensed Material: 307 | 308 | a. for the avoidance of doubt, Section 2(a)(1) grants You the right 309 | to extract, reuse, reproduce, and Share all or a substantial 310 | portion of the contents of the database; 311 | 312 | b. if You include all or a substantial portion of the database 313 | contents in a database in which You have Sui Generis Database 314 | Rights, then the database in which You have Sui Generis Database 315 | Rights (but not its individual contents) is Adapted Material, 316 | including for purposes of Section 3(b); and 317 | 318 | c. You must comply with the conditions in Section 3(a) if You Share 319 | all or a substantial portion of the contents of the database. 320 | 321 | For the avoidance of doubt, this Section 4 supplements and does not 322 | replace Your obligations under this Public License where the Licensed 323 | Rights include other Copyright and Similar Rights. 324 | 325 | 326 | Section 5 -- Disclaimer of Warranties and Limitation of Liability. 327 | 328 | a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE 329 | EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS 330 | AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF 331 | ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, 332 | IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, 333 | WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR 334 | PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, 335 | ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT 336 | KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT 337 | ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. 338 | 339 | b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE 340 | TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, 341 | NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, 342 | INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, 343 | COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR 344 | USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN 345 | ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR 346 | DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR 347 | IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. 348 | 349 | c. The disclaimer of warranties and limitation of liability provided 350 | above shall be interpreted in a manner that, to the extent 351 | possible, most closely approximates an absolute disclaimer and 352 | waiver of all liability. 353 | 354 | 355 | Section 6 -- Term and Termination. 356 | 357 | a. This Public License applies for the term of the Copyright and 358 | Similar Rights licensed here. However, if You fail to comply with 359 | this Public License, then Your rights under this Public License 360 | terminate automatically. 361 | 362 | b. Where Your right to use the Licensed Material has terminated under 363 | Section 6(a), it reinstates: 364 | 365 | 1. automatically as of the date the violation is cured, provided 366 | it is cured within 30 days of Your discovery of the 367 | violation; or 368 | 369 | 2. upon express reinstatement by the Licensor. 370 | 371 | For the avoidance of doubt, this Section 6(b) does not affect any 372 | right the Licensor may have to seek remedies for Your violations 373 | of this Public License. 374 | 375 | c. For the avoidance of doubt, the Licensor may also offer the 376 | Licensed Material under separate terms or conditions or stop 377 | distributing the Licensed Material at any time; however, doing so 378 | will not terminate this Public License. 379 | 380 | d. Sections 1, 5, 6, 7, and 8 survive termination of this Public 381 | License. 382 | 383 | 384 | Section 7 -- Other Terms and Conditions. 385 | 386 | a. The Licensor shall not be bound by any additional or different 387 | terms or conditions communicated by You unless expressly agreed. 388 | 389 | b. Any arrangements, understandings, or agreements regarding the 390 | Licensed Material not stated herein are separate from and 391 | independent of the terms and conditions of this Public License. 392 | 393 | 394 | Section 8 -- Interpretation. 395 | 396 | a. For the avoidance of doubt, this Public License does not, and 397 | shall not be interpreted to, reduce, limit, restrict, or impose 398 | conditions on any use of the Licensed Material that could lawfully 399 | be made without permission under this Public License. 400 | 401 | b. To the extent possible, if any provision of this Public License is 402 | deemed unenforceable, it shall be automatically reformed to the 403 | minimum extent necessary to make it enforceable. If the provision 404 | cannot be reformed, it shall be severed from this Public License 405 | without affecting the enforceability of the remaining terms and 406 | conditions. 407 | 408 | c. No term or condition of this Public License will be waived and no 409 | failure to comply consented to unless expressly agreed to by the 410 | Licensor. 411 | 412 | d. Nothing in this Public License constitutes or may be interpreted 413 | as a limitation upon, or waiver of, any privileges and immunities 414 | that apply to the Licensor or You, including from the legal 415 | processes of any jurisdiction or authority. 416 | 417 | 418 | ======================================================================= 419 | 420 | Creative Commons is not a party to its public licenses. 421 | Notwithstanding, Creative Commons may elect to apply one of its public 422 | licenses to material it publishes and in those instances will be 423 | considered the “Licensor.” The text of the Creative Commons public 424 | licenses is dedicated to the public domain under the CC0 Public Domain 425 | Dedication. Except for the limited purpose of indicating that material 426 | is shared under a Creative Commons public license or as otherwise 427 | permitted by the Creative Commons policies published at 428 | creativecommons.org/policies, Creative Commons does not authorize the 429 | use of the trademark "Creative Commons" or any other trademark or logo 430 | of Creative Commons without its prior written consent including, 431 | without limitation, in connection with any unauthorized modifications 432 | to any of its public licenses or any other arrangements, 433 | understandings, or agreements concerning use of licensed material. For 434 | the avoidance of doubt, this paragraph does not form part of the public 435 | licenses. 436 | 437 | Creative Commons may be contacted at creativecommons.org. 438 | 439 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 440 | 441 | MIT License 442 | 443 | Copyright (c) 2023 John Lemme et al. 444 | 445 | Permission is hereby granted, free of charge, to any person obtaining a copy 446 | of this software and associated documentation files (the "Software"), to deal 447 | in the Software without restriction, including without limitation the rights 448 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 449 | copies of the Software, and to permit persons to whom the Software is 450 | furnished to do so, subject to the following conditions: 451 | 452 | The above copyright notice and this permission notice shall be included in all 453 | copies or substantial portions of the Software. 454 | 455 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 456 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 457 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 458 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 459 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 460 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 461 | SOFTWARE. 462 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | misfin (is) mail (for the) small web 2 | ==================================== 3 | 4 | 💬 📯 📬 5 | 6 | misfin://hello@misfin.org What's up?\r\n 7 | ↓↓↓↓ 8 | 20 1e:9f:11:e4:8f:aa:12:b3:cb... 9 | 10 | what is this? 11 | ------------- 12 | Misfin is to email what Gemini is to the Web. Set up a Misfin server alongside your Gemini capsule, and start networking with other capsuleers - no signup required. For the full details, see gemini://misfin.org/ . 13 | 14 | details? 15 | -------- 16 | A Misfin message is a single string of UTF-8 gemtext, no more than 2,048 characters long. Want to write more? Send two messages. What about attachments? Host it on a Gemini server and add a link line - you get the fingerprint of your recipient back, so you can even gate access if it's eyes only. 17 | Keeping Misfin mail simple makes it a better fit for the small web - it's easier to implement, easier to secure, and easier to keep running. 18 | 19 | i don't like it? 20 | ---------------- 21 | Good, because we're still trying to nail down the details. This version is Misfin(B), but there's another, more SMTP-like version - Misfin(A) - that is also implemented here. Eventually we're settling on one or the other, but feel free to experiment. 22 | For the moment though? Download the reference implementation, make a certificate, and send your comments to misfin://rfc@misfin.org. (Or make a ticket on [sourcehut](https://todo.sr.ht/~lem/misfin-rfc), or on [Github](https://github.com/JCLemme/misfin), or post about it on station, or w/e). 23 | 24 | run your own 25 | ------------ 26 | There isn't a production mailserver written yet, but you can run the (new! improved!) testing suite and send/receive mail. Run `python -m transponder.debug` to see how. (You'll need to install `pyopenssl` first). 27 | 28 | python -m transponder.debug make-cert queen "Queen bee" hive.com queen_hive.pem 29 | python -m transponder.debug receive-as queen_hive.pem 30 | 31 | ... 32 | 33 | python -m transponder.debug make-cert bee "Worker bee" hive.com bee_hive.pem 34 | python -m transponder.debug send-as bee_hive.pem queen@hive.com "Where's the flowers at" 35 | 36 | -------------------------------------------------------------------------------- /best-practices.gmi: -------------------------------------------------------------------------------- 1 | # Best practices for Misfin clients and servers 2 | (updated 11 May 2023) 3 | (for comments, suggestions, well-wishes, insults, etc. - send a Misfin letter to rfc@misfin.org) 4 | 5 | This is a living document, listing (in no particular order) things that Misfin software writers *should* keep in mind. Expect things to change as the first real servers and clients get written, and of course feel free to make suggestions. 6 | 7 | ## 1 Sending messages 8 | 9 | Misfin messages are described as a "single line" of text - and they can be! - but they're not intended to be single sentences/paragraphs. Clients that include a text editor should allow users to add newlines (0xA) to their messages. 10 | 11 | If a message is too big to fit within one request, prompt the user to split it into two messages. Making the user split the message means servers don't need to pay attention to a "message 1 of n..." field, and avoids messages having random splits between words or sentences. 12 | 13 | When sending mail, take care not to send sender lines ("<") or timestamp lines ("@") - those are up to the receiving mailserver to add. (You should include them if you're forwarding a message, or more precisely, you shouldn't change anything about a forwarded message and send it as-is). 14 | 15 | There is no formal way to send attachments, but hyperlinks are supported, so you can link to any content you want to attach. For privacy, you can secure these files with the fingerprint you receive from the recipient's mailserver, so only your recipient can download the attachment. 16 | 17 | Clients are welcome to support a "rich" view of incoming messages, and replace or reformat sender/timestamp/recipient lines to make viewing easier, just like Gemini clients can style link or heading lines. 18 | 19 | Redirects and temporary failures imply that the message should be resent, but clients should ask the user before resending anything, so they're kept informed. Try not to spam, either - if you get a temp failure, wait a few minutes before resending. 20 | 21 | ## 2 Receiving messages 22 | 23 | Following links in messages, by necessity, reveals your IP address to the sender (thanks, Jeremy). If this is undesireable, consider downloading attachments via your mailserver - its IP is public anyway. 24 | 25 | You don't need to store mail with sender/recipient/timestamp lines, if you want to store them some other way (like in a database)... 26 | 27 | ...but they should be added as per the spec when replying to messages or sending mail to other mailservers (e.g. forwards, replies, etc). 28 | 29 | ## 3 Technical details 30 | 31 | Misfin servers are allowed to serve other protocols, which is why requests have "misfin://" prepended. The intention is to use this for serving attachments or mailboxes over Gemini. Don't add Misfin support to your Gemini server if it's not listening over port 1958! Clients are welcome to support sending mail to mailservers on alternate ports, but for everyone else's sake, keep your mailserver on the known port. 32 | 33 | The fingerprint sent alongside a 20 status code should be the fingerprint of that mailbox, if it has its own certificate. Alongside securing attachments, the fingerprint is intended for use validating senders of messages you've received, via sending a blank message back to the sender's address and seeing if the fingerprint matches. Obviously, this won't work if you're not sending the right fingerprint. 34 | 35 | Mailboxes that don't have their own certificate - in other words, a mailbox that doesn't strictly *exist* on the mailserver, but that the mailserver chooses to receive mail for - are an open question. I'm leaning towards sending the fingerprint of the mailserver's certificate, but maybe we're better off sending nothing and letting clients interpret that as "can't receive from them". 36 | 37 | The reference implementation generates 2048-bit RSA keys, but anything supported by TLS should also be supported by Misfin. It was suggested that lighter devices might benefit from using elliptical-curve or ASCON keys, which are smaller. 38 | 39 | A fun bug discovered in the reference implementation: don't assume the client or server will send its *whole* message in one go. Set a timeout and attempt to read until you get CRLF. 40 | 41 | ## 4 "Do something sensible" 42 | 43 | Above all, Misfin is intended to fit into the small web ecosystem. Respect the user, respect privacy, play nice with others. 44 | -------------------------------------------------------------------------------- /make-cert.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Simplest way to make a Misfin certificate. 4 | 5 | if [ $# -lt 4 ] 6 | then 7 | echo "usage: make-cert.sh " 8 | exit 9 | fi 10 | 11 | openssl req -x509 -newkey rsa:2048 -keyout $4 -out $4 -sha256 -days 8192 -nodes -subj "/CN=$2/UID=$1" -addext "subjectAltName = DNS:$3" 12 | 13 | echo "$2 ($1@$3): saved to $4" 14 | -------------------------------------------------------------------------------- /old/misfin-send.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Simplest possible way to send a Misfin message. 4 | # Doesn't even show the return code. 5 | 6 | if [ $# -lt 5 ] 7 | then 8 | echo "usage: misfin-send.sh " 9 | exit -1 10 | fi 11 | 12 | printf "misfin://$2@$3 text/gemini $4\r\n$5" | openssl s_client -cert $1 -key $1 -connect $3:1958 13 | -------------------------------------------------------------------------------- /old/misfin_a.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | import socket 5 | import datetime 6 | 7 | import OpenSSL.SSL as ossl 8 | import OpenSSL.crypto as ocrypt 9 | 10 | from cryptography import x509 11 | from cryptography.x509 import NameOID, ExtensionOID 12 | from cryptography.hazmat.primitives import serialization, hashes 13 | from cryptography.hazmat.primitives.asymmetric import rsa 14 | 15 | # _______ 16 | # |== []| misfin(a) protocol 17 | # | ==== | implemented in one file 18 | # '-------' lem (2023) 19 | 20 | 21 | # ---------- 22 | # Certificate handling. 23 | 24 | # A nice round number... 25 | default_expiry = datetime.timedelta(days=32768) 26 | 27 | class Identity: 28 | """ An identified user, either local (i.e. with a private key) or a peer. """ 29 | def __init__(self, cert, private=None, password=None): 30 | """ Load an Identity from certificate and key objects, or from PEM data. """ 31 | if isinstance(cert, bytes): 32 | self._cert = x509.load_pem_x509_certificate(cert) 33 | elif isinstance(cert, ocrypt.X509): 34 | self._cert = cert.to_cryptography() 35 | elif isinstance(cert, x509.Certificate): 36 | self._cert = cert 37 | else: 38 | raise TypeError("Can't load certificate") 39 | 40 | if isinstance(private, bytes): 41 | self._private = serialization.load_pem_private_key(private, password=password) 42 | elif isinstance(private, rsa.RSAPrivateKey) or private is None: 43 | self._private = private 44 | else: 45 | raise TypeError("Can't load private key") 46 | 47 | def _build_name(mailbox, blurb, additional_names=[]): 48 | """ Builds an x509 Name with the right format for a Misfin certificate. """ 49 | mandatory = [x509.NameAttribute(NameOID.USER_ID, mailbox), x509.NameAttribute(NameOID.COMMON_NAME, blurb)] 50 | return x509.Name(mandatory + additional_names) 51 | 52 | def _build_key(): 53 | """ Common method for building a private key. """ 54 | return rsa.generate_private_key(public_exponent=65537, key_size=2048) 55 | 56 | def _build_cert(pubkey, privkey, subject, issuer, hostname, is_ca, expires_in): 57 | """ Common method for building and signing an x509 certificate. """ 58 | return x509.CertificateBuilder() \ 59 | .subject_name(subject) \ 60 | .issuer_name(issuer) \ 61 | .public_key(pubkey) \ 62 | .serial_number(x509.random_serial_number()) \ 63 | .not_valid_before(datetime.datetime.utcnow()) \ 64 | .not_valid_after(datetime.datetime.utcnow() + expires_in) \ 65 | .add_extension(x509.SubjectAlternativeName([x509.DNSName(hostname)]), critical=False) \ 66 | .add_extension(x509.BasicConstraints(ca=is_ca, path_length=None), critical=True) \ 67 | .sign(privkey, hashes.SHA256()) 68 | 69 | @classmethod 70 | def new(cls, mailbox, blurb, hostname, is_ca=False, additional_names=[], expires_in=default_expiry): 71 | """ Generate a new, self-signed identity. """ 72 | ob = cls.__new__(cls) 73 | 74 | ob._private = Identity._build_key() 75 | subject = Identity._build_name(mailbox, blurb, additional_names) 76 | ob._cert = Identity._build_cert(ob._private.public_key(), ob._private, subject, subject, hostname, is_ca, expires_in) 77 | 78 | return ob 79 | 80 | @classmethod 81 | def child_of(cls, parent, mailbox, blurb, additional_names=[], expires_in=default_expiry): 82 | """ Generate a child certificate, signed by a parent certificate. """ 83 | if not parent.is_ca(): raise TypeError("Parent certificate can't be used to sign children") 84 | if parent.is_peer(): raise TypeError("Parent certificate is missing a private key") 85 | 86 | ob = cls.__new__(cls) 87 | ob._private = Identity._build_key() 88 | subject = Identity._build_name(mailbox, blurb, additional_names) 89 | 90 | csr = x509.CertificateSigningRequestBuilder() \ 91 | .subject_name(subject) \ 92 | .sign(ob._private, hashes.SHA256()) 93 | 94 | ob._cert = Identity._build_cert( 95 | csr.public_key(), parent._private, 96 | subject, parent._cert.subject, parent.hostname(), 97 | is_ca=False, expires_in=expires_in 98 | ) 99 | 100 | return ob 101 | 102 | def as_pem(self, encryption=serialization.NoEncryption()): 103 | """ Serializes the Identity as PEM data. """ 104 | built = self._cert.public_bytes(serialization.Encoding.PEM) 105 | if self._private is not None: 106 | built += self._private.private_bytes( 107 | encoding=serialization.Encoding.PEM, 108 | format=serialization.PrivateFormat.TraditionalOpenSSL, 109 | encryption_algorithm=encryption 110 | ) 111 | 112 | return built 113 | 114 | # Ugly ugly ugly. 115 | # Note that these are hardcoded to the first found result for their attribute. 116 | # Misfin certs don't support multiple values for USER_ID and COMMON_NAME, and support for 117 | # multiple hostnames is possible but not implemented. 118 | def is_peer(self): 119 | return self._private is None 120 | def is_ca(self): 121 | return self._cert.extensions.get_extension_for_oid(ExtensionOID.BASIC_CONSTRAINTS).value.ca 122 | def hostname(self): 123 | return self._cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(x509.DNSName)[0] # ew 124 | def blurb(self): 125 | return self._cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value 126 | def mailbox(self): 127 | return self._cert.subject.get_attributes_for_oid(NameOID.USER_ID)[0].value 128 | def parent_blurb(self): 129 | return self._cert.issuer.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value 130 | def parent_mailbox(self): 131 | return self._cert.issuer.get_attributes_for_oid(NameOID.USER_ID)[0].value 132 | 133 | # Built addresses. 134 | def address(self): return self.mailbox() + "@" + self.hostname() 135 | def parent_address(self): return self.parent_mailbox() + "@" + self.hostname() 136 | 137 | # For TOFU. 138 | def fingerprint(self, hash_method=hashes.SHA256()): 139 | raw = self._cert.fingerprint(hash_method) 140 | return ":".join("%02x" % b for b in raw) 141 | 142 | 143 | # ---------- 144 | # Requests and responses. 145 | 146 | 147 | class Request: 148 | """ The basic unit of data transfer for Misfin. Here's some data, here's where it's going. """ 149 | 150 | def __init__(self, mailbox, host, subject, mime, body=None): 151 | self.mailbox = mailbox 152 | self.host = host 153 | self.subject = subject 154 | self.mime = mime 155 | self.body = body 156 | 157 | @classmethod 158 | def from_incoming(cls, req): 159 | """ Create a Request object from the client's greeting. """ 160 | ob = cls.__new__(cls) 161 | 162 | # Auto-convert from a bytes object, makes socket code a little cleaner 163 | if isinstance(req, bytes): req = req.decode("utf-8") 164 | 165 | # Maybe this isn't even a Misfin request... 166 | if not req.startswith("misfin://"): raise TypeError("Not a Misfin request") 167 | req = req.removeprefix("misfin://") 168 | 169 | # Make sure we have the whole request, and save any body that might have made it through 170 | if "\r\n" not in req: raise ValueError("Incomplete request") 171 | header, ob.body = req.split("\r\n", 1) 172 | 173 | try: 174 | # Split up the relevant bits of the header 175 | dest, ob.mime, ob.subject = header.split(" ", 2) 176 | ob.mailbox, ob.host = dest.split("@", 1) 177 | return ob 178 | except: 179 | raise ValueError("Malformed request") 180 | 181 | def build(self): 182 | """ Builds the Misfin request greeting. """ 183 | return "misfin://{}@{} {} {}\r\n".format(self.mailbox, self.host, self.mime, self.subject) 184 | 185 | def append_body(self, data): 186 | """ Appends more data to the message body - useful for large files. """ 187 | if isinstance(data, bytes): data = data.decode("utf-8") 188 | if self.body is None: self.body = data 189 | else: self.body += data 190 | 191 | # A Misfin server response - either a go ahead, or some flavor of error. 192 | class Response: 193 | """ Tells the client what to do - either a go ahead, or some flavor of error. """ 194 | 195 | # Handy error messages for a server to send. 196 | # Note that 20, 30, and 31 shouldn't use these messages, but they are included 197 | # here for completeness 198 | meta_tags = { 199 | 20: "message accepted", 200 | 201 | 30: "mailbox changed, look here", 202 | 31: "mailbox changed, look here (permanent)", 203 | 204 | 40: "temporary error", 205 | 41: "server is unavailable", 206 | 42: "cgi error", 207 | 43: "proxying error", 208 | 44: "slow down", 209 | 45: "mailbox full", 210 | 211 | 50: "permanent error", 212 | 51: "mailbox doesn't exist", 213 | 52: "mailbox has been removed", 214 | 53: "that domain isn't served here", 215 | 54: "filetype not allowed", 216 | 59: "bad request", 217 | 218 | 60: "certificate required", 219 | 61: "you can't send mail there", 220 | 62: "your certificate is invalid", 221 | 63: "you're lying about your certificate", 222 | 64: "prove it" 223 | } 224 | 225 | @classmethod 226 | def of(cls, status, meta=None): 227 | """ Build a Response object for a status code. """ 228 | ob = cls.__new__(cls) 229 | ob.status = str(status) 230 | if meta is None: ob.meta = Response.meta_tags[status] 231 | else: ob.meta = meta 232 | return ob 233 | 234 | # Some shortcuts for responses that actually use the meta tag 235 | def proceed(max_size): 236 | return Response.of(20, max_size) 237 | 238 | def redirect(to): 239 | return Response.of(30, to) 240 | 241 | def redirect_forever(to): 242 | return Response.of(31, to) 243 | 244 | @classmethod 245 | def from_server(cls, resp): 246 | """ Creates a Response object from the server's response. """ 247 | ob = cls.__new__(cls) 248 | 249 | # Auto-convert from a bytes object, makes socket code a little cleaner 250 | if isinstance(resp, bytes): resp = resp.decode("utf-8") 251 | 252 | try: 253 | ob.status, ob.meta = resp.split(" ", 1) 254 | return ob 255 | except: 256 | raise ValueError("Malformed response") 257 | 258 | def build(self): 259 | return "{} {}\r\n".format(self.status, self.meta) 260 | 261 | def __str__(self): 262 | return "{} {}".format(self.status, self.meta) 263 | 264 | def was_successful(self): return self.status[0] == "2" 265 | def send_max(self): return int(self.meta) 266 | 267 | def was_redirect(self): return self.status[0] == "3" 268 | def was_temporary_error(self): return self.status[0] == "4" 269 | def was_permanent_error(self): return self.status[0] == "5" 270 | def was_certificate_error(self): return self.status[0] == "6" 271 | 272 | 273 | # ---------- 274 | # Sending, receiving, and TLS. 275 | 276 | 277 | # Default port to communicate over. 278 | default_port = 1958 279 | 280 | # Maximum amount of data to accept - by default, anyway. 281 | max_request_size = 1024 282 | max_request_data = 1024 * 1024 283 | 284 | def _validate_nothing(conn, cert, err, depth, rtrn): 285 | """ Callback that lets us steal certificate verification from OpenSSL. """ 286 | """ This is !!!DANGEROUS!!! but necessary to allow us to accept self-signed certs. """ 287 | return True 288 | 289 | def send_as(sender, request, port=default_port, check_valid_method=_validate_nothing): 290 | """ Sends a Misfin message as a user. """ 291 | 292 | # For some reason, this block doesn't survive being moved to a separate function, so it's 293 | # repeated below in an ugly way. 294 | context = ossl.Context( ossl.TLS_CLIENT_METHOD ) 295 | context.set_verify( ossl.VERIFY_PEER | ossl.VERIFY_FAIL_IF_NO_PEER_CERT, callback=check_valid_method) 296 | context.use_certificate( ocrypt.X509.from_cryptography(sender._cert) ) 297 | context.use_privatekey( ocrypt.PKey.from_cryptography_key(sender._private) ) 298 | sock = ossl.Connection(context, socket.socket(socket.AF_INET, socket.SOCK_STREAM)) 299 | 300 | sock.connect((request.host, port)) 301 | sock.set_connect_state() 302 | sock.do_handshake() 303 | 304 | # Send the first bit of our message and see if the destination accepts. 305 | sock.write(bytes(request.build(), "utf-8")) 306 | response = Response.from_server(sock.read(max_request_size)) 307 | if not response.was_successful(): return response 308 | 309 | # They're happy, so send over the rest. 310 | if response.send_max() >= len(request.body): 311 | sock.sendall(bytes(request.body, 'utf-8')) 312 | else: 313 | raise ValueError("Message is {} bytes, but server only accepts {}".format(len(request.body), response.send_max())) 314 | 315 | # Skadoodle 316 | sock.shutdown() 317 | sock.close() 318 | return True 319 | 320 | def _allow_anything(server, peer, request): 321 | """ Callback that accepts any message to the server's mailbox. """ 322 | """ SCARY! Only use for testing. """ 323 | print("Incoming from {} ({})".format(peer.blurb(), peer.address())) 324 | print("Fingerprint is {}".format(peer.fingerprint())) 325 | print("Message type is {}, subject: {}".format(request.mime, request.subject)) 326 | 327 | if request.mailbox == server.mailbox(): 328 | return Response.proceed(max_request_data) 329 | else: 330 | print("...but we aren't {}, we're {}".format(server.mailbox(), request.mailbox)) 331 | return Response.of(51) 332 | 333 | def _echo_messages(server, peer, message): 334 | """ Callback that prints messages to the console, or bytes received for non-text mimetypes. """ 335 | """ Not really scary, but still just for testing. """ 336 | if message.mime.startswith("text/"): 337 | print(message.body) 338 | else: 339 | print("Content is {} bytes long - not printing though".format(len(message.body))) 340 | 341 | def receive_from(connection, server, peer, is_allowed_method, received_method): 342 | """ Receives a Misfin message from a client. """ 343 | # Do we want to receive this message? 344 | try: 345 | request = Request.from_incoming(connection.read(max_request_size)) 346 | response = is_allowed_method(server, peer, request) 347 | connection.write(bytes(response.build(), 'utf-8')) 348 | 349 | if response.was_successful(): 350 | # Get some bytes, but not too many. 351 | to_get = response.send_max() 352 | while to_get > 0: 353 | # The client should yeet the connection when they finish sending, so 354 | # catch that and interpret it as "we're done here". 355 | try: 356 | got = connection.recv(to_get) 357 | except ossl.Error: 358 | got = b"" 359 | if len(got) < 1: break 360 | request.append_body(got) 361 | to_get -= len(got) 362 | 363 | except Exception as err: 364 | # Something fucked up, be nice and tell the client. 365 | connection.write(bytes(Response.of(40).build(), "utf-8")) 366 | raise err 367 | 368 | # Skadoodle 369 | connection.shutdown() 370 | connection.close() 371 | 372 | # Call the callback, or! just handle the return 373 | received_method(server, peer, request) 374 | return request 375 | 376 | def receive_forever(server, is_allowed_method=_allow_anything, received_method=_echo_messages, check_valid_method=_validate_nothing, port=default_port): 377 | """ Receives Misfin messages, forever and ever. """ 378 | # See above. 379 | context = ossl.Context( ossl.TLS_SERVER_METHOD ) 380 | context.set_verify( ossl.VERIFY_PEER | ossl.VERIFY_FAIL_IF_NO_PEER_CERT, callback=check_valid_method) 381 | context.use_certificate( ocrypt.X509.from_cryptography(server._cert) ) 382 | context.use_privatekey( ocrypt.PKey.from_cryptography_key(server._private) ) 383 | sock = ossl.Connection(context, socket.socket(socket.AF_INET, socket.SOCK_STREAM)) 384 | 385 | sock.bind((server.hostname(), port)) 386 | sock.listen(3) 387 | 388 | while True: 389 | print("") 390 | try: 391 | # Set up a connection... 392 | connection, addr = sock.accept() 393 | connection.set_accept_state() 394 | connection.do_handshake() 395 | 396 | # ...and do something about it 397 | peer = Identity(connection.get_peer_certificate()) 398 | receive_from(connection, server, peer, is_allowed_method, received_method) 399 | 400 | except ossl.Error as err: 401 | print("Client disconnected before finishing.") 402 | 403 | except Exception as err: 404 | print(err) 405 | print("Aborting receive due to exception.") 406 | 407 | # ---------- 408 | # Stupid simple command-line interface. 409 | 410 | 411 | if __name__ == "__main__": 412 | 413 | # I wasn't kidding. 414 | def print_usage(): 415 | print("usage: python -m misfin_a [make-cert mailbox blurb hostname output.who]") 416 | print("usage: [cert-from parent.who mailbox blurb output.who]") 417 | print("usage: [send-as identity.who destination 'subject' 'message']") 418 | print("usage: [receive-as identity.who]") 419 | sys.exit(-1) 420 | 421 | try: 422 | command = sys.argv[1] 423 | 424 | if command == "make-cert": 425 | mailbox, blurb, hostname, output = sys.argv[2:] 426 | 427 | ident = Identity.new(mailbox, blurb, hostname, is_ca=True) 428 | with open(output, "wb") as dest: dest.write(ident.as_pem()) 429 | 430 | print("Generated cert for {} ({}) - saved to {}".format(ident.blurb(), ident.address(), output)) 431 | 432 | elif command == "cert-from": 433 | parent, mailbox, blurb, output = sys.argv[2:] 434 | 435 | loaded_pem = open(parent, "rb").read() 436 | parent_ident = Identity(loaded_pem, loaded_pem) 437 | ident = Identity.child_of(parent_ident, mailbox, blurb) 438 | with open(output, "wb") as dest: dest.write(ident.as_pem()) 439 | 440 | print("Generated cert for {} ({}), child of {} ({}) - saved to {}".format(ident.blurb(), ident.address(), ident.parent_blurb(), ident.parent_address(), output)) 441 | 442 | elif command == "send-as": 443 | sender, destination, subject, message = sys.argv[2:] 444 | mailbox, host = destination.split("@", 1) 445 | 446 | loaded_pem = open(sender, "rb").read() 447 | ident = Identity(loaded_pem, loaded_pem) 448 | msg = Request(mailbox, host, subject, mime="text/gemini", body=message) 449 | 450 | print(send_as(ident, msg)) 451 | 452 | elif command == "receive-as": 453 | loaded_pem = open(sys.argv[2], "rb").read() 454 | ident = Identity(loaded_pem, loaded_pem) 455 | 456 | print("Receiving for {} ({})".format(ident.blurb(), ident.address())) 457 | receive_forever(ident) 458 | 459 | except Exception as err: 460 | # Hehe 461 | raise err 462 | print(err) 463 | print_usage() 464 | -------------------------------------------------------------------------------- /old/misfin_original.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | import socket 5 | import datetime 6 | 7 | import OpenSSL.SSL as ossl 8 | import OpenSSL.crypto as ocrypt 9 | 10 | from cryptography import x509 11 | from cryptography.x509 import NameOID, ExtensionOID 12 | from cryptography.hazmat.primitives import serialization, hashes 13 | from cryptography.hazmat.primitives.asymmetric import rsa 14 | 15 | # _______ 16 | # |== []| misfin(b) protocol 17 | # | ==== | implemented in one file 18 | # '-------' lem (2023) 19 | 20 | # This is the first implementation. It is brittle and you shouldn't use it. 21 | 22 | # ---------- 23 | # Certificate handling. 24 | 25 | # A nice round number... 26 | default_expiry = datetime.timedelta(days=32768) 27 | 28 | class Identity: 29 | """ An identified user, either local (i.e. with a private key) or a peer. """ 30 | def __init__(self, cert, private=None, password=None): 31 | """ Load an Identity from certificate and key objects, or from PEM data. """ 32 | if isinstance(cert, bytes): 33 | self._cert = x509.load_pem_x509_certificate(cert) 34 | elif isinstance(cert, ocrypt.X509): 35 | self._cert = cert.to_cryptography() 36 | elif isinstance(cert, x509.Certificate): 37 | self._cert = cert 38 | else: 39 | raise TypeError("Can't load certificate") 40 | 41 | if isinstance(private, bytes): 42 | self._private = serialization.load_pem_private_key(private, password=password) 43 | elif isinstance(private, rsa.RSAPrivateKey) or private is None: 44 | self._private = private 45 | else: 46 | raise TypeError("Can't load private key") 47 | 48 | def _build_name(mailbox, blurb, additional_names=[]): 49 | """ Builds an x509 Name with the right format for a Misfin certificate. """ 50 | mandatory = [x509.NameAttribute(NameOID.USER_ID, mailbox), x509.NameAttribute(NameOID.COMMON_NAME, blurb)] 51 | return x509.Name(mandatory + additional_names) 52 | 53 | def _build_key(): 54 | """ Common method for building a private key. """ 55 | return rsa.generate_private_key(public_exponent=65537, key_size=2048) 56 | 57 | def _build_cert(pubkey, privkey, subject, issuer, hostname, is_ca, expires_in): 58 | """ Common method for building and signing an x509 certificate. """ 59 | return x509.CertificateBuilder() \ 60 | .subject_name(subject) \ 61 | .issuer_name(issuer) \ 62 | .public_key(pubkey) \ 63 | .serial_number(x509.random_serial_number()) \ 64 | .not_valid_before(datetime.datetime.utcnow()) \ 65 | .not_valid_after(datetime.datetime.utcnow() + expires_in) \ 66 | .add_extension(x509.SubjectAlternativeName([x509.DNSName(hostname)]), critical=False) \ 67 | .add_extension(x509.BasicConstraints(ca=is_ca, path_length=None), critical=True) \ 68 | .sign(privkey, hashes.SHA256()) 69 | 70 | @classmethod 71 | def new(cls, mailbox, blurb, hostname, is_ca=False, additional_names=[], expires_in=default_expiry): 72 | """ Generate a new, self-signed identity. """ 73 | ob = cls.__new__(cls) 74 | 75 | ob._private = Identity._build_key() 76 | subject = Identity._build_name(mailbox, blurb, additional_names) 77 | ob._cert = Identity._build_cert(ob._private.public_key(), ob._private, subject, subject, hostname, is_ca, expires_in) 78 | 79 | return ob 80 | 81 | @classmethod 82 | def child_of(cls, parent, mailbox, blurb, additional_names=[], expires_in=default_expiry): 83 | """ Generate a child certificate, signed by a parent certificate. """ 84 | if not parent.is_ca(): raise TypeError("Parent certificate can't be used to sign children") 85 | if parent.is_peer(): raise TypeError("Parent certificate is missing a private key") 86 | 87 | ob = cls.__new__(cls) 88 | ob._private = Identity._build_key() 89 | subject = Identity._build_name(mailbox, blurb, additional_names) 90 | 91 | csr = x509.CertificateSigningRequestBuilder() \ 92 | .subject_name(subject) \ 93 | .sign(ob._private, hashes.SHA256()) 94 | 95 | ob._cert = Identity._build_cert( 96 | csr.public_key(), parent._private, 97 | subject, parent._cert.subject, parent.hostname(), 98 | is_ca=False, expires_in=expires_in 99 | ) 100 | 101 | return ob 102 | 103 | def as_pem(self, encryption=serialization.NoEncryption()): 104 | """ Serializes the Identity as PEM data. """ 105 | built = self._cert.public_bytes(serialization.Encoding.PEM) 106 | if self._private is not None: 107 | built += self._private.private_bytes( 108 | encoding=serialization.Encoding.PEM, 109 | format=serialization.PrivateFormat.TraditionalOpenSSL, 110 | encryption_algorithm=encryption 111 | ) 112 | 113 | return built 114 | 115 | # Ugly ugly ugly. 116 | # Note that these are hardcoded to the first found result for their attribute. 117 | # Misfin certs don't support multiple values for USER_ID and COMMON_NAME, and support for 118 | # multiple hostnames is possible but not implemented. 119 | def is_peer(self): 120 | return self._private is None 121 | def is_ca(self): 122 | return self._cert.extensions.get_extension_for_oid(ExtensionOID.BASIC_CONSTRAINTS).value.ca 123 | def hostname(self): 124 | return self._cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(x509.DNSName)[0] # ew 125 | def blurb(self): 126 | return self._cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value 127 | def mailbox(self): 128 | return self._cert.subject.get_attributes_for_oid(NameOID.USER_ID)[0].value 129 | def parent_blurb(self): 130 | return self._cert.issuer.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value 131 | def parent_mailbox(self): 132 | return self._cert.issuer.get_attributes_for_oid(NameOID.USER_ID)[0].value 133 | 134 | # Built addresses. 135 | def address(self): return self.mailbox() + "@" + self.hostname() 136 | def parent_address(self): return self.parent_mailbox() + "@" + self.hostname() 137 | 138 | # For TOFU. 139 | def fingerprint(self, hash_method=hashes.SHA256()): 140 | raw = self._cert.fingerprint(hash_method) 141 | return ":".join("%02x" % b for b in raw) 142 | 143 | 144 | # ---------- 145 | # Requests and responses. 146 | 147 | 148 | class Request: 149 | """ The basic unit of data transfer for Misfin. Here's some data, here's where it's going. """ 150 | 151 | def __init__(self, mailbox, host, message): 152 | self.mailbox = mailbox 153 | self.host = host 154 | self.message = message 155 | 156 | @classmethod 157 | def from_incoming(cls, req): 158 | """ Create a Request object from the client's greeting. """ 159 | ob = cls.__new__(cls) 160 | 161 | # Auto-convert from a bytes object, makes socket code a little cleaner 162 | if isinstance(req, bytes): req = req.decode("utf-8") 163 | 164 | # Maybe this isn't even a Misfin request... 165 | if not req.startswith("misfin://"): raise TypeError("Not a Misfin request") 166 | req = req.removeprefix("misfin://") 167 | 168 | # Make sure we have the whole request 169 | if "\r\n" not in req: raise ValueError("Incomplete request") 170 | header, _ = req.split("\r\n", 1) 171 | 172 | try: 173 | # Split up the relevant bits of the header 174 | dest, ob.message= header.split(" ", 1) 175 | ob.mailbox, ob.host = dest.split("@", 1) 176 | return ob 177 | except: 178 | raise ValueError("Malformed request") 179 | 180 | def build(self): 181 | """ Builds the Misfin request. """ 182 | return "misfin://{}@{} {}\r\n".format(self.mailbox, self.host, self.message) 183 | 184 | # A Misfin server response - either a success, or some flavor of error. 185 | class Response: 186 | """ Tells the client what to do - either a go ahead, or some flavor of error. """ 187 | 188 | # Handy error messages for a server to send. 189 | # Note that 20, 30, and 31 shouldn't use these messages, but they are included 190 | # here for completeness 191 | meta_tags = { 192 | 20: "message accepted", 193 | 194 | 30: "mailbox changed, look here", 195 | 31: "mailbox changed, look here (permanent)", 196 | 197 | 40: "temporary error", 198 | 41: "server is unavailable", 199 | 42: "cgi error", 200 | 43: "proxying error", 201 | 44: "slow down", 202 | 45: "mailbox full", 203 | 204 | 50: "permanent error", 205 | 51: "mailbox doesn't exist", 206 | 52: "mailbox has been removed", 207 | 53: "that domain isn't served here", 208 | 59: "bad request", 209 | 210 | 60: "certificate required", 211 | 61: "you can't send mail there", 212 | 62: "your certificate is invalid", 213 | 63: "you're lying about your certificate", 214 | 64: "prove it" 215 | } 216 | 217 | @classmethod 218 | def of(cls, status, meta=None): 219 | """ Build a Response object for a status code. """ 220 | ob = cls.__new__(cls) 221 | ob.status = str(status) 222 | if meta is None: ob.meta = Response.meta_tags[status] 223 | else: ob.meta = meta 224 | return ob 225 | 226 | # Some shortcuts for responses that actually use the meta tag 227 | def delivered(fingerprint): 228 | return Response.of(20, fingerprint) 229 | 230 | def redirect(to): 231 | return Response.of(30, to) 232 | 233 | def redirect_forever(to): 234 | return Response.of(31, to) 235 | 236 | @classmethod 237 | def from_server(cls, resp): 238 | """ Creates a Response object from the server's response. """ 239 | ob = cls.__new__(cls) 240 | 241 | # Auto-convert from a bytes object, makes socket code a little cleaner 242 | if isinstance(resp, bytes): resp = resp.decode("utf-8") 243 | 244 | try: 245 | ob.status, ob.meta = resp.split(" ", 1) 246 | return ob 247 | except: 248 | raise ValueError("Malformed response") 249 | 250 | def build(self): 251 | return "{} {}\r\n".format(self.status, self.meta) 252 | 253 | def __str__(self): 254 | return "{} {}".format(self.status, self.meta) 255 | 256 | def was_successful(self): return self.status[0] == "2" 257 | def send_max(self): return int(self.meta) 258 | 259 | def was_redirect(self): return self.status[0] == "3" 260 | def was_temporary_error(self): return self.status[0] == "4" 261 | def was_permanent_error(self): return self.status[0] == "5" 262 | def was_certificate_error(self): return self.status[0] == "6" 263 | 264 | 265 | # ---------- 266 | # Sending, receiving, and TLS. 267 | 268 | 269 | # Default port to communicate over. 270 | default_port = 1958 271 | 272 | # Maximum amount of data to accept - by default, anyway. 273 | max_request_size = 2048 274 | 275 | def _validate_nothing(conn, cert, err, depth, rtrn): 276 | """ Callback that lets us steal certificate verification from OpenSSL. """ 277 | """ This is !!!DANGEROUS!!! but necessary to allow us to accept self-signed certs. """ 278 | return True 279 | 280 | def send_as(sender, request, port=default_port, check_valid_method=_validate_nothing): 281 | """ Sends a Misfin message as a user. """ 282 | 283 | # For some reason, this block doesn't survive being moved to a separate function, so it's 284 | # repeated below in an ugly way. 285 | context = ossl.Context( ossl.TLS_CLIENT_METHOD ) 286 | context.set_verify( ossl.VERIFY_PEER | ossl.VERIFY_FAIL_IF_NO_PEER_CERT, callback=check_valid_method) 287 | context.use_certificate( ocrypt.X509.from_cryptography(sender._cert) ) 288 | context.use_privatekey( ocrypt.PKey.from_cryptography_key(sender._private) ) 289 | sock = ossl.Connection(context, socket.socket(socket.AF_INET, socket.SOCK_STREAM)) 290 | 291 | sock.connect((request.host, port)) 292 | sock.set_connect_state() 293 | sock.do_handshake() 294 | 295 | # Send our message and see if the destination accepts. 296 | sock.write(bytes(request.build(), "utf-8")) 297 | response = Response.from_server(sock.read(max_request_size)) 298 | return response 299 | 300 | # Skadoodle 301 | sock.shutdown() 302 | sock.close() 303 | return True 304 | 305 | def _allow_anything(server, peer, request): 306 | """ Callback that accepts any message to the server's mailbox. """ 307 | """ SCARY! Only use for testing. """ 308 | print("Incoming from {} ({})".format(peer.blurb(), peer.address())) 309 | print("Fingerprint is {}".format(peer.fingerprint())) 310 | print("Message (sent to {}):".format(request.mailbox)) 311 | print("{}".format(request.message)) 312 | 313 | if request.mailbox == server.mailbox(): 314 | return Response.delivered(server.fingerprint()) 315 | else: 316 | print("...but we aren't {}, we're {}".format(request.mailbox, server.mailbox())) 317 | return Response.of(51) 318 | 319 | def receive_from(connection, server, peer, is_allowed_method): 320 | """ Receives a Misfin message from a client. """ 321 | # Do we want to receive this message? 322 | try: 323 | request = Request.from_incoming(connection.read(max_request_size)) 324 | response = is_allowed_method(server, peer, request) 325 | connection.write(bytes(response.build(), 'utf-8')) 326 | 327 | except Exception as err: 328 | # Something fucked up, be nice and tell the client before handling it. 329 | connection.write(bytes(Response.of(40).build(), "utf-8")) 330 | 331 | print("Error during receive - here's what we recovered:") 332 | print(peer.address()) 333 | print(request.message) 334 | 335 | raise err 336 | 337 | # Skadoodle 338 | connection.shutdown() 339 | connection.close() 340 | 341 | return request 342 | 343 | def receive_forever(server, is_allowed_method=_allow_anything, check_valid_method=_validate_nothing, port=default_port): 344 | """ Receives Misfin messages, forever and ever. """ 345 | # See above. 346 | context = ossl.Context( ossl.TLS_SERVER_METHOD ) 347 | context.set_verify( ossl.VERIFY_PEER | ossl.VERIFY_FAIL_IF_NO_PEER_CERT, callback=check_valid_method) 348 | context.use_certificate( ocrypt.X509.from_cryptography(server._cert) ) 349 | context.use_privatekey( ocrypt.PKey.from_cryptography_key(server._private) ) 350 | sock = ossl.Connection(context, socket.socket(socket.AF_INET, socket.SOCK_STREAM)) 351 | 352 | sock.bind((server.hostname(), port)) 353 | sock.listen(3) 354 | 355 | while True: 356 | print("") 357 | try: 358 | # Set up a connection... 359 | connection, addr = sock.accept() 360 | connection.set_accept_state() 361 | connection.do_handshake() 362 | 363 | print("Incoming connection at {}".format(datetime.datetime.utcnow())) 364 | 365 | # ...and do something about it 366 | peer = Identity(connection.get_peer_certificate()) 367 | receive_from(connection, server, peer, is_allowed_method) 368 | 369 | except ossl.Error as err: 370 | print(err) 371 | print("Client disconnected before sending a request.") 372 | 373 | except Exception as err: 374 | print(err) 375 | print("Aborting receive due to exception.") 376 | 377 | 378 | # ---------- 379 | # Stupid simple command-line interface. 380 | 381 | 382 | if __name__ == "__main__": 383 | 384 | # I wasn't kidding. 385 | def print_usage(): 386 | print("usage: python -m misfin_b [make-cert mailbox blurb hostname output.who]") 387 | print("usage: [cert-from parent.who mailbox blurb output.who]") 388 | print("usage: [send-as identity.who destination 'message']") 389 | print("usage: [receive-as identity.who]") 390 | sys.exit(-1) 391 | 392 | try: 393 | command = sys.argv[1] 394 | 395 | if command == "make-cert": 396 | mailbox, blurb, hostname, output = sys.argv[2:] 397 | 398 | ident = Identity.new(mailbox, blurb, hostname, is_ca=True) 399 | with open(output, "wb") as dest: dest.write(ident.as_pem()) 400 | 401 | print("Generated cert for {} ({}) - saved to {}".format(ident.blurb(), ident.address(), output)) 402 | 403 | elif command == "cert-from": 404 | parent, mailbox, blurb, output = sys.argv[2:] 405 | 406 | loaded_pem = open(parent, "rb").read() 407 | parent_ident = Identity(loaded_pem, loaded_pem) 408 | ident = Identity.child_of(parent_ident, mailbox, blurb) 409 | with open(output, "wb") as dest: dest.write(ident.as_pem()) 410 | 411 | print("Generated cert for {} ({}), child of {} ({}) - saved to {}".format(ident.blurb(), ident.address(), ident.parent_blurb(), ident.parent_address(), output)) 412 | 413 | elif command == "send-as": 414 | sender, destination, message = sys.argv[2:] 415 | mailbox, host = destination.split("@", 1) 416 | 417 | loaded_pem = open(sender, "rb").read() 418 | ident = Identity(loaded_pem, loaded_pem) 419 | 420 | msg = Request(mailbox, host, message) 421 | if len(msg.build()) > max_request_size: 422 | raise ValueError("Message is too long - needs to fit in {} bytes but totals {}".format(max_request_size, len(msg.build()))) 423 | 424 | print(send_as(ident, msg)) 425 | 426 | elif command == "receive-as": 427 | loaded_pem = open(sys.argv[2], "rb").read() 428 | ident = Identity(loaded_pem, loaded_pem) 429 | 430 | print("Receiving for {} ({})".format(ident.blurb(), ident.address())) 431 | receive_forever(ident) 432 | 433 | except Exception as err: 434 | # Hehe 435 | # raise err 436 | print(err) 437 | print_usage() 438 | -------------------------------------------------------------------------------- /old/spec_A.gmi: -------------------------------------------------------------------------------- 1 | # The Misfin mail protocol (prototype A) 2 | 3 | ## 1 Overview 4 | 5 | Misfin is a client-server mail transport protocol, broadly similar to SMTP and heavily influenced by Gemini. All connections follow the pattern request-response-proceed, and are closed at the end of this transaction. 6 | 7 | Misfin servers on TCP/IP should be bound to port 1958, which is unprivledged and should be accessible without administrator permissions on most systems. 8 | 9 | ### 1.1 Misfin transactions 10 | 11 | The sole type of Misfin transaction delivers a message from a client to a server. This transaction proceeds as so: 12 | 13 | C: Opens connection 14 | S: Accepts connection 15 | C/S: Complete TLS handshake 16 | C/S: Validate peer's certificate[s] 17 | C: Sends one CRLF terminated line - the request 18 | S: Sends one CRLF terminated line - the response 19 | C: If response is OK, sends the rest of the message to the server 20 | C: Closes connection, handling response if not OK 21 | 22 | Clients should, but are not obligated to, wait for the server to send the go-ahead to start sending message data. Both clients and servers should gracefully handle connections that close before they are expected to. 23 | 24 | ### 1.2 Misfin request scheme 25 | 26 | A single request line is sent by the Misfin client to a Misfin server, following this structure: 27 | 28 | @ 29 | 30 | MAILBOX is the user receiving the message. 31 | HOSTNAME is the domain name that points to the Misfin server. 32 | MIMETYPE is the MIME media type of the message, as per RFC 2046. 33 | SPACE is a single space character, i.e. the byte 0x20. 34 | 35 | The remainder of the request following the second SPACE, and up until the terminating CRLF, makes up the SUBJECT, which is (usually) a short description of the following message. All strings are UTF-8 encoded, and the entire request should not exceed 1024 bytes. 36 | 37 | By including the hostname of the server in the request, advanced Misfin servers can host mailboxes for multiple domains on the same host, ~or provide a relay service for mailboxes hosted on private servers.~ Servers are not required to implement this feature, and are only expected to service mail for a single domain. 38 | 39 | ### 1.3 Misfin response scheme 40 | 41 | The Misfin server will send a single response line to the requesting client, following this structure: 42 | 43 | 44 | 45 | STATUS is a two-digit numeric status code. 46 | META is a string whose meaning is defined by the status code. 47 | 48 | Like requests, response strings are UTF-8 encoded and should not exceed 1024 bytes in length. If the server does not respond with a status code in the "SUCCESS" range, it must close the connection immediately after sending its response. 49 | 50 | ### 1.4 Misfin data transfer scheme 51 | 52 | If the client receives a "SUCCESS" status, it means that the server is prepared to read their message. The default "SUCCESS" status is 20, and its META string is the maximum amount of data, in bytes, that the server will receive. 53 | 54 | The client should then start sending data to the server, up to the server's maximum size. A client should not send more data than the server asks for; a server must not receive any more data past its maximum size. 55 | 56 | Simple clients are allowed to ignore the server's response, and immediately follow their request with the message data; servers should not assume that the client will wait after sending the request. These simple clients should be prepared to handle the connection closing before they finish sending the message data, as servers are still obligated to close the connection after sending a non "SUCCESS" status or after receiving their maximum amount of data. 57 | 58 | ## 2 Status codes 59 | 60 | Misfin servers send a two-digit status code in their response to the client, which either gives it the go-ahead to send message data, or explains why the transfer is disallowed. The status code's category is indicated by the first digit, so simple clients only need to read the first character of the response to know, broadly, what it should do. 61 | 62 | These status codes are designed to be compatible with Gemini's, so developers comfortable with Gemini status codes should intuitively know the meaning of Misfin status codes. 63 | 64 | ### 2.1 1x (INPUT) 65 | 66 | These codes are reserved, and must not be sent by a Misfin server. 67 | 68 | ### 2.2 2x (SUCCESS) 69 | 70 | Status codes beginning with 2 are SUCCESS status codes, which mean that the client can begin sending message data to the server. 71 | 72 | ### 2.3 3x (REDIRECT) 73 | 74 | Status codes beginning with 3 are REDIRECT status codes, which tell the client to resend their request to a different Misfin server. 75 | 76 | ### 2.4 4x (TEMPORARY FAILURE) 77 | 78 | Status codes beginning with 4 are TEMPORARY FAILURE status codes, which mean the request did not succeed, but might succeed if retried in the future. 79 | 80 | ### 2.5 5x (PERMANENT FAILURE) 81 | 82 | Status codes beginning with 5 are PERMANENT FAILURE status codes, which mean the request did not succeed, and should not be retried. 83 | 84 | ### 2.6 6x (AUTHENTICATION FAILURE) 85 | 86 | Status codes beginning with 6 are AUTHENTICATION FAILURE status codes, which mean that there was an issue with the client's certificate. 87 | 88 | ## 3 TLS 89 | 90 | The use of TLS is mandatory for Misfin transactions. The use of Server Name Indication extensions is also mandatory, to facilitate name-based server identification. 91 | 92 | The minimum permissible version of TLS allowed for transactions is TLS 1.2, but clients and servers may choose a more recent version of TLS to support and disallow connections from earlier versions. 93 | 94 | Misfin clients and servers must send a TLS "close-notify" prior to closing the connection, so that a complete transaction can be distinguished from one that has ended prematurely. 95 | 96 | ### 3.1 Host and mailbox certificates 97 | 98 | Senders and recipients are identified via x509 certificates, sent as part of a Misfin transaction's TLS handshake. Host certificates are sent by Misfin servers and may be self-signed; mailbox certificates are sent by Misfin clients and must be signed by a host certificate. A simple Misfin server can serve a self-signed certificate that is its own host certificate, if a single mailbox is desired. 99 | 100 | Misfin certificates store data in the USER_ID and COMMON_NAME fields of both the subject and issuer Distinguished Names (DNs), as per RFC 4514. The USER_ID field stores the certificate's associated mailbox identifier. The COMMON_NAME field stores a human-readable description of the mailbox, or the mailbox owner's name/pseudonym. Other fields in the subject and issuer DNs can be present, but are not required to be present, and should not be relied on by Misfin utilities. 101 | 102 | A Misfin address is assembled from the subject's USER_ID and the certificate's SubjectAltName. 103 | 104 | A host certificate must have its CA constraint enabled, so that it can be used to cryptographically verify its mailbox certificates, or it should be signed by another, trusted CA. 105 | 106 | ### 3.2 Certificate validation 107 | 108 | Misfin clients and servers must send certificates during a transaction, but have no obligation to verify these certificates; however, this is highly, highly discouraged. 109 | 110 | Like Gemini, the default validation method for certificates is TOFU, or Trust on First Use. Misfin clients and servers should store the fingerprint of a received certificate the first time it is received, and subsequent certificates from that client or server should be matched against the stored fingerprint. 111 | 112 | Advanced Misfin servers may perform CA validation in addition to TOFU. In this scheme, upon receiving a message from a sender with an unrecognized host, the Misfin server may perform a single blank request to the sender's host, and store its certificate. That stored certificate can then be used to verify the certificates of senders purporting to be from that host. 113 | 114 | Certificates may be signed by other CAs; Misfin servers may choose to verify these signatures, but are not required to. 115 | 116 | ### 3.3 Security implications 117 | 118 | TOFU is a better-than-nothing strategy that should be suitable for most Misfin users; as long as you've successfully interacted with a legitimate sender once, future attempts to intercept or forge interactions with them will fail. 119 | 120 | CA validation is even better; you only need to have an uncompromised connection to a server once, and you can then verify the legitimacy of anyone reporting to have a mailbox with them. This method also has the effect of disallowing messages from senders that are not associated with a Misfin server, which might be desireable in some cases. 121 | 122 | Since the sender of a message is identified solely by their certificate, it is not possible to spoof the sender's address in a way that is not visible to the recipient. For instance, you could generate a new, self-signed certificate claiming to be bob@example.com, and send mail pretending to be Bob; however, any replies to those messages will be delivered to the Misfin server at example.com, and not to you. 123 | 124 | 125 | -------------------------------------------------------------------------------- /show-cert.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Shortcut that displays information about a Misfin cert. 4 | 5 | if [ $# -lt 1 ] 6 | then 7 | echo "usage: show-cert.sh " 8 | exit -1 9 | fi 10 | 11 | SCRAPED=$(openssl x509 -in $1 -subject -ext subjectAltName -noout -nameopt multiline) 12 | 13 | # This could prolly be replaced with an Awk one-liner, but my beard is not long enough yet :( 14 | BLURB=$(echo "$SCRAPED" | grep commonName | awk -F= '{sub(" ", "", $NF); print $NF}') 15 | MAILBOX=$(echo "$SCRAPED" | grep userId | awk -F= '{sub(" ", "", $NF); print $NF}') 16 | HOSTNAME=$(echo "$SCRAPED" | grep DNS | awk -F: '{print $NF}') 17 | 18 | if [ -z "$BLURB" ] || [ -z "$MAILBOX" ] || [ -z "$HOSTNAME" ] 19 | then 20 | echo "That doesn't look like a valid Misfin cert" 21 | else 22 | echo "$BLURB ($MAILBOX@$HOSTNAME)" 23 | fi 24 | -------------------------------------------------------------------------------- /specification.gmi: -------------------------------------------------------------------------------- 1 | # The Misfin mail protocol (prototype B) 2 | (updated 11 May 2023) 3 | (for comments, suggestions, well-wishes, insults, etc. - send a Misfin letter to rfc@misfin.org) 4 | 5 | ## 1 Overview 6 | 7 | Misfin is a client-server mail transport protocol, broadly similar to SMTP and heavily influenced by Gemini. All connections follow the pattern request-response, and are closed at the end of this transaction. 8 | 9 | Misfin servers on TCP/IP should be bound to port 1958, which is unprivledged and should be accessible without administrator permissions on most systems. 10 | 11 | ### 1.1 Misfin transactions 12 | 13 | The sole type of Misfin transaction delivers a message from a client to a server. This transaction proceeds as so: 14 | 15 | C: Opens connection 16 | S: Accepts connection 17 | C/S: Complete TLS handshake 18 | C/S: Validate peer's certificate[s] 19 | C: Sends one CRLF terminated line - the request 20 | S: Sends one CRLF terminated line - the response 21 | C/S: Closes connection, handling response if not OK 22 | 23 | Both clients and servers should gracefully handle connections that close before they are expected to. 24 | 25 | ### 1.2 Misfin request scheme 26 | 27 | A single request line is sent by the Misfin client to a Misfin server, following this structure: 28 | ``` 29 | misfin://@ 30 | ``` 31 | MAILBOX is the user receiving the message. 32 | HOSTNAME is the domain name that points to the Misfin server. 33 | SPACE is a single space character, i.e. the byte 0x20. 34 | 35 | The remainder of the request following the SPACE, and up until the terminating CRLF, makes up the MESSAGE, which is assumed to be Gemtext (text/gemini). All strings are UTF-8 encoded, and the entire request should not exceed 2048 bytes. 36 | 37 | By including the hostname of the server in the request, advanced Misfin servers can host mailboxes for multiple domains on the same host. Servers are not required to implement this feature, and are only expected to service mail for a single domain. 38 | 39 | ### 1.3 Misfin response scheme 40 | 41 | The Misfin server will send a single response line to the requesting client, following this structure: 42 | ``` 43 | 44 | ``` 45 | STATUS is a two-digit numeric status code. 46 | META is a string whose meaning is defined by the status code. 47 | 48 | Like requests, response strings are UTF-8 encoded and should not exceed 2048 bytes in length. After the response is sent, the transaction is finished, and the connection should be closed. 49 | 50 | ## 2 Status codes 51 | 52 | Misfin servers send a two-digit status code in their response to the client, which either confirms the message was delivered, or explains why it wasn't. The status code's category is indicated by the first digit, so simple clients only need to read the first character of the response to know, broadly, what it should do. 53 | 54 | These status codes are designed to be compatible with Gemini's, so developers comfortable with Gemini status codes should intuitively know the meaning of Misfin status codes. 55 | 56 | ### 2.1 1x (INPUT) 57 | These codes are reserved, and must not be sent by a Misfin server. 58 | 59 | ### 2.2 2x (SUCCESS) 60 | Status codes beginning with 2 are SUCCESS status codes, which mean that the client's message has been delivered. 61 | 62 | #### 20 - MESSAGE DELIVERED 63 | The message was delivered successfully. The META tag is the fingerprint of the recipient's certificate - see section 3.1. 64 | 65 | ### 2.3 3x (REDIRECT) 66 | 67 | Status codes beginning with 3 are REDIRECT status codes, which tell the client to resend their request to a different Misfin server. 68 | 69 | #### 30
- SEND HERE INSTEAD 70 | The mailbox has moved to a different address, and this message should be resent to that address. 71 | 72 | #### 31
- SEND HERE FOREVER 73 | The mailbox has moved to a different address, and all future messages should be sent to that address. 74 | 75 | ### 2.4 4x (TEMPORARY FAILURE) 76 | 77 | Status codes beginning with 4 are TEMPORARY FAILURE status codes, which mean the request did not succeed, but might succeed if retried in the future. 78 | 79 | #### 40 - TEMPORARY ERROR 80 | The mailserver experienced a transient issue, and the message should be resent. 81 | 82 | #### 41 - SERVER IS UNAVAILABLE 83 | The mailserver can't accept mail right now. 84 | 85 | #### 42 - CGI ERROR 86 | A mailserver script ran for your message, but experienced an error. 87 | 88 | #### 43 - PROXYING ERROR 89 | There was a problem accepting mail for that domain, but it might resolve itself. 90 | 91 | #### 44 - SLOW DOWN 92 | You are being rate limited - wait before trying to send more mail. 93 | 94 | #### 45 - MAILBOX FULL 95 | The mailbox isn't accepting mail right now, but it might in the future. 96 | 97 | ### 2.5 5x (PERMANENT FAILURE) 98 | 99 | Status codes beginning with 5 are PERMANENT FAILURE status codes, which mean the request did not succeed, and should not be retried. 100 | 101 | #### 50 - PERMANENT ERROR 102 | Something is wrong with the mailserver, and you should not try to resend your message. 103 | 104 | #### 51 - MAILBOX DOESN'T EXIST 105 | The mailbox you are trying to send to doesn't exist, and the mailserver won't accept your message. 106 | 107 | #### 52 - MAILBOX GONE 108 | The mailbox you are trying to send to existed once, but doesn't anymore. 109 | 110 | #### 53 - DOMAIN NOT SERVICED 111 | This mailserver doesn't serve mail for the hostname you provided. 112 | 113 | #### 59 - BAD REQUEST 114 | Your request is malformed, and won't be accepted by the mailserver. 115 | 116 | ### 2.6 6x (AUTHENTICATION FAILURE) 117 | 118 | Status codes beginning with 6 are AUTHENTICATION FAILURE status codes, which mean that there was an issue with the client's certificate. 119 | 120 | #### 60 - CERTIFICATE REQUIRED 121 | This mailserver doesn't accept anonymous mail, and you need to repeat your request with a certificate. 122 | 123 | #### 61 - UNAUTHORIZED SENDER 124 | Your certificate was validated, but you are not allowed to send mail to that mailbox. 125 | 126 | #### 62 - CERTIFICATE INVALID 127 | Your certificate might be legitimate, but it has a problem - it is expired, or it doesn't point to a valid Misfin identity, etc. 128 | 129 | #### 63 - YOU'RE A LIAR 130 | Your certificate matches an identity that the mailserver recognizes, but the fingerprint has changed, so it is rejecting your message. 131 | 132 | #### 64 - PROVE IT 133 | The mailserver needs you to complete a task to confirm that you are a legitimate sender. (This is reserved for a Hashcash style anti-spam measure). 134 | 135 | ## 3 TLS 136 | 137 | The use of TLS is mandatory for Misfin transactions. The minimum permissible version of TLS allowed for transactions is TLS 1.2, but clients and servers may choose a more recent version of TLS to support and disallow connections from earlier versions. 138 | 139 | Misfin clients and servers must send a TLS "close-notify" prior to closing the connection, so that a complete transaction can be distinguished from one that has ended prematurely. 140 | 141 | ### 3.1 Misfin identity certificates 142 | 143 | Senders and recipients are identified via self-signed x509 certificates, sent as part of a Misfin transaction's TLS handshake. Senders are not required to send a certificate, but are strongly urged to do so, and mailservers should require sender certificates (unless you really know what you are doing). 144 | 145 | A Misfin identity consists of a mailbox name, the hostname of the user's mailserver, and a human-readable description of the mailbox or user (the blurb). The mailbox and blurb are stored in the USER_ID and COMMON_NAME fields, respectively, of the certificate's Distinguished Name (as per RFC 4514). The hostname is stored as a DNS record in the certificate's SUBJECT_ALT_NAME extension, to be compatible with Server Name Indication (SNI). Other fields in the subject and issuer names can be present, but are not required to be present, and should not be relied on by Misfin utilities. 146 | 147 | A Misfin address is written as "mailbox@hostname", or "blurb (mailbox@hostname)" in longform. 148 | 149 | Multiple mailboxes can be serviced by a single mailserver; in this case, the mailserver's certificate should be self-signed, and each mailbox certificate should be signed by the mailserver certificate, so other clients and servers can confirm that those mailboxes actually exist on the mailserver. Mailserver certificates used this way should have the CA constraint set to True, so the mailserver certificate can cryptographically verify its mailbox certificates. 150 | 151 | The fingerprint of a Misfin certificate should be a SHA256 hash, and sent as a hexadecimal number without octet separators. Clients and servers should make an effort to normalize received fingerprints that don't match this specification, by lowering the case of the fingerprint or stripping out non-alphanumeric characters. 152 | 153 | ### 3.2 Certificate validation 154 | 155 | Misfin clients and servers send certificates during a transaction, but have no obligation to verify these certificates; however, this is highly, highly discouraged. 156 | 157 | Like Gemini, the default validation method for certificates is TOFU, or Trust on First Use. Misfin clients and servers should store the fingerprint of a received certificate the first time it is received, and subsequent certificates from that client or server should be matched against the stored fingerprint. 158 | 159 | Advanced Misfin servers may perform CA validation in addition to TOFU. In this scheme, upon receiving a message from a sender with an unrecognized host, the Misfin server may perform a single blank request to the sender's host, and store its certificate. That stored certificate can then be used to verify the certificates of senders purporting to be from that host. 160 | 161 | Certificates may be signed by other CAs; Misfin servers may choose to verify these signatures, but are not required to. 162 | 163 | ### 3.3 Security implications 164 | 165 | TOFU is a better-than-nothing strategy that should be suitable for most Misfin users; as long as you've successfully interacted with a legitimate sender once, future attempts to intercept or forge interactions with them will fail. 166 | 167 | CA validation is even better; you only need to have an uncompromised connection to a server once, and you can then verify the legitimacy of anyone reporting to have a mailbox with them. This method also has the effect of disallowing messages from senders that are not associated with a Misfin server, which might be desireable in some cases. 168 | 169 | Since the sender of a message is identified solely by their certificate, it is not possible to spoof the sender's address in a way that is not visible to the recipient. For instance, you could generate a new, self-signed certificate claiming to be bob@example.com, and send mail pretending to be Bob; however, any replies to those messages will be delivered to the Misfin server at example.com, and not to you. 170 | 171 | ## 4 Mail file format (gemmail) 172 | 173 | The default encoding of Misfin messages is text/gemini; however, Misfin extends this by adding three new line types. Misfin utilites must only parse the first occurance of these lines in a file. 174 | 175 | ### 4.1 Sender line 176 | 177 | The sender line records the Misfin address of the user that sent the message. Sender lines begin with a single "<" character, and have this syntax: 178 | ``` 179 | < mailbox@hostname.com blurb 180 | ``` 181 | The whitespace separating the < and the address is optional. The blurb is also optional, but if it is included, it must be separated from the address by whitespace. 182 | 183 | Sender lines should be added by the server when saving or retransmitting a message. If a message is forwarded, the original sender line should be preserved and sent alongside, so the final recipient will see both senders: 184 | ``` 185 | < development@mailing-lists.com Development mailing list 186 | < source@example.com Source user 187 | ... 188 | ``` 189 | 190 | ### 4.2 Recipients line 191 | 192 | The recipients line begins with a single ":" character, and denotes all receivers of a message, separated by whitespace: 193 | ``` 194 | : one@example.com two@example.com three@example.com ... 195 | ``` 196 | When replying to a Misfin message, it should be delivered to the address in the sender line (if present), followed by the addresses in the recipients line. Misfin clients are required to check for duplicate addresses, and not send multiple copies of the message to the same recipient. Misfin clients must also check to make sure they are not sending mail back to their sending address. 197 | 198 | A recipient line can added for messages going to only one person, but since replies go to the sending address anyway, this shouldn't be done - it's overkill. 199 | 200 | ### 4.3 Timestamp line 201 | 202 | This line type is a work in progress, but the intention is to record when the message was received - useful if your mailbox is implemented as a single text file (like UNIX back in the day) or when forwarding a message. 203 | 204 | Timestamp lines begin with a single "@" character, and are followed by the time in ISO-8601 format: 205 | ``` 206 | @ 2023-05-09T19:39:15Z 207 | ``` 208 | Like sender lines, timestamp lines should be added by the receiving mailserver, and only sent if forwarding a message, in which case they should be left as-is. 209 | 210 | ### 4.4 Message subject 211 | 212 | The first heading line in a file, if provided, should be considered as the message's subject, and advanced Misfin utilities may elect to show it to the user in place of the full message contents. 213 | 214 | ### 4.5 Example messages 215 | 216 | The simplest messages may just consist of a sender line: 217 | ``` 218 | < friend@example.com Your Friend 219 | 220 | What's up? 221 | ``` 222 | 223 | A group message can be sent with a recipients line: 224 | ``` 225 | < one@example.com Person One 226 | : two@example.com three@example.com 227 | 228 | A funny joke 229 | ``` 230 | ...and the replies will have the addresses shuffled around to make sense: 231 | ``` 232 | < two@example.com Person Two 233 | : one@example.com three@example.com 234 | 235 | Rolling on the floor laughing 236 | ``` 237 | 238 | A message from a mailing list might read: 239 | ``` 240 | < workers@hive.com Worker bees list 241 | < 33@hive.com Bee #33 242 | 243 | # A note on flowers 244 | 245 | The green snappy looking ones are venus flytraps and you SHOULD NOT interact 246 | ``` 247 | -------------------------------------------------------------------------------- /transponder/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JCLemme/misfin/d5b33016096fed6ec825df89acc48fcc45b150eb/transponder/__init__.py -------------------------------------------------------------------------------- /transponder/debug.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import datetime 4 | 5 | from transponder.identity import Identity, LocalIdentity 6 | from transponder.letter import Letter 7 | from transponder.misfin import Request, Response 8 | import transponder.misfin as tmm 9 | 10 | # transponder - development misfin client/server 11 | # debugging interface 12 | # lem (2023) 13 | 14 | # this is software in flux - don't rely on it 15 | 16 | 17 | def _allow_anything(server, peer, request): 18 | """ Callback that accepts any message to the server's mailbox. """ 19 | """ SCARY! Only use for testing. """ 20 | print("Incoming to {} from {}".format(request.recipient.mailbox, peer.longform())) 21 | print("Fingerprint is {}".format(peer.fingerprint)) 22 | print("Message:") 23 | print("{}".format(request.payload)) 24 | 25 | if request.recipient.mailbox == server.mailbox or request.recipient.mailbox in []: 26 | return Response.delivered(server.fingerprint) 27 | else: 28 | print("...but we aren't {}, we're {}".format(request.recipient.mailbox, server.mailbox)) 29 | return Response.of(51) 30 | 31 | 32 | 33 | if __name__ == "__main__": 34 | 35 | # I wasn't kidding. 36 | def print_usage(): 37 | print("usage: python -m transponder.debug...") 38 | print("usage: [make-cert mailbox blurb hostname output.who]") 39 | print("usage: [cert-from parent.who mailbox blurb output.who]") 40 | print("usage: [send-as identity.who destination 'message']") 41 | print("usage: [receive-as identity.who]") 42 | sys.exit(-1) 43 | 44 | try: 45 | command = sys.argv[1] 46 | 47 | if command == "make-cert": 48 | mailbox, blurb, hostname, output = sys.argv[2:] 49 | 50 | ident = LocalIdentity.new(mailbox, blurb, hostname, is_ca=True) 51 | with open(output, "wb") as dest: dest.write(ident.as_pem()) 52 | 53 | print("Generated cert for {} - saved to {}".format(ident.longform(), output)) 54 | 55 | elif command == "cert-from": 56 | parent, mailbox, blurb, output = sys.argv[2:] 57 | 58 | loaded_pem = open(parent, "rb").read() 59 | parent_ident = LocalIdentity(loaded_pem, loaded_pem) 60 | ident = LocalIdentity.child_of(parent_ident, mailbox, blurb) 61 | with open(output, "wb") as dest: dest.write(ident.as_pem()) 62 | 63 | print("Generated cert for {}, child of {} - saved to {}".format(ident.longform(), parent_ident.longform(), output)) 64 | 65 | elif command == "send-as": 66 | sender, destination, message = sys.argv[2:] 67 | 68 | loaded_pem = open(sender, "rb").read() 69 | ident = LocalIdentity(loaded_pem, loaded_pem) 70 | 71 | msg = Request(Identity(destination), message) 72 | 73 | print(tmm.send_as(ident, msg)) 74 | 75 | elif command == "send-file": 76 | sender, destination, mfile = sys.argv[2:] 77 | 78 | loaded_pem = open(sender, "rb").read() 79 | ident = LocalIdentity(loaded_pem, loaded_pem) 80 | 81 | message = open(mfile, "r").read() 82 | print(message) 83 | msg = Request(Identity(destination), message) 84 | 85 | print(tmm.send_as(ident, msg)) 86 | elif command == "receive-as": 87 | loaded_pem = open(sys.argv[2], "rb").read() 88 | ident = LocalIdentity(loaded_pem, loaded_pem) 89 | 90 | print("Receiving for {}".format(ident.longform())) 91 | tmm.receive_forever(ident, _allow_anything) 92 | 93 | except Exception as err: 94 | # Hehe 95 | #raise err 96 | print(type(err).__name__, err) 97 | print_usage() 98 | -------------------------------------------------------------------------------- /transponder/identity.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import datetime 4 | 5 | import OpenSSL.SSL as ossl 6 | import OpenSSL.crypto as ocrypt 7 | 8 | from cryptography import x509 9 | from cryptography.x509 import NameOID, ExtensionOID 10 | from cryptography.hazmat.primitives import serialization, hashes 11 | from cryptography.hazmat.primitives.asymmetric import rsa 12 | 13 | # transponder - development misfin client/server 14 | # identities and certificate handling 15 | # lem (2023) 16 | 17 | # this is software in flux - don't rely on it 18 | 19 | 20 | # A nice round number... 21 | default_expiry = datetime.timedelta(days=32768) 22 | 23 | 24 | class Identity: 25 | """ A Misfin identity - mailbox, mailserver, and blurb. """ 26 | 27 | def __init__(self, address, hostname=None, blurb=None, fingerprint=None): 28 | """ Make an identity, gracefully handling formatted addresses. """ 29 | self.blurb = blurb 30 | self.fingerprint = fingerprint 31 | 32 | if hostname is None: 33 | # Assume the address is complete 34 | self.mailbox, self.hostname = address.split("@", 1) 35 | else: 36 | self.mailbox = address 37 | self.hostname = hostname 38 | 39 | def address(self): 40 | """ Just the contact address. """ 41 | return "{}@{}".format(self.mailbox, self.hostname) 42 | 43 | def longform(self): 44 | """ A written form of the address that's easier to read. """ 45 | return "{} ({})".format(self.blurb, self.address()) 46 | 47 | def tofu(self): 48 | """ A format for storing the identity as text, vis-a-vis "trust on first use" validation. """ 49 | if self.fingerprint is None: 50 | raise ValueError("No fingerprint to store for validation") 51 | return "{} {} {}".format(self.address(), self.fingerprint, self.blurb) 52 | 53 | @classmethod 54 | def from_tofu(cls, raw): 55 | """ Rebuilds an Identity from a string built by the above. """ 56 | address, fingerprint, blurb = raw.split(" ", 2) 57 | return cls(address, fingerprint=fingerprint, blurb=blurb) 58 | 59 | 60 | class PeerIdentity(Identity): 61 | """ An Identity constructed from a Misfin certificate. """ 62 | 63 | def __init__(self, cert): 64 | """ Make a PeerIdentity, gracefully handling different cert types. """ 65 | if isinstance(cert, bytes): 66 | self._cert = x509.load_pem_x509_certificate(cert) 67 | elif isinstance(cert, ocrypt.X509): 68 | self._cert = cert.to_cryptography() 69 | elif isinstance(cert, x509.Certificate): 70 | self._cert = cert 71 | else: 72 | raise TypeError("Can't load certificate") 73 | 74 | # Extract the juicy deets - very, very ugly 75 | hostname = self._cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(x509.DNSName)[0] # ew 76 | blurb = self._cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value 77 | mailbox = self._cert.subject.get_attributes_for_oid(NameOID.USER_ID)[0].value 78 | 79 | # Pretty up the fingerprint 80 | fingerprint = "".join("%02x" % b for b in self._cert.fingerprint(hashes.SHA256())) 81 | 82 | super().__init__(mailbox, hostname, blurb, fingerprint) 83 | 84 | def is_ca(self): 85 | return self._cert.extensions.get_extension_for_oid(ExtensionOID.BASIC_CONSTRAINTS).value.ca 86 | 87 | def as_pem(self): 88 | """ Serializes the PeerIdentity as PEM data. """ 89 | return self._cert.public_bytes(serialization.Encoding.PEM) 90 | 91 | 92 | class LocalIdentity(PeerIdentity): 93 | """ An Identity that can send messages (i.e. has a private key). """ 94 | 95 | def __init__(self, cert, private, password=None): 96 | """ Make a LocalIdentity for an extant cert/key pair. """ 97 | if isinstance(private, bytes): 98 | self._private = serialization.load_pem_private_key(private, password=password) 99 | elif isinstance(private, rsa.RSAPrivateKey) or private is None: 100 | self._private = private 101 | else: 102 | raise TypeError("Can't load private key") 103 | 104 | super().__init__(cert) 105 | 106 | def as_pem(self, encrypt=serialization.NoEncryption()): 107 | cert_data = super().as_pem() 108 | return cert_data + self._private.private_bytes( 109 | encoding=serialization.Encoding.PEM, 110 | format=serialization.PrivateFormat.TraditionalOpenSSL, 111 | encryption_algorithm=encrypt 112 | ) 113 | 114 | # Below are methods for making new identities... 115 | def _build_name(mailbox, blurb, additional_names=[]): 116 | """ Builds an x509 Name with the right format for a Misfin certificate. """ 117 | mandatory = [x509.NameAttribute(NameOID.USER_ID, mailbox), x509.NameAttribute(NameOID.COMMON_NAME, blurb)] 118 | return x509.Name(mandatory + additional_names) 119 | 120 | def _build_key(): 121 | """ Common method for building a private key. """ 122 | return rsa.generate_private_key(public_exponent=65537, key_size=2048) 123 | 124 | def _build_cert(pubkey, privkey, subject, issuer, hostname, is_ca, expires_in): 125 | """ Common method for building and signing an x509 certificate. """ 126 | return x509.CertificateBuilder() \ 127 | .subject_name(subject) \ 128 | .issuer_name(issuer) \ 129 | .public_key(pubkey) \ 130 | .serial_number(x509.random_serial_number()) \ 131 | .not_valid_before(datetime.datetime.utcnow()) \ 132 | .not_valid_after(datetime.datetime.utcnow() + expires_in) \ 133 | .add_extension(x509.SubjectAlternativeName([x509.DNSName(hostname)]), critical=False) \ 134 | .add_extension(x509.BasicConstraints(ca=is_ca, path_length=None), critical=True) \ 135 | .sign(privkey, hashes.SHA256()) 136 | 137 | @classmethod 138 | def new(cls, mailbox, blurb, hostname, is_ca=False, additional_names=[], expires_in=default_expiry): 139 | """ Generate a new, self-signed identity. """ 140 | private = LocalIdentity._build_key() 141 | subject = LocalIdentity._build_name(mailbox, blurb, additional_names) 142 | cert = LocalIdentity._build_cert(private.public_key(), private, subject, subject, hostname, is_ca, expires_in) 143 | 144 | return cls(cert, private) 145 | 146 | @classmethod 147 | def child_of(cls, parent, mailbox, blurb, additional_names=[], expires_in=default_expiry): 148 | """ Generate a child certificate, signed by a parent certificate. """ 149 | if not parent.is_ca(): raise TypeError("Parent certificate can't be used to sign children") 150 | if not isinstance(parent, LocalIdentity): raise TypeError("Parent certificate is missing a private key") 151 | 152 | private = LocalIdentity._build_key() 153 | subject = LocalIdentity._build_name(mailbox, blurb, additional_names) 154 | 155 | csr = x509.CertificateSigningRequestBuilder() \ 156 | .subject_name(subject) \ 157 | .sign(private, hashes.SHA256()) 158 | 159 | cert = LocalIdentity._build_cert( 160 | csr.public_key(), parent._private, 161 | subject, parent._cert.subject, parent.hostname, 162 | is_ca=False, expires_in=expires_in 163 | ) 164 | 165 | return cls(cert, private) 166 | 167 | -------------------------------------------------------------------------------- /transponder/letter.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import datetime 4 | 5 | from transponder.identity import Identity, LocalIdentity 6 | from transponder.misfin import Request 7 | 8 | # transponder - development misfin client/server 9 | # misfin letter format ("gemmail") 10 | # lem (2023) 11 | 12 | # this is software in flux - don't rely on it 13 | 14 | class Letter: 15 | """ A Misfin letter, with the ability to parse gemmail lines. """ 16 | """ This class is still being worked on, but is a lot less nasty than it was """ 17 | 18 | def __init__(self, sender: LocalIdentity, recipients: list[Identity], message: str, received_at=datetime.datetime.now()): 19 | self.sender = sender 20 | self.recipients = recipients 21 | self.message = message 22 | self.received_at = received_at 23 | 24 | def _seek_and_destroy(message, linetype): 25 | """ Extracts out the first line of a given type from the message. """ 26 | found = None 27 | message_lines = message.splitlines() 28 | for idx, line in enumerate(message_lines): 29 | if len(line) > 0 and line[0] == linetype: 30 | found = line.removeprefix(linetype).strip() 31 | del message_lines[i] 32 | break 33 | 34 | return found, "\n".join(message_lines) 35 | 36 | def _extract_sender(message): 37 | found, message = super()._seek_and_destroy(message, "<") 38 | if found is None: return None, message 39 | 40 | sender = None 41 | contents = found.split(" ", 1) 42 | address = contents[0] 43 | 44 | if len(contents) > 1: blurb = contents[1] 45 | else: blurb = "" 46 | 47 | try: sender = Identity(address, blurb) 48 | except: pass 49 | 50 | return sender, message 51 | 52 | def _extract_recipients(message): 53 | found, message = super()._seek_and_destroy(message, ":") 54 | if found is None: return None, message 55 | 56 | recipients = [] 57 | for address in found: 58 | try: recipients.append(Identity(address)) 59 | except: pass 60 | 61 | return recipients, message 62 | 63 | def _extract_timestamp(message): 64 | found, message = super()._seek_and_destroy(message, "@") 65 | if found is None: return None, message 66 | 67 | try: timestamp = datetime.fromisoformat(found) 68 | except: timestamp = None 69 | 70 | return timestamp, message 71 | 72 | @classmethod 73 | def incoming(cls, sender: Identity, req: Request): 74 | """ Reassembles a Letter from an incoming request. """ 75 | found_recipients, message = cls._extract_recipients(req.payload) 76 | return cls(sender, [req.recipient] + found_recipients, message) 77 | 78 | @classmethod 79 | def load(cls, raw): 80 | """ Reassembles a Letter from a text file. """ 81 | recipients, raw = cls._extract_recipients(raw) 82 | sender, raw = cls._extract_sender(raw) 83 | received_at, raw = cls._extract_timestamp(raw) 84 | return cls(sender, recipients, raw, received_at) 85 | 86 | def build(self, include_timestamp=True, force_recipients=False): 87 | message = "< {} {}\n".format(self.sender.address(), self.sender.blurb) 88 | 89 | if force_recipients or len(self.recipients) > 1: 90 | message += ": " 91 | for a in self.recipients: message += "{} ".format(a.address()) 92 | message += "\n" 93 | 94 | if include_timestamp and isinstance(self.received_at, datetime.datetime): 95 | message += "@ {}\n".format(self.received_at.isoformat()) 96 | 97 | message += self.message 98 | return message 99 | -------------------------------------------------------------------------------- /transponder/misfin.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import socket 4 | import datetime 5 | 6 | from transponder.identity import Identity, PeerIdentity, LocalIdentity 7 | 8 | import OpenSSL.SSL as ossl 9 | import OpenSSL.crypto as ocrypt 10 | 11 | from cryptography import x509 12 | from cryptography.x509 import NameOID, ExtensionOID 13 | from cryptography.hazmat.primitives import serialization, hashes 14 | from cryptography.hazmat.primitives.asymmetric import rsa 15 | 16 | # transponder - development misfin client/server 17 | # misfin protocol requests, responses, and networking 18 | # lem (2023) 19 | 20 | # this is software in flux - don't rely on it 21 | 22 | 23 | max_request_size = 2048 24 | default_port = 1958 25 | 26 | 27 | class Request: 28 | """ A Misfin request - here's some data, here's where it's going. """ 29 | 30 | def __init__(self, recipient: Identity, payload: str): 31 | self.recipient = recipient 32 | self.payload = payload 33 | 34 | if len(self.build()) > 2048: 35 | raise ValueError("Request is too large to send ({} bytes)".format(len(self.build()))) 36 | 37 | def build(self): 38 | return "misfin://{} {}\r\n".format(self.recipient.address(), self.payload) 39 | 40 | @classmethod 41 | def incoming(cls, raw): 42 | """ Reassemble a Request sent by a client. """ 43 | # Auto-convert from a bytes object, makes socket code a little cleaner 44 | if isinstance(raw, bytes): raw = raw.decode("utf-8") 45 | 46 | # Maybe this isn't even a Misfin request... 47 | if not raw.startswith("misfin://"): raise TypeError("Not a Misfin request") 48 | raw = raw.removeprefix("misfin://") 49 | 50 | # Make sure we have the whole request 51 | if "\r\n" not in raw: raise ValueError("Incomplete request - didn't end with crlf") 52 | header, _ = raw.split("\r\n", 1) 53 | 54 | try: 55 | # Split up the relevant bits of the header 56 | address, payload = header.split(" ", 1) 57 | return cls(Identity(address), payload) 58 | except: 59 | raise ValueError("Malformed request") 60 | 61 | 62 | class Response: 63 | """ Tells the client what to do - either a go ahead, or some flavor of error. """ 64 | 65 | # Handy error messages for a server to send. 66 | # Note that 20, 30, and 31 shouldn't use these messages, but they are included 67 | # here for completeness 68 | meta_tags = { 69 | 20: "message accepted", 70 | 71 | 30: "mailbox changed, look here", 72 | 31: "mailbox changed, look here (permanent)", 73 | 74 | 40: "temporary error", 75 | 41: "server is unavailable", 76 | 42: "cgi error", 77 | 43: "proxying error", 78 | 44: "slow down", 79 | 45: "mailbox full", 80 | 81 | 50: "permanent error", 82 | 51: "mailbox doesn't exist", 83 | 52: "mailbox has been removed", 84 | 53: "that domain isn't served here", 85 | 59: "bad request", 86 | 87 | 60: "certificate required", 88 | 61: "you can't send mail there", 89 | 62: "your certificate is invalid", 90 | 63: "you're lying about your certificate", 91 | 64: "prove it" 92 | } 93 | 94 | @classmethod 95 | def of(cls, status, meta=None): 96 | """ Build a Response object for a status code. """ 97 | ob = cls.__new__(cls) 98 | ob.status = str(status) 99 | if meta is None: ob.meta = Response.meta_tags[status] 100 | else: ob.meta = meta 101 | return ob 102 | 103 | # Some shortcuts for responses that actually use the meta tag 104 | def delivered(fingerprint): 105 | return Response.of(20, fingerprint) 106 | 107 | def redirect(to): 108 | return Response.of(30, to) 109 | 110 | def redirect_forever(to): 111 | return Response.of(31, to) 112 | 113 | @classmethod 114 | def incoming(cls, resp): 115 | """ Creates a Response object from the server's response. """ 116 | ob = cls.__new__(cls) 117 | 118 | # Auto-convert from a bytes object, makes socket code a little cleaner 119 | if isinstance(resp, bytes): resp = resp.decode("utf-8") 120 | 121 | try: 122 | ob.status, ob.meta = resp.split(" ", 1) 123 | return ob 124 | except: 125 | raise ValueError("Malformed response") 126 | 127 | def build(self): 128 | return bytes("{} {}\r\n".format(self.status, self.meta), "utf-8") 129 | 130 | def __str__(self): 131 | return "{} {}".format(self.status, self.meta) 132 | 133 | def was_successful(self): return self.status[0] == "2" 134 | def was_redirect(self): return self.status[0] == "3" 135 | def was_temporary_error(self): return self.status[0] == "4" 136 | def was_permanent_error(self): return self.status[0] == "5" 137 | def was_certificate_error(self): return self.status[0] == "6" 138 | 139 | 140 | def _receive_line(conn, size=max_request_size, timeout=20, until=b"\r\n"): 141 | """ Receives a Misfin request/response, with configurable timeout etc. """ 142 | """ Note that this doesn't guarantee the received data will be valid... """ 143 | raw = b"" 144 | conn.settimeout(timeout) 145 | 146 | try: 147 | while len(raw) <= size and until not in raw: 148 | try: raw += conn.recv(size - len(raw)) 149 | except ossl.WantReadError: pass 150 | 151 | except socket.timeout: 152 | pass 153 | 154 | return raw 155 | 156 | 157 | def _validate_nothing(conn, cert, err, depth, rtrn): 158 | """ Callback that lets us steal certificate verification from OpenSSL. """ 159 | """ This is !!!DANGEROUS!!! but necessary to allow us to accept self-signed certs. """ 160 | return True 161 | 162 | 163 | def send_as(sender: LocalIdentity, req: Request, check_valid_method=_validate_nothing): 164 | """ Sends a Misfin message. """ 165 | # For some reason, this block doesn't survive being moved to a separate function, so it's 166 | # repeated below in an ugly way. 167 | context = ossl.Context( ossl.TLS_CLIENT_METHOD ) 168 | context.set_verify( ossl.VERIFY_PEER | ossl.VERIFY_FAIL_IF_NO_PEER_CERT, callback=check_valid_method) 169 | context.use_certificate( ocrypt.X509.from_cryptography(sender._cert) ) 170 | context.use_privatekey( ocrypt.PKey.from_cryptography_key(sender._private) ) 171 | sock = ossl.Connection(context, socket.socket(socket.AF_INET, socket.SOCK_STREAM)) 172 | 173 | sock.connect((req.recipient.hostname, default_port)) 174 | sock.set_connect_state() 175 | sock.do_handshake() 176 | 177 | # Send our message and see if the destination accepts. 178 | sock.sendall(req.build()) 179 | response = Response.incoming(_receive_line(sock)) 180 | 181 | # Skadoodle 182 | sock.shutdown() 183 | sock.close() 184 | return response 185 | 186 | 187 | def receive_from(conn, server: LocalIdentity, peer: PeerIdentity, on_letter_received): 188 | """ Receives a Misfin message from a client. """ 189 | # Do we want to receive this message? 190 | try: 191 | req = Request.incoming(_receive_line(conn)) 192 | resp = on_letter_received(server, peer, req) 193 | conn.sendall(resp.build()) 194 | 195 | except ossl.ZeroReturnError: 196 | # The client closed the connection intentionally. Carry on... 197 | pass 198 | 199 | except ossl.SysCallError: 200 | # Pretty sure this is also OK... 201 | pass 202 | 203 | except Exception as err: 204 | # Something fucked up, be nice and tell the client before handling it. 205 | conn.sendall(Response.of(40).build()) 206 | conn.shutdown() 207 | conn.close() 208 | 209 | print("Error during receive - here's what we recovered:") 210 | print(peer.address()) 211 | print(req.payload) 212 | 213 | raise err 214 | 215 | # Skadoodle 216 | conn.shutdown() 217 | conn.close() 218 | 219 | return req 220 | 221 | 222 | def receive_forever(server: LocalIdentity, on_letter_received, check_valid_method=_validate_nothing): 223 | """ Receives Misfin messages, forever and ever. """ 224 | # See above. 225 | context = ossl.Context( ossl.TLS_SERVER_METHOD ) 226 | context.set_verify( ossl.VERIFY_PEER | ossl.VERIFY_FAIL_IF_NO_PEER_CERT, callback=check_valid_method) 227 | context.use_certificate( ocrypt.X509.from_cryptography(server._cert) ) 228 | context.use_privatekey( ocrypt.PKey.from_cryptography_key(server._private) ) 229 | sock = ossl.Connection(context, socket.socket(socket.AF_INET, socket.SOCK_STREAM)) 230 | 231 | sock.bind((server.hostname, default_port)) 232 | sock.listen(3) 233 | 234 | while True: 235 | print("") 236 | try: 237 | # Set up a connection... 238 | conn, addr = sock.accept() 239 | conn.set_accept_state() 240 | conn.do_handshake() 241 | 242 | print("Incoming connection at {}".format(datetime.datetime.utcnow())) 243 | 244 | # ...and do something about it 245 | peer = PeerIdentity(conn.get_peer_certificate()) 246 | receive_from(conn, server, peer, on_letter_received) 247 | 248 | except Exception as err: 249 | #raise err 250 | print(type(err).__name__, err) 251 | print("Aborting due to exception.") 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | # Shhhhh don't look 260 | 261 | def do_via_tls(credentials: LocalIdentity, hostname: str, port: int, lo): 262 | """ Sets up a TLS client context, and hands it off to a more useful function. """ 263 | context = ossl.Context( ossl.TLS_CLIENT_METHOD ) 264 | context.set_verify( ossl.VERIFY_PEER | ossl.VERIFY_FAIL_IF_NO_PEER_CERT, callback=check_valid_method) 265 | context.use_certificate( ocrypt.X509.from_cryptography(credentials._cert) ) 266 | context.use_privatekey( ocrypt.PKey.from_cryptography_key(credentials._private) ) 267 | sock = ossl.Connection(context, socket.socket(socket.AF_INET, socket.SOCK_STREAM)) 268 | 269 | sock.connect((hostname, port)) 270 | sock.set_connect_state() 271 | sock.do_handshake() 272 | 273 | result = callback(sock) 274 | 275 | sock.shutdown() 276 | sock.close() 277 | return result 278 | -------------------------------------------------------------------------------- /transponder/send.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import argparse 4 | 5 | import transponder.identity 6 | import transponder.misfin 7 | 8 | # transponder - development misfin client/server 9 | # interactive message sender 10 | # lem (2023) 11 | 12 | # this is software in flux - don't rely on it 13 | 14 | 15 | if __name__ == "__main__": 16 | 17 | parser = argparse.ArgumentParser(prog="transponder.send", description="Sends a Misfin message, either from arguments or interactively") 18 | 19 | parser.add_argument("-a", "--from", dest="sender", help="Address or identity to send as") 20 | parser.add_argument("-t", "--to", dest="recipient", help="Recipient's address") 21 | parser.add_argument("-m", "--message", help="Message to send, or - to read from stdin") 22 | 23 | parser.add_argument("-c", "--confirm", action="store_true", help="Ask user before sending message") 24 | 25 | args = parser.parse_args() 26 | 27 | # --- 28 | 29 | if args.sender is None: 30 | args.sender = input("From (filename or address): ") 31 | 32 | if any(ext in args.sender for ext in [".who", ".cert", ".pem"]): 33 | try: 34 | raw_ident = open(args.sender, "rb").read() 35 | ident = transponder.identity.LocalIdentity(raw_ident, raw_ident) 36 | except Exception as err: 37 | print(err) 38 | print("Couldn't load that identity.") 39 | sys.exit(1) 40 | else: 41 | print("Stub: no maildir to load identity from yet.") 42 | sys.exit(1) 43 | 44 | 45 | if args.recipient is None: 46 | args.recipient = input("To: ") 47 | 48 | try: 49 | recipient = transponder.identity.Identity(args.recipient) 50 | except Exception as err: 51 | print(err) 52 | print("Can't send there - malformed address?") 53 | sys.exit(1) 54 | 55 | 56 | if args.message is None or args.message == "-": 57 | # Ugly way to hide prompt for users that plan on piping 58 | if args.message is None: print("Enter your message - hit Ctrl+D on a blank line to finish") 59 | args.message = sys.stdin.read() 60 | args.message = args.message.rstrip() 61 | 62 | try: 63 | req = transponder.misfin.Request(recipient, args.message) 64 | except Exception as err: 65 | print(err) 66 | print("Can't send that message.") 67 | sys.exit(1) 68 | 69 | 70 | if args.confirm: 71 | print("") 72 | print(f"Sending this message from {ident.longform()} to {recipient.address()}:") 73 | print(f"{req.payload}") 74 | 75 | if ["y", "yes"] not in input("Go ahead? [y/n]").lower(): 76 | print("Not sending.") 77 | sys.exit(1) 78 | 79 | 80 | resp = transponder.misfin.send_as(ident, req) 81 | 82 | if resp.was_successful(): 83 | print(f"Message delivered. Recipient fingerprint is {resp.meta}") 84 | sys.exit() 85 | elif resp.was_redirect(): 86 | print(f"Message bounced - try sending to {resp.meta}") 87 | sys.exit(1) 88 | else: 89 | print(f"Couldn't deliver message. Response was ({resp}).") 90 | sys.exit(1) 91 | --------------------------------------------------------------------------------