Vault.pm 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. package Trog::Vault;
  2. use v5.36;
  3. use re '/aa';
  4. use Crypt::AuthEnc::GCM ();
  5. use Crypt::KeyDerivation ();
  6. use Crypt::Misc ();
  7. use Crypt::PRNG ();
  8. use Digest::SHA ();
  9. use File::Slurper ();
  10. use Trog::Auth;
  11. use Trog::Log qw{WARN INFO};
  12. use Trog::SQLite;
  13. =head1 Trog::Vault
  14. Things a user asked us to remember, kept where the database alone will not give
  15. them up.
  16. =head1 WHAT THIS IS FOR
  17. Some of what tCMS does on a user's behalf needs a password that is not tCMS's:
  18. the KeePass passphrase every provisioning recipe's secret: values are behind,
  19. for one. Asking for it every time works, and is what reprovisioning used to do,
  20. but it means the person has to be holding it -- so it gets written down, and the
  21. thing that was too dangerous to keep in a config file ends up in a text file
  22. instead.
  23. So we keep it, and ask for a TOTP code instead of the password when it is time
  24. to use it.
  25. =head1 WHAT A TOTP CODE IS AND IS NOT
  26. It is not a key. Six digits is twenty bits, it changes every thirty seconds,
  27. and we have to know the secret to check it, so nothing can be encrypted with it
  28. and stay encrypted. What it is, is evidence that the person is at the keyboard
  29. right now, which is exactly what you want to demand before handing a stored
  30. password to a process. See Trog::Auth::spend_totp: a code is worth one thing
  31. rather than everything you can do inside its window.
  32. The key is elsewhere.
  33. =head1 WHERE THE KEY IS
  34. An installation has one master key, and it is not in the database, so the
  35. database on its own is ciphertext. It is looked for in this order:
  36. TCMS_VAULT_KEY base64, in the environment
  37. $CREDENTIALS_DIRECTORY/tcms-vault
  38. config/secrets.key base64, in a file
  39. The first is how a real deployment does it, and the reason it is first is the
  40. chroot: tPSGI runs the workers chrooted into the installation, and systemd's
  41. credential store is a ramdisk at /run/credentials which is outside it. So the
  42. unit takes the credential, service/tpsgi.sh reads it before the chroot happens
  43. and puts it in the environment, and the key never touches a disk anywhere.
  44. It is taken out of the environment at load, so nothing we fork afterwards
  45. inherits it -- which matters, because one of the things we fork is a provisioner
  46. that we are handing a decrypted password to on its stdin. Read once, in the
  47. parent, before there are any workers; the workers inherit the key as a variable
  48. and an environment without it.
  49. The second is for a service that is not chrooted. The third is for a machine
  50. that is not running systemd at all, and it is the weakest of the three: a file
  51. next to the database it protects is a file that goes into the same backup.
  52. No key means no vault. Nothing is stored and nothing is offered, and the parts
  53. of tCMS which would have used one ask for the password directly, as they did
  54. before. That is a feature being off, not a failure.
  55. =head1 WHAT IS ACTUALLY DONE TO A SECRET
  56. AES-256-GCM, under a key derived for that one row:
  57. salt 32 random bytes, fresh for every write
  58. key, iv HKDF-SHA256(master, salt) -- 44 bytes, split 32 and 12
  59. aad the username and the secret's name
  60. The salt being fresh per write is what makes the derived key and iv fresh per
  61. write, which is the thing GCM cares about. The username and name go in as
  62. associated data rather than being merely stored alongside, so a row cannot be
  63. lifted into another user's account or renamed into being the answer to a
  64. different question -- the tag stops checking out.
  65. key_id is a fingerprint of the master key. It is not needed to decrypt
  66. anything; it is there so that a row written under a key you no longer have says
  67. so, rather than presenting as corruption.
  68. =head1 FUNCTIONS
  69. =cut
  70. # 32 raw bytes. Read at load: see WHERE THE KEY IS -- this is the moment the
  71. # environment is scrubbed, and it has to be before anything forks.
  72. our $key_file = 'config/secrets.key';
  73. our $credential = 'tcms-vault';
  74. our $key_bytes = 32;
  75. our $master = _from_env();
  76. our $looked_further = 0;
  77. sub _from_env {
  78. my $raw = delete $ENV{TCMS_VAULT_KEY};
  79. return undef unless defined $raw && length($raw);
  80. return _decode( $raw, 'TCMS_VAULT_KEY' );
  81. }
  82. sub _decode ( $raw, $whence ) {
  83. $raw =~ s/\s+//g;
  84. my $key = eval { Crypt::Misc::decode_b64($raw) };
  85. if ( !defined $key || length($key) != $key_bytes ) {
  86. WARN("The vault key in $whence is not $key_bytes base64 encoded bytes; ignoring it");
  87. return undef;
  88. }
  89. return $key;
  90. }
  91. # The two places worth looking that are not the environment. Deferred rather
  92. # than done at load, since both are files and this module is loaded by things
  93. # which have no interest in a vault.
  94. sub _look_further {
  95. $looked_further = 1;
  96. my $dir = $ENV{CREDENTIALS_DIRECTORY};
  97. if ($dir) {
  98. my $path = "$dir/$credential";
  99. my $raw = eval { File::Slurper::read_binary($path) };
  100. return _decode( $raw, $path ) if defined $raw;
  101. }
  102. my $raw = eval { File::Slurper::read_binary($key_file) };
  103. return undef unless defined $raw;
  104. # A key file the group or the world can read is a key file that has already
  105. # been read. Said rather than refused: the alternative is a site that
  106. # silently stops being able to open its own secrets.
  107. my @stat = stat($key_file);
  108. WARN("$key_file is readable by more than its owner; chmod 600 it") if @stat && ( $stat[2] & oct('077') );
  109. return _decode( $raw, $key_file );
  110. }
  111. =head2 key() = STRING or undef
  112. The master key, or undef when this installation has none.
  113. =head2 has_key() = BOOL
  114. Whether there is a vault at all. Ask this before offering a user anywhere to
  115. put a secret, so that a site with no key says so rather than accepting one and
  116. losing it.
  117. =cut
  118. sub key {
  119. $master //= _look_further() unless $looked_further;
  120. return $master;
  121. }
  122. sub has_key { return key() ? 1 : 0 }
  123. =head2 key_id() = STRING
  124. A fingerprint of the master key, stored on each row so that a row written under
  125. a key which is no longer here can say which.
  126. =cut
  127. sub key_id {
  128. my $key = key() or return '';
  129. return substr( Digest::SHA::sha256_hex($key), 0, 16 );
  130. }
  131. # Bound into the ciphertext, so that moving a row between users or renaming it
  132. # breaks the tag rather than working.
  133. sub _aad ( $user, $name ) { return join( "\0", $user, $name ) }
  134. sub _derive ($salt) {
  135. my $material = Crypt::KeyDerivation::hkdf( key(), $salt, 'SHA256', 44, 'tcms/user-secret' );
  136. return ( substr( $material, 0, 32 ), substr( $material, 32, 12 ) );
  137. }
  138. =head2 set($user, $name, $secret) = ($ok, $message)
  139. Remember one thing for one user, replacing whatever was there under that name.
  140. =cut
  141. sub set ( $user, $name, $secret ) {
  142. return ( 0, 'this installation has no vault key, so there is nowhere to keep it' ) unless has_key();
  143. return ( 0, 'a secret needs a name' ) unless _safe_name($name);
  144. return ( 0, 'nothing to store' ) unless defined $secret && length($secret);
  145. my $salt = Crypt::PRNG::random_bytes(32);
  146. my ( $key, $iv ) = _derive($salt);
  147. my $gcm = Crypt::AuthEnc::GCM->new( 'AES', $key, $iv );
  148. $gcm->adata_add( _aad( $user, $name ) );
  149. my $ciphertext = $gcm->encrypt_add($secret);
  150. my $tag = $gcm->encrypt_done();
  151. my $dbh = _dbh();
  152. $dbh->do(
  153. "INSERT OR REPLACE INTO user_secret (username, name, key_id, salt, ciphertext, tag, created) VALUES (?,?,?,?,?,?,?)",
  154. undef, $user, $name, key_id(), $salt, $ciphertext, $tag, time()
  155. ) or return ( 0, 'could not store it' );
  156. Trog::Auth::log_event( 'secret_write', $user );
  157. return ( 1, "'$name' stored" );
  158. }
  159. =head2 get($user, $name) = STRING or undef
  160. What was stored, or undef if there is nothing under that name, no key to open it
  161. with, or a key which is not the one it was sealed under.
  162. Every call is an audit_log row. Reading a stored password is the interesting
  163. event here, not writing one.
  164. =cut
  165. sub get ( $user, $name ) {
  166. return undef unless has_key();
  167. my $dbh = _dbh();
  168. my $rows = $dbh->selectall_arrayref(
  169. "SELECT key_id, salt, ciphertext, tag FROM user_secret WHERE username=? AND name=?",
  170. { Slice => {} }, $user, $name
  171. );
  172. return undef unless ref $rows eq 'ARRAY' && @$rows;
  173. my $row = $rows->[0];
  174. if ( $row->{key_id} ne key_id() ) {
  175. WARN( "'$name' for $user was sealed under vault key $row->{key_id}, and this installation has " . key_id() );
  176. return undef;
  177. }
  178. my ( $key, $iv ) = _derive( $row->{salt} );
  179. my $gcm = Crypt::AuthEnc::GCM->new( 'AES', $key, $iv );
  180. $gcm->adata_add( _aad( $user, $name ) );
  181. my $secret = $gcm->decrypt_add( $row->{ciphertext} );
  182. # The tag is the whole point: it says this ciphertext, under this key, for
  183. # this user and this name, is the one that was written.
  184. if ( !$gcm->decrypt_done( $row->{tag} ) ) {
  185. WARN("'$name' for $user did not survive its own tag check; it has been tampered with or the database is damaged");
  186. return undef;
  187. }
  188. $dbh->do( "UPDATE user_secret SET last_used=? WHERE username=? AND name=?", undef, time(), $user, $name );
  189. Trog::Auth::log_event( 'secret_read', $user );
  190. return $secret;
  191. }
  192. =head2 list($user) = ARRAYREF
  193. What this user has stored, as names and dates. Never values -- there is no
  194. route, page or log line anywhere that shows one back to them, because a secret
  195. you can read off a screen is one you did not need us to keep.
  196. =cut
  197. sub list ($user) {
  198. my $dbh = _dbh();
  199. my $rows = $dbh->selectall_arrayref(
  200. "SELECT name, created, last_used, key_id FROM user_secret WHERE username=? ORDER BY name",
  201. { Slice => {} }, $user
  202. );
  203. return [] unless ref $rows eq 'ARRAY';
  204. # A row we cannot open is worth saying so about rather than listing as
  205. # though it were usable.
  206. my $id = key_id();
  207. $_->{readable} = ( $id && $_->{key_id} eq $id ) ? 1 : 0 foreach @$rows;
  208. return $rows;
  209. }
  210. =head2 has($user, $name) = BOOL
  211. Whether there is something stored under that name, without opening it.
  212. Separate from get() on purpose: a page that wants to know whether to draw a
  213. passphrase box or a code box is asking a different question from a request that
  214. is about to hand a password to a provisioner, and only the second one belongs in
  215. the audit log.
  216. =cut
  217. sub has ( $user, $name ) {
  218. my $dbh = _dbh();
  219. my $rows = $dbh->selectall_arrayref( "SELECT name FROM user_secret WHERE username=? AND name=? AND key_id=?", { Slice => {} }, $user, $name, key_id() );
  220. return ref $rows eq 'ARRAY' && @$rows ? 1 : 0;
  221. }
  222. =head2 forget($user, $name) = BOOL
  223. =cut
  224. sub forget ( $user, $name ) {
  225. my $dbh = _dbh();
  226. my $res = $dbh->do( "DELETE FROM user_secret WHERE username=? AND name=?", undef, $user, $name );
  227. Trog::Auth::log_event( 'secret_delete', $user ) if $res && $res != 0;
  228. return $res && $res != 0 ? 1 : 0;
  229. }
  230. # A name is a label in a form and a key in a table, and it is half of what binds
  231. # a row to its owner, so it is a short plain thing or it is nothing.
  232. sub _safe_name ($name) {
  233. return 0 unless defined $name && length($name) && length($name) <= 64;
  234. return $name =~ m/^[A-Za-z0-9][A-Za-z0-9._-]*$/ ? 1 : 0;
  235. }
  236. # The same database the users are in, since a secret belongs to one.
  237. sub _dbh { return Trog::SQLite::dbh( 'schema/auth.schema', 'config/auth.db' ) }
  238. 1;