moss

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

moss.rb (10721B)


      1 #!/usr/bin/env ruby
      2 require 'English'
      3 require "pathname"
      4 require "tempfile"
      5 require "json"
      6 
      7 # don't change these lines without changing the sed commands in Makefile
      8 AGE='age'
      9 AGE_KEYGEN='age-keygen'
     10 GIT='git'
     11 QRENCODE='qrencode'
     12 
     13 def xdg_data_home
     14   ENV.fetch("XDG_DATA_HOME", "#{ENV['HOME']}/.local/share")
     15 end
     16 
     17 class Moss
     18   MossError = Class.new(RuntimeError)
     19   attr_reader :root
     20 
     21   def initialize(root)
     22     @root = Pathname.new(root)
     23   end
     24 
     25   def encrypted?(filename)
     26     magic = "age-encryption.org/v1"
     27     File.read(filename, magic.length) == magic
     28   end
     29 
     30   # this works like backticks, but can be passed an array instead of a
     31   # string, thus avoiding shell quoting pitfalls
     32   private def capture_output(command)
     33     if command.respond_to?(:first) then command = command.map(&:to_s) end
     34     IO.popen(command, &:read)
     35   end
     36 
     37   def pubkey_for_identity(filename)
     38     pubkey =
     39       if encrypted?(filename)
     40         plaintext = capture_output([AGE, "-d", filename])
     41         IO.popen([AGE_KEYGEN, "-y", "-"], "r+") do |f|
     42           f.write(plaintext)
     43           f.close_write
     44           f.read
     45         end
     46       else
     47         capture_output([AGE_KEYGEN, "-y", filename.to_s])
     48       end
     49     if $CHILD_STATUS.exitstatus.zero?
     50       pubkey
     51     else
     52       raise MossError, "can't get public key from identity #{filename}"
     53     end
     54   end
     55 
     56   # This is the method that "moss init" calls, which could be better
     57   # named. "initialize" would be a good name if it weren't special to
     58   # Ruby
     59   def create(keyfile)
     60     keyfile.exist? or raise MossError, "Cannot read identity at #{keyfile}"
     61     recipient = pubkey_for_identity(keyfile)
     62     store.mkpath
     63     FileUtils.cp(keyfile, store.parent.join("identity"))
     64     File.open(store.join(".recipients"), "w") do |f|
     65       f.write recipient
     66     end
     67   end
     68 
     69   def store
     70     root.join("store")
     71   end
     72 
     73   def identity_file
     74     root.join("identity")
     75   end
     76 
     77   def identity_stream
     78     unless @identity
     79       if encrypted?(identity_file)
     80         @identity = Tempfile.new('id')
     81         @identity.unlink
     82         Kernel.system(AGE, "-d", identity_file.to_s, { 1 => @identity.fileno })
     83       else
     84         @identity = File.open(identity_file)
     85       end
     86     end
     87     @identity.tap(&:rewind)
     88   end
     89 
     90   def git_managed?
     91     store.join(".git").exist?
     92   end
     93 
     94   # ascend the directory hierarchy looking for the filename,
     95   # stopping at the store directory. this method is badly named
     96   private def find_in_subtree(subtree, filename)
     97     pathname = subtree.join(filename)
     98     if pathname.readable?
     99       pathname
    100     elsif subtree.to_s > store.to_s
    101       find_in_subtree(subtree.parent, filename)
    102     end
    103   end
    104 
    105   private def recipients_for_secret(name)
    106     pathname = store.join("#{name}.age")
    107     find_in_subtree(pathname.parent, ".recipients") or
    108       raise MossError, "Can't find .recipients for #{name}"
    109   end
    110 
    111   def write_secret(name, content, overwrite: false)
    112     pathname = store.join("#{name}.age")
    113     if  pathname.exist? && !overwrite
    114       raise MossError,"#{pathname.to_s} exists, use --force to overwrite"
    115     end
    116     pathname.dirname.mkpath
    117     IO.popen([AGE,
    118               "-a", "--recipients-file", recipients_for_secret(name).to_s,
    119               "-o", pathname.to_s], "w") do |f|
    120       f.write(content)
    121     end
    122     if git_managed?
    123       Kernel.system(GIT, "add", pathname.relative_path_from(store).to_s,
    124                     {chdir: MOSS.store})
    125       Kernel.system(GIT, "commit", "-m", "new secret",
    126                     {chdir: MOSS.store})
    127     end
    128   end
    129 
    130   def read_secret(name)
    131     pathname = store.join("#{name}.age")
    132     File.exist?(pathname) or
    133       raise MossError, "Can't open non-existent file #{pathname}"
    134 
    135     IO.pipe do |r_io, w_io|
    136       pid = Kernel.spawn(AGE, "-i", "-", "-d", pathname.to_s,
    137                          { 0 => identity_stream.fileno,
    138                            1 => w_io.fileno
    139                          })
    140       w_io.close
    141       Process.waitpid(pid)
    142       r_io.read
    143     end
    144   end
    145 
    146   def remove_secret(name)
    147     pathname = store.join("#{name}.age")
    148     File.exist?(pathname) or raise MossError, "Can't remove non-existent file #{pathname}"
    149     pathname.delete
    150      if git_managed?
    151        Kernel.system(GIT, "rm", pathname.relative_path_from(store).to_s,
    152                      {chdir: MOSS.store})
    153        Kernel.system(GIT, "commit", "-m", "removed secret",
    154                      {chdir: MOSS.store})
    155      end
    156   end
    157 
    158   def rebuild_store(verbose:)
    159     secrets.each do |name|
    160       warn name if verbose
    161       write_secret(name, read_secret(name), overwrite: true)
    162     end
    163   end
    164 
    165   def secrets
    166     Dir[store.join("**/*.age")].map { |n|
    167       Pathname.new(n).relative_path_from(store).sub_ext("").to_s
    168     }
    169   end
    170 
    171   def config
    172     {
    173       store: store.to_s,
    174       identity_file: identity_file.to_s,
    175       git: git_managed?
    176     }
    177   end
    178 
    179   def git_operation(parameters)
    180     if git_managed? || parameters.first == "init"
    181       Kernel.system(GIT, *parameters, { chdir: store })
    182     else
    183       raise MossError, "not a git repo"
    184     end
    185   end
    186 end
    187 
    188 MOSS = Moss.new(ENV["MOSS_HOME"] || "#{xdg_data_home}/moss")
    189 
    190 def random_alnum(length)
    191   bytes = File.open("/dev/urandom", "rb") do |random|
    192     random.read(length).unpack("C*").map { |c|
    193       c = c % 62
    194       if c < 26
    195         ("A".ord + c)
    196       elsif c < 52
    197         ("a".ord + (c - 26))
    198       else
    199         ("0".ord + (c - 52))
    200       end
    201     }
    202   end
    203   bytes.pack("C*")
    204 end
    205 
    206 class CLI
    207   class << self
    208     # This would be more robust with class instance variables instead
    209     # of class variables as used here, because different subclasses
    210     # will share this hash.
    211 
    212     @@commands = {}
    213 
    214     def command(command_name, docstring, &blk)
    215       define_method command_name, &blk
    216       @@commands[command_name] = { name: command_name, doc: docstring }
    217     end
    218 
    219     def link(ends)
    220       dest, src = ends.first
    221       @@commands[dest] = { name: @@commands[src][:name] }
    222     end
    223   end
    224 
    225   def parse_arguments(argv)
    226     name = argv.first.to_sym
    227     args = argv.drop(1)
    228 
    229     begin
    230       command = @@commands.fetch(name)
    231     rescue KeyError
    232       raise UsageError,"moss: unrecognised command. See \"moss help\""
    233     end
    234 
    235     method_signature = method(command[:name]).parameters
    236 
    237     return [name, []] if method_signature.empty?
    238 
    239     # The method we dispatch to expects either a varargs (splat)
    240     # array, or a hash in which every argument is named. So if there
    241     # is a method blah(peter:, paul:, mary:) and the user runs
    242     # `blah jones tall --mary=contrary`, first we look for
    243     # the named arguments (mary => contrary),  then we assign the remaining
    244     # parameters (peter => jones, paul => tall) from the argument array
    245     # in the order they appear in the method signature
    246 
    247     # (I've never used JCL, but I was once an Amiga programmer)
    248 
    249     flags = argv.reduce({}) {|m, flag|
    250       key, val= flag.match(/\A--(\w+)(?:=(.+))?/)&.captures
    251       key ?
    252         m.merge(key.to_sym => (val || :key_present)) :
    253         m
    254     }
    255 
    256     payload = method_signature.reduce({}) { |m, (type, name)|
    257       case type
    258       when :rest
    259         args
    260       when :keyreq
    261         args.first ? m.merge(name => args.shift) : m
    262       when :key
    263         m
    264       end
    265     }
    266     [command[:name],
    267      payload.respond_to?(:merge) ? flags.merge(payload) : payload]
    268   end
    269 
    270   def params_to_s(meth)
    271     meth.parameters.map { |(type, name)|
    272       case type
    273       when :req, :keyreq
    274         "<#{name}>"
    275       when :rest
    276         "<#{name}...>"
    277       when :key
    278         "[--#{name}]"
    279       end
    280     }.join(" ")
    281   end
    282 
    283   def describe_usage(method_name)
    284     param_string = params_to_s(method(method_name))
    285     "#{method_name} #{param_string}"
    286   end
    287 
    288   UsageError = Class.new(Moss::MossError)
    289 
    290   def dispatch(argv)
    291     name, payload = parse_arguments(argv)
    292     begin
    293       if payload.respond_to?(:keys)
    294         public_send(name,  **payload)
    295       else
    296         public_send(name,  *payload)
    297       end
    298     rescue ArgumentError
    299       raise UsageError, "usage: moss #{argv.first} #{params_to_s(method(name))}"
    300     end
    301   end
    302 
    303   def usage
    304     command_texts = @@commands.map { |name, command|
    305       if command[:doc]
    306         format "  %-40s - %s",
    307                describe_usage(name),
    308                command[:doc]
    309       else
    310         nil
    311       end
    312     }.compact
    313 
    314     usage_header + command_texts.join("\n")
    315   end
    316 end
    317 
    318 def cli
    319   cli = CLI.new
    320   class << cli
    321     def usage_header
    322       "Store and retrieve encrypted secrets\n\n" +
    323         "Usage: moss [command] [parameters]...\n\n"
    324     end
    325     command :generate, "generate a random secret" do |filename:, length:, force: false|
    326       secret = random_alnum(length.to_i)
    327       print secret
    328       MOSS.write_secret(filename, secret, overwrite: force)
    329     end
    330 
    331     command :add, "add a secret to the store (reads from standard input)" do |filename:, force: false|
    332       secret = $stdin.read
    333       MOSS.write_secret(filename, secret, overwrite: force)
    334     end
    335     link :insert => :add
    336 
    337     command :show, "display a secret" do |file:|
    338       $stdout.write(MOSS.read_secret(file))
    339     end
    340     link :cat => :show
    341 
    342     command :qr, "display QR code for a secret" do |file:|
    343       secret = MOSS.read_secret(file)
    344       IO.popen([QRENCODE,"-t", "ansiutf8"], "w") do |f|
    345         f.write(secret)
    346       end
    347     end
    348 
    349     command :search, "search secrets with names matching term" do |*term|
    350       files = MOSS.secrets.filter { |f|
    351         f.match(Regexp.new(term.join(" ")))
    352       }
    353       files.each do |n|
    354         puts n
    355       end
    356     end
    357     link :list => :search
    358 
    359     command :edit, "edit a secret" do |file:|
    360       content = MOSS.read_secret(file)
    361       Tempfile.create(file.gsub(/\W/, "")) do |f|
    362         f.write(content)
    363         f.flush
    364         if Kernel.system(ENV["EDITOR"], f.path)
    365           f.rewind
    366           secret = f.read
    367           MOSS.write_secret(file, secret, overwrite: true)
    368         else
    369           raise MossError, $CHILD_STATUS.to_s
    370         end
    371       end
    372     end
    373 
    374     command :config, "show configuration" do
    375       puts JSON.generate(MOSS.config)
    376     end
    377 
    378     command :init, "create new moss repository" do |keyfile:|
    379       MOSS.create(Pathname.new(keyfile))
    380       puts JSON.generate(MOSS.config)
    381     end
    382 
    383     command :rm, "remove a secret" do |secret:|
    384       MOSS.remove_secret(secret)
    385     end
    386 
    387     command :rebuild, "re-encrypt all secrets in the store (for use e.g. after changing recipients)" do |quiet: false|
    388      MOSS.rebuild_store(verbose: ! quiet)
    389     end
    390 
    391     command :git, "perform git operation in store" do |* git_command|
    392       MOSS.git_operation(git_command)
    393     end
    394 
    395     command :help, "display this help text" do
    396       puts usage
    397     end
    398   end
    399   cli
    400 end
    401 
    402 
    403 if $PROGRAM_NAME == __FILE__
    404   begin
    405     File.umask(0o77)
    406     cli.dispatch(ARGV)
    407   rescue Moss::MossError => e
    408     warn e.message
    409     exit 1
    410   end
    411 end