Explorar o código

Ask for a code, not the passphrase

/secrets is where a user manages what tCMS remembers for them, and the first
thing worth remembering is the provisioner's KeePass passphrase.  Store it and
the reprovision button stops asking for it: it asks for a TOTP code, spends the
code, and exchanges it for what was stored.  The code is proof somebody is at
the keyboard, not a password -- there is nothing to derive from six digits --
so it authorizes the exchange rather than performing it.

Everything on that page is the asker's own.  The username comes off the session
and never off the request, and it is bound into the ciphertext, so a row is not
readable as anybody but its owner however it is posted at.  No value is ever
rendered back: a secret you can read off a screen is one you did not need us to
keep.

The old way still works.  Type the passphrase and it is used and forgotten,
exactly as before, with a checkbox to keep it -- which is the whole point, since
a passphrase you must have to hand is a passphrase somebody wrote down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADwKSaBSvUTdA3RtGQeNau
George Baugh hai 1 mes
pai
achega
5550b4f665

+ 32 - 0
Readme.md

@@ -197,6 +197,38 @@ to that one process; it is not written to the config, the log, or the process
 table.  The alternative would be keeping the master password for every secret you
 hold in a file the webserver can read.
 
+Typing it every time has its own cost -- a passphrase you have to have to hand is
+one you write down somewhere -- so you can tick "remember it" and have it stored
+in your vault instead.  After that the button asks for a TOTP code, which is
+spent on that one reprovision and exchanged for the passphrase.  See Stored
+Secrets, below.
+
+Stored Secrets
+==============
+
+Some of what tCMS does for you needs a password that is not tCMS's.  /secrets is
+where each user keeps those, and what is kept there is sealed with a key the
+database does not contain, so a stolen `config/auth.db` is ciphertext.
+
+AES-256-GCM per row, under a key derived for that row from a fresh random salt,
+with the username and the secret's name bound in as associated data -- so a row
+cannot be lifted into somebody else's account or renamed into being the answer to
+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:
+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
+that would have used one goes on asking for the password directly.
+
+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
+the next code.  That is the cost, and it is thirty seconds.
+
 DirIndex is the one to copy if you are writing your own.  Point a series at a
 directory with its "Directory to index" field, build a child type in the wizard
 with DirIndex as its datasource, and every file in there becomes a post carrying

+ 11 - 0
design.md

@@ -410,6 +410,17 @@ band, which is what covers a lost authenticator. The first user to register
 becomes the administrator and registration then closes; further users are made by
 an admin through the `about` post type -- because a user *is* their profile page.
 
+**Secrets** (`Trog::Vault`) keeps the passwords that are not tCMS's -- the
+provisioner's KeePass passphrase to begin with -- in `user_secret`, sealed with
+AES-256-GCM under a key derived per row from the installation's master key. That
+key is not in the database and not in the checkout: it arrives as a systemd
+credential, through the environment, and is deleted from the environment at load
+because this process forks a provisioner it hands decrypted passwords to. A TOTP
+code is what authorizes opening one, and `Trog::Auth::spend_totp` walks a
+per-user counter forwards so a code buys one thing rather than everything inside
+its window. No key means no vault, and the things that would have used one ask
+for the password directly.
+
 **Config** (`Trog::Config`) is a thin wrapper over `Config::Simple` reading
 `config/main.cfg` and falling back to the shipped `config/default.cfg`. It is
 memoised, so a config change signals the workers to restart.

+ 87 - 9
lib/Trog/DataSource/ProvisionedVirt.pm

@@ -15,6 +15,8 @@ use POSIX               ();
 use Trog::Config     ();
 use Trog::DataSource ();
 use Trog::DataSource::Virt();
+use Trog::Auth  ();
+use Trog::Vault ();
 
 use Trog::Log qw{WARN INFO};
 
@@ -62,6 +64,11 @@ shell, so a guest name is an argument and can never be a command.
 our $recipe_dir = 'recipes.d';
 our $log_dir    = 'logs/reprovision';
 
+# What a stored provisioning passphrase is called in a user's vault.  One name
+# per person rather than one per guest: it is the passphrase to their secrets
+# database, and there is one of those.
+our $secret_name = 'provisioner';
+
 # Where an installation's own files live, which is trog-provisioner's rule
 # rather than ours -- see its Trog::Config.
 our $provisioner_config = '/etc/trog-provisioner';
@@ -104,6 +111,12 @@ sub posts ( $series, $query ) {
     my @guests = Trog::DataSource::Virt::posts( $series, $query );
     my $config = _config();
 
+    # Asked once for the page rather than once per guest, and asked rather than
+    # opened: what the form needs to know is which box to draw.
+    my $user = $query->{user} // '';
+    $config->{can_remember} = ( $user                   && Trog::Vault::has_key() )                  ? 1 : 0;
+    $config->{remembered}   = ( $config->{can_remember} && Trog::Vault::has( $user, $secret_name ) ) ? 1 : 0;
+
     return map { _with_recipe( $_, $config ) } @guests;
 }
 
@@ -131,6 +144,10 @@ sub _with_recipe ( $post, $config ) {
     $post->{can_reprovision} =
       ( $post->{has_recipe} && $config->{trog_provisioner} && !$post->{is_self} ) ? 1 : 0;
 
+    # Which of the two things the button has to ask for.
+    $post->{passphrase_remembered} = $config->{remembered};
+    $post->{can_remember}          = $config->{can_remember};
+
     my $status = status($domain);
     if ($status) {
         $post->{reprovision_state}    = $status->{state};
@@ -338,19 +355,35 @@ Run bin/provision for one guest, which generates its configuration from its
 recipe and then builds the machine.
 
     domain      which guest.  Required, and validated as a hostname.
+    user        who asked.  For the log, and whose vault to look in.
     passphrase  the KeePass passphrase the recipe's secrets need, on stdin.
-    user        who asked, for the log.
+    totp        a code, instead of the passphrase, when they have stored one.
+    remember    store the passphrase they just typed, so that next time is a code.
 
 Returns immediately.  Provisioning a machine takes minutes and an HTTP worker
 does not have minutes, so the work is handed to a detached process and the
 caller is told it started rather than told it finished.  What happened is in
 logs/reprovision/$domain.log, which is what _log_tail reads back onto the page.
 
-The passphrase is passed down the child's stdin and is never written anywhere:
-not to the log, not to the process table, not to the configuration.  It is asked
-for because every recipe inherits secret: values from _base, and the alternative
-to asking each time is keeping the master password for every secret the operator
-holds in a file the webserver can read.
+=head3 WHY IT ASKS FOR ANYTHING AT ALL
+
+Every recipe inherits secret: values from _base, so the provisioner opens a
+KeePass database on its way past and wants the passphrase to it on stdin.  The
+alternative to asking is keeping the master password for every secret an
+operator holds in a file the webserver can read, which is worse than asking.
+
+Asking every time has its own cost, though: a passphrase somebody has to have to
+hand is a passphrase that gets written down, and then it is in a text file
+instead of a config file, which is not an improvement.  So it can be stored --
+see Trog::Vault, where it is sealed under a key the database does not contain --
+and then what this asks for is a TOTP code, which proves the person is here
+without their having to be holding anything.
+
+The code is spent either way, so it authorizes this one reprovision rather than
+every reprovision inside its window.
+
+Whichever it arrives as, the passphrase goes down the child's stdin and is
+written nowhere: not the log, not the process table, not the configuration.
 
 =cut
 
@@ -372,7 +405,8 @@ sub reprovision (%args) {
     my $recipe = _recipe_path( $config, $safe );
     return ( 0, "there is no recipe for '$safe' in $config->{recipes}" ) unless $recipe;
 
-    return ( 0, 'a passphrase is required, as the provisioner asks for one' ) unless length( $args{passphrase} // '' );
+    my ( $passphrase, $why ) = _passphrase(%args);
+    return ( 0, $why ) unless defined $passphrase;
 
     # One at a time.  Two provisions of one machine racing each other is not
     # something anybody should be able to start by double clicking.
@@ -384,12 +418,56 @@ sub reprovision (%args) {
 
     INFO( "Reprovision of '$safe' requested by " . ( $args{user} // 'somebody' ) );
 
-    my ( $ok, $why ) = _spawn( $safe, $args{passphrase}, $config->{trog_provisioner}, \@command, $args{user} );
-    return ( 0, $why ) unless $ok;
+    my ( $ok, $failed ) = _spawn( $safe, $passphrase, $config->{trog_provisioner}, \@command, $args{user} );
+    return ( 0, $failed ) unless $ok;
 
     return ( 1, "reprovision started; watch $log_dir/$safe.log" );
 }
 
+=head2 _passphrase(%args) = ($passphrase, $why)
+
+The passphrase to hand the provisioner, out of whichever of the two things the
+form sent.
+
+A code is only ever exchanged for a passphrase that is already stored -- it is
+proof of presence, not a password, and there is nothing to derive from it.  A
+typed passphrase is used as it is, and stored on the way past if they asked for
+it to be, so that the next run is a code.
+
+Undef with a reason for anything else, and the reason is what the person sees:
+this is the message that has to distinguish 'that code is stale' from 'there is
+nothing stored to unlock'.
+
+=cut
+
+sub _passphrase (%args) {
+    my $user = $args{user} // '';
+
+    if ( length( $args{totp} // '' ) ) {
+        return ( undef, 'a code identifies somebody, and nobody is logged in' ) unless $user;
+
+        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 ( $stored, '' );
+    }
+
+    return ( undef, 'a passphrase is required, as the provisioner asks for one' ) unless length( $args{passphrase} // '' );
+
+    if ( $args{remember} && $user ) {
+        my ( $ok, $why ) = Trog::Vault::set( $user, $secret_name, $args{passphrase} );
+
+        # Said rather than refused: they asked for a reprovision and gave us
+        # what it needs, and failing to file the passphrase away is not a
+        # reason not to do the thing they asked for.
+        WARN("Could not remember the provisioning passphrase for $user: $why") unless $ok;
+    }
+
+    return ( $args{passphrase}, '' );
+}
+
 =head2 _spawn($domain, $passphrase, $dir, $command, $user)
 
 Fork the provisioner off where nothing is waiting for it.

+ 109 - 5
lib/Trog/Routes/HTML.pm

@@ -85,6 +85,27 @@ our %routes = (
         totp_exempt => 1,
         callback    => \&Trog::Routes::HTML::totp,
     },
+    '/secrets' => {
+        method   => 'GET',
+        auth     => 1,
+        nocache  => 1,
+        noindex  => 1,
+        callback => \&Trog::Routes::HTML::secrets,
+    },
+    '/secrets/save' => {
+        method   => 'POST',
+        auth     => 1,
+        nocache  => 1,
+        noindex  => 1,
+        callback => \&Trog::Routes::HTML::secrets_save,
+    },
+    '/secrets/forget' => {
+        method   => 'POST',
+        auth     => 1,
+        nocache  => 1,
+        noindex  => 1,
+        callback => \&Trog::Routes::HTML::secrets_forget,
+    },
     '/post/save' => {
         method   => 'POST',
         auth     => 1,
@@ -921,6 +942,81 @@ rather than a new one.
 
 =cut
 
+=head2 secrets
+
+=head2 secrets_save
+
+=head2 secrets_forget
+
+Implements /secrets, where a user manages what tCMS remembers on their behalf.
+
+These are passwords that are not tCMS's -- the KeePass passphrase a provisioning
+recipe's secret: values are behind, to begin with.  Storing one means the next
+thing that needs it asks for a TOTP code instead, which is a thing you have on
+you rather than a thing you have to have written down somewhere.
+
+Everything here is that user's own.  There is no route by which one person's
+secrets are listed, opened or deleted by another, admin included: the username
+is taken from the session and never from the request, and it is bound into the
+ciphertext, so a row is not readable as anybody but its owner.
+
+Nothing ever renders a value back.  A secret you can read off a screen is one
+you did not need us to keep -- if they have forgotten it, the answer is to
+replace it, not to be shown it.
+
+=cut
+
+sub secrets ($query) {
+    require Trog::Vault;
+
+    my @secrets = map {
+        my $secret = {%$_};
+        $secret->{stored} = _stamp( $secret->{created} );
+        $secret->{used}   = $secret->{last_used} ? _stamp( $secret->{last_used} ) : 'never';
+        $secret
+    } @{ Trog::Vault::list( $query->{user} ) };
+
+    return Trog::Routes::HTML::index(
+        {
+            title       => 'Stored Secrets',
+            theme_dir   => Trog::Themes::td(),
+            template    => 'secrets.tx',
+            stylesheets => [qw{config.css}],
+            secrets     => \@secrets,
+            has_vault   => Trog::Vault::has_key(),
+            failure     => $query->{failure} // -1,
+            %$query,
+        },
+        undef,
+        [qw{config.css}],
+    );
+}
+
+sub _stamp ($epoch) {
+    my @t = localtime($epoch);
+    return sprintf( '%04d-%02d-%02d %02d:%02d', $t[5] + 1900, $t[4] + 1, $t[3], $t[2], $t[1] );
+}
+
+sub secrets_save ($query) {
+    require Trog::Vault;
+
+    my ( $ok, $message ) = Trog::Vault::set( $query->{user}, $query->{name} // '', $query->{secret} // '' );
+
+    # This hash is cloned, logged around and handed to renderers.
+    delete $query->{secret};
+
+    return _feedback_redirect( $query, '/secrets', $ok ? 0 : 1, $message );
+}
+
+sub secrets_forget ($query) {
+    require Trog::Vault;
+
+    my $name = $query->{name} // '';
+    my $gone = Trog::Vault::forget( $query->{user}, $name );
+
+    return _feedback_redirect( $query, '/secrets', $gone ? 0 : 1, $gone ? "'$name' forgotten" : "there was nothing stored as '$name'" );
+}
+
 sub resetpass ($query) {
     $query->{failure} //= -1;
 
@@ -2439,8 +2535,12 @@ trog-provisioner.  The only route which reaches
 Trog::DataSource::ProvisionedVirt::reprovision.
 
 This is not a recoverable operation, so read that function before changing
-anything here.  It refuses for the guest tCMS is itself running on, and the
-passphrase the provisioner asks for arrives with the request and is not stored.
+anything here.  It refuses for the guest tCMS is itself running on.
+
+What the provisioner needs to open its secrets database arrives with the request
+and never lands in this hash: either a passphrase, which is used and forgotten
+unless they asked for it to be kept, or a TOTP code, which is spent and
+exchanged for the passphrase they kept last time.
 
 =cut
 
@@ -2457,13 +2557,17 @@ sub guest_reprovision ($query) {
     # host, so a form field by that name never arrives.
     my ( $ok, $message ) = Trog::DataSource::ProvisionedVirt::reprovision(
         domain     => $query->{guest},
-        passphrase => $query->{passphrase},
         user       => $query->{user},
+        passphrase => $query->{passphrase},
+        totp       => $query->{totp},
+        remember   => $query->{remember},
     );
 
     # Deleted rather than merely unused: this hash is cloned, logged around and
-    # handed to renderers, and the passphrase has no business in any of that.
-    delete $query->{passphrase};
+    # handed to renderers, and neither of these has any business in any of that.
+    # The code as much as the passphrase -- it is spent by now, but a spent code
+    # in a log is still a code somebody wrote down.
+    delete $query->{$_} foreach qw{passphrase totp};
 
     return _feedback_redirect( $query, $to, $ok ? 0 : 1, ( $query->{guest} // 'guest' ) . ": $message" );
 }

+ 17 - 0
lib/Trog/Vault.pm

@@ -277,6 +277,23 @@ sub list ($user) {
     return $rows;
 }
 
+=head2 has($user, $name) = BOOL
+
+Whether there is something stored under that name, without opening it.
+
+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.
+
+=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;
+}
+
 =head2 forget($user, $name) = BOOL
 
 =cut

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

@@ -27,7 +27,16 @@ BEGIN {
     $ROOT   = File::Temp::tempdir( 'tcms-provisioned-XXXXXX', TMPDIR => 1, CLEANUP => 1 );
 
     $ENV{HOME} = $ROOT;
-    File::Path::make_path("$ROOT/$_") for qw{config logs www/assets};
+    File::Path::make_path("$ROOT/$_") for qw{config schema logs totp www/assets};
+
+    # A vault, so that the half of this which is about not having to type a
+    # passphrase every time has somewhere to keep one.  The key arrives the way
+    # a deployment hands one over, and Trog::Vault takes it out of %ENV as it
+    # loads -- so this has to be set before the SUT is required, below.
+    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) );
 
     # 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
@@ -74,6 +83,9 @@ sub write_config {
 
 require_ok('Trog::DataSource::ProvisionedVirt') or BAIL_OUT("Can't find SUT");
 
+Trog::Auth::useradd( 'admin', 'The Admin', 'hunter2', ['admin'], 'admin@example.com' )
+  or BAIL_OUT('could not make a user to test with');
+
 my $log = Test::MockModule->new('Trog::DataSource::ProvisionedVirt');
 $log->redefine( WARN => sub { note(shift) } );
 $log->redefine( INFO => sub { note(shift) } );
@@ -99,6 +111,9 @@ sub reprovision {
     return Trog::DataSource::ProvisionedVirt::reprovision( passphrase => 'hunter2', user => 'admin', @_ );
 }
 
+require Trog::Auth;
+require Trog::Vault;
+
 subtest 'it is Virt, with more' => sub {
     ok( Trog::DataSource::ProvisionedVirt->isa('Trog::DataSource::Virt'), 'it is a Virt' );
 
@@ -203,6 +218,50 @@ subtest 'what it would run' => sub {
     is( scalar( @{ $run->{command} } ), 2, 'a program and one argument -- nothing to be parsed' );
 };
 
+subtest 'a stored passphrase, and a code instead of one' => sub {
+    no warnings qw{once};
+
+    # Nothing stored yet, so the form asks for the passphrase and offers to keep
+    # it -- there is a vault key in this sandbox, so keeping it is on the table.
+    $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}, 0, 'with nothing stored the form asks for the passphrase' );
+    is( $post->{can_remember},          1, 'and can offer to remember it' );
+
+    # A code is worth nothing when there is nothing stored to unlock: it is
+    # proof somebody is here, not a password.
+    my ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => undef, totp => '123456' );
+    is( $ok, 0, 'a code with nothing stored behind it does not provision' );
+    is_deeply( \@SPAWNED, [], 'having run nothing' );
+
+    # Reprovision the old way, and ask for it to be kept.
+    ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => 'correct horse', remember => 1 );
+    is( $ok,                     1,               'a typed passphrase still provisions' ) or diag($why);
+    is( $SPAWNED[0]{passphrase}, 'correct horse', 'and is what reaches the provisioner' );
+
+    ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, { user => 'admin' } );
+    is( $post->{passphrase_remembered}, 1, 'after which the form asks for a code instead' );
+
+    # And now the point of all of it: a code, spent, exchanged for what was
+    # stored.  Enrol first -- a code is only meaningful for somebody enrolled.
+    my ( $uri, $qr, $failure, $message, $totp ) = Trog::Auth::totp( 'admin', 'example.com' );
+    ok( $uri, 'the user is enrolled' ) or diag($message);
+    my $code = $totp->expected_totp_code( time() );
+
+    ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => undef, totp => $code );
+    is( $ok,                     1,               'a code provisions' ) or diag($why);
+    is( $SPAWNED[0]{passphrase}, 'correct horse', 'handing the provisioner the passphrase that was stored' );
+
+    # Spent, so it is worth this one reprovision and not every reprovision
+    # somebody can click inside the window.
+    ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => undef, totp => $code );
+    is( $ok, 0, 'and cannot be used again' );
+    like( $why, qr/already been used/, 'saying so' );
+    is_deeply( \@SPAWNED, [], 'without running a second one' );
+
+    Trog::Vault::forget( 'admin', $Trog::DataSource::ProvisionedVirt::secret_name );
+};
+
 subtest 'without the provisioner configured it is just Virt' => sub {
     write_config();
 

+ 48 - 0
t/Trog-Vault.t

@@ -255,4 +255,52 @@ 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 'the page a user manages them from' => sub {
+    require Trog::Routes::HTML;
+
+    my $tpsgi = bless {}, 'FakeTPSGI';
+    {
+        no warnings qw{once};
+        *FakeTPSGI::see_also  = sub { return [ 303, [ Location => $_[1] ], [''] ] };
+        *FakeTPSGI::forbidden = sub { return [ 403, [], ['no'] ] };
+    }
+
+    my $route = $Trog::Routes::HTML::routes{'/secrets'};
+    ok( $route,                 'the page is a route' );
+    ok( $route->{auth},         'behind a login' );
+    ok( $route->{nocache},      'and never cached, since it is about who is asking' );
+    ok( !$route->{totp_exempt}, 'and not somewhere an unenrolled login can get to' );
+
+    my $rendered;
+    my $index = Test::MockModule->new('Trog::Routes::HTML');
+    $index->redefine( index => sub { $rendered = $_[0]; return [ 200, [], [''] ] } );
+
+    Trog::Routes::HTML::secrets( { user => 'bob', user_acls => ['admin'], tpsgi => $tpsgi } );
+    ok( $rendered->{has_vault}, 'the page knows there is a vault' );
+    my ($keepass) = grep { $_->{name} eq 'keepass' } @{ $rendered->{secrets} };
+    ok( $keepass,                       "bob's own secret is listed" );
+    ok( !exists $keepass->{ciphertext}, 'without its ciphertext' );
+    ok( $keepass->{stored},             'with when it was stored' );
+
+    # The username comes off the session, so there is no field to put somebody
+    # else's in.
+    Trog::Routes::HTML::secrets( { user => 'eve', user_acls => ['public'], tpsgi => $tpsgi } );
+    is_deeply( $rendered->{secrets}, [], "and eve sees none of bob's" );
+
+    # Storing, and the thing stored not surviving the request that stored it.
+    my $query = { user => 'eve', tpsgi => $tpsgi, name => 'ssh', secret => 'a passphrase' };
+    my $res   = Trog::Routes::HTML::secrets_save($query);
+    is( $res->[0], 303, 'saving redirects back to the page' );
+    ok( !exists $query->{secret}, 'and the secret is gone from the query afterwards' );
+    is( Trog::Vault::get( 'eve', 'ssh' ), 'a passphrase', 'having been stored' );
+
+    $res = Trog::Routes::HTML::secrets_forget( { user => 'eve', tpsgi => $tpsgi, name => 'ssh' } );
+    is( $res->[0],                        303,   'forgetting redirects too' );
+    is( Trog::Vault::get( 'eve', 'ssh' ), undef, 'and it is gone' );
+
+    # One user cannot reach into another's, whatever they post.
+    Trog::Routes::HTML::secrets_forget( { user => 'eve', tpsgi => $tpsgi, name => 'keepass' } );
+    is( Trog::Vault::get( 'bob', 'keepass' ), 'correct horse battery staple', "bob's is untouched by eve asking to forget it" );
+};
+
 done_testing();

+ 21 - 1
www/templates/html/components/forms/provisioned_guests.tx

@@ -122,11 +122,31 @@
                             This destroys the guest and builds it again from its recipe.
                             Anything on it the recipe does not describe will not come back.
                         </p>
+                        : # Either the passphrase, or a code standing in for the
+                        : # passphrase they already stored.  See _passphrase() in
+                        : # ProvisionedVirt: a code is proof they are here, and is
+                        : # exchanged for the stored one rather than being any part
+                        : # of it.
+                        : if ( $post.passphrase_remembered ) {
+                        <label>
+                            TOTP code
+                            <input required autofocus class="cooltext" type="password" name="totp" autocomplete="off"
+                                   inputmode="numeric" pattern="[0-9]{6}"
+                                   title="unlocks the provisioning passphrase you stored; spent on this run, so wait for the next code if you just used one" />
+                        </label>
+                        : } else {
                         <label>
                             Secrets passphrase
                             <input required autofocus class="cooltext" type="password" name="passphrase" autocomplete="off"
-                                   title="the provisioner asks for this on stdin; it is used for this run and not stored" />
+                                   title="the provisioner asks for this on stdin" />
+                        </label>
+                        : if ( $post.can_remember ) {
+                        <label class="guest-reprovision-remember">
+                            <input type="checkbox" name="remember" value="1" />
+                            Remember it, and ask me for a TOTP code next time
                         </label>
+                        : }
+                        : }
                         <div class="guest-reprovision-actions">
                             <button class="coolbutton" type="button"
                                     command="hide-popover" commandfor="reprovision-<: $post.id :>"

+ 76 - 0
www/templates/html/components/secrets.tx

@@ -0,0 +1,76 @@
+<div id="backoffice">
+    <h2>Stored Secrets</h2>
+    <p>
+        Passwords that are not tCMS's, which tCMS needs in order to do something
+        on your behalf &mdash; the passphrase to the provisioner's secrets
+        database, to begin with.  Store one here and the next thing that needs
+        it asks you for a TOTP code instead, which is a thing you have on you
+        rather than a thing you have written down.
+    </p>
+
+    : if ( !$has_vault ) {
+    <p class="guest-reprovision-warning">
+        This installation has no vault key, so there is nowhere to keep a secret
+        and nothing below will save.  See <code>Trog::Vault</code>: the key is
+        handed to the service by systemd, and without one the things that would
+        have used a stored secret go on asking for the password directly.
+    </p>
+    : }
+
+    : if ( $secrets ) {
+    <table class="cooltext" style="width:100%;border-collapse:collapse;">
+        <thead>
+            <tr style="text-align:left;">
+                <th style="padding:4px 8px;">Name</th>
+                <th style="padding:4px 8px;">Stored</th>
+                <th style="padding:4px 8px;">Last used</th>
+                <th style="padding:4px 8px;"></th>
+            </tr>
+        </thead>
+        <tbody>
+            : for $secrets -> $secret {
+            <tr>
+                <td style="padding:4px 8px;font-family:monospace;"><: $secret.name :></td>
+                <td style="padding:4px 8px;white-space:nowrap;"><: $secret.stored :></td>
+                <td style="padding:4px 8px;white-space:nowrap;"><: $secret.used :></td>
+                <td style="padding:4px 8px;">
+                    : # A row sealed under a key this installation no longer has
+                    : # 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>
+                    : }
+                    <form method="POST" action="/secrets/forget" style="display:inline;">
+                        <input type="hidden" name="name" value="<: $secret.name :>" />
+                        <button class="coolbutton guest-destroy" type="submit">Forget</button>
+                    </form>
+                </td>
+            </tr>
+            : }
+        </tbody>
+    </table>
+    : } else {
+    <p>Nothing stored.</p>
+    : }
+
+    <br />
+    <h3>Store one</h3>
+    <p>
+        Storing under a name that is already here replaces it.  Nothing on this
+        page ever shows a value back to you &mdash; if you have forgotten one,
+        replace it.
+    </p>
+    <form method="POST" action="/secrets/save">
+        <label>
+            Name
+            <input required class="cooltext" type="text" name="name" pattern="[A-Za-z0-9][A-Za-z0-9._-]*" maxlength="64"
+                   placeholder="provisioner"
+                   title="letters, digits, dot, dash and underscore.  'provisioner' is the one reprovisioning looks for." />
+        </label>
+        <label>
+            Secret
+            <input required class="cooltext" type="password" name="secret" autocomplete="off" />
+        </label>
+        <input class="coolbutton" type="submit" value="Store it" />
+    </form>
+</div>

+ 1 - 1
www/templates/html/components/sessions.tx

@@ -5,7 +5,7 @@
         <label>Event:
             <select class="cooltext" name="filter_event">
                 <option value="">All</option>
-                : for ['login_success','login_failure','totp_failure','logout'] -> $ev {
+                : for ['login_success','login_failure','totp_failure','totp_replay','logout','secret_read','secret_write','secret_delete'] -> $ev {
                 <option value="<: $ev :>" <: $ev == $filter_event ? 'selected' : '' | mark_raw :>><: $ev :></option>
                 : }
             </select>

+ 1 - 0
www/templates/html/sysbar.tx

@@ -8,6 +8,7 @@
         <a href="/admin/sessions"  title="Session Audit Log" class="topbar">Sessions</a>
         <a href="/admin/wyzzerdd"  title="Post Type wizard" class="topbar">Post Types</a>
         <a href="/totp"        title="TOTP"          class="topbar">View TOTP 2fa</a>
+        <a href="/secrets"     title="Stored Secrets" class="topbar">Secrets</a>
         <a href="/password_reset" title="Reset Password" class="topbar">Reset Password</a>
         <a href="/logout"      title="Logout"        class="topbar">🚪</a>
     </span>