This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new 043f3741c1 Add a JDK-only SecretStore SPI + juneau-secret-keychain
module (TODO-356); fix ObjectPaginator bare-position pagination (TODO-357)
043f3741c1 is described below
commit 043f3741c181a3e19ef7b9c74e8a8d94bed0c976
Author: James Bognar <[email protected]>
AuthorDate: Thu Aug 13 12:53:01 2026 -0400
Add a JDK-only SecretStore SPI + juneau-secret-keychain module (TODO-356);
fix ObjectPaginator bare-position pagination (TODO-357)
TODO-356: introduces a small SecretStore SPI in juneau-commons (new
org.apache.juneau.commons.secret package) — store/find/exists/delete over
char[] values with intrinsic sensitivity — plus a process-local
InMemorySecretStore default, a read-only EnvVarSecretStore, a FailMode enum, a
SecretStoreProvider ServiceLoader SPI, BeanStore-based resolution
(SecretStores), and an opt-in Settings bridge (SensitivePropertySource marker +
SecretStorePropertySource that redacts). A new opt-in june [...]
TODO-357: ObjectPaginator threw on a bare position with no limit (?p=N
without ?l=N) because the -1 "unlimited" limit sentinel from PageArgs was never
special-cased, producing an inverted sublist bound (500 via the REST
Queryable/NativeQueryProtocol path). Now treats limit < 0 as "page to the end"
in both the array and collection branches, with regression tests.
---
juneau-bom/pom.xml | 5 +
.../juneau/commons/secret/EnvVarSecretStore.java | 67 +++++++
.../commons/secret/EnvVarSecretStoreProvider.java | 35 ++++
.../org/apache/juneau/commons/secret/FailMode.java | 42 ++++
.../juneau/commons/secret/InMemorySecretStore.java | 82 ++++++++
.../apache/juneau/commons/secret/SecretStore.java | 105 ++++++++++
.../commons/secret/SecretStorePropertySource.java | 72 +++++++
.../juneau/commons/secret/SecretStoreProvider.java | 51 +++++
.../apache/juneau/commons/secret/SecretStores.java | 75 +++++++
.../commons/secret/SensitivePropertySource.java | 33 +++
.../apache/juneau/commons/secret/package-info.java | 40 ++++
...pache.juneau.commons.secret.SecretStoreProvider | 16 ++
.../commons/secret/EnvVarSecretStore_Test.java | 72 +++++++
.../commons/secret/InMemorySecretStore_Test.java | 100 +++++++++
.../secret/SecretStorePropertySource_Test.java | 86 ++++++++
.../commons/secret/SecretStoreProvider_Test.java | 53 +++++
.../juneau/commons/secret/SecretStores_Test.java | 61 ++++++
.../marshall/objecttools/ObjectPaginator.java | 4 +-
.../marshall/objecttools/ObjectPaginator_Test.java | 16 ++
juneau-secret-keychain/pom.xml | 117 +++++++++++
.../secret/keychain/KeychainSecretStore.java | 223 +++++++++++++++++++++
.../keychain/KeychainSecretStoreProvider.java | 51 +++++
.../juneau/secret/keychain/package-info.java | 29 +++
...pache.juneau.commons.secret.SecretStoreProvider | 16 ++
.../keychain/KeychainSecretStoreProvider_Test.java | 58 ++++++
.../secret/keychain/KeychainSecretStore_Test.java | 140 +++++++++++++
pom.xml | 1 +
27 files changed, 1648 insertions(+), 2 deletions(-)
diff --git a/juneau-bom/pom.xml b/juneau-bom/pom.xml
index 01f1f43825..892a09d6d4 100644
--- a/juneau-bom/pom.xml
+++ b/juneau-bom/pom.xml
@@ -111,6 +111,11 @@
<!--
=====================================================================================
-->
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-sc-server</artifactId><version>${project.version}</version></dependency>
+ <!--
=====================================================================================
-->
+ <!-- juneau-secret
-->
+ <!--
=====================================================================================
-->
+
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-secret-keychain</artifactId><version>${project.version}</version></dependency>
+
<!--
=====================================================================================
-->
<!-- Curated dependency bundles (transitive module sets
per deployment shape) -->
<!--
=====================================================================================
-->
diff --git
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/EnvVarSecretStore.java
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/EnvVarSecretStore.java
new file mode 100644
index 0000000000..cedac255d3
--- /dev/null
+++
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/EnvVarSecretStore.java
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.commons.secret;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+/**
+ * A <b>read-only</b> {@link SecretStore} backed by {@link
System#getenv(String)}.
+ *
+ * <p>
+ * Gives 12-factor deployments a zero-dependency way to feed secrets in as
environment variables: {@link #find} and
+ * {@link #exists} resolve straight from the process environment. Because the
environment is immutable from within the
+ * process, the mutating operations are unsupported:
+ * <ul>
+ * <li>{@link #store(String, char[])} throws {@link
UnsupportedOperationException}.
+ * <li>{@link #delete(String)} throws {@link
UnsupportedOperationException}.
+ * </ul>
+ *
+ * <p>
+ * Note that environment variables are inherently more exposed than a
dedicated secret backend (they are visible to
+ * child processes and to anything that can read the process environment), so
this store trades secrecy strength for
+ * deployment simplicity. The retrieved value is never {@code toString()}'d,
logged, or dumped by this class.
+ *
+ * @since 10.0.0
+ */
+public class EnvVarSecretStore implements SecretStore {
+
+ @Override /* SecretStore */
+ public void store(String key, char[] secret) {
+ throw uoex("EnvVarSecretStore is read-only; environment
variables cannot be modified from within the process.");
+ }
+
+ @Override /* SecretStore */
+ public Optional<char[]> find(String key) {
+ assertArgNotNull("key", key);
+ var v = System.getenv(key);
+ return v == null ? oe() : o(v.toCharArray());
+ }
+
+ @Override /* SecretStore */
+ public boolean exists(String key) {
+ assertArgNotNull("key", key);
+ return System.getenv(key) != null;
+ }
+
+ @Override /* SecretStore */
+ public boolean delete(String key) {
+ throw uoex("EnvVarSecretStore is read-only; environment
variables cannot be modified from within the process.");
+ }
+}
diff --git
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/EnvVarSecretStoreProvider.java
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/EnvVarSecretStoreProvider.java
new file mode 100644
index 0000000000..5aa6240de5
--- /dev/null
+++
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/EnvVarSecretStoreProvider.java
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.commons.secret;
+
+/**
+ * Provider for {@link EnvVarSecretStore}, discoverable through {@link
SecretStores#fromServiceLoader()}.
+ *
+ * @since 10.0.0
+ */
+public class EnvVarSecretStoreProvider implements SecretStoreProvider {
+
+ @Override /* SecretStoreProvider */
+ public SecretStore create() {
+ return new EnvVarSecretStore();
+ }
+
+ @Override /* SecretStoreProvider */
+ public int order() {
+ return 30;
+ }
+}
diff --git
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/FailMode.java
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/FailMode.java
new file mode 100644
index 0000000000..f01d42e6cc
--- /dev/null
+++
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/FailMode.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.commons.secret;
+
+/**
+ * The policy a network- or OS-backed {@link SecretStore} applies when its
backing store cannot answer — for
+ * example when the OS keychain tool is missing or a remote vault is
unreachable.
+ *
+ * <p>
+ * Modeled on the {@code FailMode} of {@link
org.apache.juneau.commons.concurrent.ReplayCache}: the policy is a
+ * consumer decision rather than a fixed SPI behavior. The built-in {@link
InMemorySecretStore} and
+ * {@link EnvVarSecretStore} never encounter an unavailable backend, so this
enum is only meaningful for the
+ * out-of-commons implementations that reach an external secret backend.
+ *
+ * <p>
+ * Note this is orthogonal to a key simply being <i>absent</i> — a store
that successfully answers "no secret
+ * under this key" is not a failure and is never resolved through a {@link
FailMode}.
+ *
+ * @since 10.0.0
+ */
+public enum FailMode {
+
+ /** Treat a backend failure as a missing secret: {@code find} returns
empty, {@code exists}/{@code delete} return <jk>false</jk>
(availability-preserving). */
+ FAIL_OPEN,
+
+ /** Treat a backend failure as an error and propagate it, so the
operation fails loudly rather than silently reporting a secret as absent (the
safe default). */
+ FAIL_CLOSED
+}
diff --git
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/InMemorySecretStore.java
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/InMemorySecretStore.java
new file mode 100644
index 0000000000..189532fc9b
--- /dev/null
+++
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/InMemorySecretStore.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.commons.secret;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+import java.util.concurrent.*;
+
+/**
+ * Built-in default {@link SecretStore}: a per-process, {@link
ConcurrentHashMap}-backed key-to-secret map.
+ *
+ * <p>
+ * This is the zero-config store {@link
SecretStores#resolve(org.apache.juneau.commons.inject.BeanStore)} falls back
+ * to when no store has been contributed. It is honest about its limits:
+ *
+ * <p>
+ * <b>Process-local only.</b> Secrets live in this JVM's heap and are gone
when the process exits — this store is
+ * <b>not</b> persistent. Two separate JVMs (or two instances behind a load
balancer) each hold their own map and do
+ * <b>not</b> see each other's secrets, so it is <b>not</b> cross-node. It is
a convenience default and a test double,
+ * not a production secret backend; deployments that need durability or
sharing must contribute a store backed by an
+ * external secret manager.
+ *
+ * <p>
+ * <b>Defensive copies.</b> {@link #store} copies the supplied array and
{@link #find} returns a fresh copy, so the
+ * caller may zero its own arrays without disturbing the stored value and
vice-versa. {@link #delete} zeroes the
+ * retained array before dropping it. The value is never {@code
toString()}'d, logged, or dumped.
+ *
+ * <p>
+ * <b>Thread-safety.</b> All operations are safe for concurrent invocation.
+ *
+ * @since 10.0.0
+ */
+public class InMemorySecretStore implements SecretStore {
+
+ private final ConcurrentHashMap<String,char[]> secrets = new
ConcurrentHashMap<>();
+
+ @Override /* SecretStore */
+ public void store(String key, char[] secret) {
+ assertArgNotNull("key", key);
+ assertArgNotNull("secret", secret);
+ secrets.put(key, secret.clone());
+ }
+
+ @Override /* SecretStore */
+ public Optional<char[]> find(String key) {
+ assertArgNotNull("key", key);
+ var v = secrets.get(key);
+ return v == null ? oe() : o(v.clone());
+ }
+
+ @Override /* SecretStore */
+ public boolean exists(String key) {
+ assertArgNotNull("key", key);
+ return secrets.containsKey(key);
+ }
+
+ @Override /* SecretStore */
+ public boolean delete(String key) {
+ assertArgNotNull("key", key);
+ var v = secrets.remove(key);
+ if (v == null)
+ return false;
+ Arrays.fill(v, '\0');
+ return true;
+ }
+}
diff --git
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/SecretStore.java
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/SecretStore.java
new file mode 100644
index 0000000000..05a1ff5a0f
--- /dev/null
+++
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/SecretStore.java
@@ -0,0 +1,105 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.commons.secret;
+
+import java.util.*;
+
+/**
+ * SPI for storing and retrieving a secret by key — the secure, mutable
sibling of
+ * {@link org.apache.juneau.commons.settings.PropertySource}.
+ *
+ * <p>
+ * Where a {@code PropertySource} is a read-only source of ordinary
configuration values, a {@code SecretStore}
+ * adds <i>write</i>/<i>delete</i> plus <i>sensitivity</i> semantics. The SPI
is deliberately narrow — it
+ * deals only in a {@link String} key and a {@code char[]} secret, with no
dependency on any particular backend.
+ *
+ * <p>
+ * <b>Sensitivity is intrinsic.</b> Secret values are held as {@code char[]}
rather than {@link String} so they can
+ * be explicitly zeroed and do not linger in the string pool. An
implementation must never {@code toString()}, log,
+ * or otherwise dump a secret value. Callers are responsible for zeroing the
arrays they pass to {@link #store}
+ * and receive from {@link #find} once done with them.
+ *
+ * <p>
+ * <b>Three-state presence model.</b> The three operations together model a
clean present-with-value /
+ * present-without-materializing / absent distinction:
+ * <ul>
+ * <li>{@link #find(String)} — returns the secret value when present
({@link Optional} of {@code char[]}),
+ * or {@link Optional#empty()} when absent. This is the only
method that materializes the value.
+ * <li>{@link #exists(String)} — reports whether a secret is stored
under the key <i>without</i> retrieving
+ * (and, for backends that decrypt on read, without decrypting)
it. Prefer this over {@link #find} when only
+ * presence matters, so the secret is never pulled into memory
needlessly.
+ * <li><i>absent</i> — {@link #find} returns empty and {@link
#exists} returns <jk>false</jk>.
+ * </ul>
+ *
+ * <p>
+ * <b>Backend-unavailable behavior is a consumer decision.</b> The built-in
{@link InMemorySecretStore} and
+ * {@link EnvVarSecretStore} never fail on a well-formed call, so they simply
throw on a genuine error. Network- or
+ * OS-backed implementations (for example an OS-keychain-backed store in a
separate module) should let the consumer
+ * pick fail-open vs fail-closed via a {@link FailMode} rather than baking a
policy into the SPI.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link InMemorySecretStore}
+ * <li class='jc'>{@link EnvVarSecretStore}
+ * <li class='jc'>{@link FailMode}
+ * <li class='jc'>{@link SecretStores}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public interface SecretStore {
+
+ /**
+ * Stores a secret under the specified key, replacing any existing
value.
+ *
+ * <p>
+ * The caller retains ownership of <jv>secret</jv> and may zero it
after this call returns; an implementation
+ * that needs to retain the value must copy it.
+ *
+ * @param key The key under which to store the secret. Must not be
<jk>null</jk>.
+ * @param secret The secret value. Must not be <jk>null</jk>.
+ * @throws UnsupportedOperationException If this store is read-only.
+ */
+ void store(String key, char[] secret);
+
+ /**
+ * Returns the secret stored under the specified key.
+ *
+ * <p>
+ * The returned array is the caller's to own and zero; it is not a live
view of the store's internal state.
+ *
+ * @param key The key to look up. Must not be <jk>null</jk>.
+ * @return The secret value, or {@link Optional#empty()} if no secret
is stored under the key.
+ */
+ Optional<char[]> find(String key);
+
+ /**
+ * Returns whether a secret is stored under the specified key without
materializing its value.
+ *
+ * @param key The key to check. Must not be <jk>null</jk>.
+ * @return <jk>true</jk> if a secret is stored under the key.
+ */
+ boolean exists(String key);
+
+ /**
+ * Deletes the secret stored under the specified key.
+ *
+ * @param key The key to delete. Must not be <jk>null</jk>.
+ * @return <jk>true</jk> if a secret was present and removed;
<jk>false</jk> if the key was already absent.
+ * @throws UnsupportedOperationException If this store is read-only.
+ */
+ boolean delete(String key);
+}
diff --git
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/SecretStorePropertySource.java
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/SecretStorePropertySource.java
new file mode 100644
index 0000000000..2ef12e4147
--- /dev/null
+++
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/SecretStorePropertySource.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.commons.secret;
+
+import java.util.*;
+
+import org.apache.juneau.commons.settings.*;
+
+/**
+ * Opt-in bridge that exposes a chosen {@link SecretStore} as a {@link
PropertySource}, so a secret can be resolved
+ * through the property/{@code $P{...}}/{@code @Value} machinery <b>only where
a consumer deliberately wires it in</b>.
+ *
+ * <p>
+ * By default a {@link SecretStore} is intentionally <b>not</b> part of the
general config/SVL namespace — that is
+ * what keeps a secret one careless {@code Settings} dump or debug log away
from disclosure. This adapter is the
+ * conscious, greppable exception: attach it as a caller-scoped source (for
example a session-scoped
+ * {@code PropertySource[]} bean on a {@code VarResolverSession}) and its
bridged keys become resolvable there, and
+ * nowhere else.
+ *
+ * <p>
+ * It implements {@link SensitivePropertySource} so any dump/log path that
honors that marker redacts its values, and
+ * its own {@link #toString()} never reveals the wrapped store or any secret.
+ *
+ * <p>
+ * <b>Materialization trade-off.</b> The {@link PropertySource} contract
returns values as {@link String}, so this
+ * bridge necessarily converts the retrieved {@code char[]} into a {@link
String} that cannot be explicitly zeroed and
+ * may linger in the string pool. That reintroduced exposure is precisely why
bridging is opt-in and marked
+ * sensitive rather than being the default way to reach a secret.
+ *
+ * @since 10.0.0
+ */
+public class SecretStorePropertySource implements SensitivePropertySource {
+
+ private final SecretStore store;
+
+ /**
+ * Constructor.
+ *
+ * @param store The secret store to expose. Must not be <jk>null</jk>.
+ */
+ public SecretStorePropertySource(SecretStore store) {
+ this.store = Objects.requireNonNull(store, "store");
+ }
+
+ @Override /* PropertySource */
+ public PropertyLookupResult get(String name) {
+ if (name == null)
+ return PropertyLookupResult.missing();
+ return store.find(name)
+ .map(v -> PropertyLookupResult.present(new String(v)))
+ .orElseGet(PropertyLookupResult::missing);
+ }
+
+ @Override /* Object */
+ public String toString() {
+ return "SecretStorePropertySource(<redacted>)";
+ }
+}
diff --git
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/SecretStoreProvider.java
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/SecretStoreProvider.java
new file mode 100644
index 0000000000..a8740d28df
--- /dev/null
+++
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/SecretStoreProvider.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.commons.secret;
+
+/**
+ * Provider SPI for contributing a {@link SecretStore} via {@code
ServiceLoader}.
+ *
+ * <p>
+ * Mirrors {@link org.apache.juneau.commons.settings.PropertySourceProvider}:
a module on the classpath can register
+ * a store (for example an OS-keychain-backed store, or the {@link
EnvVarSecretStore}) by listing its provider in a
+ * {@code
META-INF/services/org.apache.juneau.commons.secret.SecretStoreProvider} file.
Discovery is opt-in through
+ * {@link SecretStores#fromServiceLoader()}; it does not change the {@link
SecretStores#resolve} default, which
+ * remains {@link InMemorySecretStore}.
+ *
+ * @since 10.0.0
+ */
+public interface SecretStoreProvider {
+
+ /**
+ * Creates a secret store.
+ *
+ * @return The secret store, or <jk>null</jk> to skip registration.
+ */
+ SecretStore create();
+
+ /**
+ * Sort key used by {@code ServiceLoader} wiring.
+ *
+ * <p>
+ * Lower values sort first.
+ *
+ * @return The order value.
+ */
+ default int order() {
+ return 0;
+ }
+}
diff --git
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/SecretStores.java
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/SecretStores.java
new file mode 100644
index 0000000000..9b48a06201
--- /dev/null
+++
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/SecretStores.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.commons.secret;
+
+import java.util.*;
+
+import org.apache.juneau.commons.inject.*;
+
+/**
+ * Resolution helpers for selecting the active {@link SecretStore}.
+ *
+ * <p>
+ * The active store is resolved from a {@link BeanStore}, defaulting to an
{@link InMemorySecretStore} when none has
+ * been contributed — the same {@code
beanStore.getBean(...)}-with-drop-in-default idiom other framework-owned
+ * components use. An explicit {@link BeanStore} contribution always wins.
+ *
+ * <p>
+ * {@link #fromServiceLoader()} is the separate classpath-contribution path,
mirroring
+ * {@link org.apache.juneau.commons.settings.Settings}'s opt-in {@code
useServiceLoader()}. It is deliberately
+ * <b>not</b> consulted by {@link #resolve(BeanStore)}, so the no-contribution
default stays a deterministic
+ * {@link InMemorySecretStore} rather than silently becoming whatever store
happens to be on the classpath (which, for
+ * an environment-variable-backed provider, would quietly turn arbitrary env
vars into secrets). A consumer that
+ * <i>wants</i> a classpath-contributed store can seed its {@link BeanStore}
from {@link #fromServiceLoader()}.
+ *
+ * @since 10.0.0
+ */
+public final class SecretStores {
+
+ private SecretStores() {}
+
+ /**
+ * Resolves the active secret store from the specified bean store,
defaulting to a new
+ * {@link InMemorySecretStore} when none is contributed.
+ *
+ * @param beanStore The bean store to resolve from. Can be
<jk>null</jk>, in which case the default is returned.
+ * @return The resolved store. Never <jk>null</jk>.
+ */
+ public static SecretStore resolve(BeanStore beanStore) {
+ if (beanStore == null)
+ return new InMemorySecretStore();
+ return
beanStore.getBean(SecretStore.class).orElseGet(InMemorySecretStore::new);
+ }
+
+ /**
+ * Discovers a secret store contributed on the classpath via {@link
SecretStoreProvider} and {@code ServiceLoader}.
+ *
+ * <p>
+ * Providers are sorted by {@link SecretStoreProvider#order()} (lowest
first) and the first one that yields a
+ * non-<jk>null</jk> store wins.
+ *
+ * @return The discovered store, or {@link Optional#empty()} if none is
registered on the classpath.
+ */
+ public static Optional<SecretStore> fromServiceLoader() {
+ return ServiceLoader.load(SecretStoreProvider.class).stream()
+ .map(ServiceLoader.Provider::get)
+
.sorted(Comparator.comparingInt(SecretStoreProvider::order))
+ .map(SecretStoreProvider::create)
+ .filter(Objects::nonNull)
+ .findFirst();
+ }
+}
diff --git
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/SensitivePropertySource.java
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/SensitivePropertySource.java
new file mode 100644
index 0000000000..926bfe0afa
--- /dev/null
+++
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/SensitivePropertySource.java
@@ -0,0 +1,33 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.commons.secret;
+
+import org.apache.juneau.commons.settings.*;
+
+/**
+ * Marker specialization of {@link PropertySource} whose values are sensitive
and must be redacted anywhere they
+ * would otherwise be surfaced — a {@code toString()}, a settings dump,
or a log line.
+ *
+ * <p>
+ * This marker is the seam that keeps the opt-in {@link
SecretStorePropertySource} bridge from leaking bridged
+ * secrets: a dump/log path that iterates {@link PropertySource}s should test
+ * {@code src instanceof SensitivePropertySource} and redact rather than print
resolved values. The marker is scoped
+ * to the bridge on purpose — it is not forced across all of {@code
Settings}.
+ *
+ * @since 10.0.0
+ */
+public interface SensitivePropertySource extends PropertySource {}
diff --git
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/package-info.java
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/package-info.java
new file mode 100644
index 0000000000..29b18ca5d6
--- /dev/null
+++
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/secret/package-info.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * A small, JDK-only SPI for storing and retrieving secrets by key — the
secure, mutable sibling of
+ * {@link org.apache.juneau.commons.settings.PropertySource}.
+ *
+ * <p>
+ * The interface + built-in implementations in this package have zero
third-party runtime dependencies.
+ * Sensitivity is intrinsic: secret values are held as {@code char[]} (never
{@link java.lang.String String}) and are never
+ * {@code toString()}'d, logged, or dumped. Backends that reach an OS
keychain, a remote vault, or a cloud
+ * secret manager live in separate opt-in modules, never here.
+ *
+ * <h5 class='section'>Key types:</h5>
+ * <ul>
+ * <li>{@link org.apache.juneau.commons.secret.SecretStore} — the
SPI (store / find / exists / delete).
+ * <li>{@link org.apache.juneau.commons.secret.InMemorySecretStore}
— the zero-config, process-local default.
+ * <li>{@link org.apache.juneau.commons.secret.EnvVarSecretStore} —
a read-only, environment-variable-backed source.
+ * <li>{@link org.apache.juneau.commons.secret.SecretStoreProvider}
— {@code ServiceLoader} discovery.
+ * <li>{@link org.apache.juneau.commons.secret.SecretStores} —
{@code BeanStore} resolution (default {@code InMemorySecretStore}).
+ * <li>{@link org.apache.juneau.commons.secret.SecretStorePropertySource}
— an opt-in bridge exposing a store as a redacting {@code PropertySource}.
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+package org.apache.juneau.commons.secret;
diff --git
a/juneau-core/juneau-commons/src/main/resources/META-INF/services/org.apache.juneau.commons.secret.SecretStoreProvider
b/juneau-core/juneau-commons/src/main/resources/META-INF/services/org.apache.juneau.commons.secret.SecretStoreProvider
new file mode 100644
index 0000000000..66ef0870a1
--- /dev/null
+++
b/juneau-core/juneau-commons/src/main/resources/META-INF/services/org.apache.juneau.commons.secret.SecretStoreProvider
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+org.apache.juneau.commons.secret.EnvVarSecretStoreProvider
diff --git
a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/secret/EnvVarSecretStore_Test.java
b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/secret/EnvVarSecretStore_Test.java
new file mode 100644
index 0000000000..ff6ac7068a
--- /dev/null
+++
b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/secret/EnvVarSecretStore_Test.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.commons.secret;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.util.*;
+
+import org.apache.juneau.commons.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Coverage for {@link EnvVarSecretStore}: find/exists resolve from the
process environment, and the mutating
+ * operations are unsupported (read-only).
+ */
+class EnvVarSecretStore_Test extends TestBase {
+
+ /** Returns the name of some environment variable that is set in this
process, or empty if the environment is empty. */
+ private static Optional<String> anyEnvVar() {
+ return System.getenv().keySet().stream().findFirst();
+ }
+
+ @Test void a01_findExistingEnvVar() {
+ var name = anyEnvVar();
+ assumeTrue(name.isPresent(), "No environment variables
available");
+ var store = new EnvVarSecretStore();
+ assertArrayEquals(System.getenv(name.get()).toCharArray(),
store.find(name.get()).orElseThrow());
+ }
+
+ @Test void a02_existsExistingEnvVar() {
+ var name = anyEnvVar();
+ assumeTrue(name.isPresent(), "No environment variables
available");
+ assertTrue(new EnvVarSecretStore().exists(name.get()));
+ }
+
+ @Test void a03_findAbsentEnvVarReturnsEmpty() {
+ assertTrue(new
EnvVarSecretStore().find("JUNEAU_DEFINITELY_NOT_SET_9f3a").isEmpty());
+ }
+
+ @Test void a04_existsAbsentEnvVarReturnsFalse() {
+ assertFalse(new
EnvVarSecretStore().exists("JUNEAU_DEFINITELY_NOT_SET_9f3a"));
+ }
+
+ @Test void a05_storeIsUnsupported() {
+ assertThrows(UnsupportedOperationException.class, () -> new
EnvVarSecretStore().store("k", "v".toCharArray()));
+ }
+
+ @Test void a06_deleteIsUnsupported() {
+ assertThrows(UnsupportedOperationException.class, () -> new
EnvVarSecretStore().delete("k"));
+ }
+
+ @Test void a07_nullKeyRejected() {
+ var store = new EnvVarSecretStore();
+ assertThrows(IllegalArgumentException.class, () ->
store.find(null));
+ assertThrows(IllegalArgumentException.class, () ->
store.exists(null));
+ }
+}
diff --git
a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/secret/InMemorySecretStore_Test.java
b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/secret/InMemorySecretStore_Test.java
new file mode 100644
index 0000000000..5a1f86ddab
--- /dev/null
+++
b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/secret/InMemorySecretStore_Test.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.commons.secret;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.commons.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Coverage for {@link InMemorySecretStore}: the three-state
find/exists/absent model, replacement, deletion, null
+ * rejection, and the defensive-copy guarantees that let callers zero their
own arrays.
+ */
+class InMemorySecretStore_Test extends TestBase {
+
+ @Test void a01_findAbsentReturnsEmpty() {
+ var store = new InMemorySecretStore();
+ assertTrue(store.find("nope").isEmpty());
+ }
+
+ @Test void a02_existsAbsentReturnsFalse() {
+ var store = new InMemorySecretStore();
+ assertFalse(store.exists("nope"));
+ }
+
+ @Test void a03_storeThenFindReturnsValue() {
+ var store = new InMemorySecretStore();
+ store.store("db.password", "hunter2".toCharArray());
+ assertArrayEquals("hunter2".toCharArray(),
store.find("db.password").orElseThrow());
+ }
+
+ @Test void a04_storeThenExistsReturnsTrue() {
+ var store = new InMemorySecretStore();
+ store.store("k", "v".toCharArray());
+ assertTrue(store.exists("k"));
+ }
+
+ @Test void a05_storeReplacesExistingValue() {
+ var store = new InMemorySecretStore();
+ store.store("k", "old".toCharArray());
+ store.store("k", "new".toCharArray());
+ assertArrayEquals("new".toCharArray(),
store.find("k").orElseThrow());
+ }
+
+ @Test void a06_deletePresentReturnsTrueAndRemoves() {
+ var store = new InMemorySecretStore();
+ store.store("k", "v".toCharArray());
+ assertTrue(store.delete("k"));
+ assertFalse(store.exists("k"));
+ assertTrue(store.find("k").isEmpty());
+ }
+
+ @Test void a07_deleteAbsentReturnsFalse() {
+ var store = new InMemorySecretStore();
+ assertFalse(store.delete("nope"));
+ }
+
+ @Test void a08_storeCopiesInput_callerMayZeroWithoutAffectingStore() {
+ // Defensive copy on store(): zeroing the caller's array must
not corrupt the stored secret.
+ var store = new InMemorySecretStore();
+ var secret = "hunter2".toCharArray();
+ store.store("k", secret);
+ Arrays.fill(secret, '\0');
+ assertArrayEquals("hunter2".toCharArray(),
store.find("k").orElseThrow());
+ }
+
+ @Test void a09_findCopiesOutput_callerMayZeroWithoutAffectingStore() {
+ // Defensive copy on find(): zeroing a returned array must not
corrupt the stored secret for the next reader.
+ var store = new InMemorySecretStore();
+ store.store("k", "hunter2".toCharArray());
+ var first = store.find("k").orElseThrow();
+ Arrays.fill(first, '\0');
+ assertArrayEquals("hunter2".toCharArray(),
store.find("k").orElseThrow());
+ }
+
+ @Test void a10_nullArgumentsRejected() {
+ var store = new InMemorySecretStore();
+ assertThrows(IllegalArgumentException.class, () ->
store.store(null, "v".toCharArray()));
+ assertThrows(IllegalArgumentException.class, () ->
store.store("k", null));
+ assertThrows(IllegalArgumentException.class, () ->
store.find(null));
+ assertThrows(IllegalArgumentException.class, () ->
store.exists(null));
+ assertThrows(IllegalArgumentException.class, () ->
store.delete(null));
+ }
+}
diff --git
a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/secret/SecretStorePropertySource_Test.java
b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/secret/SecretStorePropertySource_Test.java
new file mode 100644
index 0000000000..ae1f2e989d
--- /dev/null
+++
b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/secret/SecretStorePropertySource_Test.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.commons.secret;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.commons.*;
+import org.apache.juneau.commons.settings.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Coverage for {@link SecretStorePropertySource}: bridged keys resolve where
wired, but the source is marked
+ * sensitive and never leaks values through {@code toString()} or a
marker-honoring dump path.
+ */
+class SecretStorePropertySource_Test extends TestBase {
+
+ private static SecretStorePropertySource bridge() {
+ var store = new InMemorySecretStore();
+ store.store("db.password", "hunter2".toCharArray());
+ return new SecretStorePropertySource(store);
+ }
+
+ @Test void a01_bridgedKeyResolves() {
+ var r = bridge().get("db.password");
+ assertTrue(r.isPresent());
+ assertEquals("hunter2", r.value().orElseThrow());
+ }
+
+ @Test void a02_absentKeyMissing() {
+ assertFalse(bridge().get("nope").isPresent());
+ }
+
+ @Test void a03_nullKeyMissing() {
+ assertFalse(bridge().get(null).isPresent());
+ }
+
+ @Test void a04_isSensitivePropertySource() {
+ var b = bridge();
+ assertInstanceOf(SensitivePropertySource.class, b);
+ assertInstanceOf(PropertySource.class, b);
+ }
+
+ @Test void a05_toStringDoesNotLeakSecret() {
+ var s = bridge().toString();
+ assertFalse(s.contains("hunter2"), "toString leaked the secret
value");
+ assertTrue(s.contains("redacted"));
+ }
+
+ @Test void a06_markerHonoringDumpRedacts() {
+ // A dump/log path that honors the SensitivePropertySource
marker must redact rather than print resolved
+ // values -- this is the whole point of the marker.
+ var b = bridge();
+ var dumped = dump(b, "db.password");
+ assertFalse(dumped.contains("hunter2"), "marker-honoring dump
leaked the secret");
+ assertEquals("db.password=<redacted>", dumped);
+ }
+
+ @Test void a07_nonSensitiveSourceIsNotRedactedBySameDump() {
+ // Control: the same dump helper prints a plain (non-sensitive)
source's value verbatim, proving the
+ // redaction in a06 is driven by the marker and not
unconditional.
+ var store = new MapStore();
+ store.set("app.name", "MyApp");
+ assertEquals("app.name=MyApp", dump(store, "app.name"));
+ }
+
+ /** Minimal stand-in for a settings dump/log path that redacts sources
marked {@link SensitivePropertySource}. */
+ private static String dump(PropertySource src, String key) {
+ var r = src.get(key);
+ var shown = src instanceof SensitivePropertySource ?
"<redacted>" : r.value().orElse(null);
+ return key + "=" + shown;
+ }
+}
diff --git
a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/secret/SecretStoreProvider_Test.java
b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/secret/SecretStoreProvider_Test.java
new file mode 100644
index 0000000000..ffd4ec7281
--- /dev/null
+++
b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/secret/SecretStoreProvider_Test.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.commons.secret;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.commons.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Coverage for {@link SecretStoreProvider} discovery: the {@link
EnvVarSecretStoreProvider} registered in
+ * {@code META-INF/services} is found by {@code ServiceLoader}, and its {@code
create()} yields an
+ * {@link EnvVarSecretStore}.
+ */
+class SecretStoreProvider_Test extends TestBase {
+
+ @Test void a01_serviceLoaderFindsEnvVarProvider() {
+ var found = false;
+ for (var p : ServiceLoader.load(SecretStoreProvider.class))
+ if (p instanceof EnvVarSecretStoreProvider)
+ found = true;
+ assertTrue(found, "EnvVarSecretStoreProvider not discovered via
ServiceLoader");
+ }
+
+ @Test void a02_envVarProviderCreatesEnvVarStore() {
+ assertInstanceOf(EnvVarSecretStore.class, new
EnvVarSecretStoreProvider().create());
+ }
+
+ @Test void a03_envVarProviderDefaultOrder() {
+ assertEquals(30, new EnvVarSecretStoreProvider().order());
+ }
+
+ @Test void a04_defaultOrderIsZero() {
+ SecretStoreProvider p = InMemorySecretStore::new;
+ assertEquals(0, p.order());
+ }
+}
diff --git
a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/secret/SecretStores_Test.java
b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/secret/SecretStores_Test.java
new file mode 100644
index 0000000000..a3e11f4b64
--- /dev/null
+++
b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/secret/SecretStores_Test.java
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.commons.secret;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.commons.*;
+import org.apache.juneau.commons.inject.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Coverage for {@link SecretStores}: BeanStore-first resolution with an
{@link InMemorySecretStore} drop-in default,
+ * and opt-in {@code ServiceLoader} discovery via {@link SecretStoreProvider}.
+ */
+@SuppressWarnings({
+ "resource" // Closeable resources in tests are intentionally
unassigned; closing is handled by test infrastructure.
+})
+class SecretStores_Test extends TestBase {
+
+ @Test void a01_resolveContributedStoreWins() {
+ var contributed = new InMemorySecretStore();
+ var beanStore = new BasicBeanStore().addBean(SecretStore.class,
contributed);
+ assertSame(contributed, SecretStores.resolve(beanStore));
+ }
+
+ @Test void a02_resolveEmptyBeanStoreReturnsInMemoryDefault() {
+ var resolved = SecretStores.resolve(new BasicBeanStore());
+ assertInstanceOf(InMemorySecretStore.class, resolved);
+ }
+
+ @Test void a03_resolveNullBeanStoreReturnsInMemoryDefault() {
+ assertInstanceOf(InMemorySecretStore.class,
SecretStores.resolve(null));
+ }
+
+ @Test void a04_fromServiceLoaderDiscoversRegisteredProvider() {
+ // juneau-commons registers EnvVarSecretStoreProvider in
META-INF/services, so discovery finds it.
+ var discovered = SecretStores.fromServiceLoader();
+ assertTrue(discovered.isPresent());
+ assertInstanceOf(EnvVarSecretStore.class,
discovered.orElseThrow());
+ }
+
+ @Test void a05_resolveIgnoresServiceLoaderSoDefaultStaysInMemory() {
+ // The classpath has a registered provider, but resolve() must
not consult ServiceLoader -- the
+ // no-contribution default is a deterministic
InMemorySecretStore, never the env-backed provider.
+ assertInstanceOf(InMemorySecretStore.class,
SecretStores.resolve(new BasicBeanStore()));
+ }
+}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/objecttools/ObjectPaginator.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/objecttools/ObjectPaginator.java
index 52af513f5c..6dbed39ac1 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/objecttools/ObjectPaginator.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/objecttools/ObjectPaginator.java
@@ -85,7 +85,7 @@ public class ObjectPaginator implements ObjectTool<PageArgs> {
if (type.isArray()) {
var size = Array.getLength(input);
- var end = (limit + pos >= size) ? size : limit + pos;
+ var end = (limit < 0 || limit + pos >= size) ? size :
limit + pos;
pos = Math.min(pos, size);
var et = type.getElementType();
if (! et.isPrimitive())
@@ -108,7 +108,7 @@ public class ObjectPaginator implements
ObjectTool<PageArgs> {
}
var l = type.isList() ? (List)input : new
ArrayList((Collection)input);
- var end = (limit + pos >= l.size()) ? l.size() : limit + pos;
+ var end = (limit < 0 || limit + pos >= l.size()) ? l.size() :
limit + pos;
pos = Math.min(pos, l.size());
return l.subList(pos, end);
}
diff --git
a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/objecttools/ObjectPaginator_Test.java
b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/objecttools/ObjectPaginator_Test.java
index 3bb1aa028b..dc596aa683 100644
---
a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/objecttools/ObjectPaginator_Test.java
+++
b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/objecttools/ObjectPaginator_Test.java
@@ -101,4 +101,20 @@ class ObjectPaginator_Test extends TestBase {
assertList(op.run(in2, 4, 1));
assertList(op.run(in2, 0, 0));
}
+
+
//-----------------------------------------------------------------------------------------------------------------
+ // Position with no limit
+
//-----------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_positionOnly_noLimit_collection() {
+ var in = l(1,2,3,4,5);
+ assertList(op.run(bs, in, PageArgs.create(2, null)), 3,4,5);
+ assertList(op.run(bs, in, PageArgs.create(0, null)), 1,2,3,4,5);
+ assertList(op.run(bs, in, PageArgs.create(5, null)));
+ }
+
+ @Test void d02_positionOnly_noLimit_array() {
+ var in = ints(1,2,3,4,5);
+ assertList(op.run(bs, in, PageArgs.create(2, null)), 3,4,5);
+ }
}
\ No newline at end of file
diff --git a/juneau-secret-keychain/pom.xml b/juneau-secret-keychain/pom.xml
new file mode 100644
index 0000000000..2e86a44a8c
--- /dev/null
+++ b/juneau-secret-keychain/pom.xml
@@ -0,0 +1,117 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau</artifactId>
+ <version>10.0.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>juneau-secret-keychain</artifactId>
+ <name>Apache Juneau Secret Store - OS Keychain</name>
+ <description>Opt-in SecretStore implementation backed by the macOS
'security' keychain CLI.</description>
+ <packaging>bundle</packaging>
+
+ <properties>
+
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-commons</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter</artifactId>
+ <version>${junit.version}</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.felix</groupId>
+ <artifactId>maven-bundle-plugin</artifactId>
+ <extensions>true</extensions>
+ <configuration>
+
<supportIncrementalBuild>true</supportIncrementalBuild>
+ </configuration>
+ <executions>
+ <execution>
+ <id>bundle-manifest</id>
+ <phase>process-classes</phase>
+ <goals>
+ <goal>manifest</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-source-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>attach-sources</id>
+ <phase>verify</phase>
+ <goals>
+ <goal>jar-no-fork</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-jar-plugin</artifactId>
+ </plugin>
+ <plugin>
+ <groupId>org.jacoco</groupId>
+ <artifactId>jacoco-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>default-prepare-agent</id>
+ <goals>
+
<goal>prepare-agent</goal>
+ </goals>
+ </execution>
+ <execution>
+ <id>default-report</id>
+ <phase>prepare-package</phase>
+ <goals>
+ <goal>report</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-surefire-plugin</artifactId>
+ <configuration>
+ <includes>
+
<include>**/*Test.class</include>
+ </includes>
+ </configuration>
+ </plugin>
+ </plugins>
+ </build>
+</project>
diff --git
a/juneau-secret-keychain/src/main/java/org/apache/juneau/secret/keychain/KeychainSecretStore.java
b/juneau-secret-keychain/src/main/java/org/apache/juneau/secret/keychain/KeychainSecretStore.java
new file mode 100644
index 0000000000..a1449b1941
--- /dev/null
+++
b/juneau-secret-keychain/src/main/java/org/apache/juneau/secret/keychain/KeychainSecretStore.java
@@ -0,0 +1,223 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.secret.keychain;
+
+import static java.nio.charset.StandardCharsets.*;
+import static java.util.concurrent.TimeUnit.*;
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.io.*;
+import java.nio.*;
+import java.util.*;
+
+import org.apache.juneau.commons.secret.*;
+
+/**
+ * A {@link SecretStore} backed by the macOS <c>security</c> keychain CLI
(generic passwords).
+ *
+ * <p>
+ * Each operation shells out to <c>/usr/bin/security</c>:
+ * <ul>
+ * <li>{@link #store} → <c>add-generic-password -U</c> (create or
update).
+ * <li>{@link #find} → <c>find-generic-password -w</c> (print the
password).
+ * <li>{@link #exists} → <c>find-generic-password</c> (presence only,
without the password).
+ * <li>{@link #delete} → <c>delete-generic-password</c>.
+ * </ul>
+ * Items are namespaced by a caller-supplied <i>service</i> name (the keychain
<c>-s</c> attribute) with the secret
+ * key as the <i>account</i> (<c>-a</c>). Choose a stable,
collision-resistant service name (for example a
+ * reverse-DNS string) so unrelated consumers do not clobber each other's
entries.
+ *
+ * <p>
+ * <b>Platform.</b> Only macOS is supported; on any other OS, or when
<c>/usr/bin/security</c> is unavailable, calls
+ * are treated as a backend failure and resolved per the configured {@link
FailMode}.
+ *
+ * <p>
+ * <b>Backend-unavailable behavior.</b> A key that is simply not present is a
normal <i>absent</i> result, never a
+ * failure. A genuine failure — the tool missing, a non-zero exit that
is not "item not found", or a timeout
+ * — is resolved by the {@link FailMode} chosen at construction: {@link
FailMode#FAIL_OPEN} degrades to
+ * absent/no-op, while {@link FailMode#FAIL_CLOSED} (the default) throws.
+ *
+ * <p>
+ * <b>Exposure caveats (inherent to shelling out to a CLI).</b> {@link #store}
passes the secret as a
+ * <c>-w <secret></c> command-line argument, so it is briefly visible in
the process table and is materialized
+ * as a {@link String}; {@link #find} decodes the retrieved bytes into a
{@code char[]} without an intermediate
+ * {@link String}. The value is never logged or {@code toString()}'d by this
class.
+ *
+ * @since 10.0.0
+ */
+public class KeychainSecretStore implements SecretStore {
+
+ /** The macOS keychain "item not found" exit status ({@code
errSecItemNotFound}). */
+ static final int NOT_FOUND = 44;
+
+ /** Default per-call timeout, in seconds, bounding a slow/hung {@code
security} invocation. */
+ public static final long DEFAULT_TIMEOUT_SECONDS = 15L;
+
+ private static final String SECURITY = "/usr/bin/security";
+
+ private final String service;
+ private final FailMode failMode;
+ private final long timeoutSeconds;
+ private final String binary;
+
+ /**
+ * Constructor using {@link FailMode#FAIL_CLOSED} and the {@link
#DEFAULT_TIMEOUT_SECONDS default} timeout.
+ *
+ * @param service The keychain service name that namespaces this
store's items. Must not be <jk>null</jk> or blank.
+ */
+ public KeychainSecretStore(String service) {
+ this(service, FailMode.FAIL_CLOSED);
+ }
+
+ /**
+ * Constructor using the {@link #DEFAULT_TIMEOUT_SECONDS default}
timeout.
+ *
+ * @param service The keychain service name that namespaces this
store's items. Must not be <jk>null</jk> or blank.
+ * @param failMode The policy applied on a backend failure. Must not
be <jk>null</jk>.
+ */
+ public KeychainSecretStore(String service, FailMode failMode) {
+ this(service, failMode, DEFAULT_TIMEOUT_SECONDS);
+ }
+
+ /**
+ * Constructor.
+ *
+ * @param service The keychain service name that namespaces this
store's items. Must not be <jk>null</jk> or blank.
+ * @param failMode The policy applied on a backend failure. Must not
be <jk>null</jk>.
+ * @param timeoutSeconds The per-call timeout, in seconds, bounding a
slow/hung {@code security} invocation. Must be {@code > 0}.
+ */
+ public KeychainSecretStore(String service, FailMode failMode, long
timeoutSeconds) {
+ this(service, failMode, timeoutSeconds, SECURITY);
+ }
+
+ KeychainSecretStore(String service, FailMode failMode, long
timeoutSeconds, String binary) {
+ this.service = assertArgNotNullOrBlank("service", service);
+ this.failMode = assertArgNotNull("failMode", failMode);
+ assertArg(timeoutSeconds > 0, "Argument 'timeoutSeconds' must
be > 0.");
+ this.timeoutSeconds = timeoutSeconds;
+ this.binary = binary;
+ }
+
+ @Override /* SecretStore */
+ public void store(String key, char[] secret) {
+ assertArgNotNull("key", key);
+ assertArgNotNull("secret", secret);
+ try {
+ var r = run("add-generic-password", "-U", "-a", key,
"-s", service, "-w", new String(secret));
+ if (r.exit() != 0)
+ throw backendFailure("add-generic-password", r);
+ } catch (RuntimeException e) {
+ if (failMode == FailMode.FAIL_OPEN)
+ return;
+ throw e;
+ }
+ }
+
+ @Override /* SecretStore */
+ public Optional<char[]> find(String key) {
+ assertArgNotNull("key", key);
+ try {
+ var r = run("find-generic-password", "-a", key, "-s",
service, "-w");
+ if (r.exit() == 0)
+ return o(decodeTrimmed(r.stdout()));
+ if (r.exit() == NOT_FOUND)
+ return oe();
+ throw backendFailure("find-generic-password", r);
+ } catch (RuntimeException e) {
+ if (failMode == FailMode.FAIL_OPEN)
+ return oe();
+ throw e;
+ }
+ }
+
+ @Override /* SecretStore */
+ public boolean exists(String key) {
+ assertArgNotNull("key", key);
+ try {
+ var r = run("find-generic-password", "-a", key, "-s",
service);
+ if (r.exit() == 0)
+ return true;
+ if (r.exit() == NOT_FOUND)
+ return false;
+ throw backendFailure("find-generic-password", r);
+ } catch (RuntimeException e) {
+ if (failMode == FailMode.FAIL_OPEN)
+ return false;
+ throw e;
+ }
+ }
+
+ @Override /* SecretStore */
+ public boolean delete(String key) {
+ assertArgNotNull("key", key);
+ try {
+ var r = run("delete-generic-password", "-a", key, "-s",
service);
+ if (r.exit() == 0)
+ return true;
+ if (r.exit() == NOT_FOUND)
+ return false;
+ throw backendFailure("delete-generic-password", r);
+ } catch (RuntimeException e) {
+ if (failMode == FailMode.FAIL_OPEN)
+ return false;
+ throw e;
+ }
+ }
+
+ /** The outcome of a single {@code security} invocation. */
+ private record Result(int exit, byte[] stdout, String stderr) {}
+
+ private Result run(String... args) {
+ var cmd = new ArrayList<String>(args.length + 1);
+ cmd.add(binary);
+ cmd.addAll(Arrays.asList(args));
+ try {
+ var p = new ProcessBuilder(cmd).start();
+ p.getOutputStream().close();
+ if (! p.waitFor(timeoutSeconds, SECONDS)) {
+ p.destroyForcibly();
+ throw isex("Timed out invoking '%s' after %s
seconds.", binary, timeoutSeconds);
+ }
+ var out = p.getInputStream().readAllBytes();
+ var err = new String(p.getErrorStream().readAllBytes(),
UTF_8);
+ return new Result(p.exitValue(), out, err);
+ } catch (IOException e) {
+ throw rex(e, "Unable to invoke '%s' (is this macOS with
the keychain CLI available?).", binary);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw rex(e, "Interrupted while invoking '%s'.",
binary);
+ }
+ }
+
+ private static RuntimeException backendFailure(String subcommand,
Result r) {
+ return isex("Keychain '%s' failed with exit code %s: %s",
subcommand, r.exit(), r.stderr().strip());
+ }
+
+ /** Decodes UTF-8 stdout bytes into a char[], dropping a single
trailing newline, without an intermediate String. */
+ private static char[] decodeTrimmed(byte[] bytes) {
+ var len = bytes.length;
+ if (len > 0 && bytes[len - 1] == '\n')
+ len--;
+ if (len > 0 && bytes[len - 1] == '\r')
+ len--;
+ var cb = UTF_8.decode(ByteBuffer.wrap(bytes, 0, len));
+ var chars = new char[cb.remaining()];
+ cb.get(chars);
+ return chars;
+ }
+}
diff --git
a/juneau-secret-keychain/src/main/java/org/apache/juneau/secret/keychain/KeychainSecretStoreProvider.java
b/juneau-secret-keychain/src/main/java/org/apache/juneau/secret/keychain/KeychainSecretStoreProvider.java
new file mode 100644
index 0000000000..3049987f29
--- /dev/null
+++
b/juneau-secret-keychain/src/main/java/org/apache/juneau/secret/keychain/KeychainSecretStoreProvider.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.secret.keychain;
+
+import org.apache.juneau.commons.secret.*;
+
+/**
+ * Provider that contributes a {@link KeychainSecretStore} via {@code
ServiceLoader}, discoverable through
+ * {@link SecretStores#fromServiceLoader()} when this module is on the
classpath and the host is macOS.
+ *
+ * <p>
+ * The service name defaults to {@value #DEFAULT_SERVICE} but can be
overridden via the
+ * {@value #SERVICE_PROPERTY} system property; the fail mode defaults to
{@link FailMode#FAIL_CLOSED}. On a
+ * non-macOS host (or when the keychain CLI is absent) {@link #create()}
returns <jk>null</jk> so discovery skips it.
+ *
+ * @since 10.0.0
+ */
+public class KeychainSecretStoreProvider implements SecretStoreProvider {
+
+ /** System property overriding the keychain service name used by the
discovered store. */
+ public static final String SERVICE_PROPERTY =
"juneau.secret.keychain.service";
+
+ /** Default keychain service name when {@link #SERVICE_PROPERTY} is
unset. */
+ public static final String DEFAULT_SERVICE = "org.apache.juneau";
+
+ @Override /* SecretStoreProvider */
+ public SecretStore create() {
+ if (! System.getProperty("os.name",
"").toLowerCase().contains("mac"))
+ return null;
+ return new
KeychainSecretStore(System.getProperty(SERVICE_PROPERTY, DEFAULT_SERVICE));
+ }
+
+ @Override /* SecretStoreProvider */
+ public int order() {
+ return 10;
+ }
+}
diff --git
a/juneau-secret-keychain/src/main/java/org/apache/juneau/secret/keychain/package-info.java
b/juneau-secret-keychain/src/main/java/org/apache/juneau/secret/keychain/package-info.java
new file mode 100644
index 0000000000..aa40ddf9a6
--- /dev/null
+++
b/juneau-secret-keychain/src/main/java/org/apache/juneau/secret/keychain/package-info.java
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * An opt-in {@link org.apache.juneau.commons.secret.SecretStore} backed by
the macOS <c>security</c> keychain CLI.
+ *
+ * <p>
+ * This is deliberately a separate module rather than part of
<c>juneau-commons</c>: it shells out to an external OS
+ * process, which the commons zero-runtime-deps / no-OS-integration philosophy
keeps out of core. Add this module to
+ * the classpath (or wire {@link
org.apache.juneau.secret.keychain.KeychainSecretStore} into a
+ * {@code BeanStore}) to back secret storage with the user's login keychain on
macOS.
+ *
+ * @since 10.0.0
+ */
+package org.apache.juneau.secret.keychain;
diff --git
a/juneau-secret-keychain/src/main/resources/META-INF/services/org.apache.juneau.commons.secret.SecretStoreProvider
b/juneau-secret-keychain/src/main/resources/META-INF/services/org.apache.juneau.commons.secret.SecretStoreProvider
new file mode 100644
index 0000000000..83909ec1b9
--- /dev/null
+++
b/juneau-secret-keychain/src/main/resources/META-INF/services/org.apache.juneau.commons.secret.SecretStoreProvider
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+org.apache.juneau.secret.keychain.KeychainSecretStoreProvider
diff --git
a/juneau-secret-keychain/src/test/java/org/apache/juneau/secret/keychain/KeychainSecretStoreProvider_Test.java
b/juneau-secret-keychain/src/test/java/org/apache/juneau/secret/keychain/KeychainSecretStoreProvider_Test.java
new file mode 100644
index 0000000000..ce88c61364
--- /dev/null
+++
b/juneau-secret-keychain/src/test/java/org/apache/juneau/secret/keychain/KeychainSecretStoreProvider_Test.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.secret.keychain;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.util.*;
+
+import org.apache.juneau.commons.secret.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Coverage for {@link KeychainSecretStoreProvider}: discoverable via {@code
ServiceLoader}, macOS-gated
+ * {@code create()}, and a fixed order.
+ */
+class KeychainSecretStoreProvider_Test {
+
+ private static boolean isMac() {
+ return System.getProperty("os.name",
"").toLowerCase().contains("mac");
+ }
+
+ @Test void a01_serviceLoaderFindsProvider() {
+ var found = false;
+ for (var p : ServiceLoader.load(SecretStoreProvider.class))
+ if (p instanceof KeychainSecretStoreProvider)
+ found = true;
+ assertTrue(found, "KeychainSecretStoreProvider not discovered
via ServiceLoader");
+ }
+
+ @Test void a02_createOnMacYieldsKeychainStore() {
+ assumeTrue(isMac(), "Not macOS");
+ assertInstanceOf(KeychainSecretStore.class, new
KeychainSecretStoreProvider().create());
+ }
+
+ @Test void a03_createOnNonMacReturnsNull() {
+ assumeFalse(isMac(), "Only meaningful off macOS");
+ assertNull(new KeychainSecretStoreProvider().create());
+ }
+
+ @Test void a04_order() {
+ assertEquals(10, new KeychainSecretStoreProvider().order());
+ }
+}
diff --git
a/juneau-secret-keychain/src/test/java/org/apache/juneau/secret/keychain/KeychainSecretStore_Test.java
b/juneau-secret-keychain/src/test/java/org/apache/juneau/secret/keychain/KeychainSecretStore_Test.java
new file mode 100644
index 0000000000..59bb9d2a1e
--- /dev/null
+++
b/juneau-secret-keychain/src/test/java/org/apache/juneau/secret/keychain/KeychainSecretStore_Test.java
@@ -0,0 +1,140 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.secret.keychain;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.io.*;
+import java.util.*;
+
+import org.apache.juneau.commons.secret.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Coverage for {@link KeychainSecretStore}.
+ *
+ * <p>
+ * The round-trip tests are guarded by JUnit assumptions so they skip cleanly
when not on macOS or when the
+ * {@code security} CLI is unavailable. The {@link FailMode} and validation
tests use a deliberately bad binary path
+ * so they run deterministically on any platform without touching a real
keychain.
+ */
+class KeychainSecretStore_Test {
+
+ private static boolean keychainAvailable() {
+ return System.getProperty("os.name",
"").toLowerCase().contains("mac") && new File("/usr/bin/security").canExecute();
+ }
+
+ private static String uniqueService() {
+ return "org.apache.juneau.test." + UUID.randomUUID();
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // Round-trip against the real keychain (assumption-guarded).
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_roundTrip() {
+ assumeTrue(keychainAvailable(), "macOS keychain CLI not
available");
+ var service = uniqueService();
+ var store = new KeychainSecretStore(service);
+ try {
+ assertFalse(store.exists("acct"));
+ assertTrue(store.find("acct").isEmpty());
+
+ store.store("acct", "hunter2".toCharArray());
+ assertTrue(store.exists("acct"));
+ assertArrayEquals("hunter2".toCharArray(),
store.find("acct").orElseThrow());
+
+ // Update-in-place.
+ store.store("acct", "s3cr3t".toCharArray());
+ assertArrayEquals("s3cr3t".toCharArray(),
store.find("acct").orElseThrow());
+
+ assertTrue(store.delete("acct"));
+ assertFalse(store.exists("acct"));
+ assertTrue(store.find("acct").isEmpty());
+ assertFalse(store.delete("acct"));
+ } finally {
+ try {
+ store.delete("acct");
+ } catch (RuntimeException ignored) { /* best-effort
cleanup */ }
+ }
+ }
+
+ @Test void a02_absentKeyIsCleanlyAbsent() {
+ assumeTrue(keychainAvailable(), "macOS keychain CLI not
available");
+ var store = new KeychainSecretStore(uniqueService());
+ assertTrue(store.find("missing").isEmpty());
+ assertFalse(store.exists("missing"));
+ assertFalse(store.delete("missing"));
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // FailMode on an unavailable backend (deterministic via a bad binary
path).
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ private static KeychainSecretStore unavailable(FailMode failMode) {
+ return new KeychainSecretStore("svc", failMode, 5L,
"/nonexistent/juneau-not-security");
+ }
+
+ @Test void b01_failOpenReadsDegradeToAbsent() {
+ var store = unavailable(FailMode.FAIL_OPEN);
+ assertTrue(store.find("k").isEmpty());
+ assertFalse(store.exists("k"));
+ assertFalse(store.delete("k"));
+ }
+
+ @Test void b02_failOpenStoreIsNoOp() {
+ assertDoesNotThrow(() ->
unavailable(FailMode.FAIL_OPEN).store("k", "v".toCharArray()));
+ }
+
+ @Test void b03_failClosedReadsThrow() {
+ var store = unavailable(FailMode.FAIL_CLOSED);
+ assertThrows(RuntimeException.class, () -> store.find("k"));
+ assertThrows(RuntimeException.class, () -> store.exists("k"));
+ assertThrows(RuntimeException.class, () -> store.delete("k"));
+ }
+
+ @Test void b04_failClosedStoreThrows() {
+ assertThrows(RuntimeException.class, () ->
unavailable(FailMode.FAIL_CLOSED).store("k", "v".toCharArray()));
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // Construction validation (platform-independent).
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_nullOrBlankServiceRejected() {
+ assertThrows(IllegalArgumentException.class, () -> new
KeychainSecretStore(null));
+ assertThrows(IllegalArgumentException.class, () -> new
KeychainSecretStore(" "));
+ }
+
+ @Test void c02_nullFailModeRejected() {
+ assertThrows(IllegalArgumentException.class, () -> new
KeychainSecretStore("svc", null));
+ }
+
+ @Test void c03_nonPositiveTimeoutRejected() {
+ assertThrows(IllegalArgumentException.class, () -> new
KeychainSecretStore("svc", FailMode.FAIL_CLOSED, 0L));
+ }
+
+ @Test void c04_nullKeyRejected() {
+ var store = unavailable(FailMode.FAIL_OPEN);
+ assertThrows(IllegalArgumentException.class, () ->
store.find(null));
+ assertThrows(IllegalArgumentException.class, () ->
store.exists(null));
+ assertThrows(IllegalArgumentException.class, () ->
store.delete(null));
+ assertThrows(IllegalArgumentException.class, () ->
store.store(null, "v".toCharArray()));
+ assertThrows(IllegalArgumentException.class, () ->
store.store("k", null));
+ }
+}
diff --git a/pom.xml b/pom.xml
index 48e0433c5e..0206d98a39 100644
--- a/pom.xml
+++ b/pom.xml
@@ -88,6 +88,7 @@
<module>juneau-rest</module>
<module>juneau-microservice</module>
<module>juneau-sc</module>
+ <module>juneau-secret-keychain</module>
<module>juneau-examples</module>
<module>juneau-petstore</module>
<module>juneau-integration-tests</module>