001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.secret.macos.keychain;
018
019import static java.nio.charset.StandardCharsets.*;
020import static java.util.concurrent.TimeUnit.*;
021import static org.apache.juneau.commons.utils.Shorts.*;
022
023import java.io.*;
024import java.nio.*;
025import java.util.*;
026
027import org.apache.juneau.commons.secret.*;
028
029/**
030 * A {@link SecretStore} backed by the macOS <c>security</c> keychain CLI (generic passwords).
031 *
032 * <p>
033 * Each operation shells out to <c>/usr/bin/security</c>:
034 * <ul>
035 *    <li>{@link #store} &rarr; <c>add-generic-password -U</c> (create or update).
036 *    <li>{@link #find} &rarr; <c>find-generic-password -g</c> (print the password to stderr).
037 *    <li>{@link #exists} &rarr; <c>find-generic-password</c> (presence only, without the password).
038 *    <li>{@link #delete} &rarr; <c>delete-generic-password</c>.
039 * </ul>
040 * Items are namespaced by a caller-supplied <i>service</i> name (the keychain <c>-s</c> attribute) with the secret
041 * key as the <i>account</i> (<c>-a</c>).  Choose a stable, collision-resistant service name (for example a
042 * reverse-DNS string) so unrelated consumers do not clobber each other's entries.
043 *
044 * <p>
045 * <b>Platform.</b> Only macOS is supported; on any other OS, or when <c>/usr/bin/security</c> is unavailable, calls
046 * are treated as a backend failure and resolved per the configured {@link FailMode}.
047 *
048 * <p>
049 * <b>Backend-unavailable behavior.</b> A key that is simply not present is a normal <i>absent</i> result, never a
050 * failure.  A genuine failure &mdash; the tool missing, a non-zero exit that is not "item not found", or a timeout
051 * &mdash; is resolved by the {@link FailMode} chosen at construction: {@link FailMode#FAIL_OPEN} degrades to
052 * absent/no-op, while {@link FailMode#FAIL_CLOSED} (the default) throws.
053 *
054 * <p>
055 * <b>Secret handling.</b> {@link #store} never places the secret on the {@code security} process's argv, and never
056 * relies on the CLI's password prompt (a bare <c>-w</c> makes {@code security} read from the controlling terminal,
057 * <c>/dev/tty</c>, whenever the JVM has one &mdash; not from stdin &mdash; so it would hang).  Instead it runs
058 * <c>security -i</c> (interactive mode, which reads commands from stdin) and writes a single command line to stdin:
059 * <c>add-generic-password -U -a "<i>key</i>" -s "<i>service</i>" -w "<i>secret</i>"</c>.  The secret is therefore
060 * never visible in the process table (e.g. to {@code ps}), and no terminal is ever read.  Each of the three values is
061 * double-quoted, with only <c>"</c> and <c>\</c> escaped by a preceding backslash (verified against {@code security -i}
062 * for spaces, tabs, single and double quotes, backslashes, shell metacharacters, and non-ASCII text).  Neither
063 * {@link #store} nor {@link #find} materializes the secret as a {@link String}: {@code store} encodes the
064 * {@code char[]} directly to the stdin byte payload, and {@code find} decodes the retrieved bytes into a
065 * {@code char[]} (zeroing the intermediate buffers).  The value is never logged or {@code toString()}'d by this class.  Because the command is
066 * newline-terminated, a key, service, or secret containing {@code '\n'} or {@code '\r'} cannot be delivered this way
067 * (it would end or inject a command) and is rejected by {@link #store} before any process is started.
068 *
069 * <p>
070 * <b>Reading secrets.</b> {@link #find} deliberately does not use <c>-w</c>: for a secret holding a control character
071 * (such as a tab) or any non-ASCII byte, <c>-w</c> prints a bare hex dump of the bytes instead of the text, which
072 * cannot be told apart from a secret that merely looks like hex (e.g. <c>abcd</c>).  The <c>-g</c> flag instead writes
073 * one unambiguous line to stderr: <c>password: "text"</c> for a plain printable secret, <c>password: 0x<i>HEX</i>
074 * "<i>escaped</i>"</c> for anything else (including a secret containing <c>"</c> or <c>\</c>), or a bare
075 * <c>password: </c> for an empty secret.  The hex form is decoded to bytes and then as UTF-8.  The stdout of
076 * <c>-g</c> holds only the non-secret item attributes and is ignored.
077 *
078 * @since 10.0.0
079 */
080public class KeychainSecretStore implements SecretStore {
081
082   /** The macOS keychain "item not found" exit status ({@code errSecItemNotFound}). */
083   static final int NOT_FOUND = 44;
084
085   /** Default per-call timeout, in seconds, bounding a slow/hung {@code security} invocation. */
086   public static final long DEFAULT_TIMEOUT_SECONDS = 15L;
087
088   private static final String SECURITY = "/usr/bin/security";
089   private static final String FIND_GENERIC_PASSWORD = "find-generic-password";
090
091   private final String service;
092   private final FailMode failMode;
093   private final CommandRunner commandRunner;
094
095   /**
096    * Constructor using {@link FailMode#FAIL_CLOSED} and the {@link #DEFAULT_TIMEOUT_SECONDS default} timeout.
097    *
098    * @param service The keychain service name that namespaces this store's items.  Must not be <jk>null</jk> or blank.
099    */
100   public KeychainSecretStore(String service) {
101      this(service, FailMode.FAIL_CLOSED);
102   }
103
104   /**
105    * Constructor using the {@link #DEFAULT_TIMEOUT_SECONDS default} timeout.
106    *
107    * @param service The keychain service name that namespaces this store's items.  Must not be <jk>null</jk> or blank.
108    * @param failMode The policy applied on a backend failure.  Must not be <jk>null</jk>.
109    */
110   public KeychainSecretStore(String service, FailMode failMode) {
111      this(service, failMode, DEFAULT_TIMEOUT_SECONDS);
112   }
113
114   /**
115    * Constructor.
116    *
117    * @param service The keychain service name that namespaces this store's items.  Must not be <jk>null</jk> or blank.
118    * @param failMode The policy applied on a backend failure.  Must not be <jk>null</jk>.
119    * @param timeoutSeconds The per-call timeout, in seconds, bounding a slow/hung {@code security} invocation.  Must be {@code > 0}.
120    */
121   public KeychainSecretStore(String service, FailMode failMode, long timeoutSeconds) {
122      this(service, failMode, timeoutSeconds, SECURITY);
123   }
124
125   KeychainSecretStore(String service, FailMode failMode, long timeoutSeconds, String binary) {
126      this(service, failMode, new ProcessCommandRunner(validateTimeout(timeoutSeconds), binary));
127   }
128
129   /**
130    * Constructor that bypasses the {@code security} subprocess entirely, invoking {@code commandRunner} directly.
131    *
132    * <p>
133    * This is the seam tests use to exercise this store's argument construction and output parsing against an
134    * in-memory fake keychain, so round-trip coverage runs deterministically on every OS without shelling out to
135    * {@code security} or touching a real keychain.
136    *
137    * @param service The keychain service name that namespaces this store's items.  Must not be <jk>null</jk> or blank.
138    * @param failMode The policy applied on a backend failure.  Must not be <jk>null</jk>.
139    * @param commandRunner The command invocation seam.  Must not be <jk>null</jk>.
140    */
141   KeychainSecretStore(String service, FailMode failMode, CommandRunner commandRunner) {
142      this.service = reqnb("service", service);
143      this.failMode = reqnn("failMode", failMode);
144      this.commandRunner = reqnn("commandRunner", commandRunner);
145   }
146
147   private static long validateTimeout(long timeoutSeconds) {
148      req(timeoutSeconds > 0, "Argument 'timeoutSeconds' must be > 0.");
149      return timeoutSeconds;
150   }
151
152   @Override /* SecretStore */
153   public void store(String key, char[] secret) {
154      reqnn("key", key);
155      reqnn("secret", secret);
156      req(! containsNewline(key), "Argument 'key' must not contain a newline or carriage return character.");
157      req(! containsNewline(service), "The service name must not contain a newline or carriage return character.");
158      req(! containsNewline(secret), "Argument 'secret' must not contain a newline or carriage return character.");
159      var payload = addCommandPayload(key, secret);
160      try {
161         // "security -i" reads commands from stdin; a bare "-w" would prompt on /dev/tty (hanging when the JVM has
162         // a terminal), and "-w <secret>" on argv would expose the secret to ps.  The command line goes on stdin.
163         var r = run(payload, "-i");
164         if (r.exit() != 0)
165            throw backendFailure("add-generic-password", r);
166      } catch (RuntimeException e) {
167         if (failMode == FailMode.FAIL_OPEN)
168            return;
169         throw e;
170      } finally {
171         Arrays.fill(payload, (byte) 0);
172      }
173   }
174
175   @Override /* SecretStore */
176   public Optional<char[]> find(String key) {
177      reqnn("key", key);
178      try {
179         var r = run(FIND_GENERIC_PASSWORD, "-a", key, "-s", service, "-g");
180         if (r.exit() == 0) {
181            try {
182               return o(parsePassword(r.stderr()));
183            } finally {
184               Arrays.fill(r.stderr(), (byte) 0);
185            }
186         }
187         if (r.exit() == NOT_FOUND)
188            return oe();
189         throw backendFailure(FIND_GENERIC_PASSWORD, r);
190      } catch (RuntimeException e) {
191         if (failMode == FailMode.FAIL_OPEN)
192            return oe();
193         throw e;
194      }
195   }
196
197   @Override /* SecretStore */
198   public boolean exists(String key) {
199      reqnn("key", key);
200      try {
201         var r = run(FIND_GENERIC_PASSWORD, "-a", key, "-s", service);
202         if (r.exit() == 0)
203            return true;
204         if (r.exit() == NOT_FOUND)
205            return false;
206         throw backendFailure(FIND_GENERIC_PASSWORD, r);
207      } catch (RuntimeException e) {
208         if (failMode == FailMode.FAIL_OPEN)
209            return false;
210         throw e;
211      }
212   }
213
214   @Override /* SecretStore */
215   public boolean delete(String key) {
216      reqnn("key", key);
217      try {
218         var r = run("delete-generic-password", "-a", key, "-s", service);
219         if (r.exit() == 0)
220            return true;
221         if (r.exit() == NOT_FOUND)
222            return false;
223         throw backendFailure("delete-generic-password", r);
224      } catch (RuntimeException e) {
225         if (failMode == FailMode.FAIL_OPEN)
226            return false;
227         throw e;
228      }
229   }
230
231   /**
232    * The seam through which this store invokes the underlying keychain command.
233    *
234    * <p>
235    * Production code always runs against {@link ProcessCommandRunner}, which shells out to the real
236    * {@code security} CLI.  Tests substitute an in-memory fake keychain instead (via the package-private
237    * {@link #KeychainSecretStore(String,FailMode,CommandRunner) constructor}), so this store's argument
238    * construction and output parsing run deterministically on every OS without shelling out or touching a real
239    * keychain.
240    */
241   @FunctionalInterface
242   interface CommandRunner {
243
244      /**
245       * Invokes the command with the given arguments, optionally writing {@code stdin} to it first.
246       *
247       * @param stdin The bytes to write to the command's stdin before closing it, or <jk>null</jk> to close stdin
248       *    immediately without writing anything.
249       * @param args The command's arguments (not including the executable path/name itself).
250       * @return The command's exit code, captured stdout, and captured stderr.
251       */
252      Result run(byte[] stdin, String[] args);
253   }
254
255   /** The outcome of a single {@code security} invocation. */
256   @SuppressWarnings({
257      "java:S6218" // stdout can hold retrieved SECRET bytes; identity-based equals/hashCode/toString are intentional so a content-based toString can never surface the secret. Result instances are never compared or printed.
258   })
259   record Result(int exit, byte[] stdout, byte[] stderr) {
260
261      // stderr can hold the retrieved secret (find-generic-password -g), so it is kept as bytes that can be zeroed.
262      Result(int exit, byte[] stdout, String stderr) {
263         this(exit, stdout, stderr.getBytes(UTF_8));
264      }
265
266      String stderrText() {
267         return new String(stderr, UTF_8);
268      }
269   }
270
271   /** {@link CommandRunner} that shells out to the real {@code security} binary via {@link ProcessBuilder}. */
272   private static final class ProcessCommandRunner implements CommandRunner {
273
274      private final long timeoutSeconds;
275      private final String binary;
276
277      ProcessCommandRunner(long timeoutSeconds, String binary) {
278         this.timeoutSeconds = timeoutSeconds;
279         this.binary = binary;
280      }
281
282      /**
283       * Invokes {@link #binary} with the given arguments, optionally writing {@code stdin} to the child process
284       * before closing its input stream.  {@code stdin}, when non-<jk>null</jk>, is written and the stream closed
285       * <i>before</i> waiting on the process so a CLI that blocks on a stdin prompt (as {@code -w} with no value
286       * does) does not deadlock against this method.
287       */
288      @Override /* CommandRunner */
289      public Result run(byte[] stdin, String[] args) {
290         var cmd = new ArrayList<String>(args.length + 1);
291         cmd.add(binary);
292         cmd.addAll(Arrays.asList(args));
293         try {
294            var p = new ProcessBuilder(cmd).start();
295            if (stdin != null) {
296               try (var os = p.getOutputStream()) {
297                  os.write(stdin);
298               }
299            } else {
300               p.getOutputStream().close();
301            }
302            if (! p.waitFor(timeoutSeconds, SECONDS)) {
303               p.destroyForcibly();
304               throw isex("Timed out invoking '%s' after %s seconds.", binary, timeoutSeconds);
305            }
306            byte[] out;
307            String err;
308            try (var is = p.getInputStream(); var es = p.getErrorStream()) {
309               out = is.readAllBytes();
310               err = new String(es.readAllBytes(), UTF_8);
311            }
312            return new Result(p.exitValue(), out, err);
313         } catch (IOException e) {
314            throw rex(e, "Unable to invoke '%s' (is this macOS with the keychain CLI available?).", binary);
315         } catch (InterruptedException e) {
316            Thread.currentThread().interrupt();
317            throw rex(e, "Interrupted while invoking '%s'.", binary);
318         }
319      }
320   }
321
322   private Result run(String... args) {
323      return run(null, args);
324   }
325
326   private Result run(byte[] stdin, String... args) {
327      return commandRunner.run(stdin, args);
328   }
329
330   private static RuntimeException backendFailure(String subcommand, Result r) {
331      return isex("Keychain '%s' failed with exit code %s: %s", subcommand, r.exit(), r.stderrText().strip());
332   }
333
334   private static boolean containsNewline(String s) {
335      return s.indexOf('\n') >= 0 || s.indexOf('\r') >= 0;
336   }
337
338   private static boolean containsNewline(char[] chars) {
339      for (char c : chars)
340         if (c == '\n' || c == '\r')
341            return true;
342      return false;
343   }
344
345   /**
346    * Builds the <c>security -i</c> stdin payload
347    * <c>add-generic-password -U -a "key" -s "service" -w "secret"\n</c>, double-quoting each value and escaping
348    * <c>"</c> and <c>\</c> with a backslash.  The secret is encoded and escaped directly from the {@code char[]},
349    * without an intermediate {@link String}, and the intermediate byte copy is zeroed.
350    */
351   private byte[] addCommandPayload(String key, char[] secret) {
352      var head = ("add-generic-password -U -a " + quote(key) + " -s " + quote(service) + " -w \"").getBytes(UTF_8);
353      var encoded = UTF_8.encode(CharBuffer.wrap(secret));
354      var secretBytes = new byte[encoded.remaining()];
355      encoded.get(secretBytes);
356      if (encoded.hasArray())
357         Arrays.fill(encoded.array(), (byte) 0);
358      try {
359         var escapes = 0;
360         for (var b : secretBytes)
361            if (b == '"' || b == '\\')
362               escapes++;
363         var payload = new byte[head.length + secretBytes.length + escapes + 2];
364         System.arraycopy(head, 0, payload, 0, head.length);
365         var i = head.length;
366         for (var b : secretBytes) {
367            if (b == '"' || b == '\\')
368               payload[i++] = '\\';
369            payload[i++] = b;
370         }
371         payload[i++] = '"';
372         payload[i] = '\n';
373         return payload;
374      } finally {
375         Arrays.fill(secretBytes, (byte) 0);
376      }
377   }
378
379   /** Double-quotes a non-secret value for <c>security -i</c>, escaping <c>"</c> and <c>\</c>. */
380   private static String quote(String s) {
381      return '"' + s.replace("\\", "\\\\").replace("\"", "\\\"") + '"';
382   }
383
384   private static final byte[] PASSWORD_PREFIX = "password: ".getBytes(UTF_8);
385
386   /**
387    * Extracts the secret from the stderr of <c>find-generic-password -g</c>.
388    *
389    * <p>
390    * Finds the line starting with <c>password: </c> and decodes it per its form: <c>"text"</c> (the bytes between the
391    * first and last quote), <c>0x<i>HEX</i></c> (hex digits up to the first non-hex character), or empty.  No
392    * intermediate {@link String} is created, and the hex byte buffer is zeroed.
393    */
394   @SuppressWarnings({
395      "java:S3776" // One scan with a branch per password form.
396   })
397   private static char[] parsePassword(byte[] err) {
398      var start = -1;
399      for (var i = 0; i + PASSWORD_PREFIX.length <= err.length && start < 0; i++)
400         if ((i == 0 || err[i - 1] == '\n') && Arrays.equals(err, i, i + PASSWORD_PREFIX.length, PASSWORD_PREFIX, 0, PASSWORD_PREFIX.length))
401            start = i + PASSWORD_PREFIX.length;
402      if (start < 0)
403         throw isex("Keychain '%s' succeeded but its output did not contain a password line.", FIND_GENERIC_PASSWORD);
404      var end = start;
405      while (end < err.length && err[end] != '\n')
406         end++;
407      if (end > start && err[end - 1] == '\r')
408         end--;
409      if (end == start)
410         return new char[0];
411      if (err[start] == '"') {
412         var close = end - 1;
413         while (close > start && err[close] != '"')
414            close--;
415         return decode(err, start + 1, close);
416      }
417      if (end - start > 2 && err[start] == '0' && err[start + 1] == 'x') {
418         var digits = 0;
419         while (start + 2 + digits < end && Character.digit(err[start + 2 + digits], 16) >= 0)
420            digits++;
421         if (digits % 2 != 0)
422            throw isex("Keychain '%s' returned a malformed hex password.", FIND_GENERIC_PASSWORD);
423         var bytes = new byte[digits / 2];
424         try {
425            for (var i = 0; i < bytes.length; i++)
426               bytes[i] = (byte)((Character.digit(err[start + 2 + 2 * i], 16) << 4) | Character.digit(err[start + 3 + 2 * i], 16));
427            return decode(bytes, 0, bytes.length);
428         } finally {
429            Arrays.fill(bytes, (byte) 0);
430         }
431      }
432      throw isex("Keychain '%s' returned an unrecognized password format.", FIND_GENERIC_PASSWORD);
433   }
434
435   /** Decodes UTF-8 bytes into a char[] without an intermediate String, zeroing the decoder's char buffer. */
436   private static char[] decode(byte[] bytes, int from, int to) {
437      var cb = UTF_8.decode(ByteBuffer.wrap(bytes, from, to - from));
438      var chars = new char[cb.remaining()];
439      cb.get(chars);
440      if (cb.hasArray())
441         Arrays.fill(cb.array(), '\0');
442      return chars;
443   }
444}