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