moss

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

commit 31b304d50ebda7ce5e85dee68e444ec1f8d05934
parent 4ba8e82bc8bb25f875154da43854dc2a77cfbb37
Author: Daniel Barlow <dan@telent.net>
Date:   Mon,  9 Aug 2021 14:14:05 +0100

subtree-specific recipients

Diffstat:
Mfeatures/moss.feature | 25++++++++++++++++++++++---
Mfeatures/step_definitions/steps.rb | 51+++++++++++++++++++++++++++++++++++++++++++++++----
Afixtures/keys/child.key | 3+++
Afixtures/keys/me.key | 3+++
Afixtures/keys/partner.key | 3+++
Afixtures/keys/work.key | 3+++
Dfixtures/store/.age_identity | 3---
Mfixtures/store/home/ebay.age | 10+++++-----
Mmoss.rb | 27+++++++++++++++++++++++----
9 files changed, 109 insertions(+), 19 deletions(-)

diff --git a/features/moss.feature b/features/moss.feature @@ -41,12 +41,31 @@ Feature: command-line password management When I edit "work/gmail" Then the editor opens a temporary file containing "staple correct" + Scenario Outline: It encrypts to multiple recipients + Given I am using a temporary password store + And there are recipient files in different subtrees + | pathname | identity | + | family | me.key,partner.key,child.key | + | work | me.key,work.key | + | private | me.key | + + When I store a secret for "private/medical" with content "120/70" + Then I can decrypt it with key "me.key" to "120/70" + And I cannot decrypt it with key "work.key" + + When I store a secret for "work/report" with content "persevere" + Then I can decrypt it with key "me.key" to "persevere" + And I can decrypt it with key "work.key" to "persevere" + And I cannot decrypt it with key "child.key" + + When I store a secret for "family/holiday" with content "isle of wight" + Then I can decrypt it with key "me.key" to "isle of wight" + And I can decrypt it with key "child.key" to "isle of wight" + And I cannot decrypt it with key "work.key" + Scenario: Secrets are stored in a git repo Scenario: It pushes changes to a git remote - Scenario: It encrypts to multiple recipients - - Scenario: Different recipients can be set per subtree diff --git a/features/step_definitions/steps.rb b/features/step_definitions/steps.rb @@ -9,14 +9,29 @@ def shell(s) output end +IDENTITY_FILE = "fixtures/keys/me.key" +ENV['MOSS_IDENTITY_FILE'] = IDENTITY_FILE + def store_path(s) Pathname.new(ENV["MOSS_STORE"]).join(s) end +def add_to_store(path, content) + fullpath = Pathname.new(store_path(path)) + fullpath.parent.mkpath + File.open(fullpath, "wb") do |f| + f.write(content) + end +end + +def recipient_for_identity(identity_file) + File.open(identity_file).read.match(/(age1.+)$/).captures.first +end + + Given("I am using a temporary password store") do ENV["MOSS_STORE"] = Dir.mktmpdir - Dir.mkdir(store_path(".age")) - FileUtils.cp("fixtures/store/.age/identity", store_path(".age/identity")) + add_to_store(".recipients", recipient_for_identity("fixtures/keys/me.key")) end Given("I am using the example password store") do @@ -24,10 +39,12 @@ Given("I am using the example password store") do end When("I generate a secret for {string} with length {int}") do |name, length| + @stored_secret_name = name @i_see = shell "#{MOSS} generate #{name} #{length}" end When("I store a secret for {string} with content {string}") do |name, content| + @stored_secret_name = name shell "echo -n #{content} | #{MOSS} insert #{name}" end @@ -43,15 +60,31 @@ Then("{string} plaintext is {string}") do |name, expected| path_name = store_path(name) contents = File.read(path_name) expect(contents).to match /AGE ENCRYPTED FILE/ - plaintext = shell "age -i #{store_path(".age/identity")} -d #{path_name}" + plaintext = shell "age -i #{IDENTITY_FILE} -d #{path_name}" expect(plaintext).to eq expected end +Then("I can decrypt it with key {string} to {string}") do |keyfile, expected| + path_name = store_path(@stored_secret_name + '.age') + contents = File.read(path_name) + expect(contents).to match /AGE ENCRYPTED FILE/ + plaintext = shell "age -i fixtures/keys/#{keyfile} -d #{path_name}" + expect(plaintext).to eq expected +end + +Then("I cannot decrypt it with key {string}") do |keyfile| + path_name = store_path(@stored_secret_name + '.age') + contents = File.read(path_name) + expect(contents).to match /AGE ENCRYPTED FILE/ + # we expect age to fail, redirect stderr to avoid messing up test output + shell "! age -i fixtures/keys/#{keyfile} -d #{path_name} 2>&1" +end + Then("{string} plaintext matches {word}") do |name, re| path_name = store_path(name) contents = File.read(path_name) expect(contents).to match /AGE ENCRYPTED FILE/ - shell "age -i #{store_path(".age/identity")} -d #{path_name}" + shell "age -i #{IDENTITY_FILE} -d #{path_name}" expect(@i_see).to match Regexp.new(re) end @@ -80,3 +113,13 @@ Then("the editor opens a temporary file containing {string}") do |expected| expect(pathname).to start_with(ENV['TMPDIR']) expect(content).to eq(expected) end + +Given("there are recipient files in different subtrees") do |table| + table.hashes.each do |row| + keys = row["identity"].split(/, */) + recipients = keys.map { |k| + recipient_for_identity("fixtures/keys/#{k}") + }.join("\n") + add_to_store("#{row['pathname']}/.recipients", recipients) + end +end diff --git a/fixtures/keys/child.key b/fixtures/keys/child.key @@ -0,0 +1,3 @@ +# created: 2021-08-09T12:12:11+01:00 +# public key: age1f5m96vfl87xgzdxkwd7ynes6dlee04f879chqspuapqdekfq9fksatnn77 +AGE-SECRET-KEY-1M72HGXPGM2KTLXE06UT86ASEMXD6L6D5YUJ0KAYF0EU8J3KYQPNQDY2AH3 diff --git a/fixtures/keys/me.key b/fixtures/keys/me.key @@ -0,0 +1,3 @@ +# created: 2021-08-09T12:11:51+01:00 +# public key: age13a6f7s2pw24nrpmr0gmhlqnmazt0aejcsc4a7sv9t7j5y300mfrqvaulzf +AGE-SECRET-KEY-1HWMK4W0MXGXM56G8UVVSD3ZNENHK62VJD89VSM3R53SS2VQ6KVDQAD3P6X diff --git a/fixtures/keys/partner.key b/fixtures/keys/partner.key @@ -0,0 +1,3 @@ +# created: 2021-08-09T12:12:04+01:00 +# public key: age103ndchpnhpf30nzerjrtp8tejvzwh73phkquut4qwgpa3zd08ues9m7v2a +AGE-SECRET-KEY-1UFG52CSP7UQQXCKYHT4PZ5ALZDNXLKQ2W4E69WPZMAEH4WFPK6MSRXKPC0 diff --git a/fixtures/keys/work.key b/fixtures/keys/work.key @@ -0,0 +1,3 @@ +# created: 2021-08-09T12:12:17+01:00 +# public key: age1x848lesd0se5xkrfdaz0vgdvhcztjkrdk4vs7vsduxmsfe7d6p6q66p8lq +AGE-SECRET-KEY-1WSM70SZ48KNQSRF9YL99NH7DDL0MXKX43N0ZE32X3WXTV79TTM5QSGAEEH diff --git a/fixtures/store/.age_identity b/fixtures/store/.age_identity @@ -1,3 +0,0 @@ -# created: 2021-08-08T17:24:45+01:00 -# public key: age1wc392uhfm04sy5nmpu86ea077ymjxs6e3gnda54jvpmuqchj9d5s6d0q2x -AGE-SECRET-KEY-1ZALPNKLG5794VS0QVE2Y5P5CSLPPKM6DQULEEMJFZMYCZH8Z3SMSCGWZZ7 diff --git a/fixtures/store/home/ebay.age b/fixtures/store/home/ebay.age @@ -1,7 +1,7 @@ -----BEGIN AGE ENCRYPTED FILE----- -YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBDdHJxUjl5ZUorNUxJZHBW -LzB1TVE4YS9qQm93R3lleGQxK0Z3MjhNWDMwCmEvbjgxK1B3MnBra245OHRDTkMr -QzZ6SDA1OXZWM3dwd01RdVk2MFVaVkUKLS0tIFUxUWYyazdUVjVkZitKalRTcThs -cTliNnFvVXFwaWQ1S1RnOFV3K3pMOEEKp37wj6WdTuT1SHUBlQll7e2LL6QVZEUb -S17aYC9NCBTnRYF2JBgyN8EhBrI= +YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBtTmxhQ2UxZzFOSCt0YXZi +dDNoNzkzMUx4S2cyNExIaGxyWEhnUll1aUNBCmlaNFl3UExwS3JhZ0ZQaTJqWWR4 +SzdlZnc3NmdMZksrblUrTHhidGRQQnMKLS0tIFIxejh4Q2ZKRUlSTzFOMkt1ek1K +OGxGZ29RU20vc0krV0kzY1hVUXc0bTAKhP+2yPlyFH5MMOudJIrc7uVb4G8f+CJO ++C409IeeRAlZjUf2jFVO5u4gN1g= -----END AGE ENCRYPTED FILE----- diff --git a/moss.rb b/moss.rb @@ -2,8 +2,9 @@ require 'pathname' require 'tempfile' -STORE = Pathname.new(ENV['MOSS_STORE'] || "/tmp/store") -IDENTITY = "age1wc392uhfm04sy5nmpu86ea077ymjxs6e3gnda54jvpmuqchj9d5s6d0q2x" + +STORE = Pathname.new(ENV['MOSS_STORE'] || "/tmp/store").realpath +IDENTITY_FILE = ENV['MOSS_IDENTITY_FILE'] def random_alnum(length) bytes = File.open("/dev/urandom", "rb") do |random| @@ -25,15 +26,33 @@ end def write_secret(name, content) pathname = STORE.join("#{name}.age") pathname.dirname.mkpath - IO.popen("age -a -r #{IDENTITY} -o #{pathname.to_s}", "w") do |f| + IO.popen("age -a --recipients-file #{recipients_for_secret(name)} -o #{pathname.to_s}", "w") do |f| f.write(content) end end +def find_in_subtree(subtree, filename) + pathname = subtree.join(filename) + case + when pathname.readable? + pathname + when subtree.to_s > STORE.to_s + find_in_subtree(subtree.parent, filename) + else + nil + end +end + +def recipients_for_secret(name) + pathname = STORE.join("#{name}.age") + find_in_subtree(pathname.parent, ".recipients") or + raise "Can't find .recipients for #{name}" +end + def read_secret(name) pathname = STORE.join("#{name}.age") File.exist?(pathname) or raise "Can't open #{pathname}: $!" - `age -i #{STORE.join(".age/identity")} -d #{pathname}` + `age -i #{IDENTITY_FILE} -d #{pathname}` end action, *parameters = ARGV