Vault.pm 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  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. TPSGI_VAULT_KEY base64, in the environment
  37. $CREDENTIALS_DIRECTORY/tpsgi-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 named for tPSGI rather than for tCMS because tPSGI is what owns the unit
  45. and runs whatever is in the directory, and tCMS is only one of the things that
  46. can be. One service, one key.
  47. It is taken out of the environment at load, so nothing we fork afterwards
  48. inherits it -- which matters, because one of the things we fork is a provisioner
  49. that we are handing a decrypted password to on its stdin. Read once, in the
  50. parent, before there are any workers; the workers inherit the key as a variable
  51. and an environment without it.
  52. The second is for a service that is not chrooted.
  53. The third is for a tCMS nothing is handing a key to, which is what you get from
  54. running tPSGI by hand -- the ordinary way to develop against this, since it is
  55. easier to watch and to restart. There is no unit and no service/tpsgi.sh in that
  56. path, so nothing put a key in the environment, and bin/tcms-vault-key --file
  57. writes one here instead.
  58. It is the weakest of the three and the only one you should not be running in
  59. production: a file next to the database it protects is a file that goes into the
  60. same backup.
  61. No key means no vault. Nothing is stored and nothing is offered, and the parts
  62. of tCMS which would have used one ask for the password directly, as they did
  63. before. That is a feature being off, not a failure.
  64. =head1 WHAT IS ACTUALLY DONE TO A SECRET
  65. AES-256-GCM, under a key derived for that one row:
  66. salt 32 random bytes, fresh for every write
  67. key, iv HKDF-SHA256(master, salt) -- 44 bytes, split 32 and 12
  68. aad the username and the secret's name
  69. The salt being fresh per write is what makes the derived key and iv fresh per
  70. write, which is the thing GCM cares about. The username and name go in as
  71. associated data rather than being merely stored alongside, so a row cannot be
  72. lifted into another user's account or renamed into being the answer to a
  73. different question -- the tag stops checking out.
  74. key_id is a fingerprint of the master key. It is not needed to decrypt
  75. anything; it is there so that a row written under a key you no longer have says
  76. so, rather than presenting as corruption.
  77. =head1 FUNCTIONS
  78. =cut
  79. # 32 raw bytes. Read at load: see WHERE THE KEY IS -- this is the moment the
  80. # environment is scrubbed, and it has to be before anything forks.
  81. our $key_file = 'config/secrets.key';
  82. our $credential = 'tpsgi-vault';
  83. our $key_bytes = 32;
  84. our $master = _from_env();
  85. our $looked_further = 0;
  86. sub _from_env {
  87. my $raw = delete $ENV{TPSGI_VAULT_KEY};
  88. return undef unless defined $raw && length($raw);
  89. return _decode( $raw, 'TPSGI_VAULT_KEY' );
  90. }
  91. sub _decode ( $raw, $whence ) {
  92. $raw =~ s/\s+//g;
  93. my $key = eval { Crypt::Misc::decode_b64($raw) };
  94. if ( !defined $key || length($key) != $key_bytes ) {
  95. WARN("The vault key in $whence is not $key_bytes base64 encoded bytes; ignoring it");
  96. return undef;
  97. }
  98. return $key;
  99. }
  100. # The two places worth looking that are not the environment. Deferred rather
  101. # than done at load, since both are files and this module is loaded by things
  102. # which have no interest in a vault.
  103. sub _look_further {
  104. $looked_further = 1;
  105. my $dir = $ENV{CREDENTIALS_DIRECTORY};
  106. if ($dir) {
  107. my $path = "$dir/$credential";
  108. my $raw = eval { File::Slurper::read_binary($path) };
  109. return _decode( $raw, $path ) if defined $raw;
  110. }
  111. my $raw = eval { File::Slurper::read_binary($key_file) };
  112. return undef unless defined $raw;
  113. # A key file the group or the world can read is a key file that has already
  114. # been read. Said rather than refused: the alternative is a site that
  115. # silently stops being able to open its own secrets.
  116. my @stat = stat($key_file);
  117. WARN("$key_file is readable by more than its owner; chmod 600 it") if @stat && ( $stat[2] & oct('077') );
  118. return _decode( $raw, $key_file );
  119. }
  120. =head2 key() = STRING or undef
  121. The master key, or undef when this installation has none.
  122. =head2 has_key() = BOOL
  123. Whether there is a vault at all. Ask this before offering a user anywhere to
  124. put a secret, so that a site with no key says so rather than accepting one and
  125. losing it.
  126. =cut
  127. sub key {
  128. $master //= _look_further() unless $looked_further;
  129. return $master;
  130. }
  131. sub has_key { return key() ? 1 : 0 }
  132. =head2 key_id() = STRING
  133. A fingerprint of the master key, stored on each row so that a row written under
  134. a key which is no longer here can say which.
  135. =cut
  136. sub key_id {
  137. my $key = key() or return '';
  138. return substr( Digest::SHA::sha256_hex($key), 0, 16 );
  139. }
  140. # Bound into the ciphertext, so that moving a row between users or renaming it
  141. # breaks the tag rather than working.
  142. sub _aad ( $user, $name ) { return join( "\0", $user, $name ) }
  143. sub _derive ($salt) {
  144. my $material = Crypt::KeyDerivation::hkdf( key(), $salt, 'SHA256', 44, 'tcms/user-secret' );
  145. return ( substr( $material, 0, 32 ), substr( $material, 32, 12 ) );
  146. }
  147. =head2 set($user, $name, $secret) = ($ok, $message)
  148. Remember one thing for one user, replacing whatever was there under that name.
  149. =cut
  150. sub set ( $user, $name, $secret ) {
  151. return ( 0, 'this installation has no vault key, so there is nowhere to keep it' ) unless has_key();
  152. return ( 0, 'a secret needs a name' ) unless _safe_name($name);
  153. return ( 0, 'nothing to store' ) unless defined $secret && length($secret);
  154. my $salt = Crypt::PRNG::random_bytes(32);
  155. my ( $key, $iv ) = _derive($salt);
  156. my $gcm = Crypt::AuthEnc::GCM->new( 'AES', $key, $iv );
  157. $gcm->adata_add( _aad( $user, $name ) );
  158. my $ciphertext = $gcm->encrypt_add($secret);
  159. my $tag = $gcm->encrypt_done();
  160. my $dbh = _dbh();
  161. eval {
  162. $dbh->do(
  163. "INSERT OR REPLACE INTO user_secret (username, name, key_id, salt, ciphertext, tag, created) VALUES (?,?,?,?,?,?,?)",
  164. undef, $user, $name, key_id(), $salt, $ciphertext, $tag, time()
  165. );
  166. 1;
  167. } or do {
  168. WARN("Could not store the secret '$name' of $user: $@");
  169. return ( 0, 'could not store it' );
  170. };
  171. Trog::Auth::log_event( 'secret_write', $user );
  172. return ( 1, "'$name' stored" );
  173. }
  174. =head2 get($user, $name) = STRING or undef
  175. What was stored, or undef if there is nothing under that name, no key to open it
  176. with, or a key which is not the one it was sealed under.
  177. Every call is an audit_log row. Reading a stored password is the interesting
  178. event here, not writing one.
  179. =cut
  180. sub get ( $user, $name ) {
  181. my $secret = _stored( $user, $name );
  182. return undef unless defined $secret;
  183. my $dbh = _dbh();
  184. $dbh->do( "UPDATE user_secret SET last_used=? WHERE username=? AND name=?", undef, time(), $user, $name );
  185. Trog::Auth::log_event( 'secret_read', $user );
  186. return $secret;
  187. }
  188. # What is in one row, or nothing.
  189. #
  190. # Every question anybody asks about a stored secret comes through here, get()
  191. # and has() alike, and they all get the same answer -- which is the point. A
  192. # page that says a secret is there and a request that finds it will not open are
  193. # a way to send somebody round a loop typing codes at a row that was never going
  194. # to work. Opening it is the only way to know, and it is microseconds.
  195. #
  196. # What get() adds is that reading a secret is an event: it moves last_used and
  197. # writes to the audit log. Asking whether one is usable is not an event, and
  198. # does not.
  199. sub _stored ( $user, $name ) {
  200. return undef unless has_key();
  201. my $dbh = _dbh();
  202. my $rows = $dbh->selectall_arrayref(
  203. "SELECT key_id, salt, ciphertext, tag FROM user_secret WHERE username=? AND name=?",
  204. { Slice => {} }, $user, $name
  205. );
  206. return undef unless ref $rows eq 'ARRAY' && @$rows;
  207. return _open( $rows->[0], $user, $name );
  208. }
  209. # The plaintext, or undef with a reason in the log. Never dies: a row that will
  210. # not open is a thing to report to somebody who can store it again, not an
  211. # exception halfway through drawing a page.
  212. sub _open ( $row, $user, $name ) {
  213. if ( $row->{key_id} ne key_id() ) {
  214. WARN( "'$name' for $user was sealed under vault key $row->{key_id}, and this installation has " . key_id() );
  215. return undef;
  216. }
  217. my ( $key, $iv ) = _derive( $row->{salt} );
  218. my $gcm = Crypt::AuthEnc::GCM->new( 'AES', $key, $iv );
  219. $gcm->adata_add( _aad( $user, $name ) );
  220. my $secret = $gcm->decrypt_add( $row->{ciphertext} );
  221. # The tag is the whole point: it says this ciphertext, under this key, for
  222. # this user and this name, is the one that was written.
  223. if ( !$gcm->decrypt_done( $row->{tag} ) ) {
  224. WARN("'$name' for $user did not survive its own tag check; it has been tampered with or the database is damaged");
  225. return undef;
  226. }
  227. return $secret;
  228. }
  229. =head2 list($user) = ARRAYREF
  230. What this user has stored, as names and dates. Never values -- there is no
  231. route, page or log line anywhere that shows one back to them, because a secret
  232. you can read off a screen is one you did not need us to keep.
  233. =cut
  234. sub list ($user) {
  235. my $dbh = _dbh();
  236. my $rows = $dbh->selectall_arrayref(
  237. "SELECT name, created, last_used, key_id, salt, ciphertext, tag FROM user_secret WHERE username=? ORDER BY name",
  238. { Slice => {} }, $user
  239. );
  240. return [] unless ref $rows eq 'ARRAY';
  241. # Opened rather than guessed at from the key fingerprint, so that a row this
  242. # page calls readable is one that will actually open when something asks for
  243. # it. This is the page somebody comes to when a secret has stopped working,
  244. # and it is no use to them if it agrees that everything looks fine.
  245. foreach my $row (@$rows) {
  246. $row->{readable} = ( has_key() && defined _open( $row, $user, $row->{name} ) ) ? 1 : 0;
  247. delete $row->{$_} foreach qw{salt ciphertext tag};
  248. }
  249. return $rows;
  250. }
  251. =head2 has($user, $name) = BOOL
  252. Whether there is something stored under that name that this installation can
  253. actually open.
  254. Not "is there a row": a row sealed under a key that is gone is a row that will
  255. never open again, and answering yes to this would have somebody typing codes at
  256. it. The question this is really being asked is whether to draw a passphrase box
  257. or a code box, and the honest answer to that is the one where the code works.
  258. Separate from get() because reading a secret is an event and asking about one is
  259. not. This moves nothing and writes nothing to the audit log.
  260. =cut
  261. sub has ( $user, $name ) { return defined _stored( $user, $name ) ? 1 : 0 }
  262. =head2 forget($user, $name) = BOOL
  263. =cut
  264. sub forget ( $user, $name ) {
  265. my $dbh = _dbh();
  266. my $res = $dbh->do( "DELETE FROM user_secret WHERE username=? AND name=?", undef, $user, $name );
  267. Trog::Auth::log_event( 'secret_delete', $user ) if $res && $res != 0;
  268. return $res && $res != 0 ? 1 : 0;
  269. }
  270. # A name is a label in a form and a key in a table, and it is half of what binds
  271. # a row to its owner, so it is a short plain thing or it is nothing.
  272. sub _safe_name ($name) {
  273. return 0 unless defined $name && length($name) && length($name) <= 64;
  274. return $name =~ m/^[A-Za-z0-9][A-Za-z0-9._-]*$/ ? 1 : 0;
  275. }
  276. # The same database the users are in, since a secret belongs to one.
  277. sub _dbh { return Trog::SQLite::dbh( 'schema/auth.schema', 'config/auth.db' ) }
  278. 1;