Jelajahi Sumber

Say so when the key is gone, rather than asking for codes

The key lives and dies with its machine on purpose, so losing it is the ordinary
consequence of a rebuild rather than a disaster.  What must not happen is the
page going on asking for a TOTP code it cannot honour.

Trog::Vault::has() now opens the row to answer instead of comparing key
fingerprints, so every question about a stored secret gets the same answer the
request that needs it will get.  list() does the same, so /secrets marks a row
that will not open as one that will not open, and hides the form to store a new
one when there is no key to store it under.  Reprovisioning asks that question
before it spends the code, because being told to go and re-store a passphrase
should not also cost somebody thirty seconds.

So a machine that came back without its credential shows a listing that says
which secrets are dead, a reprovision button that has quietly gone back to
asking for the passphrase, and a checkbox to store it again.  Nothing throws,
and the message names /secrets rather than leaving somebody to try the same code
twice.

Also renames the credential to tpsgi-vault: tPSGI owns the unit and runs
whatever is in the directory, and tCMS is only one of the things that can be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADwKSaBSvUTdA3RtGQeNau
George Baugh 1 bulan lalu
induk
melakukan
14459d9241

+ 18 - 7
Readme.md

@@ -216,15 +216,26 @@ 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.
 
-`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
-it hands a decrypted password to.  With no key there is no vault, and everything
+The master key is looked for in `TPSGI_VAULT_KEY`, then
+`$CREDENTIALS_DIRECTORY/tpsgi-vault`, then `config/secrets.key`.  Use the first:
+tPSGI's `bin/build_service` mints it and puts it in systemd's credential store,
+sealed to this machine's TPM if it has one, and its unit hands it over before it
+chroots -- so it never touches a disk here.  It is named for tPSGI rather than
+tCMS because tPSGI owns the unit and runs whatever is in the directory.
+`bin/tcms-vault-key --file` is the fallback for a machine with no systemd at all.
+
+tCMS deletes the key from its environment as it loads, because one of the things
+it forks is a provisioner it hands a decrypted password to.  With no key there is
+no vault: nothing is stored, nothing already stored is offered, and everything
 that would have used one goes on asking for the password directly.
 
+The key lives and dies with the machine.  It is deliberately not in any backup,
+not in the checkout, and not something trog-provisioner carries onto a rebuilt
+guest -- so rebuilding the host means every stored secret is gone and each user
+stores theirs again.  That is the intended cost.  Nothing breaks in the meantime:
+a row that will not open says so on /secrets, the reprovision button goes back to
+asking for the passphrase, and no code is spent finding out.
+
 What unlocks a secret is a TOTP code, and a code is spent when it is used -- a
 code authorizes one thing rather than everything you can do in its window.
 That includes logging in, so if you have just logged in you will be waiting for

+ 39 - 65
bin/tcms-vault-key

@@ -13,43 +13,39 @@ use Trog::Vault ();
 
 =head1 SYNOPSIS
 
-Make the key that seals this installation's stored secrets, and say where to put
-it.
+Write a vault key into config/secrets.key, for an installation that is not run
+by systemd.
 
 =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
+    bin/tcms-vault-key --file
 
-=head2 WHAT THIS IS
+=head2 YOU PROBABLY DO NOT WANT THIS
 
-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.
+tPSGI's bin/build_service makes the key and puts it in systemd's credential
+store, sealed to this machine if it has a TPM to seal it to, and its unit hands
+it to tCMS before it chroots.  That is the way to do this, and it needs nothing
+from here.
 
-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.
+This is the fallback for a machine with no systemd, and it is genuinely weaker:
+a key file beside the database it protects is a key file in the same backup as
+the database it protects.  Take the trade knowingly or not at all.
 
-=head2 WHERE TO PUT IT
+=head2 WHAT THE KEY IS
 
-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.
+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.
 
-=head2 CAVEATS
+There is one key per installation and it does not need rotating on a schedule.
+It is meant to live and die with the machine -- nothing backs it up, and
+trog-provisioner does not carry it onto a rebuilt guest -- so a rebuild loses
+every stored secret and each user stores theirs again.  That is the intended
+cost of the key not being anywhere the database is.
 
-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.
+Replacing it has the same effect.  Everything sealed under the old one stops
+opening and says so, on /secrets and wherever it was going to be used, rather
+than presenting as corruption or as a thing worth trying again.
 
 =cut
 
@@ -59,46 +55,24 @@ GetOptions(
     'help' => \$help,
 ) or exit 2;
 
-if ($help) {
-    print "Usage: bin/tcms-vault-key [--file]\n";
-    exit 0;
+if ( $help || !$file ) {
+    print "Usage: bin/tcms-vault-key --file\n\n";
+    print "Writes $Trog::Vault::key_file for an installation with no systemd.\n";
+    print "Under systemd, tPSGI's bin/build_service does this properly; see perldoc bin/tcms-vault-key.\n";
+    exit( $help ? 0 : 2 );
 }
 
-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;
+# 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;
 }
 
-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.
+open( my $fh, '>', $Trog::Vault::key_file ) or die "Could not write $Trog::Vault::key_file: $!\n";
+print {$fh} Crypt::Misc::encode_b64( Crypt::PRNG::random_bytes(32) ) . "\n";
+close($fh);
+chmod( oct('600'), $Trog::Vault::key_file ) or die "Could not chmod $Trog::Vault::key_file: $!\n";
 
-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
+print "Wrote $Trog::Vault::key_file.  Restart tCMS to pick it up.\n";
+print "Back it up somewhere that is not where you back up config/auth.db, or do not back it up at all.\n";

+ 11 - 1
lib/Trog/DataSource/ProvisionedVirt.pm

@@ -446,11 +446,21 @@ sub _passphrase (%args) {
     if ( length( $args{totp} // '' ) ) {
         return ( undef, 'a code identifies somebody, and nobody is logged in' ) unless $user;
 
+        # Asked before the code is spent, and the reason is that a code is
+        # spent whether or not the thing it authorized worked.  Somebody whose
+        # key has gone would otherwise burn a code to be told there is nothing
+        # to unlock, then wait thirty seconds to be told it again.  has() opens
+        # the row to answer, so this is the same answer get() is about to give.
+        return (
+            undef,
+            "there is no provisioning passphrase this installation can open for $user." . "  Store it again under /secrets, or reprovision with the passphrase itself."
+        ) unless Trog::Vault::has( $user, $secret_name );
+
         my ( $ok, $why ) = Trog::Auth::spend_totp( $user, $args{totp} );
         return ( undef, $why ) unless $ok;
 
         my $stored = Trog::Vault::get( $user, $secret_name );
-        return ( undef,   "there is no provisioning passphrase stored for $user" ) unless defined $stored && length($stored);
+        return ( undef,   "the stored provisioning passphrase for $user could not be read" ) unless defined $stored && length($stored);
         return ( $stored, '' );
     }
 

+ 56 - 23
lib/Trog/Vault.pm

@@ -47,8 +47,8 @@ The key is elsewhere.
 An installation has one master key, and it is not in the database, so the
 database on its own is ciphertext.  It is looked for in this order:
 
-    TCMS_VAULT_KEY              base64, in the environment
-    $CREDENTIALS_DIRECTORY/tcms-vault
+    TPSGI_VAULT_KEY             base64, in the environment
+    $CREDENTIALS_DIRECTORY/tpsgi-vault
     config/secrets.key          base64, in a file
 
 The first is how a real deployment does it, and the reason it is first is the
@@ -57,6 +57,10 @@ credential store is a ramdisk at /run/credentials which is outside it.  So the
 unit takes the credential, service/tpsgi.sh reads it before the chroot happens
 and puts it in the environment, and the key never touches a disk anywhere.
 
+It is named for tPSGI rather than for tCMS because tPSGI is what owns the unit
+and runs whatever is in the directory, and tCMS is only one of the things that
+can be.  One service, one key.
+
 It is taken out of the environment at load, so nothing we fork afterwards
 inherits it -- which matters, because one of the things we fork is a provisioner
 that we are handing a decrypted password to on its stdin.  Read once, in the
@@ -96,15 +100,15 @@ so, rather than presenting as corruption.
 # 32 raw bytes.  Read at load: see WHERE THE KEY IS -- this is the moment the
 # environment is scrubbed, and it has to be before anything forks.
 our $key_file       = 'config/secrets.key';
-our $credential     = 'tcms-vault';
+our $credential     = 'tpsgi-vault';
 our $key_bytes      = 32;
 our $master         = _from_env();
 our $looked_further = 0;
 
 sub _from_env {
-    my $raw = delete $ENV{TCMS_VAULT_KEY};
+    my $raw = delete $ENV{TPSGI_VAULT_KEY};
     return undef unless defined $raw && length($raw);
-    return _decode( $raw, 'TCMS_VAULT_KEY' );
+    return _decode( $raw, 'TPSGI_VAULT_KEY' );
 }
 
 sub _decode ( $raw, $whence ) {
@@ -222,6 +226,27 @@ event here, not writing one.
 =cut
 
 sub get ( $user, $name ) {
+    my $secret = _stored( $user, $name );
+    return undef unless defined $secret;
+
+    my $dbh = _dbh();
+    $dbh->do( "UPDATE user_secret SET last_used=? WHERE username=? AND name=?", undef, time(), $user, $name );
+    Trog::Auth::log_event( 'secret_read', $user );
+    return $secret;
+}
+
+# What is in one row, or nothing.
+#
+# Every question anybody asks about a stored secret comes through here, get()
+# and has() alike, and they all get the same answer -- which is the point.  A
+# page that says a secret is there and a request that finds it will not open are
+# a way to send somebody round a loop typing codes at a row that was never going
+# to work.  Opening it is the only way to know, and it is microseconds.
+#
+# What get() adds is that reading a secret is an event: it moves last_used and
+# writes to the audit log.  Asking whether one is usable is not an event, and
+# does not.
+sub _stored ( $user, $name ) {
     return undef unless has_key();
 
     my $dbh  = _dbh();
@@ -230,8 +255,14 @@ sub get ( $user, $name ) {
         { Slice => {} }, $user, $name
     );
     return undef unless ref $rows eq 'ARRAY' && @$rows;
-    my $row = $rows->[0];
 
+    return _open( $rows->[0], $user, $name );
+}
+
+# The plaintext, or undef with a reason in the log.  Never dies: a row that will
+# not open is a thing to report to somebody who can store it again, not an
+# exception halfway through drawing a page.
+sub _open ( $row, $user, $name ) {
     if ( $row->{key_id} ne key_id() ) {
         WARN( "'$name' for $user was sealed under vault key $row->{key_id}, and this installation has " . key_id() );
         return undef;
@@ -249,8 +280,6 @@ sub get ( $user, $name ) {
         return undef;
     }
 
-    $dbh->do( "UPDATE user_secret SET last_used=? WHERE username=? AND name=?", undef, time(), $user, $name );
-    Trog::Auth::log_event( 'secret_read', $user );
     return $secret;
 }
 
@@ -265,34 +294,38 @@ you can read off a screen is one you did not need us to keep.
 sub list ($user) {
     my $dbh  = _dbh();
     my $rows = $dbh->selectall_arrayref(
-        "SELECT name, created, last_used, key_id FROM user_secret WHERE username=? ORDER BY name",
+        "SELECT name, created, last_used, key_id, salt, ciphertext, tag FROM user_secret WHERE username=? ORDER BY name",
         { Slice => {} }, $user
     );
     return [] unless ref $rows eq 'ARRAY';
 
-    # A row we cannot open is worth saying so about rather than listing as
-    # though it were usable.
-    my $id = key_id();
-    $_->{readable} = ( $id && $_->{key_id} eq $id ) ? 1 : 0 foreach @$rows;
+    # Opened rather than guessed at from the key fingerprint, so that a row this
+    # page calls readable is one that will actually open when something asks for
+    # it.  This is the page somebody comes to when a secret has stopped working,
+    # and it is no use to them if it agrees that everything looks fine.
+    foreach my $row (@$rows) {
+        $row->{readable} = ( has_key() && defined _open( $row, $user, $row->{name} ) ) ? 1 : 0;
+        delete $row->{$_} foreach qw{salt ciphertext tag};
+    }
     return $rows;
 }
 
 =head2 has($user, $name) = BOOL
 
-Whether there is something stored under that name, without opening it.
+Whether there is something stored under that name that this installation can
+actually open.
+
+Not "is there a row": a row sealed under a key that is gone is a row that will
+never open again, and answering yes to this would have somebody typing codes at
+it.  The question this is really being asked is whether to draw a passphrase box
+or a code box, and the honest answer to that is the one where the code works.
 
-Separate from get() on purpose: a page that wants to know whether to draw a
-passphrase box or a code box is asking a different question from a request that
-is about to hand a password to a provisioner, and only the second one belongs in
-the audit log.
+Separate from get() because reading a secret is an event and asking about one is
+not.  This moves nothing and writes nothing to the audit log.
 
 =cut
 
-sub has ( $user, $name ) {
-    my $dbh  = _dbh();
-    my $rows = $dbh->selectall_arrayref( "SELECT name FROM user_secret WHERE username=? AND name=? AND key_id=?", { Slice => {} }, $user, $name, key_id() );
-    return ref $rows eq 'ARRAY' && @$rows ? 1 : 0;
-}
+sub has ( $user, $name ) { return defined _stored( $user, $name ) ? 1 : 0 }
 
 =head2 forget($user, $name) = BOOL
 

+ 49 - 1
t/Trog-DataSource-ProvisionedVirt.t

@@ -36,7 +36,7 @@ BEGIN {
     File::Copy::copy( "$REPO/schema/auth.schema", "$ROOT/schema/auth.schema" ) or die $!;
     require Crypt::Misc;
     require Crypt::PRNG;
-    $ENV{TCMS_VAULT_KEY} = Crypt::Misc::encode_b64( Crypt::PRNG::random_bytes(32) );
+    $ENV{TPSGI_VAULT_KEY} = Crypt::Misc::encode_b64( Crypt::PRNG::random_bytes(32) );
 
     # A stand-in for the checkout, with the one program a reprovision runs.
     # Never executed -- _spawn is mocked -- but it has to be there and
@@ -262,6 +262,54 @@ subtest 'a stored passphrase, and a code instead of one' => sub {
     Trog::Vault::forget( 'admin', $Trog::DataSource::ProvisionedVirt::secret_name );
 };
 
+subtest 'and when the vault key has gone' => sub {
+    no warnings qw{once};
+
+    # The key lives and dies with the machine, so this is the ordinary
+    # consequence of rebuilding one rather than a disaster.  What must not
+    # happen is the page going on asking for a code it cannot honour.
+    Trog::Vault::set( 'admin', $Trog::DataSource::ProvisionedVirt::secret_name, 'correct horse' );
+    $virt->redefine( posts => sub { return ( { title => 'guest.example.com', domain => 'guest.example.com', is_self => 0 } ) } );
+
+    my ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, { user => 'admin' } );
+    is( $post->{passphrase_remembered}, 1, 'with the key here, the form asks for a code' );
+
+    local $Trog::Vault::master = Crypt::PRNG::random_bytes(32);
+
+    ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, { user => 'admin' } );
+    is( $post->{passphrase_remembered}, 0, 'and with it gone, back to asking for the passphrase' );
+    is( $post->{can_reprovision},       1, 'the button is still offered, since it still works that way' );
+
+    # Posted at directly, as somebody would from a page drawn a moment before
+    # the key went away.
+    my ( $uri, $qr, $failure, $message, $totp ) = Trog::Auth::totp( 'admin', 'example.com' );
+
+    # The subtest above spent this window's codes.  Standing here for thirty
+    # seconds would say the same thing and cost thirty seconds.
+    require Trog::SQLite;
+    Trog::SQLite::dbh( 'schema/auth.schema', 'config/auth.db' )->do("DELETE FROM totp_spent WHERE username='admin'");
+
+    my $code = $totp->expected_totp_code( time() );
+    my ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => undef, totp => $code );
+    is( $ok, 0, 'a code is refused rather than exploding' );
+    like( $why, qr/can open/, 'saying the passphrase cannot be opened' );
+    like( $why, qr{/secrets}, 'and where to go about it' );
+    is_deeply( \@SPAWNED, [], 'having provisioned nothing' );
+
+    # And the refusal came before the code was spent, so the same code is still
+    # good for the thing that does work.  Being told to go and re-store a
+    # passphrase should not also cost somebody thirty seconds.
+    ( $ok, $why ) = Trog::Auth::spend_totp( 'admin', $code );
+    is( $ok, 1, 'the code was not spent on being told no' ) or diag($why);
+
+    ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => 'typed it again', remember => 1 );
+    is( $ok,                     1,                'and the passphrase still provisions' ) or diag($why);
+    is( $SPAWNED[0]{passphrase}, 'typed it again', 'with what was typed' );
+
+    ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, { user => 'admin' } );
+    is( $post->{passphrase_remembered}, 1, 'stored again under the key that is here now' );
+};
+
 subtest 'without the provisioner configured it is just Virt' => sub {
     write_config();
 

+ 62 - 4
t/Trog-Vault.t

@@ -38,7 +38,7 @@ BEGIN {
     # out of %ENV as it loads, which is the behaviour tested below.
     require Crypt::PRNG;
     require Crypt::Misc;
-    $ENV{TCMS_VAULT_KEY} = Crypt::Misc::encode_b64( Crypt::PRNG::random_bytes(32) );
+    $ENV{TPSGI_VAULT_KEY} = Crypt::Misc::encode_b64( Crypt::PRNG::random_bytes(32) );
 
     chdir($ROOT) or die "could not chdir to the sandbox: $!";
 }
@@ -56,8 +56,8 @@ Trog::Auth::useradd( 'eve', 'Eve Evilson', 'letmein', ['public'], 'eve@example.c
   or BAIL_OUT('could not make a second user');
 
 subtest 'the key is taken out of the environment' => sub {
-    ok( Trog::Vault::has_key(),       'the key was read' );
-    ok( !exists $ENV{TCMS_VAULT_KEY}, 'and is gone from the environment, so nothing we fork inherits it' );
+    ok( Trog::Vault::has_key(),        'the key was read' );
+    ok( !exists $ENV{TPSGI_VAULT_KEY}, 'and is gone from the environment, so nothing we fork inherits it' );
     like( Trog::Vault::key_id(), qr/^[0-9a-f]{16}$/, 'and fingerprints to something a row can name' );
 };
 
@@ -186,7 +186,7 @@ subtest 'the other two places a key can be' => sub {
     # 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 $!;
+    open( $fh, '>', 'creds/tpsgi-vault' ) or die $!;
     print {$fh} $in_store;
     close $fh;
 
@@ -300,6 +300,64 @@ subtest 'logging in spends a code' => sub {
     is( Trog::Auth::mksession( 'bob', 'hunter2', $code ), '', 'and that code cannot be replayed into a second one' );
 };
 
+subtest 'when the key is gone' => sub {
+    no warnings qw{once};
+    require Trog::Routes::HTML;
+
+    my $tpsgi = bless {}, 'FakeTPSGI2';
+    {
+        no warnings qw{once};
+        *FakeTPSGI2::see_also = sub { return [ 303, [ Location => $_[1] ], [''] ] };
+    }
+    my $rendered;
+    my $index = Test::MockModule->new('Trog::Routes::HTML');
+    $index->redefine( index => sub { $rendered = $_[0]; return [ 200, [], [''] ] } );
+
+    Trog::Vault::set( 'bob', 'keepass', 'correct horse battery staple' );
+
+    # The machine was rebuilt and the credential went with it.  A key lives and
+    # dies with its machine on purpose, so this is the ordinary case rather than
+    # a disaster: everything sealed under the old one is gone, and the users
+    # store theirs again.
+    foreach my $gone (
+        [ 'no key at all',   sub { $Trog::Vault::master = undef; $Trog::Vault::looked_further = 1 } ],
+        [ 'a different key', sub { $Trog::Vault::master = Crypt::PRNG::random_bytes(32) } ],
+    ) {
+        my ( $what, $break ) = @$gone;
+        local $Trog::Vault::master         = Trog::Vault::key();
+        local $Trog::Vault::looked_further = 1;
+        $break->();
+
+        # Nothing explodes, and nothing claims a secret is usable.
+        is( Trog::Vault::has( 'bob', 'keepass' ), 0,     "$what: nothing reads as usable" );
+        is( Trog::Vault::get( 'bob', 'keepass' ), undef, "$what: and nothing opens" );
+
+        Trog::Routes::HTML::secrets( { user => 'bob', user_acls => ['admin'], tpsgi => $tpsgi } );
+        my ($listed) = grep { $_->{name} eq 'keepass' } @{ $rendered->{secrets} };
+        ok( $listed, "$what: the row is still listed rather than vanishing" );
+        is( $listed->{readable}, 0, "$what: marked as one that will not open" );
+
+        # And a page which knows storing cannot work does not draw the form for
+        # it -- has_vault is what the template hangs that on.
+        is( $rendered->{has_vault}, ( $what eq 'no key at all' ? 0 : 1 ), "$what: the page knows whether there is a vault to store into" );
+
+        my ( $stored, $why ) = Trog::Vault::set( 'bob', 'replacement', 'a new one' );
+        if ( $what eq 'no key at all' ) {
+            is( $stored, 0, "$what: storing is refused rather than silently lost" );
+            like( $why, qr/no vault key/, "$what: saying why" );
+        }
+        else {
+            is( $stored,                                  1,           "$what: a new key stores new secrets fine" );
+            is( Trog::Vault::get( 'bob', 'replacement' ), 'a new one', "$what: and opens them" );
+            Trog::Vault::forget( 'bob', 'replacement' );
+        }
+    }
+
+    # The key came back: what was sealed under it was never damaged, only
+    # unreadable while it was away.
+    is( Trog::Vault::get( 'bob', 'keepass' ), 'correct horse battery staple', 'and the original key still opens the original row' );
+};
+
 subtest 'the page a user manages them from' => sub {
     require Trog::Routes::HTML;
 

+ 6 - 1
www/templates/html/components/secrets.tx

@@ -38,7 +38,7 @@
                     : # is not going to open, and saying so beats letting somebody
                     : # find out at the moment they were relying on it.
                     : if ( !$secret.readable ) {
-                    <span class="tag guest-state" title="sealed with a vault key this installation does not have">unreadable</span>
+                    <span class="tag guest-state" title="this will not open: it was sealed with a vault key this installation no longer has.  Store it again.">unreadable</span>
                     : }
                     <form method="POST" action="/secrets/forget" style="display:inline;">
                         <input type="hidden" name="name" value="<: $secret.name :>" />
@@ -53,6 +53,10 @@
     <p>Nothing stored.</p>
     : }
 
+    : # No key, no form.  Everything it could submit would be refused, and a form
+    : # that cannot work is worse than no form: it reads as something that ought
+    : # to have worked.
+    : if ( $has_vault ) {
     <br />
     <h3>Store one</h3>
     <p>
@@ -73,4 +77,5 @@
         </label>
         <input class="coolbutton" type="submit" value="Store it" />
     </form>
+    : }
 </div>