Security & Crypto · امنیت و رمزنگاری متوسطIntermediate ~59 دقیقه مطالعه~50 min read
رمزنگاری از پایه: AES، RSA، هش و امضای دیجیتالCryptography Foundations: AES, RSA, Hashing & Digital Signatures
از صفر تا سطح senior یاد میگیری AES و modeهایش، RSA و ECC، هش در برابر password hashing، HMAC و امضای دیجیتال واقعاً چه چیزی را تضمین میکنند، چطور با JCA/JCE در جاوا درست پیادهشان کنی و کدام اشتباههای کلاسیک سیستمها را در production میشکنند.A zero-to-senior tour of symmetric and asymmetric cryptography — AES and its modes, RSA and ECC, hashing versus password hashing, HMAC and digital signatures — showing exactly what each primitive guarantees, how to use the Java JCA/JCE correctly, and which classic mistakes break real systems in production.
تقریباً هر backend engineer یک روز این خط را مینویسد:
Cipher cipher = Cipher.getInstance("AES");
کامپایل میشود، تستها سبز میشوند، feature میرود روی production — و سه سال بعد یک auditor میگوید کل دیتابیس شما عملاً رمزنگارینشده است. چون "AES" در SunJCE یعنی AES/ECB/PKCS5Padding، و ECB یعنی «هر بلوک یکسان، ciphertext یکسان».
رمزنگاری تنها حوزهای در مهندسی نرمافزار است که کد «کار میکند» ولی کاملاً غلط است: داده رمز و بازرمز میشود، هیچ exception ای پرتاب نمیشود، و امنیت صفر است. مصاحبهکنندهها دقیقاً به همین دلیل این موضوع را دوست دارند.
در این فصل از مطلق صفر شروع میکنیم: هر مفهوم اول با یک تصویر ملموس، بعد با نام دقیقش، بعد با کد واقعی — و آخر با اینکه کجا در production میترکد.
۱. سه هدف رمزنگاری، و اینکه Base64 هیچکدام نیست.
۲. تصادفیبودن امن: SecureRandom در برابر Random.
۳. AES: بلوک، کلید، modeها (ECB / CBC / CTR / GCM)، IV و nonce، و AEAD.
۴. مدیریت کلید: KDF، PBKDF2، HKDF، envelope encryption و rotation.
۵. RSA: padding، OAEP، محدودیت اندازه، hybrid encryption.
۶. ECC، ECDSA و Ed25519 و دلیل جایگزینی RSA.
۷. هش (SHA-2/SHA-3) در برابر password hashing (bcrypt/scrypt/Argon2).
۸. HMAC، ترتیب encrypt/MAC، امضای دیجیتال و non-repudiation.
۹. معماری JCA/JCE، کد صحیح AES-GCM، اشتباههای کلاسیک، cheat sheet و سؤالهای مصاحبه.
پیشنیاز ذهنی: از فصلهای spring-security و ms-security میدانی OAuth2، JWT و RBAC چطور کار میکنند. این فصل یک لایه پایینتر است — همان primitiveهایی که JWT و TLS رویشان ساخته شدهاند.
۱. رمزنگاری اصلاً چه چیزی را تضمین میکند؟
- گاوصندوق محتوا را پنهان میکند: confidentiality (محرمانگی).
- مهر و موم روی پاکت محتوا را پنهان نمیکند، ولی دستکاری را لو میدهد: integrity (یکپارچگی).
- امضای پای قرارداد میگوید این را دقیقاً همین شخص نوشته و بعداً نمیتواند انکار کند: authenticity و non-repudiation.
گاوصندوق بدون مهر و موم بیفایده است: من نمیتوانم محتوا را بخوانم، ولی میتوانم عوضش کنم بدون اینکه بفهمی. این دقیقاً اشتباه AES-CBC بدون MAC است.
پس هر بار کسی گفت «داده را رمز کردیم»، سؤال درست این است: کدامیک از این سه؟
اصل Kerckhoffs (۱۸۸۳) میگوید امنیت باید فقط به مخفیبودن کلید وابسته باشد، نه الگوریتم. AES از ۱۹۹۷ تا ۲۰۰۰ در رقابت عمومی NIST زیر حملهٔ کل جامعهٔ رمزنگاری بود و بعد FIPS 197 شد؛ الگوریتم اختصاصی تو یک هفتهای است. قاعدهٔ عملی: تو الگوریتم نمینویسی و primitiveها را دستی سرهم نمیکنی؛ از کتابخانهٔ بالغ استفاده میکنی و فقط تصمیم میگیری کدام حالت را انتخاب کنی.
۲. Base64 رمزنگاری نیست
الفبای مورس یک متن را به نقطه و خط تبدیل میکند. رمزنگاری است؟ نه — هیچ کلیدی در کار نیست. مورس یک encoding است: تغییر نمایش، بدون هیچ رازی. Base64 دقیقاً همین است: نمایش دادههای باینری با ۶۴ کاراکتر متنی امن تا بتوانی باینری را در JSON یا هدر HTTP بگذاری.
echo -n 'my-db-password' | base64 # bXktZGItcGFzc3dvcmQ=
echo -n 'bXktZGItcGFzc3dvcmQ=' | base64 -d
| مفهوم | کلید لازم دارد؟ | برگشتپذیر؟ | چه میدهد | مثال |
|---|---|---|---|---|
| Encoding | نه | بله، برای همه | فقط تغییر نمایش | Base64، Hex |
| Hashing | نه | نه (یکطرفه) | اثر انگشت داده | SHA-256، SHA-3 |
| MAC | بله (کلید مشترک) | نه | integrity + authenticity بین دو طرف | HMAC-SHA256 |
| Encryption | بله | بله، فقط با کلید | confidentiality (+ integrity در AEAD) | AES-GCM |
| Digital signature | بله (کلید خصوصی) | نه | authenticity + non-repudiation برای همه | Ed25519، RSA-PSS |
۱. «پسورد را base64 کردیم که در دیتابیس امن باشد.» — امن نیست، فقط ناخواناست. ۲. «توکن را هش کردیم پس جعلناپذیر است.» — بدون کلید، مهاجم هم همان هش را میسازد؛ برای جعلناپذیری به MAC یا signature نیاز داری. ۳. «داده را با کلید خصوصی رمز کردیم پس محرمانه است.» — کلید عمومی دست همه است و بازش میکند؛ عملیات با کلید خصوصی برای امضا است.
و یک قاعدهٔ ذهنی: هر وقت کسی گفت «امن است»، بپرس در برابر چه کسی، با چه توانی، و چه چیزی را تضمین میکند؟ این را threat model میگویند.
۳. پایهٔ همهچیز: تصادفیبودن امن
قبل از AES و RSA باید این را حل کنیم، چون همهٔ کلیدها، IV ها، nonce ها، salt ها و token ها از یک منبع تصادفی میآیند. اگر آن منبع قابل حدس باشد، بهترین الگوریتم دنیا بیفایده است.
java.util.Random تاسی است که ظاهرش تصادفی است ولی در واقع یک ماشین ساعتی با وضعیت داخلی ۴۸ بیتی است: با دیدن دو خروجی میتوان وضعیتش را بازسازی و همهٔ خروجیهای بعدی را پیشبینی کرد. برای شبیهسازی عالی، برای امنیت فاجعه. SecureRandom از منبع بینظمی واقعی سیستمعامل تغذیه میشود و طوری طراحی شده که خروجیهای قبلی هیچ کمکی به پیشبینی خروجی بعدی نکنند.
- entropy: مقدار بینظمی واقعی بر حسب بیت. کلید ۲۵۶ بیتی که از
Randomآمده شاید فقط ۴۸ بیت entropy داشته باشد. - CSPRNG / DRBG: تولیدکنندهٔ شبهتصادفی رمزنگاریامن؛ NIST به آن Deterministic Random Bit Generator میگوید.
SecureRandom rng = new SecureRandom(); // پیشفرض؛ روی لینوکس معمولاً non-blocking
byte[] nonce = new byte[12];
rng.nextBytes(nonce);
// برای رازهای بسیار بلندمدت؛ الگوریتمش از security property
// به نام securerandom.strongAlgorithms خوانده میشود
SecureRandom strong = SecureRandom.getInstanceStrong();
// token امن برای reset password یا API key
byte[] raw = new byte[32]; // 256 bit entropy
rng.nextBytes(raw);
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(raw);
new SecureRandom(seed)با seed ثابت ننویس. الگوی «seed ثابت برای تکرارپذیری تست» یعنی کلید قابل پیشبینی؛ بهجایش منبع تصادفی را بهعنوان وابستگی تزریق کن.getInstance("SHA1PRNG")ننویس. استاندارد نیست و بین providerها رفتار متفاوتی دارد. یاnew SecureRandom()یاgetInstance("DRBG").getInstanceStrong()روی لینوکس ممکن است block کند (نگاشت بهNativePRNGBlockingو خواندن از/dev/random)؛ داخل container تازه با entropy کم، startup معلق میماند. برای IV و salt و token،new SecureRandom()کافی و درست است.Math.random()،ThreadLocalRandomوUUID.randomUUID()برای راز مناسب نیستند. (UUID.randomUUID()در HotSpot ازSecureRandomاستفاده میکند ولی فقط ۱۲۲ بیت تصادفی دارد و هدف امنیتی ندارد.)
۴. رمزنگاری متقارن: AES
۴.۱ متقارن یعنی چه
رمزنگاری متقارن مثل قفل کمد باشگاه است: همان کلیدی که میبندد، باز هم میکند. سریع و ساده — و یک مشکل بزرگ دارد: چطور کلید را به طرف مقابل برسانم؟ اگر کانال امنی برای فرستادن کلید داشتم، همان را برای خود پیام استفاده میکردم. این معمای مرغ و تخممرغ دقیقاً دلیل وجود رمزنگاری نامتقارن است.
AES (Advanced Encryption Standard، الگوریتم Rijndael، استاندارد FIPS 197 در ۲۰۰۱):
- block size همیشه ۱۲۸ بیت (۱۶ بایت) است — مستقل از اندازهٔ کلید.
- key size یکی از ۱۲۸، ۱۹۲ یا ۲۵۶ بیت؛ تعداد round ها به ترتیب ۱۰، ۱۲ و ۱۴.
- AES-128 هنوز کاملاً امن است؛ AES-256 عمدتاً برای الزامات مقرراتی و حاشیهٔ اطمینان کوانتومی انتخاب میشود.
block cipher یعنی الگوریتم فقط بلد است دقیقاً ۱۶ بایت را به ۱۶ بایت تبدیل کند. پیام تو ۲ مگابایت است؛ چطور بشکنیمش و پشتسرهم کنیم؟ جواب این سؤال mode of operation نام دارد و ۹۰ درصد اشتباههای رمزنگاری دقیقاً همینجاست.
۴.۲ ECB — حالتی که هرگز
ECB (Electronic Codebook) هر بلوک را مستقل و با همان کلید رمز میکند. مثل دفترچهٔ لغتی که هر کلمه را به کلمهای بیمعنی نگاشت میکند: متن ناخوانا میشود ولی الگو کاملاً باقی میماند. مشهورترین نمایشش «پنگوئن ECB» است: تصویری که پس از رمزنگاری هنوز کاملاً قابل تشخیص است.
Diagram: ECB encrypts each block independently, CBC chains each block into the next.
flowchart LR
subgraph ECB["ECB - patterns survive"]
P1[Block 1] --> E1[AES] --> C1[Cipher 1]
P2[Block 2] --> E2[AES] --> C2[Cipher 2]
end
subgraph CBC["CBC - chained"]
IV[Random IV] --> X1((XOR))
Q1[Block 1] --> X1 --> D1[AES] --> R1[Cipher 1]
R1 --> X2((XOR))
Q2[Block 2] --> X2 --> D2[AES] --> R2[Cipher 2]
end
Cipher.getInstance("AES") در SunJCE معادل AES/ECB/PKCS5Padding است. هیچ اخطاری، هیچ exception ای، و تستها پاس میشوند. همیشه transformation کامل بنویس: "AES/GCM/NoPadding". اگر در code review دیدی getInstance("AES") یا "AES/ECB/..."، این یک finding امنیتی است نه نکتهٔ سلیقهای.
از JDK 26 یک security property به نام jdk.crypto.disabledAlgorithms هست که میگذارد سازمان بهصورت مرکزی چنین الگوریتمهایی را در لایهٔ JCA غیرفعال کند.
۴.۳ CBC — زنجیره، IV و padding
CBC (Cipher Block Chaining) هر بلوک را قبل از رمزکردن با ciphertext بلوک قبلی XOR میکند. برای بلوک اول «قبلی» وجود ندارد، پس یک مقدار تصادفی میدهیم: IV (Initialization Vector). سه حقیقت دربارهٔ IV که مصاحبهگر میپرسد:
- IV راز نیست و معمولاً بهعنوان پیشوند کنار ciphertext ذخیره میشود.
- در CBC باید غیرقابل پیشبینی (تصادفی) باشد، نه فقط یکتا — IV قابل پیشبینی به حملهٔ BEAST روی TLS 1.0 منجر شد.
- هرگز با همان کلید تکرار نشود.
padding: چون AES فقط بلوک کامل میخورد، PKCS#7 (در جاوا با نام تاریخی PKCS5Padding) به تعداد n بایت کم، n بار خودِ n را اضافه میکند.
اگر سرور هنگام رمزگشایی بین «padding خراب» و «padding درست ولی محتوای نامعتبر» فرقی قابل مشاهده بگذارد — پیام خطای متفاوت، status code متفاوت، یا حتی زمان پاسخ متفاوت — مهاجم میتواند بدون داشتن کلید، کل ciphertext را بایتبهبایت رمزگشایی کند. این حملهٔ Padding Oracle است (Vaudenay، ۲۰۰۲) و نسخههای واقعیاش POODLE و Lucky Thirteen بودند.
نتیجه: CBC بدون MAC شکسته است، و درستکردن ترکیب CBC+HMAC آنقدر ظریف است که جواب عملی یکی است: AES-GCM.
۴.۴ CTR — بلوک را به stream تبدیل کن
CTR (Counter mode) بهجای داده، یک شمارنده را رمز میکند و خروجی را با داده XOR میکند؛ یعنی AES را به تولیدکنندهٔ keystream تبدیل کردهایم. padding لازم ندارد، موازیپذیر است و random access دارد — به همین دلیل زیربنای رمزنگاری دیسک است. ورودیاش nonce است: عددی که با یک کلید فقط یک بار استفاده میشود.
۴.۵ GCM و مفهوم AEAD — انتخاب پیشفرض
CTR یک پاکت مات است: نمیبینی داخلش چیست، ولی کسی میتواند سوراخش کند و محتویات را جابهجا کند بیآنکه بفهمی.
GCM همان پاکت است بهعلاوهٔ یک مهر که با همان کلید ساخته شده؛ اگر یک بیت عوض شود، مهر نمیخواند و گیرنده پیام را دور میاندازد. و میتوانی روی پشت پاکت هم چیزی بنویسی (مثلاً شناسهٔ گیرنده) که پنهان نیست ولی زیر همان مهر محافظت میشود — اسمش AAD است.
- AEAD = Authenticated Encryption with Associated Data: الگوریتمی که همزمان محرمانگی و یکپارچگی میدهد، بدون آنکه تو خودت encryption و MAC را ترکیب کنی.
- authentication tag: مقدار ۱۶بایتی که در جاوا خودکار به انتهای ciphertext چسبانده میشود.
- AAD: دادهای که رمز نمیشود ولی در محاسبهٔ tag شرکت میکند — مثلاً
tenantIdیاrecordId. اگر مهاجم ciphertext ردیف کاربر A را در ردیف کاربر B کپی کند، tag نمیخواند و حملهٔ ciphertext substitution شکست میخورد.
AES-GCM = AES در حالت CTR + تابع GHASH که در میدان گالوای GF(2^128) ضرب میکند؛ استانداردش NIST SP 800-38D است.
Diagram: how GCM combines CTR-mode encryption with the GHASH authentication tag.
flowchart TD
K[AES key] --> CTR[CTR keystream]
N[96-bit nonce, unique per message] --> CTR
P[Plaintext] --> X((XOR))
CTR --> X --> C[Ciphertext]
C --> G[GHASH]
AAD[Associated data - not encrypted] --> G
K --> G
G --> T[128-bit auth tag]
C --> OUT[nonce + ciphertext + tag]
T --> OUT
قوانین GCM که باید حفظ باشی:
- nonce را ۱۲ بایت (۹۶ بیت) بگیر — طول توصیهشدهٔ NIST که مستقیم استفاده میشود؛ هر طول دیگری اول از GHASH رد میشود.
- با هر پیام nonce تازه: یا شمارندهٔ یکتای persistشده (بهترین) یا تصادفی از
SecureRandom. - با nonce تصادفی، حداکثر ۲^۳۲ عملیات با هر کلید — حدود چهار میلیارد پیام، که در سرویس پرترافیک عدد بزرگی نیست؛ دقیقاً دلیل key rotation.
- حداکثر حجم هر پیام ۶۴ گیگابایت (۲^۳۹ − ۲۵۶ بیت)، و tag را ۱۲۸ بیت بگیر.
در CTR اگر دو پیام با یک کلید و یک nonce رمز شوند، هر دو با همان keystream XOR شدهاند:
C1 XOR C2 = (P1 XOR KS) XOR (P2 XOR KS) = P1 XOR P2
keystream حذف میشود و مهاجم رابطهٔ دو متن اصلی را دارد.
در GCM بدتر است: تکرار nonce به مهاجم اجازه میدهد کلید احراز اصالت (subkey H) را بازیابی کند و از آن به بعد برای هر پیام دلخواهی tag معتبر بسازد. اسم این حمله forbidden attack است و کل جعلناپذیری آن کلید را نابود میکند.
خبر خوب: SunJCE اگر بخواهی با همان کلید و همان IV دو بار encrypt کنی، عمداً InvalidAlgorithmParameterException: Cannot reuse iv for GCM encryption پرتاب میکند. این خطا را با «reinitialize» دور نزن؛ nonce تازه تولید کن.
۴.۶ جدول تصمیم: کدام mode؟
| Mode | محرمانگی | یکپارچگی | padding | موازیپذیر | ریسک اصلی | حکم |
|---|---|---|---|---|---|---|
| ECB | تقریباً هیچ | ندارد | دارد | بله | الگوی داده لو میرود | هرگز |
| CBC | بله | ندارد | دارد | فقط decrypt | padding oracle، IV قابل پیشبینی | فقط با HMAC، فقط برای legacy |
| CTR | بله | ندارد | ندارد | بله | تکرار nonce، malleability | فقط بهعنوان جزء داخلی |
| GCM | بله | بله (AEAD) | ندارد | بله | تکرار nonce، سقف ۲^۳۲ پیام | پیشفرض |
| ChaCha20-Poly1305 | بله | بله (AEAD) | ندارد | بله | تکرار nonce | وقتی شتاب سختافزاری AES نداری |
انتخاب بین AES-GCM و ChaCha20 یک تصمیم کارایی است نه امنیتی: روی CPU سرور مدرن (x86-64 با AES-NI، یا ARMv8 با Crypto Extensions) AES-GCM با شتاب سختافزاری سریعتر است؛ بدون آن شتاب، ChaCha20-Poly1305 هم سریعتر است و هم در برابر cache-timing مقاومتر، چون فقط جمع، XOR و چرخش دارد و lookup table ندارد. جاوا از نسخهٔ ۱۱ (JEP 329) آن را با نام "ChaCha20-Poly1305" دارد.
۴.۷ کد صحیح AES-GCM در جاوا
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import java.nio.ByteBuffer;
import java.security.SecureRandom;
public final class AesGcm {
private static final String TRANSFORM = "AES/GCM/NoPadding";
private static final int NONCE_LEN = 12; // 96 bit — توصیهٔ NIST
private static final int TAG_BITS = 128;
private static final SecureRandom RNG = new SecureRandom();
private AesGcm() { }
public static SecretKey newKey() throws Exception {
KeyGenerator kg = KeyGenerator.getInstance("AES");
kg.init(256, RNG);
return kg.generateKey();
}
/** خروجی: nonce || ciphertext || tag (tag را خود جاوا میچسباند) */
public static byte[] encrypt(SecretKey key, byte[] plaintext, byte[] aad) throws Exception {
byte[] nonce = new byte[NONCE_LEN];
RNG.nextBytes(nonce); // nonce تازه برای هر پیام
Cipher cipher = Cipher.getInstance(TRANSFORM); // نه thread-safe، نه cache شود
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, nonce));
if (aad != null) {
cipher.updateAAD(aad);
}
byte[] ct = cipher.doFinal(plaintext);
return ByteBuffer.allocate(nonce.length + ct.length).put(nonce).put(ct).array();
}
public static byte[] decrypt(SecretKey key, byte[] blob, byte[] aad) throws Exception {
if (blob.length < NONCE_LEN + TAG_BITS / 8) {
throw new IllegalArgumentException("ciphertext too short");
}
ByteBuffer buf = ByteBuffer.wrap(blob);
byte[] nonce = new byte[NONCE_LEN];
buf.get(nonce);
byte[] ct = new byte[buf.remaining()];
buf.get(ct);
Cipher cipher = Cipher.getInstance(TRANSFORM);
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, nonce));
if (aad != null) {
cipher.updateAAD(aad);
}
return cipher.doFinal(ct); // اگر tag نخواند AEADBadTagException — قورتش نده
}
}
۱. cache کردن Cipher در فیلد static. Cipher stateful و غیر thread-safe است؛ دو thread روی یک نمونه یعنی ciphertext خراب یا نشت داده. یا هر بار getInstance بزن (ارزان است) یا ThreadLocal بگذار.
۲. قورتدادن AEADBadTagException با catch (Exception e) { return null; } یعنی خاموشکردن تنها چیزی که میگوید داده دستکاری شده.
۳. نگهداشتن راز در String. String immutable است و تا GC در heap میماند و در heap dump ظاهر میشود؛ کلید و پسورد را در char[]/byte[] نگه دار و بعد با Arrays.fill(arr, (byte) 0) پاک کن.
۵. کلید از کجا میآید؟ KDF و envelope encryption
KDF (Key Derivation Function) تابعی است که از یک مادهٔ اولیه کلید باکیفیت میسازد. دو خانوادهٔ کاملاً متفاوت وجود دارد و قاطیکردنشان خطای رایجی است:
| نوع | ورودی | هدف | نمونه | باید کند باشد؟ |
|---|---|---|---|---|
| Password-based KDF | پسورد انسانی (entropy کم) | گرانکردن حدسزدن | PBKDF2، scrypt، Argon2 | بله، عمداً |
| Key-based KDF | راز پرآنتروپی (مثلاً خروجی ECDH) | ساختن چند کلید مجزا از یک راز | HKDF | نه، باید سریع باشد |
// از پسورد → کلید. salt یکتا و iteration بالا الزامی است.
SecretKeyFactory f = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
byte[] salt = new byte[16];
new SecureRandom().nextBytes(salt);
KeySpec spec = new PBEKeySpec(password /* char[] */, salt, 600_000, 256);
SecretKey aesKey = new SecretKeySpec(f.generateSecret(spec).getEncoded(), "AES");
عدد ۶۰۰٬۰۰۰ سرخود نیست: توصیهٔ فعلی OWASP برای PBKDF2 با HMAC-SHA256 است (برای HMAC-SHA512 حدود ۲۱۰٬۰۰۰).
از JDK 25 یک API استاندارد برای KDF داریم (JEP 510، در JDK 24 بهصورت preview):
import javax.crypto.KDF;
import javax.crypto.spec.HKDFParameterSpec;
KDF hkdf = KDF.getInstance("HKDF-SHA256");
SecretKey encKey = hkdf.deriveKey("AES",
HKDFParameterSpec.ofExtract()
.addIKM(sharedSecret) // input keying material
.addSalt(salt)
.thenExpand("app:v1:encryption".getBytes(UTF_8), 32));
SecretKey macKey = hkdf.deriveKey("HmacSHA256",
HKDFParameterSpec.ofExtract()
.addIKM(sharedSecret)
.addSalt(salt)
.thenExpand("app:v1:mac".getBytes(UTF_8), 32));
رشتهٔ info تصادفی نیست: domain separation میکند، یعنی تضمین میکند کلید رمزنگاری و کلید MAC هرگز به هم نخورند.
envelope encryption الگوی استاندارد صنعت است: داده را با یک کلید تصادفی یکبارمصرف رمز میکنی (DEK: Data Encryption Key)، بعد خود DEK را با کلید مادری که در HSM یا KMS است میپیچی (KEK: Key Encryption Key). فقط DEK رمزشده کنار داده ذخیره میشود و KEK هرگز از ماژول بیرون نمیآید. مزیت بزرگ: برای rotation فقط DEK ها را با KEK جدید دوباره wrap میکنی، نه ترابایتها داده را.
Diagram: the lifecycle states a symmetric key moves through in a managed system.
stateDiagram-v2
[*] --> Generated: KMS or KeyGenerator
Generated --> Active: used for encrypt and decrypt
Active --> Deprecated: rotation, decrypt only
Deprecated --> Destroyed: all data re-wrapped
Active --> Compromised: incident
Compromised --> Destroyed: emergency re-encrypt
Destroyed --> [*]
اگر فقط ciphertext را ذخیره کنی، روز rotation نمیدانی هر ردیف با کدام کلید رمز شده. یک ستون key_id (یا یک بایت نسخه در ابتدای blob) هزینهاش هیچ است و rotation را از یک پروژهٔ چندماهه به یک job پسزمینه تبدیل میکند. همین را دربارهٔ نسخهٔ الگوریتم هم بکن (v1 = AES-256-GCM).
CREATE TABLE customer_secret (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL,
key_id text NOT NULL, -- کدام DEK/KEK
alg text NOT NULL DEFAULT 'AES-256-GCM',
nonce bytea NOT NULL, -- 12 bytes
ciphertext bytea NOT NULL, -- شامل tag
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON customer_secret (key_id); -- برای re-wrap هنگام rotationCREATE TABLE customer_secret (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id NUMBER NOT NULL,
key_id VARCHAR2(64) NOT NULL, -- کدام DEK/KEK
alg VARCHAR2(32) DEFAULT 'AES-256-GCM' NOT NULL,
nonce RAW(12) NOT NULL,
ciphertext BLOB NOT NULL, -- شامل tag
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL
);
CREATE INDEX customer_secret_key_id_ix ON customer_secret (key_id);کلید داخل کد یا فایل پیکربندی (private static final String KEY = "...") در git history، در image داکر، در backup و روی هر لپتاپی که repo را clone کرده حاضر است — و rotation عملاً غیرممکن میشود.
رمزنگاری داخل دیتابیس، کلید را هم داخل دیتابیس میآورد. اگر pgp_sym_encrypt('data', 'my-passphrase') بنویسی، آن passphrase در متن SQL است — یعنی در pg_stat_statements، در لاگ کوئریهای کند و در v$sql اوراکل. اگر هدف محافظت در برابر دزدیدهشدن فایلهای دیسک است، TDE کار درست است؛ اگر هدف این است که خودِ دیتابیس هم داده را نبیند، رمزنگاری باید در application انجام شود. اینها دو threat model متفاوتاند.
و ستون رمزشده را نمیشود WHERE email = ? کرد. راهحل استاندارد blind index است: ستونی اضافی با مقدار HMAC(key, lower(email)) که رویش index میسازی؛ جستوجوی دقیق ممکن میشود بدون لو رفتن مقدار واقعی.
۶. رمزنگاری نامتقارن: RSA
یک صندوق پستی جلوی خانه: یک شکاف دارد که هر رهگذری میتواند نامه داخلش بیندازد، و یک قفل که فقط تو کلیدش را داری.
- شکاف = کلید عمومی؛ به همه میدهی، با آن فقط میشود چیزی داخل گذاشت.
- کلید قفل = کلید خصوصی؛ فقط دست توست، فقط با آن میشود بیرون آورد.
و مهمترین نکته: از روی شکل شکاف نمیشود کلید قفل را ساخت. کل ماجرا همین «عدم تقارن» است.
RSA (۱۹۷۷) روی این حقیقت بنا شده: ضرب دو عدد اول بزرگ آسان است، تجزیهٔ حاصلضرب عملاً ناممکن. به این trapdoor function میگویند. modulus یعنی n = p × q؛ «RSA-2048» یعنی n دو هزار و چهل و هشت بیت است. کلید عمومی (n, e) با e = 65537 رایج، و کلید خصوصی (n, d).
۶.۱ چرا RSA هرگز دادهٔ بزرگ را رمز نمیکند
دلیل ریاضی: RSA روی عددی کار میکند که باید از n کوچکتر باشد، و padding هم بخشی را میخورد. با RSA-2048 و OAEP-SHA-256:
حداکثر plaintext = k − 2·hLen − 2 = 256 − 2·32 − 2 = 190 بایت
با PKCS#1 v1.5 میشود k − 11 = 245 بایت. دلیل عملکردی: RSA چند مرتبهٔ بزرگی از AES کندتر است.
راهحل استاندارد hybrid encryption است: بار را در کانتینری با قفل معمولی میگذاری و فقط کلید آن قفل را از شکاف صندوق پست رد میکنی.
Diagram: sender wraps a fresh AES key with the recipient's RSA public key.
sequenceDiagram
participant S as Sender
participant R as Recipient
R->>S: RSA public key
Note over S: generate random AES-256 key (DEK)
S->>S: ciphertext = AES-GCM(DEK, message)
S->>S: wrappedKey = RSA-OAEP(pubKey, DEK)
S->>R: wrappedKey + nonce + ciphertext + tag
Note over R: DEK = RSA-OAEP-decrypt(privKey, wrappedKey)
R->>R: message = AES-GCM-decrypt(DEK, ciphertext)
جملهای که باید در مصاحبه بگویی: نامتقارن برای انتقال کلید و امضاست، متقارن برای انتقال داده.
۶.۲ Padding: OAEP در برابر PKCS#1 v1.5
RSA خام قطعی است: یک ورودی همیشه یک خروجی میدهد. اگر فضای ورودی کوچک باشد (مثلاً «بله»/«خیر» یا یک شمارهٔ کارت)، مهاجم همهٔ حالتها را رمز میکند و مقایسه میکند. padding تصادفیبودن و ساختار امن اضافه میکند.
PKCS#1 v1.5 قدیمی است. Bleichenbacher در ۱۹۹۸ نشان داد اگر سرور لو بدهد که padding درست بوده یا نه، مهاجم با کوئریهای تطبیقی پیام را رمزگشایی میکند. همان حمله در ۲۰۱۷ با نام ROBOT زنده شد، و در ۲۰۲۳ Marvin نشان داد نشت زمانی بهتنهایی کافی است — یعنی حتی پیام خطای یکسان هم نجاتت نمیدهد. برای سیستم جدید همیشه OAEP.
و دام بسیار رایج جاوا: در SunJCE رشتهٔ "RSA/ECB/OAEPWithSHA-256AndMGF1Padding" هش پیام را SHA-256 میکند ولی MGF1 را پیشفرض روی SHA-1 میگذارد — نتیجه، ناسازگاری با کتابخانههای غیرجاوایی و یک باگ «فقط در production». راه درست:
OAEPParameterSpec oaep = new OAEPParameterSpec(
"SHA-256", "MGF1",
MGF1ParameterSpec.SHA256, // این همان چیزی است که فراموش میشود
PSource.PSpecified.DEFAULT);
Cipher c = Cipher.getInstance("RSA/ECB/OAEPPadding");
c.init(Cipher.ENCRYPT_MODE, publicKey, oaep);
ضمناً ECB در این رشته بیمعنی و صرفاً تاریخی است — RSA اصلاً mode ندارد.
۶.۳ امضا با RSA
«امضا یعنی رمزکردن با کلید خصوصی» در سطح شهودی کمک میکند ولی فنی غلط است. طرح امضای مدرن RSA یعنی RSASSA-PSS که ساختار متفاوت، salt تصادفی و اثبات امنیتی دارد؛ SHA256withRSA هم PKCS#1 v1.5 signature است، نه encryption.
Signature signer = Signature.getInstance("RSASSA-PSS");
signer.setParameter(new PSSParameterSpec(
"SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1));
signer.initSign(privateKey);
signer.update(message);
byte[] sig = signer.sign();
با e = 65537، عملیات کلید عمومی (رمزکردن، verify) بسیار سریع و عملیات کلید خصوصی (رمزگشایی، sign) بسیار کند است. برای gateway ای که میلیونها JWT را verify میکند RSA بد نیست؛ برای سرویسی که میلیونها توکن صادر میکند، RSA-2048 به گلوگاه CPU تبدیل میشود و ECDSA/Ed25519 چند برابر سریعتر است. این دقیقاً جنس تحلیلی است که از senior انتظار میرود.
۷. ECC، ECDSA و Ed25519
ریاضی RSA روی اعداد بزرگ است؛ ECC روی نقاط یک منحنی بیضوی. اگر نقطهٔ پایه G را k بار با خودش جمع کنی به P میرسی؛ رفتن از k به P آسان است، برگشتن از P به k عملاً ناممکن (لگاریتم گسسته روی منحنی بیضوی). نتیجهٔ عملی: همان امنیت با کلیدهای بسیار کوچکتر.
| سطح امنیت | RSA | ECC | توضیح |
|---|---|---|---|
| ۱۱۲ بیت | ۲۰۴۸ بیت | ۲۲۴ بیت | کف امروز؛ NIST تا ۲۰۳۰ deprecated |
| ۱۲۸ بیت | ۳۰۷۲ بیت | ۲۵۶ بیت (P-256، Curve25519) | انتخاب متعارف |
| ۲۵۶ بیت | ۱۵۳۶۰ بیت | ۵۲۱ بیت (P-521) | RSA اینجا عملاً غیرقابل استفاده |
امضای ECDSA روی P-256 حدود ۶۴ بایت است در برابر ۲۵۶ بایت RSA-2048؛ در JWT و گواهی TLS این تفاوت واقعی است.
- ECDH / X25519: توافق کلید — دو طرف کلید عمومی رد و بدل میکنند و مستقلاً به یک راز مشترک میرسند بدون آنکه راز روی سیم برود. پایهٔ forward secrecy در TLS 1.3.
- ECDSA: امضا روی منحنیهای NIST (P-256 / secp256r1).
- EdDSA / Ed25519: طرح امضای مدرن روی منحنی Edwards (RFC 8032).
ECDSA برای هر امضا به یک مقدار تصادفی محرمانه k نیاز دارد. اگر k دو بار با یک کلید خصوصی استفاده شود، هر کسی با جبر ساده کلید خصوصی را از روی دو امضا حساب میکند؛ و اگر k فقط تا حدی قابل پیشبینی باشد، با حملات lattice باز هم بازیابی میشود.
این دقیقاً همان چیزی است که در ۲۰۱۰ کلید امضای یک کنسول بازی مشهور را لو داد (k ثابت بود) و در ۲۰۱۳ به سرقت از کیفپولهای بیتکوین اندرویدی انجامید (PRNG معیوب سیستمعامل).
راهحل: RFC 6979 (ECDSA قطعی؛ k از هش پیام و کلید خصوصی ساخته میشود)، یا بهتر Ed25519 که این خاصیت را ذاتاً دارد.
قطعی است (نه به RNG وابسته است نه به nonce یکتا)، سریعتر از ECDSA و بسیار سریعتر از RSA است، کوچک است (کلید ۳۲ بایت، امضا ۶۴ بایت)، و پیادهسازی مرجعش بدون شاخه و lookup وابسته به راز است پس در برابر side-channel مقاومتر.
تنها احتیاط: بعضی محیطهای تحت FIPS 140 یا سختافزارهای قدیمی هنوز Ed25519 را قبول نمیکنند و باید ECDSA P-256 بروی. این دقیقاً جواب «چه وقت انتخابش نمیکنی؟» است.
جاوا از نسخهٔ ۱۵ (JEP 339) EdDSA دارد و از نسخهٔ ۱۱ (JEP 324) منحنیهای X25519/X448 را:
KeyPairGenerator kpg = KeyPairGenerator.getInstance("Ed25519");
KeyPair kp = kpg.generateKeyPair();
Signature s = Signature.getInstance("Ed25519");
s.initSign(kp.getPrivate());
s.update("transfer 100 to account 42".getBytes(UTF_8));
byte[] signature = s.sign(); // 64 bytes
Signature v = Signature.getInstance("Ed25519");
v.initVerify(kp.getPublic());
v.update("transfer 100 to account 42".getBytes(UTF_8));
boolean ok = v.verify(signature); // فقط همین boolean؛ خودت byte مقایسه نکن
// توافق کلید — راز خام را مستقیم بهعنوان کلید AES استفاده نکن، از HKDF ردش کن
KeyAgreement ka = KeyAgreement.getInstance("X25519");
ka.init(myPrivateKey);
ka.doPhase(theirPublicKey, true);
byte[] sharedSecret = ka.generateSecret();
از جاوا ۲۱ یک API استاندارد برای KEM هم داریم (JEP 452):
KEM kem = KEM.getInstance("DHKEM");
KEM.Encapsulated e = kem.newEncapsulator(recipientPublicKey).encapsulate();
SecretKey senderKey = e.key(); // کلید متقارن تازه
byte[] capsule = e.encapsulation(); // این را روی سیم بفرست
SecretKey receiverKey = kem.newDecapsulator(recipientPrivateKey).decapsulate(capsule);
۸. هش: اثر انگشت داده
از یک انسان اثر انگشت میگیری: کوچک و ثابتطول. از روی اثر انگشت نمیتوانی انسان را بازسازی کنی، و پیداکردن دو انسان با یک اثر انگشت باید عملاً ناممکن باشد.
تابع هش رمزنگاری هر ورودی با هر طولی را به خروجی با طول ثابت نگاشت میکند و سه خاصیت دارد:
- مقاومت preimage: از روی
hنمیشودmای یافت کهhash(m) = h(برای SHA-256 هزینه ≈ ۲^۲۵۶). - مقاومت second preimage: با داشتن
m1، نمیشودm2 ≠ m1با هش یکسان یافت. - مقاومت collision: هیچ جفت
m1 ≠ m2با هش یکسان نمیشود یافت. اینجا پارادوکس تولد ضربه میزند: هزینه فقط ≈ ۲^(n/2) است، یعنی ۲^۱۲۸ برای SHA-256.
خاصیت چهارم avalanche effect است: تغییر یک بیت ورودی باید حدود نصف بیتهای خروجی را عوض کند.
| الگوریتم | خروجی | وضعیت | واقعیت |
|---|---|---|---|
| MD5 | ۱۲۸ بیت | کاملاً شکسته | collision در چند ثانیه؛ بدافزار Flame در ۲۰۱۲ با chosen-prefix collision یک گواهی امضای کد را جعل کرد |
| SHA-1 | ۱۶۰ بیت | شکسته | ۲۰۱۷ SHAttered (اولین collision عملی)؛ ۲۰۲۰ «SHA-1 is a Shambles»، اولین chosen-prefix collision با هزینهٔ حدود ۴۵ هزار دلار |
| SHA-256 / SHA-512 | ۲۵۶ / ۵۱۲ بیت | امن | خانوادهٔ SHA-2، پیشفرض امروز |
| SHA-3 / SHAKE | متغیر | امن | ساختار sponge، تنوع در برابر شکست ناگهانی SHA-2 |
تفاوت identical-prefix و chosen-prefix collision را باید بلد باشی: در اولی مهاجم هر دو سند را خودش میسازد؛ در دومی میتواند دو پیشوند کاملاً دلخواه و متفاوت بردارد (مثلاً دو قرارداد با مبالغ مختلف) و پسوندهایی بسازد که هشها را برابر کند. دومی خطرناک است و از ۲۰۲۰ روی SHA-1 عملی شده؛ به همین دلیل JDK 25 امضاهای SHA-1 را در handshake های TLS 1.2 پیشفرض غیرفعال کرد.
مرزی که خیلیها گم میکنند: MD5 و CRC32 هنوز برای خرابی تصادفی (checksum فایل، partitioning، cache key) قابل قبولاند، چون آنجا مهاجمی نیست. به محض اینکه یک مهاجم بتواند ورودی را انتخاب کند، مجاز نیستند.
length extension: SHA-1/SHA-256/SHA-512 با ساختار Merkle–Damgård ساخته شدهاند و خروجی نهایی همان وضعیت داخلی است. پس اگر مهاجم SHA256(secret || message) و طول secret را بداند (بدون دانستن secret)، میتواند آن را بهعنوان وضعیت داخلی بردارد و SHA256(secret || message || padding || evil) معتبر بسازد. یعنی هرگز hash(secret + data) را بهعنوان MAC ننویس — این باگ واقعی در APIهای امضاشدهٔ قدیمی رخ داده است. راهحل استاندارد HMAC است (RFC 2104):
HMAC(K, m) = H( (K ⊕ opad) || H( (K ⊕ ipad) || m ) )
نکتهٔ فرعی مفید: SHA-3 (sponge)، SHA-512/256 و BLAKE2 به length extension آسیبپذیر نیستند.
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest("hello".getBytes(UTF_8));
MessageDigest shake = MessageDigest.getInstance("SHAKE256-512"); // افزودهٔ جاوا ۲۵
۹. هش پسورد: مسئلهای کاملاً متفاوت
برای امضای یک فایل ۱۰ گیگابایتی میخواهی هش خیلی سریع باشد. برای پسورد، سرعت دشمن توست: مهاجمی که دیتابیس را دزدیده میخواهد میلیاردها حدس بزند. پس عمداً تابعی کند و حافظهخوار انتخاب میکنیم. یک GPU مدرن دهها میلیارد SHA-256 در ثانیه میزند، ولی با bcrypt در work factor مناسب فقط چند ده هزار حدس در ثانیه دارد و با Argon2id تنظیمشده کمتر، چون RAM تمام میشود.
- salt: مقدار تصادفی یکتا برای هر کاربر که کنار پسورد هش و کنار هش ذخیره میشود (راز نیست). دو چیز را حل میکند: rainbow table های از پیش محاسبهشده بیاثر میشوند، و دو کاربر با پسورد یکسان هش یکسان ندارند. اما salt سرعت حدسزدن یک کاربر مشخص را کم نمیکند — آن کار work factor است.
- pepper: راز سراسری در برنامه یا HSM که در دیتابیس نیست؛ اگر فقط دیتابیس دزدیده شود، مهاجم بدون آن نمیتواند حمله کند.
- work factor: پارامتر هزینهٔ محاسبه. در bcrypt لگاریتمی است: cost=12 یعنی ۲^۱۲ دور.
- memory-hard: تابعی که علاوه بر CPU، RAM زیادی میخواهد — همان چیزی که مزیت GPU و ASIC را از بین میبرد.
| الگوریتم | سال | مقاوم در برابر GPU | پارامترهای پیشنهادی (OWASP) | حکم |
|---|---|---|---|---|
| SHA-256 خام | — | خیر | — | هرگز برای پسورد |
| PBKDF2-HMAC-SHA256 | ۲۰۰۰ | ضعیف (فقط CPU-hard) | ۶۰۰٬۰۰۰ تکرار | وقتی الزام FIPS داری |
| bcrypt | ۱۹۹۹ | متوسط (۴KB حافظه) | work factor ≥ ۱۰ | خوب، برای سیستمهای موجود |
| scrypt | ۲۰۰۹ | خوب (memory-hard) | N=۲^۱۷، r=۸، p=۱ | وقتی Argon2 نداری |
| Argon2id | ۲۰۱۵ | بهترین | m=۱۹ MiB، t=۲، p=۱ (یا m=۴۶ MiB، t=۱، p=۱) | پیشفرض |
Argon2 برندهٔ Password Hashing Competition در ۲۰۱۵ و مستند در RFC 9106 است؛ سه نسخه دارد (Argon2d، Argon2i، Argon2id) و ترکیبی یعنی Argon2id همان چیزی است که باید انتخاب کنی. در Spring Security پیشفرض Argon2PasswordEncoder برابر salt=۱۶، hash=۳۲، p=۱، m=۱۶۳۸۴ KiB، t=۲ و پیشفرض BCryptPasswordEncoder برابر strength=۱۰ است.
Diagram: verifying a password and transparently upgrading its hash.
flowchart TD
A[Login request] --> B[Load stored hash by username]
B --> C{User exists?}
C -- No --> D[Run a dummy hash anyway] --> E[Generic error]
C -- Yes --> F[Verify password against stored hash]
F -- No --> E
F -- Yes --> G{Hash uses old params?}
G -- Yes --> H[Re-hash with current params and store]
G -- No --> I[Issue session]
H --> I
۱. محدودیت ۷۲ بایتی bcrypt. bcrypt فقط ۷۲ بایت اول را میبیند. اگر کسی «هوشمندانه» اول پسورد را SHA-512 کند و خروجی hex (۱۲۸ کاراکتر) بدهد، ۵۶ کاراکتر آخر نادیده میرود؛ و اگر خروجی باینری خام با بایت صفر باشد، بعضی پیادهسازیها رشته را همانجا قطع میکنند. اگر واقعاً pre-hash لازم داری خروجی را Base64 کن — یا سادهتر، Argon2id بزن.
۲. user enumeration از طریق زمان. اگر کاربر وجود نداشته باشد و بلافاصله خطا برگردانی، پاسخ در ۲ میلیثانیه میآید؛ اگر وجود داشته باشد ۲۵۰ میلیثانیه، چون Argon2 اجرا شده. مهاجم از همین تفاوت لیست کاربران معتبر را میسازد؛ در مسیر «کاربر پیدا نشد» هم یک هش ساختگی اجرا کن.
۳. مقایسه با equals. Arrays.equals زودهنگام برمیگردد و به timing attack آسیبپذیر است. برای MAC، tag، توکن و کد یکبارمصرف از MessageDigest.isEqual استفاده کن که عمداً زمانثابت است و همهٔ بایتها را بررسی میکند.
پارامترهای امن امروز، پارامترهای ضعیف سه سال بعد اند. تنها لحظهای که پسورد خام را در اختیار داری، همان لحظهٔ لاگین موفق است — همانجا اگر هش با پارامتر قدیمی ساخته شده بود، دوباره با پارامتر جدید هش و ذخیره کن. در Spring Security این کار با DelegatingPasswordEncoder و پیشوندهای {argon2} / {bcrypt} انجام میشود (جزئیات در فصل spring-security).
۱۰. HMAC و ترتیب encrypt/MAC
MAC (Message Authentication Code) برچسبی است که با یک کلید مشترک ساخته میشود و میگوید «این پیام از کسی آمده که کلید را دارد و دستکاری نشده».
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(macKey, "HmacSHA256"));
mac.update(payload);
byte[] tag = mac.doFinal();
boolean valid = MessageDigest.isEqual(tag, receivedTag); // زمانثابت
Diagram: the correct ordering — authenticate the ciphertext, verify before decrypting.
flowchart LR
P[Plaintext] --> E[Encrypt] --> C[Ciphertext]
C --> M[MAC over ciphertext] --> T[Tag]
C --> S[Send C + T]
T --> S
S --> V{Tag valid?}
V -- No --> R[Reject before decrypting]
V -- Yes --> D[Decrypt]
| ترتیب | استفاده در | مشکل | حکم |
|---|---|---|---|
| Encrypt-then-MAC | IPsec، طراحیهای مدرن | ندارد؛ اثبات امنیتی دارد | درست |
| MAC-then-Encrypt | TLS تا ۱.۲ | برای بررسی MAC باید اول decrypt کنی → padding oracle، Lucky Thirteen | خطرناک |
| Encrypt-and-MAC | SSH | MAC روی plaintext است و میتواند دربارهٔ plaintext اطلاعات بدهد | ضعیف |
با encrypt-then-MAC، گیرنده قبل از هر کار دیگری tag را چک میکند و اگر نخواند ciphertext را اصلاً باز نمیکند؛ پس هیچ دادهٔ کنترلشدهٔ مهاجم وارد مسیر رمزگشایی و padding نمیشود. این را cryptographic doom principle میگویند: هر عملیاتی روی دادهٔ احراز اصالتنشده، دیر یا زود به فاجعه ختم میشود. ولی جواب کامل این است: «encrypt-then-MAC درست است، ولی من اصلاً خودم ترکیبش نمیکنم — AES-GCM یا ChaCha20-Poly1305 این کار را با یک API و بدون جای اشتباه انجام دادهاند.»
۱۱. امضای دیجیتال و non-repudiation
MAC مثل مهری است که نسخهاش هم دست من است هم دست تو. اگر پیامی با آن مهر برسد، میدانم یا تو فرستادی یا خودم — ولی نمیتوانم به دادگاه ثابت کنم که تو فرستادی، چون خودم هم میتوانستم بسازمش.
امضای دیجیتال مثل امضایی است که فقط یک نفر میتواند بگذارد و همه میتوانند صحتش را ببینند. جعلش نمیتوانم بکنم، پس تو نمیتوانی انکارش کنی. این non-repudiation است.
| ویژگی | HMAC | امضای دیجیتال |
|---|---|---|
| نوع کلید | یک کلید مشترک | جفت کلید عمومی/خصوصی |
| چه کسی تأیید میکند | فقط دارندهٔ همان کلید | هر کسی با کلید عمومی |
| non-repudiation | ندارد | دارد |
| سرعت | بسیار سریع (میکروثانیه) | کندتر (میلیثانیه) |
| اندازه | ۳۲ بایت | ۶۴ بایت (Ed25519) تا ۲۵۶ بایت (RSA-2048) |
| کاربرد نمونه | session cookie، امضای webhook، JWT داخلی (HS256) |
گواهی TLS، امضای آرتیفکت، JWT بین سازمانها (RS256/ES256) |
Diagram: signing hashes first, then applying the private key.
sequenceDiagram
participant Signer
participant Verifier
Note over Signer: digest = SHA-256(document)
Signer->>Signer: signature = sign(privateKey, digest)
Signer->>Verifier: document + signature
Note over Verifier: recompute digest from document
Verifier->>Verifier: verify(publicKey, digest, signature)
alt valid
Verifier-->>Signer: accepted
else invalid
Verifier-->>Signer: rejected - tampered or wrong signer
end
امضا همیشه روی هش انجام میشود، نه روی خود سند — هم به دلیل اندازه، هم کارایی.
۱. الگوریتم را از خود پیام نخوان. حملهٔ alg: none در JWT دقیقاً این بود: سرور فیلد alg را از هدر همان توکنی میخواند که قرار بود تأییدش کند. نسخهٔ ظریفترش «algorithm confusion» است: مهاجم RS256 را به HS256 عوض میکند و کلید عمومی RSA را بهعنوان کلید HMAC استفاده میکند — و چون عمومی است، امضا معتبر درمیآید. الگوریتم مورد انتظار باید در پیکربندی سرور ثابت باشد. (جزئیات JWT در فصل ms-security.)
۲. امضای معتبر یعنی «دستنخورده»، نه «مجاز». یک webhook با امضای درست ممکن است replay شده باشد؛ همیشه timestamp و یک شناسهٔ یکتا داخل دادهٔ امضاشده بگذار.
۱۲. معماری JCA/JCE در جاوا
جاوا خودش الگوریتم رمزنگاری «نیست»؛ جاوا یک پریز استاندارد تعریف کرده است: تو به Cipher و MessageDigest و Signature میگویی چه میخواهی و JCA دنبال provider ای میگردد که آن را پیاده کرده باشد — یعنی میتوانی بدون تغییر یک خط کد، پیادهسازی نرمافزاری را با HSM یا ماژول تأییدشدهٔ FIPS عوض کنی. JCA چارچوب provider ها و کلاسهای امضا/هش/تولید کلید است و JCE بخش رمزنگاری (Cipher، KeyGenerator، Mac)؛ از جاوا ۹ همه در java.base ادغام شدهاند و از ۸u161 دیگر «unlimited strength policy files» لازم نیست.
رشتهٔ transformation یعنی algorithm / mode / padding؛ اگر فقط "AES" بنویسی provider خودش انتخاب میکند — و در SunJCE آن انتخاب ECB است.
| کلاس | کارش | مثال getInstance |
|---|---|---|
SecureRandom |
بایت تصادفی امن | new SecureRandom()، "DRBG" |
KeyGenerator |
کلید متقارن | "AES"، "HmacSHA256" |
KeyPairGenerator |
جفت کلید نامتقارن | "RSA"، "EC"، "Ed25519"، "X25519" |
Cipher |
رمزنگاری/رمزگشایی | "AES/GCM/NoPadding"، "ChaCha20-Poly1305" |
MessageDigest |
هش | "SHA-256"، "SHA3-256"، "SHAKE256-512" |
Mac |
MAC با کلید | "HmacSHA256" |
Signature |
امضا و راستیآزمایی | "Ed25519"، "SHA256withECDSA"، "RSASSA-PSS" |
KeyAgreement / KEM |
توافق و کپسولهکردن کلید | "X25519"، "DHKEM" (جاوا ۲۱+) |
KDF (جاوا ۲۵+) |
مشتق کلید | "HKDF-SHA256" |
فایل $JAVA_HOME/conf/security/java.security مرکز کنترل است: jdk.tls.disabledAlgorithms (در JDK 25 امضاهای SHA-1 در TLS 1.2 پیشفرض غیرفعال شدند)، jdk.certpath.disabledAlgorithms، jdk.crypto.disabledAlgorithms (از JDK 26، در لایهٔ JCA) و securerandom.strongAlgorithms. با -Djava.security.properties=... میشود رویشان نوشت و با -Djava.security.debug=all دیباگ کرد.
دربارهٔ BouncyCastle: پیشفرض این است که اضافهاش نکنی؛ آن را وقتی میآوری که چیزی بخواهی که JDK ندارد (Argon2، CMS/PKCS#7، نسخهٔ FIPS-certified) — و آنگاه نسخهاش را پین و پایش کن.
۱۳. Cheat sheet دستورها
| کار | دستور |
|---|---|
| بایت تصادفی امن | openssl rand -base64 32 |
| کلید RSA-3072 | openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out key.pem |
| کلید Ed25519 | openssl genpkey -algorithm ed25519 -out ed25519.pem |
| استخراج کلید عمومی | openssl pkey -in key.pem -pubout -out pub.pem |
| رمزکردن با RSA-OAEP | openssl pkeyutl -encrypt -inkey pub.pem -pubin -in dek.bin -out dek.enc -pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha256 |
| رمزگشایی با RSA-OAEP | openssl pkeyutl -decrypt -inkey key.pem -in dek.enc -out dek.bin -pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha256 |
| امضای فایل | openssl dgst -sha256 -sign key.pem -out doc.sig doc.txt |
| راستیآزمایی امضا | openssl dgst -sha256 -verify pub.pem -signature doc.sig doc.txt |
| keystore با کلید EC | keytool -genkeypair -alias api -keyalg EC -groupname secp256r1 -keystore ks.p12 -storetype PKCS12 |
یک تلهٔ مهم: openssl enc حالتهای AEAD را پشتیبانی نمیکند، پس openssl enc -aes-256-gcm جواب مفیدی نمیدهد — طراحی streaming آن نمیتواند tag را پیش از پردازش کل داده بررسی کند. و اگر مجبوری با enc کار کنی حتماً -pbkdf2 -iter 600000 بده، وگرنه یک KDF بسیار ضعیف قدیمی استفاده میشود.
۱۴. گالری اشتباههای کلاسیک
۱. Cipher.getInstance("AES") → ECB. همیشه transformation کامل.
۲. IV/nonce ثابت یا شمارندهٔ بازنشونده → با restart سرویس nonce ها تکرار میشوند؛ شمارنده باید persist و اتمیک باشد.
۳. کلید داخل کد یا env بدون rotation → git history برای همیشه یادش میماند.
۴. پسورد بهعنوان کلید AES بدون KDF → "password123".getBytes() بریدهشده به ۱۶ بایت، چند بیت entropy دارد.
۵. CBC بدون MAC → padding oracle.
۶. قورتدادن AEADBadTagException → خاموشکردن آژیر دزدگیر.
۷. Arrays.equals برای tag/MAC/token → timing attack؛ MessageDigest.isEqual.
۸. SHA-256 برای پسورد → میلیاردها حدس در ثانیه روی GPU.
۹. salt مشترک برای همهٔ کاربران → یک rainbow table برای کل دیتابیس کافی است.
۱۰. hash(secret || message) بهعنوان MAC → length extension؛ HMAC بنویس.
۱۱. خواندن alg از خود توکن → alg: none و algorithm confusion.
۱۵. افق: رمزنگاری پساکوانتومی
مهاجم امروز نمیتواند ترافیک TLS تو را باز کند، ولی میتواند ذخیرهاش کند و ده سال بعد بازش کند: harvest now, decrypt later.
الگوریتم Shor روی کامپیوتر کوانتومی بهقدر کافی بزرگ RSA و ECC را یکجا میشکند، چون هر دو به تجزیه و لگاریتم گسسته وابستهاند. در مقابل Grover فقط ریشهٔ دوم مزیت میدهد، پس AES-256 و هشهای ۳۸۴ بیتی و بالاتر عملاً امن میمانند — این نکته را در مصاحبه بگو، خیلیها فکر میکنند رمزنگاری متقارن هم میمیرد.
NIST در اوت ۲۰۲۴ سه استاندارد منتشر کرد: FIPS 203 — ML-KEM (Kyber) برای تبادل کلید، FIPS 204 — ML-DSA (Dilithium) برای امضا، و FIPS 205 — SLH-DSA (SPHINCS+) بهعنوان پشتیبان مبتنی بر هش. در NIST IR 8547 هم مسیر زمانی گذاشت: الگوریتمهای کلید عمومی کلاسیک (RSA، ECDSA، EdDSA، ECDH) بعد از ۲۰۳۰ deprecated و بعد از ۲۰۳۵ disallowed میشوند.
جاوا همراه شده است: JDK 24 ML-KEM (JEP 496) و ML-DSA (JEP 497)؛ JDK 25 یعنی KDF API نهایی (JEP 510)، افزودن SHAKE128-256 و SHAKE256-512، و غیرفعالشدن پیشفرض امضاهای SHA-1 در TLS 1.2؛ JDK 26 یعنی HPKE با HPKEParameterSpec روی Cipher (RFC 9180)، پیشنمایش دوم PEM API (JEP 524) و امضای JAR با jarsigner -sigalg ML-DSA-65.
لازم نیست فردا همهچیز را به PQC ببری. کاری که senior امروز انجام میدهد این است: الگوریتمها را در پیکربندی نگه دار نه در ثابتهای پراکنده؛ کنار هر ciphertext یک شناسهٔ نسخه/الگوریتم بگذار؛ یک «موجودی رمزنگاری» داشته باش که میگوید کجا چه الگوریتمی با چه اندازهٔ کلیدی استفاده میشود؛ و مسیر rotation را تمرین کن، نه فقط مستند.
چون سؤال واقعی مصاحبه این نیست که «Kyber چطور کار میکند»، بلکه این است: «اگر فردا اعلام کنند الگوریتم X شکسته است، سیستم تو چند وقت طول میکشد تا مهاجرت کند؟»
سؤالهای مصاحبه
در متقارن یک کلید واحد هم رمز میکند هم باز میکند (AES). سریع است — روی CPU مدرن با AES-NI گیگابایت بر ثانیه — ولی مسئلهٔ توزیع کلید را حل نمیکند. در نامتقارن جفت کلید داری: عمومی که همه دارند و خصوصی که فقط خودت؛ مسئلهٔ توزیع حل میشود ولی چند مرتبهٔ بزرگی کندتر است و اندازهٔ دادهٔ قابل رمزگذاری به modulus محدود میشود.
به همین دلیل هر پروتکل واقعی hybrid است: نامتقارن برای تبادل یا کپسولهکردن یک کلید متقارن تازه، و متقارن برای خود داده. TLS، PGP و هر envelope encryption در ابر دقیقاً همین است.
IV مقداری است که به mode داده میشود تا رمزکردن همان plaintext با همان کلید هر بار ciphertext متفاوتی بدهد. راز نیست و معمولاً بهصورت متن روشن کنار ciphertext میآید.
تفاوت در الزامات است: در CBC باید هم یکتا و هم غیرقابل پیشبینی باشد (IV قابل پیشبینی حملهٔ BEAST را ممکن کرد)؛ در GCM فقط باید یکتا باشد و یک شمارندهٔ یکتا حتی بهتر از تصادفی است چون احتمال برخورد را صفر میکند.
عواقب هم فرق دارد: IV تکراری در CBC اطلاعات محدودی دربارهٔ پیشوند مشترک لو میدهد؛ nonce تکراری در GCM باعث میشود مهاجم authentication subkey را بازیابی کند و برای هر پیامی tag معتبر بسازد — یعنی نابودی کامل جعلناپذیری آن کلید.
با nonce تصادفی ۹۶ بیتی، سقف امن حدود ۲^۳۲ عملیات با هر کلید است (NIST SP 800-38D)، چون بالاتر از آن احتمال برخورد nonce از حد قابل قبول رد میشود؛ حدود چهار میلیارد پیام در یک سرویس پرترافیک ممکن است در چند ماه پر شود. محدودیت دوم اندازهٔ هر پیام است: حداکثر حدود ۶۴ گیگابایت در یک عملیات.
راهحلها: key rotation بر اساس شمارندهٔ استفاده (نه فقط تاریخ)، nonce شمارندهای یکتا بهجای تصادفی، یا مشتقکردن کلید تازه برای هر پیام از یک کلید مادر با HKDF. گفتن این عدد فوراً نشان میدهد که فقط API را کپی نکردهای.
HMAC از یک کلید مشترک استفاده میکند: هر کسی که میتواند تأیید کند، میتواند تولید هم بکند؛ پس authenticity میدهد ولی non-repudiation نمیدهد. امضای دیجیتال با کلید خصوصی ساخته و با کلید عمومی تأیید میشود؛ همه میتوانند تأیید کنند و هیچکس جز دارندهٔ کلید خصوصی نمیتواند بسازد.
انتخاب عملی: داخل مرز اعتماد یک سرویس (session cookie، امضای درخواست داخلی، webhook با راز مشترک) HMAC هم امنتر است هم صدها برابر سریعتر. بین سازمانها، برای گواهی، برای امضای آرتیفکت و هر جا که «چه کسی این را ساخت» باید مستقلاً اثبات شود، امضای دیجیتال لازم است — و مزیت عملیاتی مهمش این است که برای توزیع کلید تأیید لازم نیست هیچ رازی را با مصرفکننده به اشتراک بگذاری.
SHA-256 عمداً سریع است. همان چیزی که برای checksum عالی است، برای پسورد فاجعه است: یک GPU مدرن دهها میلیارد SHA-256 در ثانیه میزند، پس دیکشنری و brute-force روی پسوردهای انسانی ارزان میشود.
salt یک مقدار تصادفی یکتا برای هر کاربر است که کنار هش ذخیره میشود (راز نیست) و دو چیز را حل میکند: rainbow table های از پیش محاسبهشده بیاثر میشوند، و دو کاربر با پسورد یکسان هش یکسان ندارند پس مهاجم نمیتواند حمله را روی همهٔ کاربران amortize کند. اما salt سرعت حدسزدن یک کاربر مشخص را کم نمیکند؛ آن کار work factor است. جواب کامل: «salt یکتا + الگوریتمی عمداً کند و memory-hard».
Argon2id پیشفرض است: برندهٔ Password Hashing Competition، مستند در RFC 9106، و memory-hard — مهاجم برای هر حدس باید RAM زیادی بدهد که مزیت GPU و ASIC را از بین میبرد. حداقل توصیهٔ OWASP: m=۱۹ MiB، t=۲، p=۱ (یا m=۴۶ MiB، t=۱، p=۱).
bcrypt برای سیستمهای موجود قابل قبول است با work factor حداقل ۱۰؛ محدودیتهایش: فقط ۷۲ بایت اول ورودی، و فقط ۴ کیلوبایت حافظه. PBKDF2 وقتی که الزام FIPS داری یا فقط JDK خالی در دسترس است؛ با HMAC-SHA256 حدود ۶۰۰٬۰۰۰ تکرار، و ضعفش این است که فقط CPU-hard است.
نکتهٔ senior: عدد را از اینترنت کپی نکن — روی سختافزار production خودت اندازه بگیر و روی ۲۵۰ تا ۵۰۰ میلیثانیه به ازای هر verify تنظیم کن.
java.util.Random یک LCG با وضعیت داخلی ۴۸ بیتی است؛ با دیدن چند خروجی میتوان وضعیت را بازسازی و همهٔ خروجیهای بعدی را پیشبینی کرد. SecureRandom یک CSPRNG است که از منبع آنتروپی سیستمعامل seed میگیرد و طراحی شده که خروجی گذشته چیزی دربارهٔ آینده نگوید — برای کلید، IV، nonce، salt، session token، OTP و توکن بازیابی رمز، همیشه این.
getInstanceStrong() نمونهای از فهرست securerandom.strongAlgorithms برمیگرداند که روی لینوکس ممکن است به منبع blocking نگاشت شود و در یک container تازه با آنتروپی کم startup را معلق کند. قاعدهٔ عملی: new SecureRandom() برای کار روزمره و getInstanceStrong() برای کلیدهای بلندمدت و بسیار حساس. و از seed ثابت برای «تکرارپذیری تست» استفاده نکن؛ منبع تصادفی را تزریق کن.
private static final byte[] KEY = "0123456789abcdef".getBytes();
private static final byte[] IV = new byte[16];
public String encrypt(String data) throws Exception {
Cipher c = Cipher.getInstance("AES/CBC/PKCS5Padding");
c.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(KEY, "AES"),
new IvParameterSpec(IV));
return Base64.getEncoder().encodeToString(c.doFinal(data.getBytes()));
}
به ترتیب شدت: (۱) کلید ثابت و داخل کد، و بدتر یک رشتهٔ قابل حدس با آنتروپی بسیار پایین — باید از KMS/HSM بیاید یا از KDF مشتق شود و قابل rotation باشد. (۲) IV ثابت و همهصفر → رمزکردن یک داده همیشه یک ciphertext میدهد و مهاجم میفهمد چه ردیفهایی مقدار یکسان دارند. (۳) CBC بدون احراز اصالت → داده قابل دستکاری است و اگر خطاهای رمزگشایی قابل تمییز باشند padding oracle داری. (۴) getBytes() بدون charset → وابسته به charset پیشفرض JVM؛ getBytes(StandardCharsets.UTF_8). (۵) راز در String → در heap dump میماند. (۶) اگر این Cipher روزی در فیلدی نگهداشته شود، thread-safe نیست.
نسخهٔ درست: کلید از KMS، AES/GCM/NoPadding، nonce ۱۲بایتی تازه از SecureRandom برای هر فراخوانی، tag ۱۲۸ بیتی، شناسهٔ رکورد بهعنوان AAD، و خروجی به شکل keyId || nonce || ciphertext+tag.
ترتیب اولویت: HSM یا KMS مدیریتشده (کلید هرگز بیرون نمیآید) > secret manager با کنترل دسترسی و audit > متغیر محیطی تزریقشده در زمان اجرا > (هرگز) فایل پیکربندی در repo.
الگوی عملی envelope encryption است: داده را با DEK تصادفی رمز کن، DEK را با KEK داخل KMS بپیچ، و DEK پیچیدهشده را کنار داده ذخیره کن؛ rotation آنگاه فقط یعنی re-wrap کردن DEK ها، نه رمزگذاری دوبارهٔ ترابایتها داده.
چیزهایی که rotation را عملاً ممکن میکنند: ستون key_id کنار هر ciphertext، پشتیبانی از چند کلید فعال همزمان (جدید برای نوشتن، قدیمیها هنوز برای خواندن)، و یک job پسزمینه برای مهاجرت تدریجی. و مهمتر از همه: مسیر rotation باید تمرینشده باشد؛ تیمی که هرگز rotate نکرده، روز حادثه هم نمیتواند.
الگوریتم Shor تجزیه و لگاریتم گسسته را در زمان چندجملهای حل میکند، یعنی RSA، ECDSA، EdDSA، ECDH و Diffie-Hellman همگی میشکنند و بزرگکردن کلید نجاتبخش نیست. Grover فقط ریشهٔ دوم بهبود در brute-force میدهد: AES-256 عملاً به سطح ۱۲۸ بیت میرسد که همچنان امن است، و هشهای ۳۸۴ بیتی و بالاتر امن میمانند — پس رمزنگاری متقارن و هش نمیمیرند.
خطر امروز «harvest now, decrypt later» است؛ برای دادهای که باید ۱۵ سال محرمانه بماند این خطر همین الان واقعی است. مسیر: NIST در ۲۰۲۴ FIPS 203/204/205 را منتشر کرد و جاوا از نسخهٔ ۲۴ ML-KEM و ML-DSA را دارد. رویکرد رایج امروز hybrid است: کلاسیک و پساکوانتومی را با هم اجرا کن تا اگر یکی شکست، دیگری نگهت دارد.
- رمزنگاری سه چیز متفاوت میدهد: محرمانگی، یکپارچگی و اصالت. همیشه بپرس کدام را میخواهی؛ Base64 هیچکدام نیست.
- متقارن (AES) سریع است ولی مسئلهٔ توزیع کلید دارد؛ نامتقارن (RSA/ECC) آن را حل میکند ولی کند و محدود است. هر سیستم واقعی hybrid است.
- در AES فقط mode مهم است: ECB هرگز، CBC فقط با MAC، GCM پیشفرض — nonce ۱۲ بایتی و یکتا، tag ۱۲۸ بیتی، و سقف ۲^۳۲ پیام با هر کلید.
- AEAD (GCM یا ChaCha20-Poly1305) رمزنگاری و احراز اصالت را با هم انجام میدهد؛ خودت ترکیبشان نکن.
- در RSA همیشه OAEP با پارامتر صریح و برای امضا PSS؛ RSA فقط کلید را میپیچد نه داده را. Ed25519 انتخاب مدرن امضاست.
- هش ≠ هش پسورد. برای پسورد Argon2id با salt یکتا و پارامتر سنجیده؛ MD5 و SHA-1 برای هر کاربرد امنیتی مردهاند.
- HMAC برای احراز اصالت با کلید مشترک، امضای دیجیتال وقتی non-repudiation میخواهی؛ مقایسهها با
MessageDigest.isEqual. - در جاوا transformation کامل بنویس،
Cipherرا به اشتراک نگذار، رازها را درStringنریز وAEADBadTagExceptionرا قورت نده. - امنیت واقعی در مدیریت کلید است: envelope encryption، شناسهٔ کلید کنار داده، rotation تمرینشده و crypto agility — چون سؤال بعدی همیشه «فردا که این الگوریتم شکست، چه میکنی؟» است.
Almost every backend engineer writes this line one day:
Cipher cipher = Cipher.getInstance("AES");
It compiles, the tests go green, the feature ships — and three years later an auditor tells you the whole database is effectively unencrypted. Because "AES" in SunJCE means AES/ECB/PKCS5Padding, and ECB means "identical block in, identical ciphertext out".
Cryptography is the one area of software engineering where code can "work" and still be completely wrong: data encrypts, data decrypts, no exception is thrown, and the security is zero. That is why interviewers love it.
We start from zero: each concept gets a concrete picture, then its precise name, then real code, then where it blows up in production.
- The three goals of cryptography, and why Base64 is none of them.
- Secure randomness:
SecureRandomversusRandom. - AES: blocks, keys, modes (ECB / CBC / CTR / GCM), IVs and nonces, and AEAD.
- Key management: KDFs, PBKDF2, HKDF, envelope encryption and rotation.
- RSA: padding, OAEP, the size limit, hybrid encryption.
- ECC, ECDSA and Ed25519 — and why they are replacing RSA.
- Hashing (SHA-2/SHA-3) versus password hashing (bcrypt/scrypt/Argon2).
- HMAC, the encrypt/MAC ordering question, digital signatures and non-repudiation.
- JCA/JCE architecture, a correct AES-GCM class, classic mistakes, a cheat sheet and interview questions.
Assumed background: spring-security and ms-security cover OAuth2, JWT and RBAC. This chapter sits one layer below — the primitives they are built on.
1. What does cryptography actually guarantee?
- A safe hides the contents: confidentiality.
- A wax seal on an envelope hides nothing, but reveals tampering: integrity.
- A signature on a contract says this exact person wrote it and cannot later deny it: authenticity and non-repudiation.
A safe without a seal is useless: I cannot read the contents, but I can change them without you noticing. That is exactly the mistake AES-CBC without a MAC makes.
So whenever somebody says "we encrypted the data", the right question is: which of the three?
Kerckhoffs's principle (1883) says security must depend only on secrecy of the key, never of the algorithm. AES spent 1997–2000 under attack from the whole cryptographic community in an open NIST competition before becoming FIPS 197; your in-house algorithm is a week old. The rule: you do not invent algorithms and do not hand-assemble primitives; you use a mature library and only decide which mode to pick.
2. Base64 is not encryption
Morse code turns text into dots and dashes. Encryption? No — there is no key. Morse is an encoding: a change of representation with no secret. Base64 is the same: binary rendered as 64 text-safe characters so bytes fit inside JSON or an HTTP header.
echo -n 'my-db-password' | base64 # bXktZGItcGFzc3dvcmQ=
echo -n 'bXktZGItcGFzc3dvcmQ=' | base64 -d
| Concept | Needs a key? | Reversible? | What it gives you | Example |
|---|---|---|---|---|
| Encoding | No | Yes, by anyone | Representation change only | Base64, Hex |
| Hashing | No | No (one-way) | A fingerprint of the data | SHA-256, SHA-3 |
| MAC | Yes (shared key) | No | Integrity + authenticity between two parties | HMAC-SHA256 |
| Encryption | Yes | Yes, only with the key | Confidentiality (+ integrity with AEAD) | AES-GCM |
| Digital signature | Yes (private key) | No | Authenticity + non-repudiation for everyone | Ed25519, RSA-PSS |
- "We base64 the password so it is safe in the database." — Not safe, only unreadable.
- "We hashed the token so it cannot be forged." — Without a key the attacker computes the same hash; forgery resistance needs a MAC or signature.
- "We encrypted it with the private key so it is confidential." — Everyone has the public key; private-key operations are for signing.
A mental rule: whenever someone says "it is secure", ask against whom, with what capability, and guaranteeing what? That is a threat model.
3. The foundation of everything: secure randomness
Settle this before AES and RSA, because every key, IV, nonce, salt and token comes from a random source; if it is guessable, the best algorithm is worthless.
java.util.Random looks random but is clockwork with 48 bits of state: observe two outputs and you can predict every future one. Great for simulations, catastrophic for security. SecureRandom is seeded from the OS entropy source so past outputs say nothing about the next.
- entropy: genuine unpredictability, in bits. A 256-bit key produced by
Randommay hold only 48 bits of real entropy. - CSPRNG / DRBG: a cryptographically secure pseudo-random generator; NIST calls it a Deterministic Random Bit Generator.
SecureRandom rng = new SecureRandom(); // default; usually non-blocking on Linux
byte[] nonce = new byte[12];
rng.nextBytes(nonce);
// For very long-lived secrets; the algorithm comes from the
// securerandom.strongAlgorithms security property
SecureRandom strong = SecureRandom.getInstanceStrong();
// A safe token for password reset or an API key
byte[] raw = new byte[32]; // 256 bits of entropy
rng.nextBytes(raw);
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(raw);
- Never
new SecureRandom(seed)with a fixed seed. "A fixed seed so tests are reproducible" means predictable keys; inject the random source as a dependency instead. - Do not write
getInstance("SHA1PRNG"). It is not a standard algorithm and behaves differently across providers. Usenew SecureRandom()orgetInstance("DRBG"). getInstanceStrong()can block on Linux (it may map toNativePRNGBlocking, reading/dev/random); in a freshly started container with little entropy your startup can hang. For IVs, salts and tokens,new SecureRandom()is correct.Math.random(),ThreadLocalRandomandUUID.randomUUID()are not for secrets. (UUID.randomUUID()usesSecureRandomon HotSpot, but carries only 122 random bits and was never a security primitive.)
4. Symmetric cryptography: AES
4.1 What symmetric means
Symmetric encryption is a gym locker: the same key that locks it also opens it. Fast and simple — with one big problem: how do I get the key to the other side? If I had a secure channel for the key, I would have used it for the message. That chicken-and-egg problem is why asymmetric cryptography exists.
AES (Advanced Encryption Standard, the Rijndael algorithm, standardised as FIPS 197 in 2001):
- The block size is always 128 bits (16 bytes) — independent of key size.
- Key size is 128, 192 or 256 bits; round counts are 10, 12 and 14.
- AES-128 is still perfectly secure; AES-256 is mostly chosen for regulatory requirements and quantum safety margin.
A block cipher only turns exactly 16 bytes into 16 bytes. Your message is 2 MB; how do you split and chain the pieces? The answer is the mode of operation, where 90% of cryptographic mistakes live.
4.2 ECB — the mode you must never use
ECB (Electronic Codebook) encrypts each block independently under the same key — a dictionary mapping every word to a meaningless one: unreadable, but the pattern survives completely. The famous demo is the "ECB penguin": an image still perfectly recognisable after encryption.
Diagram: ECB encrypts each block independently, CBC chains each block into the next.
flowchart LR
subgraph ECB["ECB - patterns survive"]
P1[Block 1] --> E1[AES] --> C1[Cipher 1]
P2[Block 2] --> E2[AES] --> C2[Cipher 2]
end
subgraph CBC["CBC - chained"]
IV[Random IV] --> X1((XOR))
Q1[Block 1] --> X1 --> D1[AES] --> R1[Cipher 1]
R1 --> X2((XOR))
Q2[Block 2] --> X2 --> D2[AES] --> R2[Cipher 2]
end
Cipher.getInstance("AES") in SunJCE equals AES/ECB/PKCS5Padding. No warning, no exception, tests pass. Always write the full transformation: "AES/GCM/NoPadding". Seeing getInstance("AES") in a review is a security finding, not a style nit.
Since JDK 26 the jdk.crypto.disabledAlgorithms security property lets an organisation disable such algorithms centrally at the JCA layer.
4.3 CBC — chaining, IVs and padding
CBC (Cipher Block Chaining) XORs each block with the previous ciphertext block before encrypting. The first block has no predecessor, so we supply a random value: the IV (Initialization Vector). Three facts interviewers ask for:
- The IV is not secret and is normally stored as a prefix beside the ciphertext.
- In CBC it must be unpredictable (random), not merely unique — a predictable IV led to BEAST on TLS 1.0.
- It must never repeat under the same key.
Padding: since AES consumes whole blocks, PKCS#7 (called PKCS5Padding in Java for historical reasons) appends n bytes each holding the value n.
If your server, while decrypting, distinguishes "bad padding" from "good padding but invalid content" in any observable way — a different error message, status code, or even response time — an attacker can decrypt the whole ciphertext byte by byte without the key. That is the Padding Oracle attack (Vaudenay, 2002); POODLE and Lucky Thirteen were real-world incarnations.
So CBC without a MAC is broken, and CBC+HMAC is so subtle to get right that the practical answer is one word: AES-GCM.
4.4 CTR — turning a block cipher into a stream
CTR (Counter mode) encrypts a counter and XORs the output with the data, turning AES into a keystream generator. No padding, parallelisable, random access — which is why it underpins disk encryption. Its input is a nonce: a number used exactly once under a given key.
4.5 GCM and AEAD — today's default
CTR is an opaque envelope: you cannot see inside, but somebody can cut it open and rearrange the contents without you noticing.
GCM is the same envelope plus a seal made with the same key; flip one bit and the seal no longer matches, so the receiver discards the message. And you may write something on the back of the envelope (say, the recipient's id) that is not hidden but is still covered by the seal — that is AAD.
- AEAD = Authenticated Encryption with Associated Data: one algorithm giving confidentiality and integrity together, so you never combine encryption and a MAC yourself.
- authentication tag: the 16-byte value Java appends to the ciphertext.
- AAD: data not encrypted but included in the tag — say
tenantIdorrecordId. If an attacker copies user A's ciphertext into user B's row the tag fails, killing the ciphertext substitution attack.
AES-GCM = AES in CTR mode + the GHASH function multiplying in the Galois field GF(2^128); the standard is NIST SP 800-38D.
Diagram: how GCM combines CTR-mode encryption with the GHASH authentication tag.
flowchart TD
K[AES key] --> CTR[CTR keystream]
N[96-bit nonce, unique per message] --> CTR
P[Plaintext] --> X((XOR))
CTR --> X --> C[Ciphertext]
C --> G[GHASH]
AAD[Associated data - not encrypted] --> G
K --> G
G --> T[128-bit auth tag]
C --> OUT[nonce + ciphertext + tag]
T --> OUT
The GCM rules to know by heart:
- Use a 12-byte (96-bit) nonce — the NIST-recommended length, used directly; any other length goes through GHASH first.
- A fresh nonce per message: a persisted unique counter (best) or random bytes from
SecureRandom. - With random nonces, at most 2^32 invocations per key — four billion messages is not a big number in a busy service; precisely why key rotation exists.
- At most ~64 GB per message (2^39 − 256 bits), and use a 128-bit tag.
In CTR, two messages under the same key and nonce are XORed with the same keystream:
C1 XOR C2 = (P1 XOR KS) XOR (P2 XOR KS) = P1 XOR P2
The keystream cancels out and the attacker holds the relationship between both plaintexts.
In GCM it is worse: nonce reuse lets an attacker recover the authentication subkey H and forge a valid tag for any message he likes. It has a name — the forbidden attack — and it destroys forgery resistance for that key entirely.
Good news: encrypt twice with the same key and IV and SunJCE deliberately throws InvalidAlgorithmParameterException: Cannot reuse iv for GCM encryption. Do not work around it by "reinitializing"; generate a fresh nonce.
4.6 Decision table: which mode?
| Mode | Confidentiality | Integrity | Padding | Parallel | Main risk | Verdict |
|---|---|---|---|---|---|---|
| ECB | Almost none | No | Yes | Yes | Leaks data patterns | Never |
| CBC | Yes | No | Yes | Decrypt only | Padding oracle, predictable IV | Only with an HMAC, only for legacy |
| CTR | Yes | No | No | Yes | Nonce reuse, malleability | Only as an internal building block |
| GCM | Yes | Yes (AEAD) | No | Yes | Nonce reuse, 2^32 message cap | Default |
| ChaCha20-Poly1305 | Yes | Yes (AEAD) | No | Yes | Nonce reuse | When you have no AES hardware acceleration |
Choosing between them is a performance decision, not a security one: on a modern server CPU (AES-NI, or ARMv8 Crypto Extensions) hardware-accelerated AES-GCM wins; without that acceleration ChaCha20-Poly1305 is faster and more cache-timing resistant, using only additions, XORs and rotations. Java has had it since 11 (JEP 329) as "ChaCha20-Poly1305".
4.7 A correct AES-GCM class in Java
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import java.nio.ByteBuffer;
import java.security.SecureRandom;
public final class AesGcm {
private static final String TRANSFORM = "AES/GCM/NoPadding";
private static final int NONCE_LEN = 12; // 96 bit — NIST recommendation
private static final int TAG_BITS = 128;
private static final SecureRandom RNG = new SecureRandom();
private AesGcm() { }
public static SecretKey newKey() throws Exception {
KeyGenerator kg = KeyGenerator.getInstance("AES");
kg.init(256, RNG);
return kg.generateKey();
}
/** Layout: nonce || ciphertext || tag (Java appends the tag for you) */
public static byte[] encrypt(SecretKey key, byte[] plaintext, byte[] aad) throws Exception {
byte[] nonce = new byte[NONCE_LEN];
RNG.nextBytes(nonce); // fresh nonce per message
Cipher cipher = Cipher.getInstance(TRANSFORM); // not thread-safe, do not cache
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, nonce));
if (aad != null) {
cipher.updateAAD(aad);
}
byte[] ct = cipher.doFinal(plaintext);
return ByteBuffer.allocate(nonce.length + ct.length).put(nonce).put(ct).array();
}
public static byte[] decrypt(SecretKey key, byte[] blob, byte[] aad) throws Exception {
if (blob.length < NONCE_LEN + TAG_BITS / 8) {
throw new IllegalArgumentException("ciphertext too short");
}
ByteBuffer buf = ByteBuffer.wrap(blob);
byte[] nonce = new byte[NONCE_LEN];
buf.get(nonce);
byte[] ct = new byte[buf.remaining()];
buf.get(ct);
Cipher cipher = Cipher.getInstance(TRANSFORM);
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, nonce));
if (aad != null) {
cipher.updateAAD(aad);
}
return cipher.doFinal(ct); // a bad tag throws AEADBadTagException — never swallow it
}
}
- Caching
Cipherin a static field. It is stateful and not thread-safe; two threads on one instance means corrupted ciphertext or leaked data. CallgetInstanceeach time (it is cheap) or use aThreadLocal. - Swallowing
AEADBadTagExceptionwithcatch (Exception e) { return null; }silences the only signal that data was tampered with. - Secrets in a
String.Stringis immutable, lingers on the heap and shows up in heap dumps; keep keys inchar[]/byte[]and wipe withArrays.fill(arr, (byte) 0).
5. Where does the key come from? KDFs and envelope encryption
A KDF (Key Derivation Function) turns input material into high-quality keys. Two different families exist, and confusing them is a common error:
| Type | Input | Purpose | Examples | Should it be slow? |
|---|---|---|---|---|
| Password-based KDF | A human password (low entropy) | Make guessing expensive | PBKDF2, scrypt, Argon2 | Yes, deliberately |
| Key-based KDF | A high-entropy secret (e.g. an ECDH output) | Derive several distinct keys from one secret | HKDF | No, it must be fast |
// password -> key. A unique salt and a high iteration count are mandatory.
SecretKeyFactory f = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
byte[] salt = new byte[16];
new SecureRandom().nextBytes(salt);
KeySpec spec = new PBEKeySpec(password /* char[] */, salt, 600_000, 256);
SecretKey aesKey = new SecretKeySpec(f.generateSecret(spec).getEncoded(), "AES");
600,000 is OWASP's current recommendation for PBKDF2-HMAC-SHA256 (about 210,000 for SHA-512).
Since JDK 25 there is a standard KDF API (JEP 510, previewed in JDK 24):
import javax.crypto.KDF;
import javax.crypto.spec.HKDFParameterSpec;
KDF hkdf = KDF.getInstance("HKDF-SHA256");
SecretKey encKey = hkdf.deriveKey("AES",
HKDFParameterSpec.ofExtract()
.addIKM(sharedSecret) // input keying material
.addSalt(salt)
.thenExpand("app:v1:encryption".getBytes(UTF_8), 32));
SecretKey macKey = hkdf.deriveKey("HmacSHA256",
HKDFParameterSpec.ofExtract()
.addIKM(sharedSecret)
.addSalt(salt)
.thenExpand("app:v1:mac".getBytes(UTF_8), 32));
The info string is not decoration: it performs domain separation, guaranteeing the encryption and MAC keys can never collide.
Envelope encryption is the industry-standard pattern: encrypt data with a random single-use DEK (Data Encryption Key), then wrap the DEK with a KEK (Key Encryption Key) held in an HSM or KMS. Only the wrapped DEK sits beside the data; the KEK never leaves the module. The win: rotation re-wraps DEKs instead of re-encrypting terabytes.
Diagram: the lifecycle states a symmetric key moves through in a managed system.
stateDiagram-v2
[*] --> Generated: KMS or KeyGenerator
Generated --> Active: used for encrypt and decrypt
Active --> Deprecated: rotation, decrypt only
Deprecated --> Destroyed: all data re-wrapped
Active --> Compromised: incident
Compromised --> Destroyed: emergency re-encrypt
Destroyed --> [*]
Store only ciphertext and on rotation day you have no idea which key encrypted which row. A key_id column (or a version byte at the head of the blob) costs nothing and turns rotation from a multi-month project into a background job. Do the same for the algorithm version (v1 = AES-256-GCM).
CREATE TABLE customer_secret (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL,
key_id text NOT NULL, -- which DEK/KEK
alg text NOT NULL DEFAULT 'AES-256-GCM',
nonce bytea NOT NULL, -- 12 bytes
ciphertext bytea NOT NULL, -- includes the tag
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON customer_secret (key_id); -- to re-wrap during rotationCREATE TABLE customer_secret (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id NUMBER NOT NULL,
key_id VARCHAR2(64) NOT NULL, -- which DEK/KEK
alg VARCHAR2(32) DEFAULT 'AES-256-GCM' NOT NULL,
nonce RAW(12) NOT NULL,
ciphertext BLOB NOT NULL, -- includes the tag
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL
);
CREATE INDEX customer_secret_key_id_ix ON customer_secret (key_id);A key in code or a config file (private static final String KEY = "...") lives in git history, the Docker image, backups and every laptop that cloned the repo — and rotation becomes impossible.
Encrypting inside the database drags the key into the database. Write pgp_sym_encrypt('data', 'my-passphrase') and that passphrase is in the SQL text — so in pg_stat_statements, slow-query logs, and Oracle's v$sql. To protect stolen disk files, TDE is right; to keep the database itself from seeing the data, encryption belongs in the application. Two different threat models.
And you cannot run WHERE email = ? on an encrypted column. The standard answer is a blind index: an extra indexed column holding HMAC(key, lower(email)), giving exact lookups without exposing the value.
6. Asymmetric cryptography: RSA
A mailbox in front of your house: a slot any passer-by can drop a letter into, and a lock only you hold the key to.
- The slot = the public key; hand it to everyone, it can only put things in.
- The lock's key = the private key; only you have it, and only it takes things out.
The crucial part: you cannot derive the lock's key from the shape of the slot. That asymmetry is the whole story.
RSA (1977) rests on one fact: multiplying two large primes is easy, factoring the product is practically impossible — a trapdoor function. The modulus is n = p × q; "RSA-2048" means n is 2048 bits. The public key is (n, e) with e = 65537, the private key (n, d).
6.1 Why RSA never encrypts large data
Mathematically: RSA operates on a number smaller than n, and padding eats part of it. With RSA-2048 and OAEP-SHA-256:
max plaintext = k − 2·hLen − 2 = 256 − 2·32 − 2 = 190 bytes
With PKCS#1 v1.5 it is k − 11 = 245 bytes. Performance-wise: RSA is orders of magnitude slower than AES.
The standard answer is hybrid encryption: put the cargo in a container with an ordinary lock and post only that lock's key through the slot.
Diagram: sender wraps a fresh AES key with the recipient's RSA public key.
sequenceDiagram
participant S as Sender
participant R as Recipient
R->>S: RSA public key
Note over S: generate random AES-256 key (DEK)
S->>S: ciphertext = AES-GCM(DEK, message)
S->>S: wrappedKey = RSA-OAEP(pubKey, DEK)
S->>R: wrappedKey + nonce + ciphertext + tag
Note over R: DEK = RSA-OAEP-decrypt(privKey, wrappedKey)
R->>R: message = AES-GCM-decrypt(DEK, ciphertext)
The sentence to say in an interview: asymmetric for key transport and signatures, symmetric for the data.
6.2 Padding: OAEP versus PKCS#1 v1.5
Textbook RSA is deterministic: one input always yields one output. If the input space is small ("yes"/"no", a card number), an attacker encrypts all candidates and compares. Padding adds randomisation and safe structure.
PKCS#1 v1.5 is the old scheme. Bleichenbacher showed in 1998 that if a server reveals whether padding was valid, an attacker decrypts the message with adaptive queries. It returned as ROBOT in 2017, and in 2023 Marvin showed a timing leak alone suffices — identical error messages do not save you. For anything new, always OAEP.
A very common Java trap: in SunJCE the transformation "RSA/ECB/OAEPWithSHA-256AndMGF1Padding" sets the message digest to SHA-256 but leaves MGF1 on SHA-1 — breaking interoperability with non-Java libraries. The correct way:
OAEPParameterSpec oaep = new OAEPParameterSpec(
"SHA-256", "MGF1",
MGF1ParameterSpec.SHA256, // this is the part everybody forgets
PSource.PSpecified.DEFAULT);
Cipher c = Cipher.getInstance("RSA/ECB/OAEPPadding");
c.init(Cipher.ENCRYPT_MODE, publicKey, oaep);
Incidentally the ECB in that string is meaningless and purely historical — RSA has no mode at all.
6.3 Signing with RSA
"Signing is encrypting with the private key" helps intuitively but is technically wrong. The modern RSA signature scheme is RSASSA-PSS, with a different construction, a random salt and a security proof; SHA256withRSA is the PKCS#1 v1.5 signature scheme, not encryption.
Signature signer = Signature.getInstance("RSASSA-PSS");
signer.setParameter(new PSSParameterSpec(
"SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1));
signer.initSign(privateKey);
signer.update(message);
byte[] sig = signer.sign();
With e = 65537, public-key operations (encrypt, verify) are very fast and private-key ones (decrypt, sign) very slow. A gateway verifying millions of JWTs is fine with RSA; a service issuing millions of tokens hits a CPU bottleneck, and ECDSA/Ed25519 is several times faster. Exactly the reasoning expected from a senior.
7. ECC, ECDSA and Ed25519
RSA's mathematics lives on large integers; ECC on points of an elliptic curve. Add a base point G to itself k times and you reach P; going from k to P is easy, going back is practically impossible (the elliptic-curve discrete logarithm problem). The consequence: the same security with far smaller keys.
| Security level | RSA | ECC | Note |
|---|---|---|---|
| 112 bits | 2048 bits | 224 bits | Today's floor; NIST deprecates it by 2030 |
| 128 bits | 3072 bits | 256 bits (P-256, Curve25519) | The conventional choice |
| 256 bits | 15360 bits | 521 bits (P-521) | RSA is effectively unusable here |
An ECDSA signature on P-256 is about 64 bytes against 256 for RSA-2048 — a real difference in a JWT or TLS certificate.
- ECDH / X25519: key agreement — both sides exchange public keys and independently derive a shared secret that never travels over the wire. The basis of forward secrecy in TLS 1.3.
- ECDSA: signatures on NIST curves (P-256 / secp256r1).
- EdDSA / Ed25519: the modern signature scheme on an Edwards curve (RFC 8032).
ECDSA needs a secret random k per signature. Use k twice with the same private key and anyone recovers that key from the two signatures with simple algebra; even partial predictability falls to lattice attacks.
That is what leaked the signing key of a famous game console in 2010 (k was constant) and drained Android Bitcoin wallets in 2013 (a broken OS PRNG).
The fixes: RFC 6979 (deterministic ECDSA, k derived from the message hash and private key), or better Ed25519, which has that property by design.
It is deterministic (dependent on neither an RNG nor a unique nonce), faster than ECDSA and far faster than RSA, small (32-byte keys, 64-byte signatures), and its reference implementation avoids secret-dependent branches and lookups, resisting side channels better.
The caveat: some FIPS 140 environments and older hardware still reject Ed25519, so you fall back to ECDSA P-256. That is the answer to "when would you not pick it?".
Java has EdDSA since 15 (JEP 339) and the X25519/X448 curves since 11 (JEP 324):
KeyPairGenerator kpg = KeyPairGenerator.getInstance("Ed25519");
KeyPair kp = kpg.generateKeyPair();
Signature s = Signature.getInstance("Ed25519");
s.initSign(kp.getPrivate());
s.update("transfer 100 to account 42".getBytes(UTF_8));
byte[] signature = s.sign(); // 64 bytes
Signature v = Signature.getInstance("Ed25519");
v.initVerify(kp.getPublic());
v.update("transfer 100 to account 42".getBytes(UTF_8));
boolean ok = v.verify(signature); // trust this boolean; never compare bytes yourself
// key agreement — never use the raw secret as an AES key, run it through HKDF
KeyAgreement ka = KeyAgreement.getInstance("X25519");
ka.init(myPrivateKey);
ka.doPhase(theirPublicKey, true);
byte[] sharedSecret = ka.generateSecret();
Since Java 21 there is also a standard KEM API (JEP 452):
KEM kem = KEM.getInstance("DHKEM");
KEM.Encapsulated e = kem.newEncapsulator(recipientPublicKey).encapsulate();
SecretKey senderKey = e.key(); // a fresh symmetric key
byte[] capsule = e.encapsulation(); // send this over the wire
SecretKey receiverKey = kem.newDecapsulator(recipientPrivateKey).decapsulate(capsule);
8. Hashing: a fingerprint of data
A fingerprint is small and fixed-length. You cannot rebuild the person from it, and finding two people sharing one must be practically impossible.
A cryptographic hash maps any input to a fixed-length output and has three properties:
- Preimage resistance: given
hyou cannot find anmwithhash(m) = h(cost ≈ 2^256 for SHA-256). - Second preimage resistance: given
m1you cannot findm2 ≠ m1with the same hash. - Collision resistance: you cannot find any pair
m1 ≠ m2with the same hash. Here the birthday paradox bites: the cost is only ≈ 2^(n/2), i.e. 2^128 for SHA-256.
A fourth property is the avalanche effect: flipping one input bit changes about half the output bits.
| Algorithm | Output | Status | Reality |
|---|---|---|---|
| MD5 | 128 bits | Completely broken | Collisions in seconds; the Flame malware forged a code-signing certificate with a chosen-prefix collision in 2012 |
| SHA-1 | 160 bits | Broken | 2017 SHAttered (first practical collision); 2020 "SHA-1 is a Shambles", the first chosen-prefix collision for roughly US$45,000 |
| SHA-256 / SHA-512 | 256 / 512 bits | Secure | The SHA-2 family, today's default |
| SHA-3 / SHAKE | Variable | Secure | Sponge construction, diversity against a sudden SHA-2 break |
Know the difference between an identical-prefix and a chosen-prefix collision: in the first the attacker builds both documents; in the second he takes two arbitrary, different prefixes (say two contracts with different amounts) and crafts suffixes making the hashes equal. The second is the dangerous one, practical against SHA-1 since 2020 — which is why JDK 25 disabled SHA-1 signatures in TLS 1.2 handshakes by default.
The boundary most people lose: MD5 and CRC32 are still fine for accidental corruption (file checksums, partitioning, cache keys) — no adversary there. The moment an attacker chooses the input, they are out.
Length extension: SHA-1/SHA-256/SHA-512 use the Merkle–Damgård construction and the final output is the internal state. So an attacker knowing SHA256(secret || message) and the secret's length (but not the secret) adopts that value as internal state and produces a valid SHA256(secret || message || padding || evil). So never use hash(secret + data) as a MAC — a real bug in older signed APIs. The standard answer is HMAC (RFC 2104):
HMAC(K, m) = H( (K ⊕ opad) || H( (K ⊕ ipad) || m ) )
A useful side note: SHA-3 (sponge), SHA-512/256 and BLAKE2 are not vulnerable to length extension.
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest("hello".getBytes(UTF_8));
MessageDigest shake = MessageDigest.getInstance("SHAKE256-512"); // added in Java 25
9. Password hashing: a completely different problem
To sign a 10 GB file you want the hash very fast. For a password, speed is your enemy: the attacker who stole your database wants billions of guesses, so we deliberately pick a slow, memory-hungry function. A modern GPU does tens of billions of SHA-256 per second, but only tens of thousands of guesses against bcrypt at a proper work factor, and fewer against a tuned Argon2id, which runs out of RAM.
- salt: a random value unique per user, hashed with the password and stored beside the hash (not secret). It makes precomputed rainbow tables useless and stops two users with the same password sharing a hash — but it does not slow down guessing a specific user; that is the work factor's job.
- pepper: a global secret in the application or an HSM but never in the database; if only the database leaks, the attacker is stuck without it.
- work factor: the cost parameter; in bcrypt it is logarithmic, so cost=12 means 2^12 rounds.
- memory-hard: demanding significant RAM as well as CPU — exactly what erases the GPU and ASIC advantage.
| Algorithm | Year | GPU resistant | Suggested parameters (OWASP) | Verdict |
|---|---|---|---|---|
| Raw SHA-256 | — | No | — | Never for passwords |
| PBKDF2-HMAC-SHA256 | 2000 | Weak (CPU-hard only) | 600,000 iterations | When FIPS forces your hand |
| bcrypt | 1999 | Moderate (4 KB memory) | work factor ≥ 10 | Fine for existing systems |
| scrypt | 2009 | Good (memory-hard) | N=2^17, r=8, p=1 | When Argon2 is unavailable |
| Argon2id | 2015 | Best | m=19 MiB, t=2, p=1 (or m=46 MiB, t=1, p=1) | Default |
Argon2 won the 2015 Password Hashing Competition (RFC 9106); of its variants (Argon2d, Argon2i, Argon2id) the hybrid is the one to choose. Spring Security's Argon2PasswordEncoder defaults are salt=16, hash=32, p=1, m=16384 KiB, t=2, and BCryptPasswordEncoder defaults to strength=10.
Diagram: verifying a password and transparently upgrading its hash.
flowchart TD
A[Login request] --> B[Load stored hash by username]
B --> C{User exists?}
C -- No --> D[Run a dummy hash anyway] --> E[Generic error]
C -- Yes --> F[Verify password against stored hash]
F -- No --> E
F -- Yes --> G{Hash uses old params?}
G -- Yes --> H[Re-hash with current params and store]
G -- No --> I[Issue session]
H --> I
1. bcrypt's 72-byte limit. bcrypt sees only the first 72 bytes. If somebody "cleverly" SHA-512s the password first and feeds the 128-character hex output, the last 56 characters are ignored; and if the output is raw binary with a zero byte, some implementations truncate there. If you truly need a pre-hash, Base64 it — or simpler, use Argon2id.
2. User enumeration through timing. If the user does not exist and you return immediately the response takes 2 ms; if the user exists it takes 250 ms because Argon2 ran. That difference alone yields a list of valid accounts; run a dummy hash on the "not found" path too.
3. Comparing with equals. Arrays.equals returns early and leaks timing. For MACs, tags, tokens and one-time codes use MessageDigest.isEqual, which is deliberately time-constant.
Today's safe parameters are tomorrow's weak ones. The only moment you hold the raw password is a successful login — so right there, if the stored hash used old parameters, re-hash with the current ones and store it. In Spring Security that is what DelegatingPasswordEncoder and the {argon2} / {bcrypt} prefixes do (see spring-security).
10. HMAC and the encrypt/MAC ordering
A MAC (Message Authentication Code) is a short tag computed with a shared key: "this came from a key holder and was not modified".
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(macKey, "HmacSHA256"));
mac.update(payload);
byte[] tag = mac.doFinal();
boolean valid = MessageDigest.isEqual(tag, receivedTag); // time-constant
Diagram: the correct ordering — authenticate the ciphertext, verify before decrypting.
flowchart LR
P[Plaintext] --> E[Encrypt] --> C[Ciphertext]
C --> M[MAC over ciphertext] --> T[Tag]
C --> S[Send C + T]
T --> S
S --> V{Tag valid?}
V -- No --> R[Reject before decrypting]
V -- Yes --> D[Decrypt]
| Ordering | Used by | Problem | Verdict |
|---|---|---|---|
| Encrypt-then-MAC | IPsec, modern designs | None; it has a security proof | Correct |
| MAC-then-Encrypt | TLS up to 1.2 | You must decrypt before checking the MAC → padding oracle, Lucky Thirteen | Dangerous |
| Encrypt-and-MAC | SSH | The MAC is over the plaintext and can leak information about it | Weak |
With encrypt-then-MAC the receiver checks the tag before anything else and never opens the ciphertext if it fails, so no attacker-controlled data enters the decryption and padding path — the cryptographic doom principle. But the complete answer is: "encrypt-then-MAC is correct, yet I never compose it myself; AES-GCM or ChaCha20-Poly1305 already does it in one API."
11. Digital signatures and non-repudiation
A MAC is a stamp we both own a copy of. If a stamped message arrives, I know it came from you or from me — but I cannot prove to a court that you sent it, because I could have made it myself.
A digital signature is a mark only one person can make and everyone can check. I cannot forge it, so you cannot deny it. That is non-repudiation.
| Property | HMAC | Digital signature |
|---|---|---|
| Key type | One shared key | Public/private key pair |
| Who can verify | Only holders of the same key | Anyone with the public key |
| Non-repudiation | No | Yes |
| Speed | Very fast (microseconds) | Slower (milliseconds) |
| Size | 32 bytes | 64 bytes (Ed25519) to 256 bytes (RSA-2048) |
| Typical use | Session cookies, webhook signing, internal JWTs (HS256) |
TLS certificates, artifact signing, cross-organisation JWTs (RS256/ES256) |
Diagram: signing hashes first, then applying the private key.
sequenceDiagram
participant Signer
participant Verifier
Note over Signer: digest = SHA-256(document)
Signer->>Signer: signature = sign(privateKey, digest)
Signer->>Verifier: document + signature
Note over Verifier: recompute digest from document
Verifier->>Verifier: verify(publicKey, digest, signature)
alt valid
Verifier-->>Signer: accepted
else invalid
Verifier-->>Signer: rejected - tampered or wrong signer
end
A signature is always over a hash, never the document itself — for size and performance.
1. Never read the algorithm from the message itself. The JWT alg: none attack was exactly that: the server read alg from the very token it was verifying. The subtler variant is "algorithm confusion": the attacker switches RS256 to HS256 and uses the RSA public key as the HMAC key — and since it is public, the signature verifies. The expected algorithm must be fixed in server configuration. (JWT details in ms-security.)
2. A valid signature means "untampered", not "authorised". A correctly signed webhook may still be a replay; always put a timestamp and unique id inside the signed data.
12. The JCA/JCE architecture in Java
Java is not itself a cryptographic algorithm; it defines a standard socket: you tell Cipher, MessageDigest and Signature what you want and the JCA finds a provider implementing it — SunJCE in software, SunPKCS11 for an HSM or smartcard, BouncyCastle for extras — so you can swap software for a FIPS-validated module without touching code. JCA is the provider framework plus signature/digest/key-generation classes; JCE is the encryption part. Since Java 9 they live in java.base, and since 8u161 the "unlimited strength policy files" are gone.
A transformation string means algorithm / mode / padding; write only "AES" and the provider decides — in SunJCE that decision is ECB.
| Class | What it does | getInstance examples |
|---|---|---|
SecureRandom |
Secure random bytes | new SecureRandom(), "DRBG" |
KeyGenerator |
Symmetric keys | "AES", "HmacSHA256" |
KeyPairGenerator |
Asymmetric key pairs | "RSA", "EC", "Ed25519", "X25519" |
Cipher |
Encrypt/decrypt | "AES/GCM/NoPadding", "ChaCha20-Poly1305" |
MessageDigest |
Hashing | "SHA-256", "SHA3-256", "SHAKE256-512" |
Mac |
Keyed MAC | "HmacSHA256" |
Signature |
Sign and verify | "Ed25519", "SHA256withECDSA", "RSASSA-PSS" |
KeyAgreement / KEM |
Key agreement and encapsulation | "X25519", "DHKEM" (Java 21+) |
KDF (Java 25+) |
Key derivation | "HKDF-SHA256" |
$JAVA_HOME/conf/security/java.security is the control centre: jdk.tls.disabledAlgorithms (JDK 25 disabled SHA-1 signatures in TLS 1.2 by default), jdk.certpath.disabledAlgorithms, jdk.crypto.disabledAlgorithms (JDK 26, at the JCA layer) and securerandom.strongAlgorithms. Override with -Djava.security.properties=..., debug with -Djava.security.debug=all.
On BouncyCastle: do not add it by default; bring it in only for what the JDK lacks (Argon2, CMS/PKCS#7, a FIPS-certified build) — then pin and monitor its version.
13. Command cheat sheet
| Task | Command |
|---|---|
| Secure random bytes | openssl rand -base64 32 |
| RSA-3072 key | openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out key.pem |
| Ed25519 key | openssl genpkey -algorithm ed25519 -out ed25519.pem |
| Extract the public key | openssl pkey -in key.pem -pubout -out pub.pem |
| Encrypt with RSA-OAEP | openssl pkeyutl -encrypt -inkey pub.pem -pubin -in dek.bin -out dek.enc -pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha256 |
| Decrypt with RSA-OAEP | openssl pkeyutl -decrypt -inkey key.pem -in dek.enc -out dek.bin -pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha256 |
| Sign a file | openssl dgst -sha256 -sign key.pem -out doc.sig doc.txt |
| Verify a signature | openssl dgst -sha256 -verify pub.pem -signature doc.sig doc.txt |
| Keystore with an EC key | keytool -genkeypair -alias api -keyalg EC -groupname secp256r1 -keystore ks.p12 -storetype PKCS12 |
One important trap: openssl enc does not support AEAD modes, so openssl enc -aes-256-gcm gets you nowhere — its streaming design cannot verify the tag before processing all the data. If you must use enc, always pass -pbkdf2 -iter 600000.
14. Gallery of classic mistakes
Cipher.getInstance("AES")→ ECB. Always the full transformation.- A fixed IV/nonce or a counter that resets → nonces repeat after a restart; a counter must be persisted and atomic.
- A key in code or an env var with no rotation → git history remembers forever.
- A password used as an AES key without a KDF →
"password123".getBytes()truncated to 16 bytes carries a handful of entropy bits. - CBC without a MAC → padding oracle.
- Swallowing
AEADBadTagException→ switching off the burglar alarm. Arrays.equalsfor tags/MACs/tokens → timing attack; useMessageDigest.isEqual.- SHA-256 for passwords → billions of guesses per second on a GPU.
- One shared salt for all users → a single rainbow table covers the whole database.
hash(secret || message)as a MAC → length extension; write HMAC.- Reading
algfrom the token itself →alg: noneand algorithm confusion.
15. On the horizon: post-quantum cryptography
An attacker today cannot open your TLS traffic, but can store it and open it ten years from now: harvest now, decrypt later.
Shor's algorithm on a large enough quantum computer breaks RSA and ECC in one stroke, since both rest on factoring and discrete logarithms. Grover's gives only a square-root speedup, so AES-256 and 384-bit-and-above hashes stay effectively safe — say this in an interview; many assume symmetric cryptography dies too.
In August 2024 NIST published FIPS 203 — ML-KEM (Kyber) for key exchange, FIPS 204 — ML-DSA (Dilithium) for signatures, and FIPS 205 — SLH-DSA (SPHINCS+) as a hash-based backup. NIST IR 8547 then set the timeline: classical public-key algorithms (RSA, ECDSA, EdDSA, ECDH) become deprecated after 2030 and disallowed after 2035.
Java followed: JDK 24 brought ML-KEM (JEP 496) and ML-DSA (JEP 497); JDK 25 finalised the KDF API (JEP 510), added SHAKE128-256/SHAKE256-512 and disabled SHA-1 TLS 1.2 signatures; JDK 26 added HPKE via HPKEParameterSpec on Cipher (RFC 9180), a second PEM API preview (JEP 524), and jarsigner -sigalg ML-DSA-65.
You do not have to move everything to PQC tomorrow. What a senior does today: keep algorithms in configuration, not scattered constants; store a version/algorithm id next to every ciphertext; keep a "cryptographic inventory" of what algorithm and key size is used where; and rehearse the rotation path, not just document it.
Because the real interview question is not "how does Kyber work?" but "if algorithm X is declared broken tomorrow, how long does your system take to migrate?".
Interview questions
In symmetric cryptography one key both encrypts and decrypts (AES): fast — gigabytes per second with AES-NI — but it does not solve key distribution. Asymmetric cryptography gives you a key pair, a public key everyone holds and a private key only you hold; distribution is solved, but it is orders of magnitude slower and the payload is capped by the modulus.
So every real protocol is hybrid: asymmetric to encapsulate a fresh symmetric key, symmetric for the data. TLS, PGP and cloud envelope encryption all work this way.
An IV makes encrypting the same plaintext under the same key produce different ciphertext each time. It is not secret and normally travels in the clear beside the ciphertext.
The difference is in the requirements: in CBC it must be unique and unpredictable (a predictable IV enabled BEAST); in GCM it need only be unique, and a counter beats randomness because it drives collision probability to zero. The consequences differ too: a repeated CBC IV leaks limited information about a shared prefix; a repeated GCM nonce lets an attacker recover the authentication subkey and forge a valid tag for any message.
With random 96-bit nonces the safe ceiling is roughly 2^32 invocations per key (NIST SP 800-38D), beyond which nonce-collision probability exceeds the acceptable bound; a busy service can consume four billion messages in months. The second limit is per message: about 64 GB.
The remedies: key rotation driven by a usage counter (not just a date), a unique counter nonce instead of a random one, or deriving a fresh per-message key with HKDF. Quoting this number signals you did not just copy an API snippet.
HMAC uses a shared key: anyone who can verify can also generate, so it gives authenticity but no non-repudiation. A signature is made with a private key and verified with a public key; everyone can verify, nobody else can produce it.
Practically: inside one service's trust boundary (session cookies, internal request signing, webhooks with a shared secret) HMAC is safer and hundreds of times faster. Across organisations, for certificates and artifact signing, and anywhere "who created this" must be provable independently, you need a signature — and distributing the verification key shares no secret at all.
SHA-256 is deliberately fast. The property that makes it great for checksums makes it terrible for passwords: a modern GPU computes tens of billions per second, making dictionary attacks on human passwords cheap.
A salt is a random per-user value stored beside the hash (not secret). It makes precomputed rainbow tables useless and stops two users with the same password sharing a hash, so the attacker cannot amortise the attack across all users. But a salt does not slow down guessing one specific user — that is the work factor's job. The complete answer is "a unique salt plus a deliberately slow, memory-hard algorithm".
Argon2id is the default: winner of the Password Hashing Competition, documented in RFC 9106, and memory-hard — the attacker must spend real RAM per guess, erasing the GPU and ASIC advantage. OWASP's minimum: m=19 MiB, t=2, p=1 (or m=46 MiB, t=1, p=1).
bcrypt is acceptable for existing systems with a work factor of at least 10; its limits are the 72-byte truncation and only 4 KB of memory. PBKDF2 is for FIPS constraints or a bare JDK: roughly 600,000 iterations with HMAC-SHA256, weak because it is CPU-hard only. The senior point: measure on your own production hardware and tune for 250–500 ms per verification.
java.util.Random is an LCG with 48 bits of state; a few observed outputs let you reconstruct it and predict everything that follows. SecureRandom is a CSPRNG seeded from the OS entropy source, designed so past output reveals nothing about future output — use it for every key, IV, nonce, salt, session token and OTP.
getInstanceStrong() returns an instance from the securerandom.strongAlgorithms list, which on Linux may map to a blocking source and stall startup in a fresh, low-entropy container. Rule: new SecureRandom() for everyday work, getInstanceStrong() for long-lived, highly sensitive keys. And never use a fixed seed "for reproducible tests"; inject the random source instead.
private static final byte[] KEY = "0123456789abcdef".getBytes();
private static final byte[] IV = new byte[16];
public String encrypt(String data) throws Exception {
Cipher c = Cipher.getInstance("AES/CBC/PKCS5Padding");
c.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(KEY, "AES"),
new IvParameterSpec(IV));
return Base64.getEncoder().encodeToString(c.doFinal(data.getBytes()));
}
In order of severity: (1) a hardcoded key, and worse, a guessable string with almost no entropy — it should come from a KMS/HSM or a KDF and be rotatable. (2) A fixed all-zero IV → the same data always yields the same ciphertext, so an attacker learns which rows share a value. (3) CBC with no authentication → the data is malleable, and distinguishable decryption errors give a padding oracle. (4) getBytes() with no charset → depends on the JVM default; use getBytes(StandardCharsets.UTF_8). (5) A secret in a String → it survives in heap dumps. (6) If this Cipher is ever held in a field, it is not thread-safe.
The correct version: key from a KMS, AES/GCM/NoPadding, a fresh 12-byte nonce from SecureRandom per call, a 128-bit tag, the record id as AAD, and an output layout of keyId || nonce || ciphertext+tag.
Priority order: an HSM or managed KMS (the key never leaves) > a secret manager with access control and auditing > an environment variable injected at runtime > (never) a config file in the repo.
The practical pattern is envelope encryption: encrypt data with a random DEK, wrap the DEK with a KEK inside the KMS, store the wrapped DEK beside the data; rotation then only re-wraps DEKs. What makes rotation possible in practice: a key_id column next to every ciphertext, several active keys at once (the new one for writes, older ones still readable), and a background migration job. Above all the rotation path must be rehearsed — a team that has never rotated cannot rotate on incident day.
Shor's algorithm solves factoring and discrete logarithms in polynomial time, so RSA, ECDSA, EdDSA, ECDH and Diffie-Hellman all fall — larger keys do not save them. Grover's algorithm gives only a square-root improvement on brute force: AES-256 effectively drops to a 128-bit security level, still perfectly safe, and 384-bit-and-above hashes remain safe — so symmetric cryptography and hashing do not die.
Today's real risk is "harvest now, decrypt later": for data that must stay confidential fifteen years it is already here. NIST published FIPS 203/204/205 in 2024 and Java has shipped ML-KEM and ML-DSA since 24. The common approach today is hybrid: run a classical and a post-quantum algorithm together so that if one breaks, the other holds.
- Cryptography delivers three different things: confidentiality, integrity and authenticity. Always ask which one you need; Base64 is none of them.
- Symmetric (AES) is fast but has a key-distribution problem; asymmetric (RSA/ECC) solves it but is slow and limited. Every real system is hybrid.
- With AES only the mode matters: never ECB, CBC only with a MAC, GCM by default — a unique 12-byte nonce, a 128-bit tag, and a 2^32 message cap per key.
- AEAD (GCM or ChaCha20-Poly1305) does encryption and authentication together; do not compose them yourself.
- With RSA always OAEP with explicit parameters and PSS for signatures; RSA wraps keys, not data. Ed25519 is the modern signing choice.
- Hashing ≠ password hashing. For passwords use Argon2id with a unique salt and measured parameters; MD5 and SHA-1 are dead for any security purpose.
- HMAC for shared-key authenticity, a digital signature when you need non-repudiation; compare with
MessageDigest.isEqual. - In Java write the full transformation, never share a
Cipher, keep secrets out ofString, and never swallowAEADBadTagException. - Real security lives in key management: envelope encryption, a key id beside the data, a rehearsed rotation path and crypto agility — because the next question is always "what will you do the day this algorithm breaks?".