Explorar el Código

Mint the vault key, and say where it goes

bin/tcms-vault-key writes one and prints the systemd-creds commands to install
it, since the right place for it is a store this application cannot see and does
not back up.  --file writes config/secrets.key for a machine with no systemd,
and says plainly that it is the weaker of the two.

It refuses to overwrite an existing key.  There is nothing to fall back on when
you lose one: everything sealed under it stops opening, and the users store
theirs again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADwKSaBSvUTdA3RtGQeNau
George Baugh hace 1 mes
padre
commit
2dd1e1e26e
Se han modificado 3 ficheros con 152 adiciones y 2 borrados
  1. 3 2
      Readme.md
  2. 104 0
      bin/tcms-vault-key
  3. 45 0
      t/Trog-Vault.t

+ 3 - 2
Readme.md

@@ -216,8 +216,9 @@ cannot be lifted into somebody else's account or renamed into being the answer t
 a different question.  Nothing renders a value back; if you have forgotten one,
 replace it.
 
-The master key is looked for in `TCMS_VAULT_KEY`, then
-`$CREDENTIALS_DIRECTORY/tcms-vault`, then `config/secrets.key`.  Use the first:
+`bin/tcms-vault-key` mints one and tells you where to put it.  The master key is
+looked for in `TCMS_VAULT_KEY`, then `$CREDENTIALS_DIRECTORY/tcms-vault`, then
+`config/secrets.key`.  Use the first:
 tPSGI's unit takes it as a systemd credential and service/tpsgi.sh puts it in the
 environment before the chroot, so it never touches a disk.  tCMS deletes it from
 the environment as it loads, because one of the things it forks is a provisioner

+ 104 - 0
bin/tcms-vault-key

@@ -0,0 +1,104 @@
+#!/usr/bin/env perl
+
+use v5.36;
+use re '/aa';
+
+use FindBin::libs;
+
+use Crypt::Misc  ();
+use Crypt::PRNG  ();
+use Getopt::Long qw{GetOptions};
+
+use Trog::Vault ();
+
+=head1 SYNOPSIS
+
+Make the key that seals this installation's stored secrets, and say where to put
+it.
+
+=head2 USAGE
+
+    bin/tcms-vault-key                  # print a key and the commands to install it
+    bin/tcms-vault-key --file           # write config/secrets.key instead
+
+=head2 WHAT THIS IS
+
+Every secret a user stores through /secrets is encrypted under a key derived
+from this one.  It is not in the database, which is the whole point: without it
+config/auth.db is ciphertext.  Losing it loses every stored secret, and there is
+no way around that -- they go back to typing the password, which is what they
+did before.
+
+There is one key per installation, and it does not need rotating on a schedule.
+If you do replace it, everything sealed under the old one stops opening and says
+so rather than presenting as corruption; the fix is for each user to store
+theirs again.
+
+=head2 WHERE TO PUT IT
+
+In systemd's credential store, which is what the default output tells you how to
+do.  tPSGI's unit imports it from there and hands it to tCMS in the environment,
+tCMS takes it back out of the environment as it loads, and it never touches a
+disk anywhere on the way.  Encrypted with systemd-creds it is also sealed to
+this machine, so the file is useless if it walks.
+
+--file is the fallback for a machine that is not running systemd.  It writes
+config/secrets.key, mode 600, and that is genuinely weaker: a key beside the
+database it protects is a key in the same backup as the database it protects.
+
+=head2 CAVEATS
+
+Prints a key to your terminal, which is a place keys end up in scrollback.  The
+default output is meant to be piped rather than read.
+
+=cut
+
+my ( $file, $help );
+GetOptions(
+    'file' => \$file,
+    'help' => \$help,
+) or exit 2;
+
+if ($help) {
+    print "Usage: bin/tcms-vault-key [--file]\n";
+    exit 0;
+}
+
+my $key = Crypt::Misc::encode_b64( Crypt::PRNG::random_bytes(32) );
+
+if ($file) {
+
+    # Printed and exited rather than died: Carp::Always is in the dependency
+    # chain, and refusing to clobber a key is not a crash.
+    if ( -e $Trog::Vault::key_file ) {    ## no critic (ProhibitFiletest_e) -- refusing to overwrite the one thing that cannot be regenerated
+        print STDERR "There is already a key at $Trog::Vault::key_file.  Move it out of the way if you really mean to replace it, and read what that costs first: perldoc bin/tcms-vault-key\n";
+        exit 2;
+    }
+
+    open( my $fh, '>', $Trog::Vault::key_file ) or die "Could not write $Trog::Vault::key_file: $!\n";
+    print {$fh} "$key\n";
+    close($fh);
+    chmod( oct('600'), $Trog::Vault::key_file ) or die "Could not chmod $Trog::Vault::key_file: $!\n";
+
+    print "Wrote $Trog::Vault::key_file.  Restart tCMS to pick it up.\n";
+    print "This is the weaker of the two places to keep it: see perldoc bin/tcms-vault-key.\n";
+    exit 0;
+}
+
+print <<"COMMANDS";
+A new vault key.  Put it in systemd's credential store, sealed to this machine:
+
+    echo '$key' | sudo systemd-creds encrypt --name=tcms-vault - /etc/credstore.encrypted/tcms-vault
+
+...or unsealed, if this machine has no TPM:
+
+    echo '$key' | sudo tee /etc/credstore/tcms-vault > /dev/null
+    sudo chmod 600 /etc/credstore/tcms-vault
+
+Then restart the service.  tPSGI's unit imports it and hands it over before it
+chroots; see the Service configuration section of its Readme.
+
+Losing this key loses every secret stored under it, and it is not in any backup
+you take of tCMS, which is the point.  Keep it wherever you keep the things you
+cannot regenerate.
+COMMANDS

+ 45 - 0
t/Trog-Vault.t

@@ -164,6 +164,51 @@ subtest 'no key, no vault' => sub {
     is( Trog::Vault::get( 'bob', 'keepass' ), undef, 'and nothing already stored is offered' );
 };
 
+subtest 'the other two places a key can be' => sub {
+    no warnings qw{once};
+    require Crypt::Misc;
+
+    # A key file, for a machine not running systemd.  bin/tcms-vault-key --file
+    # writes exactly this: base64 and a newline.
+    my $on_disk = Crypt::Misc::encode_b64( Crypt::PRNG::random_bytes(32) );
+    open( my $fh, '>', 'config/secrets.key' ) or die $!;
+    print {$fh} "$on_disk\n";
+    close $fh;
+    chmod( oct('600'), 'config/secrets.key' );
+
+    {
+        local $Trog::Vault::master         = undef;
+        local $Trog::Vault::looked_further = 0;
+        is( Crypt::Misc::encode_b64( Trog::Vault::key() ), $on_disk, 'a key file is found, newline and all' );
+    }
+
+    # And systemd's credential store, which is where it should be: the directory
+    # systemd hands over, for a service that is not chrooted away from it.
+    my $in_store = Crypt::Misc::encode_b64( Crypt::PRNG::random_bytes(32) );
+    File::Path::make_path('creds');
+    open( $fh, '>', 'creds/tcms-vault' ) or die $!;
+    print {$fh} $in_store;
+    close $fh;
+
+    {
+        local $ENV{CREDENTIALS_DIRECTORY}  = 'creds';
+        local $Trog::Vault::master         = undef;
+        local $Trog::Vault::looked_further = 0;
+        is( Crypt::Misc::encode_b64( Trog::Vault::key() ), $in_store, 'the credential store wins over the file beside the database' );
+    }
+
+    # Something that is not a key is not quietly used as one.
+    open( $fh, '>', 'config/secrets.key' ) or die $!;
+    print {$fh} "hunter2\n";
+    close $fh;
+    {
+        local $Trog::Vault::master         = undef;
+        local $Trog::Vault::looked_further = 0;
+        is( Trog::Vault::key(), undef, 'a key that is not 32 bytes of base64 is refused' );
+    }
+    unlink('config/secrets.key');
+};
+
 subtest 'a code is worth one thing' => sub {
     my ( $uri, $qr, $failure, $message, $totp ) = Trog::Auth::totp( 'bob', 'vault.example.com' );
     ok( $uri, 'bob is enrolled' ) or diag($message);