commit 31b304d50ebda7ce5e85dee68e444ec1f8d05934
parent 4ba8e82bc8bb25f875154da43854dc2a77cfbb37
Author: Daniel Barlow <dan@telent.net>
Date: Mon, 9 Aug 2021 14:14:05 +0100
subtree-specific recipients
Diffstat:
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