001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.rest.server.mcp.v20260728;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.nio.charset.StandardCharsets;
022import java.security.Principal;
023import java.security.SecureRandom;
024import java.util.Base64;
025import java.util.Optional;
026
027import javax.crypto.Cipher;
028import javax.crypto.spec.GCMParameterSpec;
029
030import org.apache.juneau.marshall.marshaller.Json;
031import org.apache.juneau.rest.server.auth.ClaimsPrincipal;
032
033/**
034 * Built-in default {@link RequestStateCodec}: AES-256-GCM keyed by a pluggable {@link KeyProvider}.
035 *
036 * <p>
037 * The no-arg constructor uses a fresh, per-process {@link EphemeralKeyProvider} &mdash; <b>not restart-durable
038 * and not shareable across process instances by design.</b> Each instance generates its own random key and
039 * {@code keyId} at construction time; a token sealed by one instance can never be unsealed by another
040 * (including the same process after a restart). Operators who need cross-restart or multi-instance resumption
041 * must supply a shared/rotating {@link KeyProvider} (e.g. {@link StaticKeyProvider}) via
042 * {@link #AeadRequestStateCodec(KeyProvider)} instead of relying on this default. This is a documented, tested
043 * property (see {@code AeadRequestStateCodec_Test}), not an accidental limitation.
044 *
045 * <p>
046 * Sealed-token format (all opaque to callers, versioned so it can evolve):
047 * {@code version '.' base64url(keyId) '.' base64url(nonce) '.' base64url(ciphertext+tag)}. The current
048 * {@code version} literal is {@code "1"}; an unrecognized version fails {@link #unseal} closed. The
049 * {@code keyId} segment is cleartext base64url of the UTF-8 {@code keyId} bytes &mdash; it has to be read
050 * before the key it names can be resolved for decryption &mdash; but it is folded into the AEAD's authenticated
051 * data, so a swapped {@code keyId} fails the GCM tag check exactly like any other tamper. The plaintext is the
052 * JSON serialization of the {@link McpRequestState} record.
053 *
054 * <p>
055 * The AAD passed to {@link #seal}/{@link #unseal} is authenticated but never encrypted (standard AES-GCM AAD
056 * semantics). The dispatcher passes the canonical {@code method + '\u0000' + protocolVersion() + '\u0000' +
057 * target} (NUL-separated) form as the caller-supplied AAD (see {@code McpRevision#aad}) &mdash; the trailing
058 * {@code target} field (the tool/prompt {@code name} or resource {@code uri}) binds the token to the specific
059 * operation it paused against, not merely the method, so it can't be resumed against a different
060 * tool/prompt/resource. This codec composes that caller-supplied {@code aad}, the {@link KeyProvider}'s
061 * {@code keyId} (per its implicit {@code keyId}-authentication contract), and {@code principalIdentity(principal)}
062 * (the principal binding described below) into the cipher's actual AAD using the same self-delimiting,
063 * length-prefixed framing described on {@link #principalIdentity(Principal)} &mdash; so the three-field outer
064 * composition is unambiguous exactly as the two-field inner one is, even if a field happened to contain a NUL.
065 *
066 * <p>
067 * <b>Principal-bound AAD.</b> {@link #seal}/{@link #unseal} fold the caller's authenticated
068 * {@link Principal} identity into the AEAD's authenticated data, so a {@code requestState} minted for one caller
069 * cannot be resumed by another: a mismatched principal fails the GCM tag check and {@link #unseal} returns
070 * {@link Optional#empty()}. The bound identity is a canonical, deterministic {@code iss|sub} (issuer + subject)
071 * string derived by {@link #principalIdentity(Principal)} &mdash; issuer-scoped so a bare {@code sub} cannot
072 * collide across IdPs. A <jk>null</jk> (anonymous / RS-auth-disabled) principal binds <b>fail-closed</b> to a
073 * fixed {@code "anonymous"} sentinel rather than skipping the binding, so an anonymous-sealed token and an
074 * authenticated-sealed token are never mutually resumable (anonymous&harr;anonymous still round-trips). See
075 * {@link #principalIdentity(Principal)} for the exact canonical form and its collision-safety guarantee.
076 *
077 * <p>
078 * Random per-seal nonces are safe up to roughly 2^32 seals under a single key (the AES-GCM birthday bound). The
079 * ephemeral default's key is per-process and never approaches that. A long-lived custom {@link KeyProvider} key
080 * (e.g. a {@link StaticKeyProvider} entry left in place for months) is the case where this bound actually
081 * matters &mdash; rotate to a fresh {@code keyId} well before it, not after.
082 *
083 * <p>
084 * {@link #unseal} is wired to untrusted, client-supplied {@code requestState} strings, so it applies defensive
085 * length guards (a plausibly-sized token, and a bounded {@code keyId} segment) before any base64 decode; an
086 * out-of-bounds input returns {@link Optional#empty()} rather than allocating from an attacker-controlled
087 * length.
088 */
089public class AeadRequestStateCodec implements RequestStateCodec {
090
091   private static final String ALGORITHM = "AES/GCM/NoPadding";
092   private static final int GCM_TAG_BITS = 128;
093   private static final int NONCE_BYTES = 12;
094
095   // Wire-format version literal (see class Javadoc). Bumping this is how the format could evolve; unseal
096   // rejects any other value.
097   private static final String VERSION = "1";
098
099   // Exact unpadded base64url length of the 12-byte nonce segment (ceil(12*4/3)=16). The nonce segment of a
100   // well-formed token is always exactly this many chars, checked after the split in unseal.
101   private static final int NONCE_B64_CHARS = 16;
102
103   // Defensive ceiling on the base64url'd keyId segment of an untrusted, client-supplied token (see unseal).
104   // Derived from KeyedSecret's MAX_KEY_ID_CHARS=128 producer limit (measured in UTF-16 code units, no
105   // charset restriction) at the worst case of 3 UTF-8 bytes/char: 128*3=384 bytes, whose unpadded base64url
106   // encoding is ceil(384/3)*4=512 chars. Set to that worst case so it dominates any legitimately-issued
107   // keyId while still capping an attacker-inflated segment before base64 decode.
108   private static final int MAX_KEY_ID_B64_CHARS = 512;
109
110   // Defensive floor on the untrusted, client-supplied token string as a whole (see unseal). The real floor of
111   // a well-formed token is VERSION(1) + '.' + smallest possible keyId segment (2, for a 1-byte keyId) + '.' +
112   // NONCE_B64_CHARS + '.' + base64url of at least the 16-byte GCM tag (22 chars). 64KB is orders of magnitude
113   // above any legitimate sealed McpRequestState.
114   private static final int MIN_TOKEN_CHARS = VERSION.length() + 1 + 2 + 1 + NONCE_B64_CHARS + 1 + 22;
115   private static final int MAX_TOKEN_CHARS = 64 * 1024;
116
117   // Anonymous-caller sentinel (settled decision 2). A null principal binds fail-closed to this fixed
118   // identity rather than skipping the binding. It is intentionally a plain literal with no NUL: a real
119   // principalIdentity(...) is always length-prefixed and begins with a decimal digit and contains NULs, so the
120   // sentinel can never collide with any derived identity.
121   static final String ANONYMOUS_IDENTITY = "anonymous";
122
123   private static final Base64.Encoder B64URL = Base64.getUrlEncoder().withoutPadding();
124
125   private final KeyProvider keyProvider;
126   private final SecureRandom random = new SecureRandom();
127
128   /**
129    * Constructor. Uses a fresh, per-process {@link EphemeralKeyProvider} &mdash; the zero-config, non-shareable,
130    * non-durable default described in the class Javadoc.
131    */
132   public AeadRequestStateCodec() {
133      this(new EphemeralKeyProvider());
134   }
135
136   /**
137    * Constructor.
138    *
139    * @param keyProvider The source of sealing/resolving keys. Must not be <jk>null</jk>.
140    */
141   public AeadRequestStateCodec(KeyProvider keyProvider) {
142      if (keyProvider == null)
143         throw iaex("keyProvider must not be null");
144      this.keyProvider = keyProvider;
145   }
146
147   @Override /* RequestStateCodec */
148   public String seal(McpRequestState state, String aad, Principal principal) {
149      try {
150         var ks = keyProvider.currentKey();
151         // Random 96-bit nonce per seal. AES-GCM's birthday bound makes random nonces safe up to roughly 2^32
152         // seals under a single key; see the class Javadoc for why this matters more for a long-lived custom
153         // KeyProvider key than for the per-process ephemeral default.
154         var nonce = new byte[NONCE_BYTES];
155         random.nextBytes(nonce);
156         var cipher = Cipher.getInstance(ALGORITHM);
157         cipher.init(Cipher.ENCRYPT_MODE, ks.key(), new GCMParameterSpec(GCM_TAG_BITS, nonce));
158         // Fold the authenticated caller identity into the AEAD's authenticated data, so a token
159         // minted for one principal cannot be resumed by another. unseal derives the SAME identity below; a
160         // mismatch fails the GCM tag check there. The three fields are framed with lengthPrefixJoin (same
161         // self-delimiting scheme as principalIdentity's iss|sub) so the outer composition is unambiguous too.
162         cipher.updateAAD(lengthPrefixJoin(aad, ks.keyId(), principalIdentity(principal)).getBytes(StandardCharsets.UTF_8));
163         var plaintext = Json.of(state).getBytes(StandardCharsets.UTF_8);
164         var ciphertext = cipher.doFinal(plaintext);
165         var keyIdB64 = B64URL.encodeToString(ks.keyId().getBytes(StandardCharsets.UTF_8));
166         return VERSION + "." + keyIdB64 + "." + B64URL.encodeToString(nonce) + "." + B64URL.encodeToString(ciphertext);
167      } catch (Exception e) { // HTT encryption with a freshly-generated 12-byte nonce cannot fail under the standard JCE provider
168         throw rex(e, "Failed to seal requestState");
169      }
170   }
171
172   @Override /* RequestStateCodec */
173   public Optional<McpRequestState> unseal(String token, String aad, Principal principal) {
174      try {
175         if (token.length() < MIN_TOKEN_CHARS || token.length() > MAX_TOKEN_CHARS)
176            return Optional.empty();
177         var parts = token.split("\\.", 4);
178         // A well-formed token is exactly version '.' base64url(keyId) '.' base64url(12-byte nonce) '.'
179         // base64url(ciphertext+tag); reject any token with the wrong segment count, an unrecognized version,
180         // an empty/oversized keyId segment, or a nonce segment that isn't the expected length before any
181         // base64 decode.
182         if (parts.length != 4 || neq(VERSION, parts[0]) || parts[1].isEmpty() || parts[1].length() > MAX_KEY_ID_B64_CHARS
183               || parts[2].length() != NONCE_B64_CHARS)
184            return Optional.empty();
185         var keyId = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8);
186         var nonce = Base64.getUrlDecoder().decode(parts[2]);
187         var ciphertext = Base64.getUrlDecoder().decode(parts[3]);
188         var resolved = keyProvider.resolveKey(keyId);
189         if (resolved.isEmpty())
190            return Optional.empty();
191         var cipher = Cipher.getInstance(ALGORITHM);
192         cipher.init(Cipher.DECRYPT_MODE, resolved.get(), new GCMParameterSpec(GCM_TAG_BITS, nonce));
193         // Fold the SAME caller identity seal bound in, so a token minted for principal A fails the GCM
194         // tag check (and returns Optional.empty() below) when replayed under principal B or anonymously. Same
195         // lengthPrefixJoin framing as seal, above.
196         cipher.updateAAD(lengthPrefixJoin(aad, keyId, principalIdentity(principal)).getBytes(StandardCharsets.UTF_8));
197         var plaintext = cipher.doFinal(ciphertext);
198         return Optional.of(Json.to(new String(plaintext, StandardCharsets.UTF_8), McpRequestState.class));
199      } catch (@SuppressWarnings("unused") Exception e) {
200         // Any failure (bad base64, unknown/retired keyId, AEAD tag mismatch from tamper/AAD/keyId mismatch,
201         // malformed JSON) is a verification failure per the RequestStateCodec contract, not an exceptional
202         // condition to propagate.
203         return Optional.empty();
204      }
205   }
206
207   /**
208    * Derives the canonical, deterministic identity string this codec folds into the AEAD's authenticated data to
209    * bind a sealed {@code requestState} to its caller.
210    *
211    * <p>
212    * <b>Bound identity (settled decision 1): {@code iss|sub}.</b> When {@code principal} is a {@link ClaimsPrincipal}
213    * (the type the resource-server layer produces &mdash; {@code JwtTokenValidator}, OAuth introspection, OIDC, and
214    * SAML all return one), the {@code iss} and {@code sub} claims are read from its claim set; the issuer scopes the
215    * subject so a bare {@code sub} cannot collide across IdPs. When {@code sub} is absent or empty (or the principal
216    * is a bare {@link Principal} that exposes only {@link Principal#getName() getName()}), {@link Principal#getName()
217    * getName()} is used as the subject with an empty issuer &mdash; a deterministic, collision-conscious fallback.
218    *
219    * <p>
220    * <b>Anonymous (settled decision 2): fixed sentinel, fail-closed.</b> A <jk>null</jk> principal (anonymous caller /
221    * RS auth disabled) binds to the fixed {@link #ANONYMOUS_IDENTITY} sentinel rather than skipping the binding, so an
222    * anonymous-sealed token and an authenticated-sealed token are never mutually resumable.
223    *
224    * <p>
225    * <b>Encoding (collision-safe).</b> The identity is a length-prefixed, fixed-order concatenation:
226    * {@code len(iss) + '\u0000' + iss + '\u0000' + len(sub) + '\u0000' + sub}. Prefixing each component with its
227    * length makes the encoding self-delimiting, so no {@code iss}/{@code sub} value that itself contains the NUL
228    * delimiter can push two distinct identities onto the same string (which a plain {@code iss + '\u0000' + sub} join
229    * could). Both {@link #seal} and {@link #unseal} derive this identical string for the same principal, so a matched
230    * caller round-trips and any mismatch (different subject, different issuer, or authenticated-vs-anonymous) yields
231    * different AAD bytes and fails GCM tag verification. The value is never logged or surfaced in an exception.
232    *
233    * @param principal The authenticated caller. May be <jk>null</jk> (binds to {@link #ANONYMOUS_IDENTITY}).
234    * @return The canonical identity string. Never <jk>null</jk>.
235    */
236   static String principalIdentity(Principal principal) {
237      if (principal == null)
238         return ANONYMOUS_IDENTITY;
239      String iss;
240      String sub;
241      if (principal instanceof ClaimsPrincipal cp) {
242         iss = cp.getClaim("iss", String.class).orElse("");
243         sub = cp.getClaim("sub", String.class).orElse("");
244         if (sub.isEmpty())
245            sub = ein(cp.getName());
246      } else {
247         iss = "";
248         sub = ein(principal.getName());
249      }
250      return lengthPrefixJoin(iss, sub);
251   }
252
253   /**
254    * Joins the given fields into a single self-delimiting string: each field is preceded by its own decimal
255    * length, and every token (each length, and each field) is NUL-separated &mdash; equivalent to
256    * {@code String.join("\u0000", len(fields[0]), fields[0], len(fields[1]), fields[1], ...)}. Because a
257    * field's exact length is always known before its bytes are consumed, an embedded NUL inside a field can
258    * never be misread as a token boundary, so two distinct field tuples never join to the same string. Shared
259    * by {@link #principalIdentity(Principal)} (framing {@code iss}/{@code sub}) and by {@link #seal}/
260    * {@link #unseal} (framing the outer {@code aad}/{@code keyId}/identity triple).
261    *
262    * @param fields The fields to frame, in order. Must not be <jk>null</jk> and must not contain a <jk>null</jk>
263    *    element.
264    * @return The length-prefixed, NUL-joined encoding. Never <jk>null</jk>.
265    */
266   static String lengthPrefixJoin(String... fields) {
267      var sb = new StringBuilder();
268      for (var f : fields) {
269         if (!sb.isEmpty())
270            sb.append('\u0000');
271         sb.append(f.length()).append('\u0000').append(f);
272      }
273      return sb.toString();
274   }
275}