SSD Nodes Learn Hosting plans →
راهنماها Matt Connorتوسط Matt Connor

راهنمای پیاده‌سازی mTLS در Nginx با استفاده از OpenSSL

با استفاده از OpenSSL یک CA خصوصی بسازید و با تنظیم ssl_verify_client در Nginx، دسترسی به پنل مدیریت را فقط برای دارندگان گواهی معتبر ممکن کنید. رفع خطای 400 Bad Request.

مفهوم mTLS

پروتکل Mutual TLS که معمولاً mTLS نامیده می‌شود، Nginx را وادار می‌کند تا از هر کلاینت یک گواهی درخواست کند و در صورت نبود گواهی یا صادر نشدن آن توسط یک مرجع صدور گواهی (CA) تحت کنترل شما، درخواست را رد کند. این بررسی در حین handshake پروتکل TLS (امنیت لایه انتقال) انجام می‌شود، بنابراین کلاینتی که گواهی معتبر ندارد، هرگز به اپلیکیشن شما دسترسی پیدا نمی‌کند. جذابیت این روش در همین است: یک پنل مدیریت یا endpoint مربوط به متریک‌ها می‌تواند بدون صفحه ورود و بدون هیچ نقطه قابل حدسی برای ربات‌ها، روی اینترنت عمومی قرار بگیرد.

پیاده‌سازی آن ساده است. یک CA خصوصی که با openssl ساخته شده، یک گواهی برای هر شخص، و سه دستورالعمل در بلاک server در Nginx. کاری که تعیین می‌کند این سیستم یک سال دوام بیاورد یا خیر، جنبه عملیاتی آن است؛ بنابراین بخش عمده این راهنما به طول عمر گواهی‌ها، ابطال (revocation)، گواهی‌های اختصاصی برای هر فرد، و نحوه عیب‌یابی در زمانی که دسترسی کلاینتی رد می‌شود و دلیل آن مشخص نیست، می‌پردازد.

دو زنجیره، نه یکی

در یک پیکربندی mTLS دو زنجیره گواهی وجود دارد که هیچ ارتباطی با یکدیگر ندارند. ادغام این دو، نخستین اشتباهی است که تقریباً همه مرتکب می‌شوند.

زنجیره اول متعلق به سرور است. VPS شما گواهی مربوط به admin.example.com را که توسط یک CA عمومی مانند Let's Encrypt صادر شده است ارائه می‌دهد و مرورگر آن را با root store موجود در سیستم‌عامل تطبیق می‌دهد. mTLS هیچ تغییری در این بخش ایجاد نمی‌کند. اگر certbot امروز آن گواهی را برای شما صادر می‌کند، آن را دقیقاً به همان شکل حفظ کنید: به صدور گواهی Let's Encrypt برای nginx با certbot مراجعه کنید.

زنجیره دوم متعلق به کلاینت است. شما یک CA کوچک برای خود ایجاد می‌کنید، برای هر شخصی که نیاز به دسترسی دارد یک گواهی امضا می‌کنید و به nginx می‌گویید که هنگام بررسی کلاینت‌ها، فقط به آن CA اعتماد کند. هیچ root store عمومی، CA شما را نمی‌شناسد و نیازی هم به شناختن آن ندارد. تنها موجودیتی که باید به آن اعتماد کند، nginx است که این کار را از طریق فایل ssl_client_certificate انجام می‌دهد.

بنابراین ssl_client_certificate هرگز بر گواهی‌ای که nginx ارائه می‌دهد تأثیری ندارد و زنجیره Let's Encrypt نیز هرگز بر اینکه کدام کلاینت‌ها اجازه ورود دارند، تأثیرگذار نیست. اشاره کردن ssl_client_certificate به fullchain.pem آن کاری را که به نظر می‌رسد انجام نمی‌دهد: این دستور مشخص می‌کند که گواهی کلاینت ممکن است از چه صادرکنندگانی باشد، که این سمت دیگر اتصال است. اعتماد کردن خودِ سرور به CA شما برای فعالیت‌های خروجی‌اش، یک وظیفه جداگانه است که در افزودن CA شخصی به trust store در Ubuntu پوشش داده شده است؛ و trust store سیستم، همان چیزی نیست که nginx هنگام تأیید کلاینت می‌خواند.

ساخت CA کلاینت اختصاصی با openssl

CA را در مکانی غیر از وب‌سرور بسازید. nginx تنها به گواهی عمومی CA نیاز دارد. کلید خصوصی CA برای امضای گواهی‌های جدید کلاینت استفاده می‌شود؛ بنابراین، نگهداری آن روی سروری که در معرض اینترنت است، به این معناست که با یک نفوذ، مهاجم می‌تواند به‌دلخواه برای خود گواهی‌های کلاینت معتبر صادر کند.

mkdir -p ~/client-ca/certs ~/client-ca/newcerts ~/client-ca/private ~/client-ca/csr
cd ~/client-ca
chmod 700 private
touch index.txt
echo 1000 > serial
echo 1000 > crlnumber

فایل‌های index.txt، serial و crlnumber پایگاه‌داده CA هستند. openssl ca بدون وجود آن‌ها اجرا نمی‌شود. این فایل‌ها همچنین امکان ابطال گواهی در آینده را فراهم می‌کنند، زیرا لیست ابطال (CRL) بر اساس شماره سریال است و CA باید بداند هر شماره سریال به چه کسی اختصاص یافته است.

فایل ~/client-ca/openssl.cnf را بنویسید. مقدار dir را روی مسیر واقعی آن دایرکتوری تنظیم کنید، زیرا openssl ca متغیر ~ را بسط نمی‌دهد.

[ ca ]
default_ca = client_ca

[ client_ca ]
dir               = /home/you/client-ca
database          = $dir/index.txt
new_certs_dir     = $dir/newcerts
certificate       = $dir/ca.crt
private_key       = $dir/private/ca.key
serial            = $dir/serial
crlnumber         = $dir/crlnumber
default_md        = sha256
default_days      = 365
default_crl_days  = 30
policy            = policy_loose
rand_serial       = no
unique_subject    = no
email_in_dn       = no

[ policy_loose ]
commonName              = supplied
countryName             = optional
stateOrProvinceName     = optional
organizationName        = optional
organizationalUnitName  = optional
emailAddress            = optional

[ client_ext ]
basicConstraints        = CA:FALSE
keyUsage                = critical, digitalSignature, keyEncipherment
extendedKeyUsage        = clientAuth
subjectKeyIdentifier    = hash
authorityKeyIdentifier  = keyid,issuer

اکنون کلید CA و گواهی خودامضای آن را بسازید:

openssl genrsa -aes256 -out private/ca.key 4096
chmod 600 private/ca.key
openssl req -x509 -new -key private/ca.key -sha256 -days 3650 \
  -subj "/O=Example Ops/CN=Example Ops Client CA" \
  -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
  -addext "keyUsage=critical,keyCertSign,cRLSign" \
  -out ca.crt

دستور -aes256 یک رمز عبور روی کلید CA قرار می‌دهد، بنابراین در هر بار عملیات امضا، رمز عبور درخواست می‌شود. هدف از این کار همین است. آنچه ساخته‌اید را بررسی کنید:

openssl x509 -in ca.crt -noout -subject -dates -ext basicConstraints

موضوع (Subject) باید CA شما باشد و اعتبار آن باید ده سال در نظر گرفته شود. خط extension باید شامل CA:TRUE, pathlen:0 باشد. مقدار pathlen:0 به این معناست که این CA می‌تواند گواهی‌های نهایی را امضا کند اما اجازه امضای یک CA دیگر را ندارد؛ این کار باعث می‌شود زنجیره دقیقاً در یک سطح باقی بماند و نیازی به تغییر ssl_verify_depth نداشته باشید.

برای هر فرد یک client certificate صادر کنید

برای هر فرد فقط یک certificate صادر کنید. هرگز از یک certificate مشترک برای یک تیم استفاده نکنید، زیرا certificate مشترک را نمی‌توان بدون قطع دسترسی همه revoke کرد و از روی آن مشخص نمی‌شود چه کسی درخواست را ارسال کرده است.

openssl genrsa -out private/alice.key 2048
openssl req -new -key private/alice.key -out csr/alice.csr \
  -subj "/O=Example Ops/CN=alice"
openssl ca -config openssl.cnf -extensions client_ext \
  -days 365 -notext -in csr/alice.csr -out certs/alice.crt

openssl ca گواهی‌ای را که قرار است sign کند چاپ می‌کند، passphrase مربوط به CA را می‌پرسد، دو بار برای تأیید درخواست می‌کند و سپس یک خط به index.txt اضافه می‌کند. هنگام script کردن، -batch را اضافه کنید. بخش client_ext به دلیل یک خط در آن اهمیت دارد: extendedKeyUsage = clientAuth. اگر certificate در فهرست extended key usage فقط serverAuth را داشته باشد، برای client authentication نامعتبر تشخیص داده می‌شود. بنابراین هدف استفاده را صریحاً مشخص کنید و به مقدار پیش‌فرض اتکا نکنید.

پیش از تحویل هر چیزی، این جفت را در برابر CA بررسی کنید:

openssl verify -CAfile ca.crt certs/alice.crt

این دستور certs/alice.crt: OK را چاپ می‌کند. هر خروجی دیگری به این معناست که certificate و CA با هم مطابقت ندارند و هیچ پیکربندی nginx نمی‌تواند این مشکل را برطرف کند.

کلید و certificate را در یک فایل واحد bundle کنید تا browser بتواند آن را import کند:

openssl pkcs12 -export -inkey private/alice.key -in certs/alice.crt \
  -name "alice at example ops" -out alice.p12

در فرایند export از شما password خواسته می‌شود تا فایل هنگام انتقال محافظت شود. فایل و password را از دو کانال متفاوت ارسال کنید و به کاربران .p12 را تحویل دهید، نه یک .key ساده. می‌توانید برای قراردادن CA در bundle، -certfile ca.crt را اضافه کنید، اما nginx به آن نیاز ندارد: nginx از قبل ca.crt را در اختیار دارد، بنابراین certificateای که مستقیماً توسط آن CA sign شده باشد، به‌تنهایی قابل اعتبارسنجی است.

OpenSSL 3، که Ubuntu 24.04 آن را عرضه می‌کند، فایل‌های PKCS#12 را به‌طور پیش‌فرض با AES-256-CBC رمزگذاری می‌کند و برای استخراج کلید از PBKDF2 و برای MAC از SHA256 استفاده می‌کند. Windows 11 این قالب را import می‌کند. طبق گزارش‌های متعدد در forum پرسش‌وپاسخ Microsoft و گزارش vendorهایی مانند Esri، نسخه‌های قدیمی‌تر Windows Server، از جمله 2016 و 2012 R2، این قالب را رد می‌کنند و wizard مربوط به import حتی با password صحیح، پیام The password you entered is incorrect را نمایش می‌دهد. راه‌حل ارائه‌شده در این گزارش‌ها، قالب قدیمی‌تر TripleDES-SHA1 است. -legacy به‌تنهایی این قالب را تولید نمی‌کند: manual مربوط به openssl-pkcs12 می‌گوید حالت legacy، کلید خصوصی را با 3DES و certificateها را، در صورتی که build شامل RC2 باشد، با RC2 رمزگذاری می‌کند؛ build مربوط به Ubuntu نیز شامل RC2 است و MAC همچنان به‌طور پیش‌فرض SHA256 باقی می‌ماند. برای این سیستم‌ها، فایل را با هر سه گزینه دوباره export کنید:

openssl pkcs12 -export -legacy -descert -macalg sha1 \
  -inkey private/alice.key -in certs/alice.crt \
  -name "alice at example ops" -out alice-legacy.p12

ترکیب -descert با -legacy هم کلید و هم certificateها را با triple DES رمزگذاری می‌کند و -macalg sha1 مقدار MAC را روی SHA-1 تنظیم می‌کند؛ این دو تنظیم همان قالبی را ایجاد می‌کنند که Windows آن را TripleDES-SHA1 می‌نامد. این فایل را فقط به ماشین‌هایی تحویل دهید که به آن نیاز دارند، زیرا triple DES حفاظت ضعیف‌تری ارائه می‌دهد.

پیکربندی nginx با ssl_client_certificate و ssl_verify_client

گواهی CA را، و فقط گواهی CA را، روی سرور کپی کنید.

scp ca.crt user@admin.example.com:/tmp/client-ca.crt
ssh user@admin.example.com \
  'sudo install -o root -g root -m 644 /tmp/client-ca.crt /etc/nginx/client-ca.crt'

در اینجا حالت 644 صحیح است. گواهی CA اطلاعات عمومی است. کلید CA روی workstation شما باقی می‌ماند.

سپس سه directive را به server blockای اضافه کنید که هم‌اکنون TLS termination را انجام می‌دهد:

server {
    listen 443 ssl;
    server_name admin.example.com;

    ssl_certificate     /etc/letsencrypt/live/admin.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/admin.example.com/privkey.pem;

    ssl_client_certificate /etc/nginx/client-ca.crt;
    ssl_verify_client      on;
    ssl_verify_depth       1;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

ssl_verify_depth 1 مقدار پیش‌فرض nginx است. OpenSSL نه گواهی client و نه CA مورداعتماد را در این عمق محاسبه نمی‌کند؛ بنابراین مقدار 1 همچنان وجود یک intermediate CA بین آن‌ها را مجاز می‌کند. pathlen:0 مربوط به CA شما از قبل intermediateها را رد می‌کند، بنابراین مقدار پیش‌فرض کافی است. فقط زمانی این مقدار را افزایش دهید که زنجیرهٔ client بیش از یک intermediate داشته باشد. nginx همچنین نام‌های subject را از ssl_client_certificate هنگام handshake برای client ارسال می‌کند. به این ترتیب، مرورگر می‌داند کدام‌یک از گواهی‌های خود را ارائه کند. به همین دلیل باید از ssl_client_certificate به‌جای ssl_trusted_certificate استفاده کنید؛ هر دو به یک شکل اعتبارسنجی می‌کنند، اما دومی هیچ فهرستی ارسال نمی‌کند.

Ubuntu 24.04، nginx 1.24 را ارائه می‌کند. در این نسخه، HTTP/2 به‌صورت listen 443 ssl http2; در خط listen قرار می‌گیرد. در nginx 1.25.1 و نسخه‌های بعدی، این شکل deprecated است و HTTP/2 directive مستقل خود، یعنی http2 on;، را دارد. هیچ‌یک از این دو انتخاب، بررسی گواهی را تغییر نمی‌دهد.

پیکربندی را reload کنید و نتیجه را بخوانید:

sudo nginx -t && sudo systemctl reload nginx
curl -i https://admin.example.com/

nginx -t، syntax is ok و test is successful را چاپ می‌کند. فراخوانی curl هیچ گواهی‌ای ارسال نمی‌کند؛ بنابراین باید با وضعیت 400 Bad Request و body برابر با No required SSL certificate was sent برگردد. در این حالت، nginx درخواست را در دروازهٔ خود رد کرده است. این یعنی پیکربندی فعال است و درخواست هرگز به application نرسیده است. اکنون درخواست را به‌درستی امتحان کنید:

curl --cert certs/alice.crt --key private/alice.key https://admin.example.com/

این درخواست باید همان چیزی را برگرداند که application شما ارائه می‌کند.

چرا گیت در بلاک server قرار می‌گیرد

گواهی در طول TLS handshake مبادله می‌شود، یعنی پیش از آنکه nginx خط درخواست را بخواند؛ بنابراین در آن لحظه nginx نمی‌داند درخواست به کدام location هدایت خواهد شد. قرار دادن ssl_verify_client on; درون یک location، کلاینت را وادار به مذاکره مجدد (renegotiation) در میانه اتصال می‌کند. پروتکل TLS 1.3 قابلیت renegotiation را حذف کرده و HTTP/2 نیز آن را ممنوع کرده است؛ در نتیجه در پشته‌های نرم‌افزاری امروزی، این الگو به‌جای درخواست گواهی، با شکست مواجه می‌شود.

محدوده‌بندی (scoping) را خودتان انجام دهید. گواهی را در سطح server درخواست کنید و سپس در سطح location تصمیم بگیرید:

ssl_verify_client optional;

location /metrics {
    if ($ssl_client_verify != SUCCESS) { return 403; }
    proxy_pass http://127.0.0.1:9090;
}

location /healthz {
    proxy_pass http://127.0.0.1:8080;
}

متغیر $ssl_client_verify حاوی SUCCESS است، یا اگر کلاینت چیزی ارسال نکند NONE، یا FAILED: به همراه دلیل آن. با استفاده از optional، nginx گواهی را درخواست کرده و تنها در صورت دریافت، آن را تایید می‌کند؛ این همان چیزی است که اجازه می‌دهد مسیر عمومی /healthz در بالا کار کند، در حالی که /metrics بسته باقی می‌ماند. گواهی‌ای که ارسال شده اما در تایید شکست می‌خورد، در همان نقطه توسط nginx رد می‌شود. اگر می‌خواهید خودتان گواهی‌های نامعتبر را بررسی کنید، از optional_no_ca استفاده کنید؛ در این صورت تست شما باید هر مقداری غیر از SUCCESS را به عنوان رد صلاحیت در نظر بگیرد.

nginx کدهای وضعیت غیر استانداردی برای این منظور دارد و error_page می‌تواند آن‌ها را دریافت کند تا بازدیدکننده رد شده، به‌جای یک خطای 400 ساده، توضیحی دریافت کند:

error_page 495 496 = @needcert;

location @needcert {
    default_type text/plain;
    return 200 "This host requires a client certificate. Ask ops for one.\n";
}

کد 495 به این معنی است که گواهی کلاینت در تایید شکست خورده است. کد 496 به این معنی است که کلاینت هیچ گواهی‌ای ارائه نکرده است. آن صفحه را به صورت متن ساده (plain text) نگه دارید، زیرا شخصی که آن را می‌خواند نه نشست (session) دارد و نه حساب کاربری.

چگونه گواهی کلاینت را در مرورگر نصب کنم؟

فایرفاکس از مخزن گواهی اختصاصی خود استفاده می‌کند: به بخش Settings، سپس Privacy and Security، و بعد View Certificates بروید. در تب Your Certificates، گزینه Import را انتخاب کرده و فایل .p12 را انتخاب کنید و رمز عبور آن را وارد نمایید.

مرورگرهای کروم و اج در ویندوز و macOS از مخزن گواهی سیستم‌عامل استفاده می‌کنند، بنابراین باز کردن فایل .p12، ویزارد وارد کردن گواهی سیستم را اجرا می‌کند. در لینوکس، کروم از یک دیتابیس جداگانه NSS (سرویس‌های امنیتی شبکه) در دایرکتوری home شما استفاده می‌کند و استفاده از ابزار خط فرمان، مطمئن‌ترین روش است:

sudo apt install -y libnss3-tools
pk12util -d sql:$HOME/.pki/nssdb -i alice.p12

پس از آن، سایت را باز کنید تا مرورگر از شما بپرسد کدام گواهی را ارسال کند. کروم این انتخاب را تا پایان نشست مرورگر به خاطر می‌سپارد، بنابراین اگر می‌خواهید دوباره از شما پرسیده شود، مرورگر را مجدداً راه‌اندازی کنید. گواهی در یک پروفایل مرورگر روی یک دستگاه ذخیره می‌شود؛ بنابراین گواهی وارد شده در فایرفاکس برای کروم قابل مشاهده نیست و هر دو برای گوشی شما نیز غیرقابل مشاهده هستند.

تست با curl --cert

برای عیب‌یابی از curl استفاده کنید، زیرا این ابزار گزارش می‌دهد که چه عملیاتی انجام داده است.

curl -v --cert certs/alice.crt --key private/alice.key https://admin.example.com/

شما می‌توانید گواهی و کلید را در یک فایل PEM واحد ترکیب کرده و آن را به عنوان --cert alice.pem ارسال کنید. اگر کلید دارای عبارت عبور (passphrase) باشد، curl آن را از شما می‌پرسد. همچنین می‌توانید از --cert alice.pem:passphrase استفاده کنید، اما این کار باعث می‌شود عبارت عبور در تاریخچه shell شما ذخیره شود؛ بنابراین بهتر است اجازه دهید curl آن را از شما بپرسد.

پیش از آنکه Nginx را مقصر بدانید، انجام دو بررسی ارزشمند است. نخست، گواهی و کلید باید یک جفت معتبر باشند:

openssl x509 -noout -pubkey -in certs/alice.crt | openssl sha256
openssl pkey -pubout -in private/alice.key | openssl sha256

دو هش یکسان به این معناست که فایل‌ها متعلق به یکدیگر هستند. دو هش متفاوت به این معناست که شما فایل‌های دو موجودیت مختلف را با هم اشتباه گرفته‌اید؛ هیچ کلاینتی دلیل این خطا را به شما اعلام نخواهد کرد.

دوم، سرور باید گواهی CA شما را درخواست کند:

openssl s_client -connect admin.example.com:443 -servername admin.example.com </dev/null

در خروجی به دنبال بلوک Acceptable client certificate CA names و موضوع (subject) مربوط به CA خود در داخل آن بگردید. اگر این بلوک کاملاً وجود ندارد، Nginx در آن server block که پاسخ داده است، درخواستی برای گواهی ارسال نمی‌کند؛ این یعنی دستورالعمل‌های شما در یک بلوک دیگر، که اغلب همان default server است، اعمال شده‌اند.

انتقال CN کلاینت به برنامه

گواهی مشخص می‌کند چه کسی درخواست را ارسال کرده است، اما برنامهٔ پشت proxy به لایهٔ TLS دسترسی ندارد؛ بنابراین nginx باید این نام را به آن منتقل کند.

map $ssl_client_s_dn $client_cn {
    default              "";
    "~,?CN=(?<cn>[^,]+)" $cn;
}

$ssl_client_s_dn نام متمایزکنندهٔ subject را در قالب RFC 2253 نگه می‌دارد؛ این مقدار شبیه CN=alice,O=Example Ops است. دستور map فیلد CN را در $client_cn قرار می‌دهد. CN را به‌صورت یک username ساده نگه دارید، زیرا وجود comma در CN در این قالب escape می‌شود و عبارت regular expression کوتاه بالا این escape را پردازش نمی‌کند.

location / {
    proxy_pass http://127.0.0.1:8080;
    proxy_set_header Host                 $host;
    proxy_set_header X-Forwarded-For      $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto    $scheme;
    proxy_set_header X-Client-Cert-CN     $client_cn;
    proxy_set_header X-Client-Cert-Serial $ssl_client_serial;
}

proxy_set_header هر header با این نام را که caller ارسال کرده باشد جایگزین می‌کند؛ بنابراین هیچ‌کس نمی‌تواند از طریق این location مقدار X-Client-Cert-CN را جعل کند. دو شرط این موضوع را تضمین می‌کنند. nginx فقط زمانی proxy_set_header را از سطح بیرونی به ارث می‌برد که سطح داخلی هیچ‌کدام از تنظیمات خود را نداشته باشد؛ بنابراین یک location دیگر که فقط یک خط proxy_set_header داشته باشد، همهٔ headerهای تنظیم‌شده در سطح بالاتر، ازجمله این مورد، را به‌صورت بی‌صدا از دست می‌دهد. همچنین برنامه باید فقط از طریق nginx قابل دسترسی باشد. این کار یعنی برنامه را به 127.0.0.1 متصل کنید، نه 0.0.0.0، یا کاری کنید روی یک Unix socket گوش دهد که فقط nginx بتواند آن را باز کند (proxy_pass http://unix:/run/app.sock:;)، زیرا برنامه‌ای که روی یک پورت عمومی اجرا شود، header جعلی را مستقیماً از اینترنت خواهد خواند. بخش مربوط به proxy در پیکربندی reverse proxy در nginx، خط‌به‌خط توضیح داده‌شده پوشش داده شده است. اگر برنامه به‌جای نام، کل گواهی را می‌خواهد، $ssl_client_escaped_cert آن را به‌صورت URL-encoded و ایمن داخل یک header منتقل می‌کند.

چگونه می‌توانم یک گواهی کلاینت را ابطال کنم؟

فردی سازمان را ترک می‌کند یا لپ‌تاپی گم می‌شود. شما فقط همان یک گواهی را ابطال می‌کنید و بقیه کاربران به کار خود ادامه می‌دهند؛ این دقیقاً همان دلیلی است که برای هر شخص یک گواهی مجزا صادر می‌کنیم.

cd ~/client-ca
openssl ca -config openssl.cnf -revoke certs/alice.crt
openssl ca -config openssl.cnf -gencrl -out crl.pem

دستور اول، وضعیت خط مربوط به آن شماره سریال را در index.txt از V به R تغییر می‌دهد. دستور دوم یک لیست ابطال گواهی (CRL) ایجاد می‌کند که فایلی امضاشده شامل شماره سریال‌های ابطال‌شده است. این فایل را منتقل کنید و با استفاده از ssl_crl /etc/nginx/client-ca.crl; در کنار سایر دستورالعمل‌ها، آن را به nginx معرفی کنید.

scp crl.pem user@admin.example.com:/tmp/client-ca.crl
ssh user@admin.example.com 'sudo install -m 644 /tmp/client-ca.crl /etc/nginx/client-ca.crl && sudo nginx -t && sudo systemctl reload nginx'

در اینجا تله‌ای وجود دارد که دسترسی همه را مسدود می‌کند. یک CRL دارای تاریخ nextUpdate است که توسط default_crl_days تعیین می‌شود (در پیکربندی بالا 30 روز). پس از گذشت این تاریخ، OpenSSL لیست را منقضی تلقی کرده و احراز هویت تمام گواهی‌های کلاینت را با خطای CRL has expired رد می‌کند، نه فقط گواهی ابطال‌شده را. nginx فایل را هنگام بارگذاری پیکربندی می‌خواند، بنابراین وجود یک CRL جدید روی دیسک تا زمانی که nginx مجدداً بارگذاری (reload) نشود، تغییری ایجاد نمی‌کند. لیست را در بازه‌های زمانی مطمئن (مثلاً هفتگی برای بازه 30 روزه) بازتولید و بارگذاری کنید و پیش از کپی کردن، تاریخ‌ها را بررسی کنید:

openssl crl -in crl.pem -noout -lastupdate -nextupdate

برای تعداد کمی از کاربران، گزینه ساده‌تری وجود دارد. از آنجا که CA متعلق به شماست، nginx می‌تواند مستقیماً یک شماره سریال را رد کند و از دردسرهای CRL صرف‌نظر کند:

map $ssl_client_serial $revoked {
    default 0;
    "1002"  1;
}

این مورد را با if ($revoked) { return 403; } در بلاک location جفت کنید. این روش تاریخ انقضایی ندارد که فراموش شود. همچنین این تنظیمات منتقل نمی‌شود، بنابراین هر سرویس دیگری که به CA شما اعتماد دارد، از این ابطال بی‌خبر خواهد بود. برای یک nginx که جلوی یک اپلیکیشن قرار دارد، این صادقانه‌ترین و ساده‌ترین پاسخ است. زمانی که بیش از یک دروازه ورودی داشتید، به سراغ استفاده از CRL بروید.

مدت اعتبار گواهی‌های کلاینت چقدر باید باشد؟

به گواهی‌های کلاینت یک سال اعتبار بدهید، یا اگر زحمت صدور مجدد برایتان مقدور است، کمتر از یک سال در نظر بگیرید. انقضای گواهی در اینجا یک شکست خاموش محسوب می‌شود، زیرا هیچ هشداری پیش از موعد به دارنده گواهی داده نمی‌شود. کاربر یک روز صبح پنل را باز می‌کند، Nginx اتصال را رد می‌کند و مرورگر امتناع از اتصال را با عبارات خاص خود توصیف می‌کند که به‌ندرت شامل کلمه expired (منقضی‌شده) است. اعتبار CA را روی 10 سال تنظیم کنید و تاریخ انقضای آن را جایی یادداشت کنید که واقعاً آن را می‌خوانید؛ زیرا وقتی گواهی CA منقضی شود، تمام گواهی‌های زیرمجموعه آن در همان روز از اعتبار می‌افتند.

دو دستور زیر به شما کمک می‌کند تا از این وضعیت عقب نمانید:

openssl x509 -in certs/alice.crt -noout -subject -serial -enddate
awk -F'\t' '{print $1, $2, $4}' ~/client-ca/index.txt

ستون اول در index.txt نشان‌دهنده وضعیت است: V برای معتبر، R برای ابطال‌شده، E برای منقضی‌شده. ستون دوم تاریخ انقضا به فرمت YYMMDDHHMMSSZ و ستون چهارم شماره سریال است. آن فایل تنها سابقه شما از این است که چه کسی چه گواهی‌ای در اختیار دارد؛ بنابراین از آن به همراه کلید CA نسخه پشتیبان تهیه کنید و هر دو را به عنوان اطلاعات محرمانه در نظر بگیرید.

تمدید گواهی به معنای صدور یک گواهی جدید است، نه تمدید اعتبار گواهی قبلی. یک کلید و CSR (درخواست امضای گواهی) جدید تولید کنید، آن را امضا کرده و تحویل دهید، سپس پس از اینکه شخص تأیید کرد گواهی جدید کار می‌کند، گواهی قدیمی را ابطال کنید.

محافظت‌های mTLS و محدودیت‌های آن

آنچه mTLS حذف می‌کند، دسترسی‌های احرازنشده است. اسکنری که نام دامنه شما را پیدا می‌کند، در مرحله handshake رد می‌شود؛ بنابراین هرگز درخواست HTTP ارسال نمی‌کند، فرم ورود را نمی‌بیند و نمی‌تواند رمز عبور سرقت‌شده‌ای را امتحان کند. در این حالت، حملات credential stuffing عملاً بی‌اثر می‌شوند. آسیب‌پذیری‌های موجود در فرآیند ورود به برنامه نیز برای هر کسی که گواهی معتبر نداشته باشد، غیرقابل‌دسترسی است. همچنین، این روش مشکل به اشتراک‌گذاری رمزهای عبور در محیط‌های چت را حل می‌کند، زیرا کلید خصوصی فایلی است که کپی کردن تصادفی آن دشوار است.

mTLS در برابر کلاینت‌های آلوده هیچ محافظتی ارائه نمی‌دهد. بدافزار موجود روی لپ‌تاپ به فایل کلید دسترسی دارد و به محض اینکه کاربر رمز عبور (passphrase) را وارد کند، آن را در اختیار می‌گیرد. برای سرور، آن مهاجم دقیقاً مانند یک کاربر قانونی به نظر می‌رسد، زیرا گواهی فقط مالکیت یک فایل را اثبات می‌کند، نه حضور فیزیکی یک شخص را. بنابراین، استفاده از .p12 و رمزنگاری کامل دیسک همچنان اهمیت حیاتی دارند.

این پروتکل همچنین جایگزین مجوزدهی (authorization) نیست. هر گواهی معتبری به تمام محتوای آن بلوک سرور دسترسی دارد، مگر اینکه $client_cn را بررسی کرده و بر اساس مقدار آن عمل کنید. به‌طور پیش‌فرض، دو دارنده گواهی دسترسی یکسانی دارند.

علاوه بر این، mTLS فقط مسیر عبور از طریق Nginx را ایمن می‌کند. اگر برنامه شما روی یک پورت عمومی نیز گوش می‌دهد، mTLS در مقابل آن صرفاً جنبه تزئینی دارد: برنامه را به 127.0.0.1 متصل کنید و پورت آن را در فایروال ببندید. راه دیگر ورود به همان سرور، SSH است که نیازمند توجه مشابهی است و در ایمن‌سازی دسترسی SSH روی VPS به آن پرداخته شده است.

یک محدودیت نهایی وجود دارد که دقیقاً در روز فعال‌سازی با آن مواجه می‌شوید: هر سرویسی که نتواند گواهی ارائه دهد، از کار می‌افتد؛ مانند سرویس‌های مانیتورینگ uptime، وب‌هوک‌های درگاه پرداخت، RSS readerها یا اپلیکیشن‌های موبایلی که به مخزن گواهی‌های شما دسترسی ندارند. پیش از تنظیم ssl_verify_client on در مورد این موارد تصمیم‌گیری کنید، زیرا خرابی ناشی از آن کامل است و در سمت آن‌ها، هیچ خطای مشخصی گزارش نمی‌شود.

هنگامی که کلاینت رد می‌شود، گزارش کلاینت را بخوانید

پیامی که کلاینتِ ردشده نمایش می‌دهد به مرورگر، نسخه curl و کتابخانه TLS زیرساختی بستگی دارد؛ بنابراین به‌جای تطبیق آن با پیامی که در جای دیگری نوشته شده است، آنچه کلاینت خودتان چاپ می‌کند را بخوانید. جزئیات مفید در سمت سرور قرار دارد.

sudo tail -n 50 /var/log/nginx/error.log

یک گواهی ردشده، خطی شامل client SSL certificate verify error را به همراه دلیلی که OpenSSL ارائه کرده است، بر جای می‌گذارد. آن دلیل، واقعیتی است که باید بر اساس آن اقدام کنید. معمولاً این مورد یکی از چند مسئله زیر است: گواهی از یک CA متفاوت نسبت به فایلی که در ssl_client_certificate نام‌برده شده صادر شده است؛ گواهی خارج از تاریخ‌های اعتبار خود است؛ یا CRL موجود در سرور از nextUpdate خود عبور کرده است و در نتیجه، به‌جای یک کلاینت، همه کلاینت‌ها را رد می‌کند.

هنگامی که مرورگر اصلاً گواهی ارائه نمی‌دهد، مشکل پیش از مرحله احراز هویت رخ داده است. nginx نام‌های صادرکننده (issuer) قابل‌قبول را در طول handshake ارسال می‌کند و مرورگر چیزی در مخزن خود پیدا نکرده که با آن‌ها مطابقت داشته باشد، بنابراین چیزی برای ارائه به شما نداشته است. .p12 را مجدداً در پروفایلی که واقعاً با آن مرور می‌کنید، وارد (Import) کنید.

یک مورد دیگر که ارزش ذکر کردن دارد: اگر با یک گواهی کلاینت self-signed تکی تست کرده‌اید و نه گواهی‌ای که توسط CA شما امضا شده باشد، احراز هویت نمی‌تواند انجام شود؛ زیرا nginx امضا را با فایل CA تطبیق می‌دهد و یک گواهی self-signed در آن فایل وجود ندارد. مکانیسم ساخت گواهی همانند تولید گواهی self-signed در Ubuntu است. mTLS فقط به یک مرحله اضافی نیاز دارد که در آن CA شما گواهی را امضا کند.

FAQ

آیا در صورت استفاده از mTLS همچنان به گواهی Let's Encrypt نیاز دارم؟

بله. این دو گواهی هیچ ارتباطی به هم ندارند. سرور شما گواهی خود را ارائه می‌دهد تا مرورگر به نام دامنه اعتماد کند و این گواهی همچنان باید از یک CA صادر شده باشد که مرورگر از قبل آن را می‌شناسد. CA کلاینت شما یک زنجیره خصوصی مجزا است که فقط برای بررسی هویت اتصال‌دهنده استفاده می‌شود. تنظیم ssl_client_certificate هیچ تغییری در گواهی ارائه‌شده توسط nginx ایجاد نمی‌کند و نباید به زنجیره Let's Encrypt شما اشاره داشته باشد.

چرا مرورگر من هرگز از من نمی‌خواهد گواهی انتخاب کنم؟

nginx در طول فرآیند handshake لیستی از صادرکنندگان قابل‌قبول را ارسال می‌کند که از فایل موجود در ssl_client_certificate ساخته شده است. مرورگر فقط گواهی‌هایی را پیشنهاد می‌دهد که صادرکننده آن‌ها در آن لیست باشد. بنابراین، عدم نمایش درخواست به این معناست که مرورگر هیچ گواهی‌ای از CA شما در اختیار ندارد: یا وارد پروفایل مرورگر دیگری شده است، یا گواهی توسط CA متفاوتی نسبت به آنچه روی سرور نصب شده، امضا شده است. دستور openssl s_client -connect admin.example.com:443 را اجرا کنید و به دنبال نام‌های CA قابل‌قبول برای گواهی کلاینت در خروجی بگردید تا ببینید سرور در واقع چه CA را درخواست می‌کند.

آیا می‌توانم گواهی کلاینت را فقط برای یک URL خاص الزامی کنم؟

خیر، با استفاده از ssl_verify_client on در داخل یک location این کار ممکن نیست. گواهی در طول handshake و پیش از آنکه nginx مسیر درخواست را بداند مبادله می‌شود؛ همچنین قابلیت renegotiation که می‌توانست این محدودیت را دور بزند، در TLS 1.3 حذف شده و در HTTP/2 ممنوع است. دستور ssl_verify_client optional; را در بلاک server تنظیم کنید، سپس در هر location محافظت‌شده، مقدار $ssl_client_verify را تست کرده و در صورتی که برابر با SUCCESS نبود، خطای 403 برگردانید.

چگونه دسترسی یک نفر را لغو کنم؟

آن گواهی را با openssl ca -revoke باطل کنید، لیست را با openssl ca -gencrl دوباره تولید کنید، آن را به سرور کپی کنید و nginx را reload کنید تا فایل جدید را بخواند. دسترسی سایرین تحت تأثیر قرار نمی‌گیرد؛ این روش تنها در صورتی کار می‌کند که هر شخص گواهی اختصاصی خود را داشته باشد و از گواهی مشترک استفاده نشود. تاریخ nextUpdate مربوط به CRL را زیر نظر داشته باشید، زیرا CRL منقضی‌شده باعث شکست در احراز هویت برای همه کلاینت‌ها می‌شود، نه فقط برای موارد باطل‌شده.

آیا mTLS جایگزین صفحه ورود (login) می‌شود؟

از نظر دسترسی، بله: بدون گواهی، هیچ درخواستی به برنامه نمی‌رسد، بنابراین فرمی برای حمله یا رمز عبوری برای حدس زدن وجود ندارد. اما برای احراز هویت در داخل برنامه، خیر. گواهی فقط ثابت می‌کند که تماس‌گیرنده فایل کلید را در اختیار دارد، بنابراین یک لپ‌تاپ سرقت‌شده به عنوان کاربر معتبر شناخته می‌شود. مقدار CN را به سمت upstream ارسال کنید، حساب‌ها و مجوزهای موجود در برنامه را حفظ کنید و گواهی را به عنوان دروازه‌ای در مقابل آن‌ها در نظر بگیرید.

#tls#mtls#nginx#openssl#access-control