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.console.views; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.nio.charset.*; 022import java.security.*; 023import java.util.*; 024 025/** 026 * A server-minted idempotency key for a row-action submit, bound at mint time to the {@code (action, targetId)} pair 027 * it was issued for. 028 * 029 * <h5 class='section'>Why the key is server-minted and bound, not a client UUID</h5> 030 * <p> 031 * The key is minted <b>when the modal opens</b> — the server is already in the loop then, since the modal-open 032 * confirmation is server-rendered against the current record — so a double-click, an impatient re-submit and a 033 * browser retry all carry the <b>same</b> key and collapse to one effect. The modal owns the key's <i>carry</i> 034 * lifecycle; it does <b>not</b> mint it. 035 * <p> 036 * A client-generated key is not the security property. A globally-unique key looked up <i>only by key</i> would let 037 * a recorded terminal outcome for incident A be replayed under a submit for incident B — returning 038 * A's "success" for a write B never performed, and resurrecting the "resolve an incident that was re-triggered in the 039 * interim" case. This key is therefore bound to {@code (action, targetId)} at mint time: a submit whose 040 * {@code (action, targetId)} does not {@link #matches(String, String) match} the binding is a <b>named refusal</b>, 041 * never a replayed success. 042 * 043 * <h5 class='section'>Unforgeability</h5> 044 * <p> 045 * The key value is {@value #KEY_BYTES} random bytes ({@value #KEY_BITS} bits) from {@link SecureRandom}, 046 * hex-encoded — well beyond the ≥128-bit bar, matching {@link org.apache.juneau.rest.server.filter.SynchronizerToken}'s 047 * 256-bit reference. The binding is what makes it safe; the width is what makes it unguessable. 048 * 049 * <h5 class='section'>Example — the artifact-bound pattern (the default):</h5> 050 * <p class='bjava'> 051 * <jc>// At modal-open: mint a key bound to this action + this row.</jc> 052 * IdempotencyKey <jv>key</jv> = IdempotencyKey.<jsm>mint</jsm>(<js>"ack"</js>, <jv>incidentId</jv>); 053 * <jv>modal</jv>.idempotencyKey(<jv>key</jv>.value()); 054 * 055 * <jc>// At submit: rehydrate the recorded binding and check it against the submitted (action, targetId).</jc> 056 * <jk>if</jk> (! <jv>recorded</jv>.matches(<jv>submittedAction</jv>, <jv>submittedTargetId</jv>)) 057 * <jk>return</jk> ActionResult.<jsm>refusal</jsm>(<js>"write-guard:idempotency-binding-mismatch"</js>); 058 * </p> 059 * 060 * <h5 class='section'>The row-less, self-targeted pattern (the opt-in)</h5> 061 * <p> 062 * A dialog opened from a view's <b>ribbon</b> has no row, so at mint time there is no artifact id to bind to. 063 * {@link #mintSelfTargeted(String)} mints a key whose {@link #targetId()} <b>is its own {@link #value()}</b>, and 064 * the client sends that value back as the submitted target — so {@link #matches(String, String)} still has a 065 * real, agreed binding to check rather than a null target. 066 * 067 * <p> 068 * <b>Two call sites have to honour one pairing contract, and it is not enforceable by either alone:</b> 069 * <ul class='spaced-list'> 070 * <li>A {@link #mintSelfTargeted(String)} key MUST be carried by a modal that also sets its 071 * {@code selfTargeted} flag — that flag is what tells the client to send the key's own value as the 072 * target. Without it the client sends the row's id (or nothing at all, row-less), and the key's binding can 073 * never match. 074 * <li>A {@link #mint(String, String)} key must <b>NEVER</b> be paired with {@code selfTargeted} — that 075 * pairing makes the client stop sending the real target the key was bound to, silently discarding the artifact 076 * identity and making {@link #matches(String, String)} vacuous. This is exactly why the flag is an explicit 077 * per-dialog opt-in rather than a blanket client-side precedence rule. 078 * </ul> 079 * 080 * <h5 class='section'>Example — the self-targeted pattern:</h5> 081 * <p class='bjava'> 082 * <jc>// At modal-open on a ribbon-hosted dialog: there is no row, so the key targets itself.</jc> 083 * IdempotencyKey <jv>key</jv> = IdempotencyKey.<jsm>mintSelfTargeted</jsm>(<js>"create"</js>); 084 * <jv>modal</jv>.idempotencyKey(<jv>key</jv>.value()).selfTargeted(<jk>true</jk>); 085 * 086 * <jc>// At submit: the submitted target IS the key value the client echoed back.</jc> 087 * <jk>if</jk> (! <jv>recorded</jv>.matches(<jv>submittedAction</jv>, <jv>submittedTargetId</jv>)) 088 * <jk>return</jk> ActionResult.<jsm>refusal</jsm>(<js>"write-guard:idempotency-binding-mismatch"</js>); 089 * </p> 090 * 091 * <h5 class='section'>What a self-targeted key does and does not guarantee</h5> 092 * <p> 093 * A self-targeted key binds to {@code (action, one-opened-dialog)} — <b>not</b> {@code (action, artifact)}. 094 * It is minted per modal-open, and it is <b>single-execution, replay-on-duplicate</b>: it stops one opened dialog's 095 * submission from being <i>re-executed</i>, and a duplicate submit <b>replays</b> the recorded outcome rather than 096 * failing. It is <b>not</b> "single-use," and it does not answer <i>"is this submission for the artifact the key 097 * was minted for?"</i> — at mint time there is no artifact. 098 * 099 * <p> 100 * A self-targeted key must therefore <b>never</b> be described as equivalent to a row-scoped one. A key minted via 101 * the unchanged {@link #mint(String, String)} binds to a real artifact identity, and that guarantee is strictly 102 * stronger; it stays available to any consumer that has an artifact to bind to. 103 * 104 * <h5 class='section'>See Also:</h5> 105 * <ul> 106 * <li class='jc'>{@link org.apache.juneau.console.widgets.ModalDef} 107 * <li class='jc'>{@link ActionResult} 108 * <li class='jc'>{@link org.apache.juneau.rest.server.filter.SynchronizerToken} 109 * <li class='jc'>{@link JobKeyLedger} 110 * </ul> 111 * 112 * @since 10.0.0 113 */ 114@SuppressWarnings({ 115 "java:S1192" // Duplicated "action" is a protocol key; a constant would obscure the wire contract. 116}) 117public final class IdempotencyKey { 118 119 /** Width of a minted key value, in bits — well beyond the ≥128-bit bar. */ 120 private static final int KEY_BITS = 256; 121 122 /** Number of random bytes behind a minted key value, derived from {@link #KEY_BITS}. */ 123 private static final int KEY_BYTES = KEY_BITS / 8; 124 125 /** Shared secure random source for minting key values; {@link SecureRandom} is safe for concurrent use. */ 126 private static final SecureRandom RANDOM = new SecureRandom(); 127 128 private final String value; 129 private final String action; 130 private final String targetId; 131 132 private IdempotencyKey(String value, String action, String targetId) { 133 this.value = value; 134 this.action = action; 135 this.targetId = targetId; 136 } 137 138 /** 139 * Mints a fresh key bound to the specified {@code (action, targetId)} pair. 140 * 141 * @param action The stable action id this key is issued for. Must not be <jk>null</jk> or blank. 142 * @param targetId The id of the row/target this key is issued for. Must not be <jk>null</jk> or blank. 143 * @return A new key holding a fresh {@value #KEY_BITS}-bit secret, hex-encoded, bound to {@code (action, targetId)}. 144 * @throws IllegalArgumentException If {@code action} or {@code targetId} is <jk>null</jk> or blank. 145 */ 146 public static IdempotencyKey mint(String action, String targetId) { 147 requireNonBlank("action", action); 148 requireNonBlank("targetId", targetId); 149 var bytes = new byte[KEY_BYTES]; 150 RANDOM.nextBytes(bytes); 151 return new IdempotencyKey(HexFormat.of().formatHex(bytes), action, targetId); 152 } 153 154 /** 155 * Mints a fresh key that is bound to <b>itself</b>: its {@link #targetId()} is its own {@link #value()}. 156 * 157 * <p> 158 * For a dialog opened where there is no artifact to bind to — a ribbon-hosted, row-less dialog whose 159 * submit creates the thing it is about. {@link #mint(String, String)} cannot express this, since it requires a 160 * non-blank target that does not exist yet, and there is nothing gained by minting a <i>second</i> random string 161 * to serve as the target: one opaque, unguessable value plays both roles, and the client already has it as the 162 * modal's carried key. 163 * 164 * <p> 165 * <b>The pairing contract is not optional</b> — a key from this factory must be carried by a modal that 166 * also sets its {@code selfTargeted} flag, or the client will not send the key's value back as the submitted 167 * target and the binding can never match. Conversely, {@code selfTargeted} must never be set on a modal 168 * carrying a {@link #mint(String, String)} key. See the class Javadoc, which also states what this key does 169 * and does not guarantee (single-execution replay protection for one opened dialog — not artifact 170 * identity). 171 * 172 * <p> 173 * {@link #mint(String, String)}, {@link #of(String, String, String)} and {@link #matches(String, String)} are 174 * unchanged by this capability: it arrives entirely as this additional factory. 175 * 176 * @param action The stable action id this key is issued for. Must not be <jk>null</jk> or blank. 177 * @return A new key holding a fresh {@value #KEY_BITS}-bit secret, hex-encoded, bound to that same value as its target. 178 * @throws IllegalArgumentException If {@code action} is <jk>null</jk> or blank. 179 */ 180 public static IdempotencyKey mintSelfTargeted(String action) { 181 requireNonBlank("action", action); 182 var bytes = new byte[KEY_BYTES]; 183 RANDOM.nextBytes(bytes); 184 var value = HexFormat.of().formatHex(bytes); 185 return new IdempotencyKey(value, action, value); 186 } 187 188 /** 189 * Rehydrates a previously-minted key from its recorded value and binding. 190 * 191 * <p> 192 * Intended for the submit path: the consumer looks up the recorded {@code (value, action, targetId)} it minted at 193 * modal-open and reconstructs this to {@link #matches(String, String) check} it against the submitted pair. 194 * 195 * @param value The recorded key value. Must not be <jk>null</jk> or blank. 196 * @param action The action id the key was bound to at mint time. Must not be <jk>null</jk> or blank. 197 * @param targetId The target id the key was bound to at mint time. Must not be <jk>null</jk> or blank. 198 * @return A key holding the recorded value and binding. 199 * @throws IllegalArgumentException If any argument is <jk>null</jk> or blank. 200 */ 201 public static IdempotencyKey of(String value, String action, String targetId) { 202 requireNonBlank("value", value); 203 requireNonBlank("action", action); 204 requireNonBlank("targetId", targetId); 205 return new IdempotencyKey(value, action, targetId); 206 } 207 208 /** 209 * The opaque key value, for carrying in the modal and echoing back on the submit. 210 * 211 * @return The key value. Never <jk>null</jk> or blank. 212 */ 213 public String value() { return value; } 214 215 /** 216 * The action id this key is bound to. 217 * 218 * @return The bound action id. 219 */ 220 public String action() { return action; } 221 222 /** 223 * The target id this key is bound to. 224 * 225 * @return The bound target id. 226 */ 227 public String targetId() { return targetId; } 228 229 /** 230 * Whether a submitted {@code (action, targetId)} matches this key's binding. 231 * 232 * <p> 233 * A mismatch must be answered with a <b>named refusal</b>, never a replayed success: it is a key being submitted 234 * against a different action or a different target than it was minted for. Compares the target id in constant 235 * time so a mismatch cannot be probed character-by-character. 236 * 237 * @param submittedAction The action id presented by the submit. Can be <jk>null</jk>. 238 * @param submittedTargetId The target id presented by the submit. Can be <jk>null</jk>. 239 * @return <jk>true</jk> only if both the action and the target id match this key's binding exactly. 240 */ 241 public boolean matches(String submittedAction, String submittedTargetId) { 242 if (submittedAction == null || submittedTargetId == null) 243 return false; 244 return constantTimeEquals(action, submittedAction) && constantTimeEquals(targetId, submittedTargetId); 245 } 246 247 private static boolean constantTimeEquals(String a, String b) { 248 return MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8)); 249 } 250 251 private static void requireNonBlank(String name, String value) { 252 reqnn(name, value); 253 if (value.isBlank()) 254 throw iaex("Argument ''%s'' must not be blank.", name); 255 } 256 257 /** 258 * Returns a description that does <b>not</b> include the key value. 259 * 260 * <p> 261 * The value is a secret; a bean-dumping logger or a debug view that stringifies it would otherwise write it 262 * somewhere it can be read back and replayed. The binding is safe to show. 263 * 264 * @return A value-free description. 265 */ 266 @Override /* Object */ 267 public String toString() { 268 return "IdempotencyKey(value=<redacted>,action=" + action + ",targetId=" + targetId + ")"; 269 } 270}