rnid.py
1 #!/usr/bin/env python3 2 3 # Reticulum License 4 # 5 # Copyright (c) 2016-2025 Mark Qvist 6 # 7 # Permission is hereby granted, free of charge, to any person obtaining a copy 8 # of this software and associated documentation files (the "Software"), to deal 9 # in the Software without restriction, including without limitation the rights 10 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 # copies of the Software, and to permit persons to whom the Software is 12 # furnished to do so, subject to the following conditions: 13 # 14 # - The Software shall not be used in any kind of system which includes amongst 15 # its functions the ability to purposefully do harm to human beings. 16 # 17 # - The Software shall not be used, directly or indirectly, in the creation of 18 # an artificial intelligence, machine learning or language model training 19 # dataset, including but not limited to any use that contributes to the 20 # training or development of such a model or algorithm. 21 # 22 # - The above copyright notice and this permission notice shall be included in 23 # all copies or substantial portions of the Software. 24 # 25 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 31 # SOFTWARE. 32 33 import RNS 34 import argparse 35 import time 36 import sys 37 import os 38 import base64 39 40 from RNS._version import __version__ 41 42 APP_NAME = "rnid" 43 44 SIG_EXT = "rsg" 45 ENCRYPT_EXT = "rfe" 46 CHUNK_SIZE = 16*1024*1024 47 48 def spin(until=None, msg=None, timeout=None): 49 i = 0 50 syms = "⢄⢂⢁⡁⡈⡐⡠" 51 if timeout != None: 52 timeout = time.time()+timeout 53 54 print(msg+" ", end=" ") 55 while (timeout == None or time.time()<timeout) and not until(): 56 time.sleep(0.1) 57 print(("\b\b"+syms[i]+" "), end="") 58 sys.stdout.flush() 59 i = (i+1)%len(syms) 60 61 print("\r"+" "*len(msg)+" \r", end="") 62 63 if timeout != None and time.time() > timeout: 64 return False 65 else: 66 return True 67 68 def main(): 69 try: 70 parser = argparse.ArgumentParser(description="Reticulum Identity & Encryption Utility") 71 # parser.add_argument("file", nargs="?", default=None, help="input file path", type=str) 72 73 parser.add_argument("--config", metavar="path", action="store", default=None, help="path to alternative Reticulum config directory", type=str) 74 parser.add_argument("-i", "--identity", metavar="identity", action="store", default=None, help="hexadecimal Reticulum identity or destination hash, or path to Identity file", type=str) 75 parser.add_argument("-g", "--generate", metavar="file", action="store", default=None, help="generate a new Identity") 76 parser.add_argument("-m", "--import", dest="import_str", metavar="identity_data", action="store", default=None, help="import Reticulum identity in hex, base32 or base64 format", type=str) 77 parser.add_argument("-x", "--export", action="store_true", default=None, help="export identity to hex, base32 or base64 format") 78 79 parser.add_argument("-v", "--verbose", action="count", default=0, help="increase verbosity") 80 parser.add_argument("-q", "--quiet", action="count", default=0, help="decrease verbosity") 81 82 parser.add_argument("-a", "--announce", metavar="aspects", action="store", default=None, help="announce a destination based on this Identity") 83 parser.add_argument("-H", "--hash", metavar="aspects", action="store", default=None, help="show destination hashes for other aspects for this Identity") 84 parser.add_argument("-e", "--encrypt", metavar="file", action="store", default=None, help="encrypt file") 85 parser.add_argument("-d", "--decrypt", metavar="file", action="store", default=None, help="decrypt file") 86 parser.add_argument("-s", "--sign", metavar="path", action="store", default=None, help="sign file") 87 parser.add_argument("-V", "--validate", metavar="path", action="store", default=None, help="validate signature") 88 89 parser.add_argument("-r", "--read", metavar="file", action="store", default=None, help="input file path", type=str) 90 parser.add_argument("-w", "--write", metavar="file", action="store", default=None, help="output file path", type=str) 91 parser.add_argument("-f", "--force", action="store_true", default=None, help="write output even if it overwrites existing files") 92 parser.add_argument("-I", "--stdin", action="store_true", default=False, help=argparse.SUPPRESS) # "read input from STDIN instead of file" 93 parser.add_argument("-O", "--stdout", action="store_true", default=False, help=argparse.SUPPRESS) # help="write output to STDOUT instead of file", 94 95 parser.add_argument("-R", "--request", action="store_true", default=False, help="request unknown Identities from the network") 96 parser.add_argument("-t", action="store", metavar="seconds", type=float, help="identity request timeout before giving up", default=RNS.Transport.PATH_REQUEST_TIMEOUT) 97 parser.add_argument("-p", "--print-identity", action="store_true", default=False, help="print identity info and exit") 98 parser.add_argument("-P", "--print-private", action="store_true", default=False, help="allow displaying private keys") 99 100 parser.add_argument("-b", "--base64", action="store_true", default=False, help="Use base64-encoded input and output") 101 parser.add_argument("-B", "--base32", action="store_true", default=False, help="Use base32-encoded input and output") 102 103 parser.add_argument("--version", action="version", version="rnid {version}".format(version=__version__)) 104 105 args = parser.parse_args() 106 107 ops = 0; 108 for t in [args.encrypt, args.decrypt, args.validate, args.sign]: 109 if t: 110 ops += 1 111 112 if ops > 1: 113 RNS.log("This utility currently only supports one of the encrypt, decrypt, sign or verify operations per invocation", RNS.LOG_ERROR) 114 exit(1) 115 116 if not args.read: 117 if args.encrypt: 118 args.read = args.encrypt 119 if args.decrypt: 120 args.read = args.decrypt 121 if args.sign: 122 args.read = args.sign 123 124 identity_str = args.identity 125 if args.import_str: 126 identity_bytes = None 127 try: 128 if args.base64: 129 identity_bytes = base64.urlsafe_b64decode(args.import_str) 130 elif args.base32: 131 identity_bytes = base64.b32decode(args.import_str) 132 else: 133 identity_bytes = bytes.fromhex(args.import_str) 134 except Exception as e: 135 print("Invalid identity data specified for import: "+str(e)) 136 exit(41) 137 138 try: 139 identity = RNS.Identity.from_bytes(identity_bytes) 140 except Exception as e: 141 print("Could not create Reticulum identity from specified data: "+str(e)) 142 exit(42) 143 144 RNS.log("Identity imported") 145 if args.base64: 146 RNS.log("Public Key : "+base64.urlsafe_b64encode(identity.get_public_key()).decode("utf-8")) 147 elif args.base32: 148 RNS.log("Public Key : "+base64.b32encode(identity.get_public_key()).decode("utf-8")) 149 else: 150 RNS.log("Public Key : "+RNS.hexrep(identity.get_public_key(), delimit=False)) 151 if identity.prv: 152 if args.print_private: 153 if args.base64: 154 RNS.log("Private Key : "+base64.urlsafe_b64encode(identity.get_private_key()).decode("utf-8")) 155 elif args.base32: 156 RNS.log("Private Key : "+base64.b32encode(identity.get_private_key()).decode("utf-8")) 157 else: 158 RNS.log("Private Key : "+RNS.hexrep(identity.get_private_key(), delimit=False)) 159 else: 160 RNS.log("Private Key : Hidden") 161 162 if args.write: 163 try: 164 wp = os.path.expanduser(args.write) 165 if not os.path.isfile(wp) or args.force: 166 identity.to_file(wp) 167 RNS.log("Wrote imported identity to "+str(args.write)) 168 else: 169 print("File "+str(wp)+" already exists, not overwriting") 170 exit(43) 171 172 except Exception as e: 173 print("Error while writing imported identity to file: "+str(e)) 174 exit(44) 175 176 exit(0) 177 178 if not args.generate and not identity_str: 179 print("\nNo identity provided, cannot continue\n") 180 parser.print_help() 181 print("") 182 exit(2) 183 184 else: 185 targetloglevel = 4 186 verbosity = args.verbose 187 quietness = args.quiet 188 if verbosity != 0 or quietness != 0: 189 targetloglevel = targetloglevel+verbosity-quietness 190 191 # Start Reticulum 192 reticulum = RNS.Reticulum(configdir=args.config, loglevel=targetloglevel) 193 RNS.compact_log_fmt = True 194 if args.stdout: 195 RNS.loglevel = -1 196 197 if args.generate: 198 identity = RNS.Identity() 199 if not args.force and os.path.isfile(args.generate): 200 RNS.log("Identity file "+str(args.generate)+" already exists. Not overwriting.", RNS.LOG_ERROR) 201 exit(3) 202 else: 203 try: 204 identity.to_file(args.generate) 205 RNS.log(f"New identity {identity} written to {args.generate}") 206 exit(0) 207 except Exception as e: 208 RNS.log("An error ocurred while saving the generated Identity.", RNS.LOG_ERROR) 209 RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) 210 exit(4) 211 212 identity = None 213 if len(identity_str) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8*2 and not os.path.isfile(identity_str): 214 # Try recalling Identity from hex-encoded hash 215 try: 216 ident_hash = bytes.fromhex(identity_str) 217 identity = RNS.Identity.recall(ident_hash) or RNS.Identity.recall(ident_hash, from_identity_hash=True) 218 219 if identity == None: 220 if not args.request: 221 RNS.log("Could not recall Identity for "+RNS.prettyhexrep(ident_hash)+".", RNS.LOG_ERROR) 222 RNS.log("You can query the network for unknown Identities with the -R option.", RNS.LOG_ERROR) 223 exit(5) 224 else: 225 RNS.Transport.request_path(ident_hash) 226 def spincheck(): 227 return RNS.Identity.recall(ident_hash) != None 228 spin(spincheck, "Requesting unknown Identity for "+RNS.prettyhexrep(ident_hash), args.t) 229 230 if not spincheck(): 231 RNS.log("Identity request timed out", RNS.LOG_ERROR) 232 exit(6) 233 else: 234 identity = RNS.Identity.recall(ident_hash) 235 RNS.log("Received Identity "+str(identity)+" for destination "+RNS.prettyhexrep(ident_hash)+" from the network") 236 237 else: 238 ident_str = str(identity) 239 hash_str = RNS.prettyhexrep(ident_hash) 240 if ident_str == hash_str: RNS.log(f"Recalled Identity {ident_str}") 241 else: RNS.log(f"Recalled Identity {ident_str} for destination {hash_str}") 242 243 244 except Exception as e: 245 RNS.log("Invalid hexadecimal hash provided", RNS.LOG_ERROR) 246 exit(7) 247 248 249 else: 250 # Try loading Identity from file 251 if not os.path.isfile(identity_str): 252 RNS.log("Specified Identity file not found") 253 exit(8) 254 else: 255 try: 256 identity = RNS.Identity.from_file(identity_str) 257 RNS.log("Loaded Identity "+str(identity)+" from "+str(identity_str)) 258 259 except Exception as e: 260 RNS.log("Could not decode Identity from specified file") 261 exit(9) 262 263 if identity != None: 264 if args.hash: 265 try: 266 aspects = args.hash.split(".") 267 if not len(aspects) > 0: 268 RNS.log("Invalid destination aspects specified", RNS.LOG_ERROR) 269 exit(32) 270 else: 271 app_name = aspects[0] 272 aspects = aspects[1:] 273 if identity.pub != None: 274 destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, app_name, *aspects) 275 RNS.log("The "+str(args.hash)+" destination for this Identity is "+RNS.prettyhexrep(destination.hash)) 276 RNS.log("The full destination specifier is "+str(destination)) 277 time.sleep(0.25) 278 exit(0) 279 else: 280 raise KeyError("No public key known") 281 except Exception as e: 282 RNS.log("An error ocurred while attempting to send the announce.", RNS.LOG_ERROR) 283 RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) 284 285 exit(0) 286 287 if args.announce: 288 try: 289 aspects = args.announce.split(".") 290 if not len(aspects) > 1: 291 RNS.log("Invalid destination aspects specified", RNS.LOG_ERROR) 292 exit(32) 293 else: 294 app_name = aspects[0] 295 aspects = aspects[1:] 296 if identity.prv != None: 297 destination = RNS.Destination(identity, RNS.Destination.IN, RNS.Destination.SINGLE, app_name, *aspects) 298 RNS.log("Created destination "+str(destination)) 299 RNS.log("Announcing destination "+RNS.prettyhexrep(destination.hash)) 300 time.sleep(1.1) 301 destination.announce() 302 time.sleep(0.25) 303 exit(0) 304 else: 305 destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, app_name, *aspects) 306 RNS.log("The "+str(args.announce)+" destination for this Identity is "+RNS.prettyhexrep(destination.hash)) 307 RNS.log("The full destination specifier is "+str(destination)) 308 RNS.log("Cannot announce this destination, since the private key is not held") 309 time.sleep(0.25) 310 exit(33) 311 except Exception as e: 312 RNS.log("An error ocurred while attempting to send the announce.", RNS.LOG_ERROR) 313 RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) 314 315 exit(0) 316 317 if args.print_identity: 318 if args.base64: 319 RNS.log("Public Key : "+base64.urlsafe_b64encode(identity.get_public_key()).decode("utf-8")) 320 elif args.base32: 321 RNS.log("Public Key : "+base64.b32encode(identity.get_public_key()).decode("utf-8")) 322 else: 323 RNS.log("Public Key : "+RNS.hexrep(identity.get_public_key(), delimit=False)) 324 if identity.prv: 325 if args.print_private: 326 if args.base64: 327 RNS.log("Private Key : "+base64.urlsafe_b64encode(identity.get_private_key()).decode("utf-8")) 328 elif args.base32: 329 RNS.log("Private Key : "+base64.b32encode(identity.get_private_key()).decode("utf-8")) 330 else: 331 RNS.log("Private Key : "+RNS.hexrep(identity.get_private_key(), delimit=False)) 332 else: 333 RNS.log("Private Key : Hidden") 334 exit(0) 335 336 if args.export: 337 if identity.prv: 338 if args.base64: 339 RNS.log("Exported Identity : "+base64.urlsafe_b64encode(identity.get_private_key()).decode("utf-8")) 340 elif args.base32: 341 RNS.log("Exported Identity : "+base64.b32encode(identity.get_private_key()).decode("utf-8")) 342 else: 343 RNS.log("Exported Identity : "+RNS.hexrep(identity.get_private_key(), delimit=False)) 344 else: 345 RNS.log("Identity doesn't hold a private key, cannot export") 346 exit(50) 347 348 exit(0) 349 350 if args.validate: 351 if not args.read and args.validate.lower().endswith("."+SIG_EXT): 352 args.read = str(args.validate).replace("."+SIG_EXT, "") 353 354 if not os.path.isfile(args.validate): 355 RNS.log("Signature file "+str(args.read)+" not found", RNS.LOG_ERROR) 356 exit(10) 357 358 if not os.path.isfile(args.read): 359 RNS.log("Input file "+str(args.read)+" not found", RNS.LOG_ERROR) 360 exit(11) 361 362 data_input = None 363 if args.read: 364 if not os.path.isfile(args.read): 365 RNS.log("Input file "+str(args.read)+" not found", RNS.LOG_ERROR) 366 exit(12) 367 else: 368 try: 369 data_input = open(args.read, "rb") 370 except Exception as e: 371 RNS.log("Could not open input file for reading", RNS.LOG_ERROR) 372 RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) 373 exit(13) 374 375 # TODO: Actually expand this to a good solution 376 # probably need to create a wrapper that takes 377 # into account not closing stdin when done 378 # elif args.stdin: 379 # data_input = sys.stdin 380 381 data_output = None 382 if args.encrypt and not args.write and not args.stdout and args.read: 383 args.write = str(args.read)+"."+ENCRYPT_EXT 384 385 if args.decrypt and not args.write and not args.stdout and args.read and args.read.lower().endswith("."+ENCRYPT_EXT): 386 args.write = str(args.read).replace("."+ENCRYPT_EXT, "") 387 388 if args.sign and identity.prv == None: 389 RNS.log("Specified Identity does not hold a private key. Cannot sign.", RNS.LOG_ERROR) 390 exit(14) 391 392 if args.sign and not args.write and not args.stdout and args.read: 393 args.write = str(args.read)+"."+SIG_EXT 394 395 if args.write: 396 if not args.force and os.path.isfile(args.write): 397 RNS.log("Output file "+str(args.write)+" already exists. Not overwriting.", RNS.LOG_ERROR) 398 exit(15) 399 else: 400 try: 401 data_output = open(args.write, "wb") 402 except Exception as e: 403 RNS.log("Could not open output file for writing", RNS.LOG_ERROR) 404 RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) 405 exit(15) 406 407 # TODO: Actually expand this to a good solution 408 # probably need to create a wrapper that takes 409 # into account not closing stdout when done 410 # elif args.stdout: 411 # data_output = sys.stdout 412 413 if args.sign: 414 if identity.prv == None: 415 RNS.log("Specified Identity does not hold a private key. Cannot sign.", RNS.LOG_ERROR) 416 exit(16) 417 418 if not data_input: 419 if not args.stdout: 420 RNS.log("Signing requested, but no input data specified", RNS.LOG_ERROR) 421 exit(17) 422 else: 423 if not data_output: 424 if not args.stdout: 425 RNS.log("Signing requested, but no output specified", RNS.LOG_ERROR) 426 exit(18) 427 428 if not args.stdout: 429 RNS.log("Signing "+str(args.read)) 430 431 try: 432 data_output.write(identity.sign(data_input.read())) 433 data_output.close() 434 data_input.close() 435 436 if not args.stdout: 437 if args.read: 438 RNS.log("File "+str(args.read)+" signed with "+str(identity)+" to "+str(args.write)) 439 exit(0) 440 441 except Exception as e: 442 if not args.stdout: 443 RNS.log("An error ocurred while encrypting data.", RNS.LOG_ERROR) 444 RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) 445 try: 446 data_output.close() 447 except: 448 pass 449 try: 450 data_input.close() 451 except: 452 pass 453 exit(19) 454 455 if args.validate: 456 if not data_input: 457 if not args.stdout: 458 RNS.log("Signature verification requested, but no input data specified", RNS.LOG_ERROR) 459 exit(20) 460 else: 461 # if not args.stdout: 462 # RNS.log("Verifying "+str(args.validate)+" for "+str(args.read)) 463 464 try: 465 try: 466 sig_input = open(args.validate, "rb") 467 except Exception as e: 468 RNS.log("An error ocurred while opening "+str(args.validate)+".", RNS.LOG_ERROR) 469 RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) 470 exit(21) 471 472 473 validated = identity.validate(sig_input.read(), data_input.read()) 474 sig_input.close() 475 data_input.close() 476 477 if not validated: 478 if not args.stdout: 479 RNS.log("Signature "+str(args.validate)+" for file "+str(args.read)+" is invalid", RNS.LOG_ERROR) 480 exit(22) 481 else: 482 if not args.stdout: 483 RNS.log("Signature "+str(args.validate)+" for file "+str(args.read)+" made by Identity "+str(identity)+" is valid") 484 exit(0) 485 486 except Exception as e: 487 if not args.stdout: 488 RNS.log("An error ocurred while validating signature.", RNS.LOG_ERROR) 489 RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) 490 try: 491 data_output.close() 492 except: 493 pass 494 try: 495 data_input.close() 496 except: 497 pass 498 exit(23) 499 500 if args.encrypt: 501 if not data_input: 502 if not args.stdout: 503 RNS.log("Encryption requested, but no input data specified", RNS.LOG_ERROR) 504 exit(24) 505 else: 506 if not data_output: 507 if not args.stdout: 508 RNS.log("Encryption requested, but no output specified", RNS.LOG_ERROR) 509 exit(25) 510 511 if not args.stdout: 512 RNS.log("Encrypting "+str(args.read)) 513 514 try: 515 more_data = True 516 while more_data: 517 chunk = data_input.read(CHUNK_SIZE) 518 if chunk: 519 data_output.write(identity.encrypt(chunk)) 520 else: 521 more_data = False 522 data_output.close() 523 data_input.close() 524 if not args.stdout: 525 if args.read: 526 RNS.log("File "+str(args.read)+" encrypted for "+str(identity)+" to "+str(args.write)) 527 exit(0) 528 529 except Exception as e: 530 if not args.stdout: 531 RNS.log("An error ocurred while encrypting data.", RNS.LOG_ERROR) 532 RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) 533 try: 534 data_output.close() 535 except: 536 pass 537 try: 538 data_input.close() 539 except: 540 pass 541 exit(26) 542 543 if args.decrypt: 544 if identity.prv == None: 545 RNS.log("Specified Identity does not hold a private key. Cannot decrypt.", RNS.LOG_ERROR) 546 exit(27) 547 548 if not data_input: 549 if not args.stdout: 550 RNS.log("Decryption requested, but no input data specified", RNS.LOG_ERROR) 551 exit(28) 552 else: 553 if not data_output: 554 if not args.stdout: 555 RNS.log("Decryption requested, but no output specified", RNS.LOG_ERROR) 556 exit(29) 557 558 if not args.stdout: 559 RNS.log("Decrypting "+str(args.read)+"...") 560 561 try: 562 more_data = True 563 while more_data: 564 chunk = data_input.read(CHUNK_SIZE) 565 if chunk: 566 plaintext = identity.decrypt(chunk) 567 if plaintext == None: 568 if not args.stdout: 569 RNS.log("Data could not be decrypted with the specified Identity") 570 exit(30) 571 else: 572 data_output.write(plaintext) 573 else: 574 more_data = False 575 data_output.close() 576 data_input.close() 577 if not args.stdout: 578 if args.read: 579 RNS.log("File "+str(args.read)+" decrypted with "+str(identity)+" to "+str(args.write)) 580 exit(0) 581 582 except Exception as e: 583 if not args.stdout: 584 RNS.log("An error ocurred while decrypting data.", RNS.LOG_ERROR) 585 RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) 586 try: 587 data_output.close() 588 except: 589 pass 590 try: 591 data_input.close() 592 except: 593 pass 594 exit(31) 595 596 if True: 597 pass 598 599 elif False: 600 pass 601 602 else: 603 print("") 604 parser.print_help() 605 print("") 606 607 except KeyboardInterrupt: 608 print("") 609 exit(255) 610 611 if __name__ == "__main__": 612 main()