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 &mdash; 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> &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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)} &mdash; 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)} &mdash; 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> &mdash; {@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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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} &mdash; the 
SPI (store / find / exists / delete).
+ *     <li>{@link org.apache.juneau.commons.secret.InMemorySecretStore} 
&mdash; the zero-config, process-local default.
+ *     <li>{@link org.apache.juneau.commons.secret.EnvVarSecretStore} &mdash; 
a read-only, environment-variable-backed source.
+ *     <li>{@link org.apache.juneau.commons.secret.SecretStoreProvider} 
&mdash; {@code ServiceLoader} discovery.
+ *     <li>{@link org.apache.juneau.commons.secret.SecretStores} &mdash; 
{@code BeanStore} resolution (default {@code InMemorySecretStore}).
+ *     <li>{@link org.apache.juneau.commons.secret.SecretStorePropertySource} 
&mdash; 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} &rarr; <c>add-generic-password -U</c> (create or 
update).
+ *     <li>{@link #find} &rarr; <c>find-generic-password -w</c> (print the 
password).
+ *     <li>{@link #exists} &rarr; <c>find-generic-password</c> (presence only, 
without the password).
+ *     <li>{@link #delete} &rarr; <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 &mdash; the tool missing, a non-zero exit that 
is not "item not found", or a timeout
+ * &mdash; 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 &lt;secret&gt;</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>

Reply via email to