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} — <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 — it has to be read 050 * before the key it names can be resolved for decryption — 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}) — 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)} — 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)} — 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↔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 — 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} — 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 — {@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 — 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 — 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}