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} → <c>add-generic-password -U</c> (create or update). 036 * <li>{@link #find} → <c>find-generic-password -g</c> (print the password to stderr). 037 * <li>{@link #exists} → <c>find-generic-password</c> (presence only, without the password). 038 * <li>{@link #delete} → <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 — the tool missing, a non-zero exit that is not "item not found", or a timeout 051 * — 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 — not from stdin — 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}