برنامه نویسی

وقتی شاخص Postgres با Bcrypt ملاقات می کند

سلام آنجا! در پست قبلی “آنچه که حادثه Okta Bcrypt می تواند در مورد طراحی API های بهتر به ما آموزش دهد” ، ما در مورد حد 72-chars از ارزش ورودی الگوریتم هشویی Bcrypt که باعث ایجاد یک حادثه امنیتی بسیار بزرگ در صنعت شد ، بحث کردیم. این به من در مورد نمونه دیگری از سوء استفاده از bcrypt یادآوری کرد که من ، شخصاً ، چند سال پیش در حالی که در مورد یک مسئله عملکرد کاملاً نامطبوع با یکی از خدمات تحقیق کردم ، روبرو شدم. بیایید درست به آن پرش کنیم!

مدیر محصول یکی از تیم های همسایه به من نزدیک شد و از من پرسید که آیا می توانم به آنها در تخریب عملکردی که اخیراً با جدیدترین ویژگی خود تجربه شده بود ، کمک کنم ، وقتی از کاربران خواسته شد تا وارد SSN (شماره تأمین اجتماعی) خود شوند ، و آنها ” D با داده های شخصی در مورد آنها داشبورد دریافت کنید. معماری ساده مانند این به نظر می رسید:

تصویر

همانطور که مشاهده می کنید ، جریان شامل 3 مرحله است:

  1. کاربر وارد SSN خود در UI می شود و UI آن را به سرویس داخلی می فرستد.
  2. سرویس داخلی بررسی می کند که آیا داده های کاربر با چنین SSN در حال حاضر در DB (Postgres) موجود است: اگر بله ، این داده ها به UI بازگردانده می شوند
  3. در غیر این صورت ، درخواست به API شخص ثالث برای واکشی داده های مورد نظر انجام می شود ، سپس در حال پردازش ، ذخیره در DB و بازگشت به کاربر است

ماهیت داده های کاربر نیازی به به روزرسانی در زمان واقعی نداشت ، بنابراین همان داده ها برای ماه ذخیره و استفاده شد ، پس از آن در نظر گرفته شد که منقضی شده و توسط Cronjob حذف شده است. یک سیستم بسیار ساده

مسئله (تیم تجربه می کرد) این واقعیت بود که 10+ ثانیه طول کشید تا جریان 3 مرحله ای را که در بالا بحث کردیم ، دنبال کرد و همین امر منجر به UX وحشتناک و نرخ گزاف گویی شد. این حس خوبی ایجاد کرد ، زیرا میانگین توجه کاربران امروزه کمتر از 10 ثانیه است. اما چرا اینقدر کند بود؟

مسئله اشکال در تنظیم تولید قابل تکرار بود ، سیاهههای مربوط/معیارها با سرنخ های علت آن چندان مفید نبودند. بنابراین ، من کد پروژه را به لپ تاپ خود کلون کردم و یک نمونه Postgres را از طریق Docker Compose راه اندازی کردم. علاوه بر این ، من MITMProxy را شروع کردم تا بتوانم درخواست های HTTP را در دستگاه خود رهگیری و بازرسی کنم و الگویی از درخواست را به API خدمات داخلی با SSN خودم در Postman ایجاد کردم. تنظیم اشکال زدایی من آماده بود ، بنابراین زمان اجرای برنامه و تست برنامه بود.

اجرای آن به صورت محلی

در کمال تعجب من ، پس از ارسال درخواست از Postman ، در مورد ده ها میلی ثانیه پاسخی دریافت کردم. میتپروکسی نشان داد که در واقع درخواست API شخص ثالث وجود دارد ، اما حداقل تأخیر داشت.

تصویر

هوم … جالب من سعی کردم دوباره همان درخواست را تکرار کنم ، و دوباره یک جواب سریع دریافت کردم ، اما این بار بدون هیچ گونه تماس با API شخص ثالث. این یک حس عالی بود ، زیرا اطلاعات کاربر من در Postgres وجود داشت. من سعی کردم چند بار دیگر همان درخواست ها را ارسال کنم ، اما با تولید مثل مسئله شانس نداشتم.

با وجود شکست ، این یک سرنخ خوب به من داد: به نظر می رسید که می توانم API شخص ثالث را از لیست مظنونان یا حداقل در حال حاضر خارج کنم. البته ذکر این نکته حائز اهمیت است که تنظیمات محلی با تولید متفاوت است ، بنابراین همیشه این احتمال وجود دارد که ممکن است چیزی در حال کاهش سرعت ترافیک Egress باشد. با این حال ، برای سادگی در تحقیقات ، من تصمیم گرفتم که قبل از بررسی عوامل خارجی احتمالی ، مانند پروکسی های ابر ، فیلترهای امنیتی و غیره ، هیچ مشکلی در برنامه ایجاد نکرده باشد.

فهمیدم که وقت آن است که نگاهی به کد بیندازیم. اگر می خواهید به دنبال آن باشید ، در اینجا نمونه های کد که تنظیماتی را که ما در مورد آن بحث می کنیم تولید می کنند ، بررسی کنید README.md پرونده برای نحوه اجرای دستورالعمل ها.

BTW ، اگر وبلاگ من را دوست دارید و نمی خواهید پست های جدید را از دست ندهید ، مشترک شدن در خبرنامه من را در اینجا در نظر بگیرید. پس از انتشار یک پست جدید ، یک ایمیل دریافت خواهید کرد.

مرور کد

ممکن است بدانید که یک جمله “همیشه DNS” وجود دارد ، که به دلیل عدم وجود برخی از خدمات به دلیل عدم وجود برخی از خدمات ، اعمال می شود. از تجربه من ، هنگامی که یک مشکل عملکرد با یک برنامه نسبتاً ساده وجود دارد ، “این اغلب بانک اطلاعاتی است”. بنابراین ، حتی اگر ، من شروع به پیمایش کد منبع از بالا به پایین (API -> DB) کردم ، انتظار نداشتم چیزی مشکوک در لایه های بالاتر از آن که نمایش داده های DB را اجرا می کند ، ببینم. و ، در واقع ، بیت های جالب کد 2 کارکرد از Repo ساختار مسئول درج کاربر جدید در DB و انتخاب کاربر خاص از DB توسط SSN. در اینجا چگونه به نظر می رسید:

func (r *Repo) SelectUserBySsn(ssn string) (*common.User, error) {
    now := time.Now()

    var user common.User
    err := r.dbPool.QueryRow(
        context.Background(),
        "SELECT id, user_info FROM users WHERE ssn_hash = crypt($1, ssn_hash)",
        ssn,
    ).Scan(&user.Id, &user.UserInfo)
    if err != nil {
        if errors.Is(err, pgx.ErrNoRows) {
            return nil, nil
        }
        fmt.Println(err)
        return nil, err
    }

    fmt.Println("Time taken to get user:", time.Since(now))
    return &user, nil
}

func (r *Repo) InsertUser(user common.NewUserEntity) error {
    _, err := r.dbPool.Exec(
        context.Background(),
        "INSERT INTO users (id, ssn_hash, user_info) VALUES ($1, crypt($2, gen_salt('bf')), $3)",
        user.Id, user.Ssn, user.UserInfo,
    )
    if err != nil {
        fmt.Println(err)
        return err
    }
    return nil
}
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

در crypt($1, ssn_hash) وت crypt($2, gen_salt('bf') قطعات بلافاصله چشمانم را به خود جلب کردند: “آها ، بنابراین ما SSN های ساده را در DB ذخیره نمی کنیم ، بلکه نسخه های هش دار”. کشف جالب! این هیچ نکاتی در مورد الگوریتم دقیق که برای هشویی استفاده شده است ، به من نمی دهد ، اما یک جستجوی سریع Google توسط gen_salt('bf') بخشی به من کمک کرد تا کشف کنم که این یک bcrypt است. و در واقع ، بعد از اینکه من این کار را کردم SELECT * FROM users در مجموعه محلی من متوجه شدم که ssn_hash ستون این مقدار را داشت $2a$06$r4UXr0F80tz57DGzCQBf6uIpQealvM3Rb6E/TvsyFkJJaiLXt9J8W، و $2a$ پیشوند در واقع یک bcrypt بود. خوب ، ما به چیزی هستیم! این احتمال وجود دارد که برخی از شما که متخصص در رمزنگاری و/یا بانک اطلاعاتی هستند (یا حداقل ، پس از آن) ، می توانید دلیل عملکرد ضعیف برنامه را ببینید (Kudos برای شما!) ، اما این نبود مورد برای من ، بنابراین مجبور شدم حفر کنم.

فهمیدم که DB در حال حاضر بهترین رهبری من است ، بنابراین من از کد منبع به کنسول Postgres در ایده Intellij خود پریدم و این پرس و جو را اجرا کردم: SELECT * FROM users WHERE ssn_hash = crypt('0852285111', ssn_hash) (0852285111 SSN جعلی من در این مثال است). من نتیجه را در 9 میلی ثانیه بدست آوردم ، که بسیار سریع بود و هیچ مشکلی در مورد عملکرد نداشتم.

اما پس از آن من به DB تولید وصل شدم و همان پرس و جو را اجرا کردم. برای دریافت پاسخ مشابه 15 ثانیه طول کشید. وای جالب تر این واقعیت بود که واکشی همه کاربران موجود از طریق SELECT * FROM users فقط 6 میلی ثانیه گرفت. گیج کننده ، اینطور نیست؟

علیرغم اینکه کمی گیج شده ام ، قطعاً پیشرفت خوبی احساس می کردم ، زیرا من یک سرنخ قوی داشتم که DB در اینجا تنگنا بود. و مشخص شد که شکست های من در تولید مثل محلی به این دلیل است که جدول محلی من فقط 1 ردیف دارد ، در حالی که تولیدی از آن لحظه 5000 کاربر داشت و ساعت به ساعت رشد می کرد. من می توانستم اشکال زدایی مسئله را در نمونه تولید پس از تولید ، همانطور که در آنجا قابل تکرار بود ، اشکال زد داده ها در آنجا بنابراین ، در ابتدا ، من می خواستم عکس فوری از تولید DB بگیرم و از آن به صورت محلی استفاده کنم ، اما فهمیدم که این ایده خیلی بهتری نیست: گرفتن عکس فوری ممکن است یک عملیات پرهزینه برای Postgres باشد. به علاوه ، پیامدهای حریم خصوصی من در مورد کپی کردن داده های حساس مشتریان به دستگاه محلی من وجود دارد. به همین دلیل من با گزینه سوم وارد کردن داده های جعلی زیادی در DB محلی خود رفتم.

آن را جعلی کنید تا زمانی که آن را درست کنید

این واقعیت که جدول ما بسیار ساده بود و فقط از 3 ستون تشکیل شده بود ، کار را بسیار ساده کرد. بنابراین ، من به نوشتن این کد تولید داده جعلی پایان دادم:

func GenRandomUsers(num int) []common.NewUserEntity {
    users := make([]common.NewUserEntity, num)
    for i := 0; i < num; i++ {
        users[i] = GenRandomUser()
    }
    return users
}

func GenRandomUser() common.NewUserEntity {
    ssn := genRandomSsn()
    fmt.Println("Generated SSN:", ssn)
    return common.NewUserEntity{
        Id:       uuid.New().String(),
        Ssn:      ssn,
        UserInfo: GenRandomUserInfo(),
    }
}

func GenRandomUserInfo() string {
    return fmt.Sprintf("Some info %s %s", strconv.FormatInt(time.Now().UnixMilli(), 10), uuid.New().String())
}

func genRandomSsn() string {
    ssn := ""
    for i := 0; i < 10; i++ {
        ssn += strconv.Itoa(rand.IntN(10))
    }
    return ssn
}
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

و از آن در داخل استفاده کرد fillDb عملکردی که من در برنامه راه اندازی برنامه ایجاد کردم:

func fillDb(repo *db.Repo) {
    users, err := repo.SelectUsers()
    if err != nil {
        panic(err)
    }
    if len(users) > 0 {
        fmt.Println("users already exist, skipping db fill")
        return
    }

    fmt.Println("generating random users...")
    newUsers := utils.GenRandomUsers(5000)

    fmt.Println("inserting random users...")
    err = repo.InsertUsers(newUsers)
    if err != nil {
        panic(err)
    }
    fmt.Println("inserted random users")
}
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

هنگامی که من برنامه را اجرا کردم ، بیش از 15 ثانیه طول کشید تا وارد کردن کاربران ، که من عجیب و غریب پیدا کردم ، به عنوان InsertUsers کد فقط 1 تماس با DB ساخته شده است:

func (r *Repo) InsertUsers(users []common.NewUserEntity) error {
    now := time.Now()

    query := "INSERT INTO users (id, ssn_hash, user_info) VALUES "
    var args []interface{}
    for i, user := range users {
        query += fmt.Sprintf("($%d, crypt($%d, gen_salt('bf')), $%d),", i*3+1, i*3+2, i*3+3)
        args = append(args, user.Id, user.Ssn, user.UserInfo)
    }
    query = query[:len(query)-1] // remove the trailing comma

    _, err := r.dbPool.Exec(
        context.Background(),
        query,
        args...,
    )
    if err != nil {
        fmt.Println(err)
        return err
    }
    fmt.Println("Time taken to insert users:", time.Since(now))
    return nil
}
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

برای من واضح تر و واضح تر شد crypt($%d, gen_salt('bf')) بخشی از آن مقصر بود ، اما من نفهمیدم که چرا هنوز چنین بود. به هر حال ، هدف میانی به دست آمد ، زیرا در آن زمان از زمان ، می توانم مسئله عملکرد را در تنظیم محلی خود نیز تولید کنم ، SELECT * FROM users WHERE ssn_hash = crypt('0852285111', ssn_hash) پرس و جو به همان اندازه در تولید DB کند شد ، حتی اگر SSN من جزو سوابق موجود DB نبود.

من تصمیم گرفتم برای لحظه ای مکث کنم و واقعیت ها را بررسی کنم:

  • دلایل عملکرد آهسته نمایش داده شدگان DB بود
  • عملکرد با رشد تعداد سوابق موجود در جدول DB تخریب شد
  • چیزی با ماهی ماهی است crypt() عملکرد برای هر دو لیست درج و انتخاب کنید

واقعیت دوم یک نکته خوب بود ، بنابراین من تصمیم گرفتم بررسی کنم که برنامه ریز پرس و جو Postgres می تواند در مورد برنامه اجرای پرس و جو به من بگوید:

EXPLAIN
SELECT *
FROM users
WHERE ssn_hash = crypt('0852285111', ssn_hash);
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

کلمه کلیدی EXPLAIN در این حالت باعث می شود که Postgres برای ساختن برنامه برای پرس و جو ایجاد شود ، اما آن را اجرا نکنید. نتایج مانند این بود:

Seq Scan on users  (cost=0.00..182.00 rows=25 width=138)
"  Filter: (ssn_hash = crypt('0852285111'::text, ssn_hash))"
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

آه ، Seq Scan on users به ما این نکته را می دهد که Postgres در حال انجام یک اسکن پی در پی است ، به این معنی که برای یافتن تطبیق هر ردیف جدول را طی می کند – O(n) اگر می خواهیم از پیچیدگی الگوریتمی در اینجا استفاده کنیم. این می تواند ارتباط بین افزایش تعداد ردیف ها و تخریب عملکرد را توضیح دهد.

بیایید اضافه کنیم ANALYSE کلمه کلیدی برای اجرای پرس و جو و دریافت معیارهای بیشتر:

EXPLAIN ANALYSE
SELECT *
FROM users
WHERE ssn_hash = crypt('0852285111', ssn_hash);
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

خروجی:

Seq Scan on users  (cost=0.00..182.00 rows=25 width=138) (actual time=15037.772..15037.774 rows=1 loops=1)
"  Filter: (ssn_hash = crypt('0852285111'::text, ssn_hash))"
  Rows Removed by Filter: 5000
Planning Time: 0.048 ms
Execution Time: 15037.788 ms
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

که آنچه را که ما در بالا دیده بودیم اثبات می کند.

بنابراین ، من فکر کردم که معما را حل کردم: ssh_hash ستون فاقد شاخص بود. من تقریباً به خودم افتخار می کردم ، اما این یک لحظه بسیار کوتاه بود ، همانطور که بعد از مرور هر دو ساختار جدول DB و نمایش داده های تعریف طرحواره ، فهمیدم که در واقع یک شاخص در آنجا وجود دارد:

CREATE INDEX users_ssn_hashed_idx ON users (ssn_hash);
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

اما چرا Postgres با این وجود نادیده گرفتن آن و انجام اسکن های متوالی را انجام داد؟ و علی رغم این واقعیت ، چرا 15 ثانیه پس از اسکن 5000 ردیف طول کشید؟ و چرا همه ردیف ها خیلی سریع (5 میلی ثانیه) انتخاب کردند؟ تنها تفاوت بین این دو استفاده از crypto() عملکرد در WHERE بیانیه این مشکل را محدود کرد. بنابراین ، من فهمیدم که زمان بازگشت به اصول اولیه و انجام تحقیقات در مورد ماهیت الگوریتم Bcrypt است.

تصویر

باکریپت

در اینجا چیزی است که ویکی پدیا در مورد bcrypt مشخص می کند:

ورودی به عملکرد BCRYPT رشته رمز عبور (حداکثر 72 بایت) ، هزینه عددی و مقدار نمک 16 بایت (128 بیتی) است. نمک به طور معمول یک مقدار تصادفی است. عملکرد BCRYPT از این ورودی ها برای محاسبه یک هش 24 بایت (192 بیتی) استفاده می کند. خروجی نهایی عملکرد BCRYPT رشته ای از فرم است:

$2$[cost]$[22 character salt][31 character hash]

به عنوان مثال ، با رمز ورود abc123xyz، هزینه 12، و یک نمک تصادفی ، خروجی Bcrypt رشته است

$2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
\__/\/ \____________________/\_____________________________/
Alg Cost      Salt                        Hash

اگر آن را کمی تقطیر کنیم ، می بینیم که ورودی عملکرد BCRYPT:

  • رمز
  • هزینه عددی
  • نمک (به طور معمول تصادفی)

اگر نگاهی به عملکرد Crypt در عبارت insert کاربر بیندازیم ، این را خواهیم دید crypt($2, gen_salt('bf')، جایی که SSN به عنوان منتقل می شود $2بشر بنابراین ، ما یک رمز عبور (SSN) و نمک داریم (gen_salt('bf')) هزینه عددی از دست رفته است ، اما بیایید نگاهی به خروجی آن بیندازیم gen_salt('bf') عملکرد. ممکن است شما نیاز به نصب pgcrypt پسوند توسط CREATE EXTENSION IF NOT EXISTS pgcrypto; اگر آن را ندارید که بتوانید در کنار هم قرار بگیرید.

SELECT gen_salt('bf');
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

خروجی است $2a$06$Qkp8j0vDoCuqNCgyOuIRxOبشر اگر با مثال از ویکی پدیا در بالا مقایسه کنیم ، می توانیم ببینیم که این نمک شامل هزینه درون است (06).

بیایید اجرا کنیم SELECT gen_salt('bf'); دوباره ما گرفتیم $2a$06$enmGwTijs9cvlsIv9dcQQe، که با نتیجه قبلی متفاوت است. همانطور که ویکی پدیا به ما هشدار داد

نمک به طور معمول یک مقدار تصادفی است.

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

SELECT crypt('0852285111', gen_salt('bf'));
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

اولین باری که آن را اجرا کردم ، این نتیجه را گرفتم: $2a$06$DQqtgCtvY9GkT3uHpShehuxb3eHD50H6XNxzEyoxZkuDjEt87/6Ceبشر RAN دوم نتیجه متفاوتی کسب کرد (همانطور که پیش بینی کردیم): $2a$06$nbwOka/0ve5RvM71f35jEOMGA5qHaa0H1I3RtkYq/sNIXW.Z90KAmبشر اگر ادامه دهیم ، نتایج متفاوتی خواهیم گرفت. و این بسیار جالب است ، همانطور که Bcrypt برای استفاده از رمزهای عبور استفاده می شود ، به این معنی که حتی اگر کاربران مختلف رمز عبور یکسانی داشته باشند ، نسخه هشدهی برای آنها متفاوت خواهد بود – بسیار مرتب!

اما اگر نتایج تصادفی باشد ، چگونه می توانیم هش را در DB با ضبط متن ساده که به عنوان ورودی کاربر دریافت می کنیم ، بررسی کنیم؟ خوب ، همانطور که می توانید حدس بزنید ، ما باید همان نمک ورودی را منتقل کنیم. اما نمک را از کجا می گیریم؟ ویکی پدیا قبلاً پاسخی به ما داده است: نمک بخشی از خروجی هشده است. بیایید با کپی کردن 29 بار اول از خروجی که در بالا دریافت کردیم ، آن را امتحان کنیم: $2a$06$DQqtgCtvY9GkT3uHpShehuxb3eHD50H6XNxzEyoxZkuDjEt87/6Ce -> $2a$06$DQqtgCtvY9GkT3uHpShehu و بررسی کنید که آیا این کمک می کند:

SELECT '$2a$06$DQqtgCtvY9GkT3uHpShehuxb3eHD50H6XNxzEyoxZkuDjEt87/6Ce' = crypt('0852285111', '$2a$06$DQqtgCtvY9GkT3uHpShehu');
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

نتیجه این است trueبشر عالی! اما یک دقیقه صبر کنید: آیا ما باید هر بار نمک و دوستان را به صورت دستی استخراج کنیم؟ بیایید نگاهی به پرس و جو مورد استفاده برای انتخاب کاربر توسط SSN از DB بیندازیم تا به دنبال برخی از نکات بگردیم:

SELECT id, user_info FROM users WHERE ssn_hash = crypt($1, ssn_hash)
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

کجا $1 مقدار SSN است این کمی متفاوت است زیرا به جای فقط نمک ، ما این بار کل مقدار هش را به عملکرد Crypt منتقل می کنیم. بیایید آن را امتحان کنیم:

SELECT '$2a$06$DQqtgCtvY9GkT3uHpShehuxb3eHD50H6XNxzEyoxZkuDjEt87/6Ce' = crypt('0852285111', '$2a$06$DQqtgCtvY9GkT3uHpShehuxb3eHD50H6XNxzEyoxZkuDjEt87/6Ce');
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

هنوز trueبشر چطور؟ پاسخ ساده است: از آنجا که نمک (با پیشوند) طول مشخصی از 29 دارد ، Postgres آن را برای ما بازیابی می کند و بقیه آن را رها می کند.

زیبایی Postgres (که یکی از ابزارهای مورد علاقه من در آنجا است) این واقعیت است که منبع باز است. بنابراین ، بیایید اوج و کد منبع آن را بگیریم تا اطمینان حاصل کنیم که فرضیات ما صحیح است.

bcrypt در کد منبع postgres

در اینجا آینه مخزن رسمی PostgreSQL GIT است. بیایید پیدا کنیم pgcrypto پرونده ای که مطابق با برنامه افزودنی است که ما نصب کردیم تا بتوانیم توابع رمزنگاری را اجرا کنیم. اینجاست. این قطعه کد هنگام انجام کار ایجاد می شود crypt(...) از طریق SQL:

/* SQL function: pg_crypt(psw:text, salt:text) returns text */
PG_FUNCTION_INFO_V1(pg_crypt);

Datum
pg_crypt(PG_FUNCTION_ARGS)
{
    text       *arg0 = PG_GETARG_TEXT_PP(0);
    text       *arg1 = PG_GETARG_TEXT_PP(1);
    char       *buf0,
               *buf1,
               *cres,
               *resbuf;
    text       *res;

    buf0 = text_to_cstring(arg0);
    buf1 = text_to_cstring(arg1);

    resbuf = palloc0(PX_MAX_CRYPT);

    cres = px_crypt(buf0, buf1, resbuf, PX_MAX_CRYPT);

    pfree(buf0);
    pfree(buf1);

    if (cres == NULL)
        ereport(ERROR,
                (errcode(ERRCODE_EXTERNAL_ROUTINE_INVOCATION_EXCEPTION),
                 errmsg("crypt(3) returned NULL")));

    res = cstring_to_text(cres);

    pfree(resbuf);

    PG_FREE_IF_COPY(arg0, 0);
    PG_FREE_IF_COPY(arg1, 1);

    PG_RETURN_TEXT_P(res);
}
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

اگر به داخل پرش کنیم pg_crypt در این خط cres = px_crypt(buf0, buf1, resbuf, PX_MAX_CRYPT);، ما در نهایت به داخل px-crypt.c پرونده در اینجا:

char *
px_crypt(const char *psw, const char *salt, char *buf, unsigned len)
{
    const struct px_crypt_algo *c;

    CheckBuiltinCryptoMode();

    for (c = px_crypt_list; c->id; c++)
    {
        if (!c->id_len)
            break;
        if (strncmp(salt, c->id, c->id_len) == 0)
            break;
    }

    if (c->crypt == NULL)
        return NULL;

    return c->crypt(psw, salt, buf, len);
}
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

همانطور که می بینیم ، این کد از طریق px_crypt_list در حالی که سعی در یافتن مسابقه برای ارائه شده است salt (در مورد ما ، این کل رشته hashed است) با مقایسه id_len تعداد شخصیت های اول با چیزی به نام idبشر در اینجا دکتر برای strncmp اگر می خواهید اطلاعات بیشتری کسب کنید ، عملکرد را انجام دهید.

بیایید ببینیم چگونه px_crypt_list به نظر می رسد ، همانطور که ممکن است ماهیت آن را برای ما توضیح دهد id_len وت id پارامترها

struct px_crypt_algo
{
    char       *id;
    unsigned    id_len;
    char       *(*crypt) (const char *psw, const char *salt,
                          char *buf, unsigned len);
};

static const struct px_crypt_algo
            px_crypt_list[] = {
    {"$2a$", 4, run_crypt_bf},
    {"$2x$", 4, run_crypt_bf},
    {"$2$", 3, NULL},           /* N/A */
    {"$1$", 3, run_crypt_md5},
    {"_", 1, run_crypt_des},
    {"", 0, run_crypt_des},
    {NULL, 0, NULL}
};
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

آه ، بنابراین id پیشوند نمکی است که ویکی پدیا نامیده می شود alg ($2a$ در مورد ما) ، و id_len طول آن پیشوند است. اکنون این حس زیادی ایجاد می کند. و run_crypt_bf عملکرد واقعی است که در مورد ما مورد استفاده قرار می گیرد. خوب ، بیایید نگاهی به آن بیندازیم:

static char *
run_crypt_bf(const char *psw, const char *salt,
             char *buf, unsigned len)
{
    char       *res;

    res = _crypt_blowfish_rn(psw, salt, buf, len);
    return res;
}
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

و بیایید با پریدن به داخل _crypt_blowfish_rn که ما را به یک پرونده جدید سوق می دهد – crypt-blowfish.cبشر من تمام بدن آن عملکرد را در اینجا ارسال نخواهم کرد ، زیرا حدود 200 خط طول دارد. با این حال ، این یک بخش جالب برای ما است:

/*
 * Blowfish salt value must be formatted as follows: "$2a$" or "$2x$", a
 * two digit cost parameter, "$", and 22 digits from the alphabet
 * "./0-9A-Za-z". -- from the PHP crypt docs. Apparently we enforce a few
 * more restrictions on the count in the salt as well.
 */
if (strlen(setting) < 29)
    ereport(ERROR,
        (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
        errmsg("invalid salt")));
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

کجا settings است saltبشر ما می توانیم شماره 29 را در اینجا ببینیم ، که زنگ می زند ، اینطور نیست؟ این دقیقاً طول پیشوند نمکی است که در یکی از مثالهای بالا از آن استفاده کردیم. همچنین ، لطفاً توجه داشته باشید که این اعتبارسنجی فقط بررسی می کند که طول ورودی کمتر از 29 نیست ، اما اهمیتی نمی دهد که بالاتر از حد مجاز باشد. این به ما اشاره می کند که کد زیر باید به نوعی آن را اداره کند:

if (setting[0] != '$' ||
        setting[1] != '2' ||
        (setting[2] != 'a' && setting[2] != 'x') ||
        setting[3] != '$' ||
        setting[4] < '0' || setting[4] > '3' ||
        setting[5] < '0' || setting[5] > '9' ||
        (setting[4] == '3' && setting[5] > '1') ||
        setting[6] != '$')
    {
        ereport(ERROR,
                (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
                 errmsg("invalid salt")));
    }

    count = (BF_word) 1 << ((setting[4] - '0') * 10 + (setting[5] - '0'));
    if (count < 16 || BF_decode(data.binary.salt, &setting[7], 16))
    {
        px_memset(data.binary.salt, 0, sizeof(data.binary.salt));
        ereport(ERROR,
                (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
                 errmsg("invalid salt")));
    }
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

به این قطعه نگاهی بیندازید: BF_decode(data.binary.salt, &setting[7], 16)بشر آیا نمونه ویکی پدیا را به خاطر می آورید:

$2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
\__/\/ \____________________/\_____________________________/
Alg Cost      Salt                        Hash

؟

همانطور که می بینیم ، نمک واقعی از هشتم کاراکتر شروع می شود (به همین دلیل setting[7]) ، و &setting[7] به این معنی است که تمام عناصر شروع شده از 8 در اینجا قطعه قطعه می شوند. در BF_decode عملکرد مانند این است:

static int
BF_decode(BF_word *dst, const char *src, int size)
{
    unsigned char *dptr = (unsigned char *) dst;
    unsigned char *end = dptr + size;
    const unsigned char *sptr = (const unsigned char *) src;
    unsigned int tmp,
                c1,
                c2,
                c3,
                c4;

    do
    {
        BF_safe_atoi64(c1, *sptr++);
        BF_safe_atoi64(c2, *sptr++);
        *dptr++ = (c1 << 2) | ((c2 & 0x30) >> 4);
        if (dptr >= end)
            break;

        BF_safe_atoi64(c3, *sptr++);
        *dptr++ = ((c2 & 0x0F) << 4) | ((c3 & 0x3C) >> 2);
        if (dptr >= end)
            break;

        BF_safe_atoi64(c4, *sptr++);
        *dptr++ = ((c3 & 0x03) << 6) | c4;
    } while (dptr < end);

    return 0;
}
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

در حالی که منطق دستکاری کمی در اینجا وجود دارد ، که من جرات توضیح آن را ندارم ، اما آنچه در اینجا مهم است این است dptr < end اطمینان حاصل خواهد کرد که نتیجه 16 بایت خواهد بود. اما نمک باید 22 باشد؟

در Bcrypt ، در حالی که نمک در واقع به عنوان 22 کاراکتر در قالب Base64 رمزگذاری شده است ، در واقع به 16 بایت داده های خام رمزگذاری می شود. هنگام رمزگذاری داده های باینری به Base64:

  • هر 3 بایت از داده های باینری در Base64 به 4 کاراکتر تبدیل می شود
  • این امر به این دلیل است که هر کاراکتر Base64 6 بیت را نشان می دهد (2^6 = 64 امکانات)
  • بنابراین 3 بایت (24 بیت) نقشه داده های باینری به 4 کاراکتر Base64 (4 * 6 = 24 بیت)

برای نمک bcrypt:

  • 16 بایت داده های خام = 128 بیت
  • هنگامی که در Base64 رمزگذاری می شود: ⌈ (16 * 8) / 6⌉ = ⌈128 / 6⌉ = 22 نویسه
  • از عملکرد سقف استفاده می شود زیرا ما باید تا شخصیت کامل BASE64 را جمع کنیم

به این ترتیب ، می توانیم ببینیم که می توان کل رشته هشده را به عنوان ورودی نمک Postgres منتقل کرد crypt() عملکرد. خوب ، این سرگرم کننده بود ، اما بیایید به بحث Bcrypt خود برگردیم.

اکنون چه می دانیم؟

قبل از اینکه آنچه را که اکنون می دانیم خلاصه کنیم ، بگذارید یک نقل قول دیگر درباره BCrypt از مقاله ویکی پدیا برای شما ارائه دهم:

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

بنابراین ، الگوریتم Bcrypt با طراحی نسبتاً کند است ، زیرا محافظت از حملات بی رحمانه است.

بیایید لیستی از حقایق شناخته شده در مورد الگوریتم BCRYPT ایجاد کنیم:

  • برای تولید هش به نمک احتیاج دارد
  • در حالت ایده آل ، نمک باید تصادفی باشد
  • نمک تصادفی منجر به نتایج مختلف برای همان ورودی هش می شود
  • برای مقایسه رشته هشده موجود با یک ساده ، نمک اصلی باید به عنوان ورودی عملکرد رمزنگاری منتقل شود
  • Postgres اجازه می دهد تا رشته هشده را به عنوان نمک عبور دهید و از استخراج قسمت نمک آن برای انجام هشویی مراقبت می کند

با تمام این اطلاعات ، در نهایت سعی می کنیم معمای عملکرد ضعیف پرس و جو را برای واکشی کاربر توسط SSN حل کنیم.

شاخص Postgres و bcrypt

بنابراین ، چرا Postgres علی رغم شاخص آن ستون ، اسکن های متوالی را انجام می دهد؟ بیایید نگاهی دیگر به پرس و جو مورد نظر خود بیندازیم:

SELECT *
FROM users
WHERE ssn_hash = crypt('0852285111', ssn_hash);
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

همانطور که از قبل می دانیم ، برای بازگرداندن هش ، crypto() عملکرد باید نمک اصلی را به عنوان ورودی دریافت کند. اما ، همانطور که می بینید ، تنها چیز شناخته شده وقتی این پرس و جو را اجرا می کنیم ، مقدار SSN ساده است. این بدان معنی است که تنها گزینه ممکن Postgres انجام موارد زیر است:

  • تکرار بیش از هر ردیف موجود در users جدول
  • واگذار کردن ssn_hash ارزش
  • SSN دشت را عبور داده و واکشی کنید ssn_hash ارزش (به عنوان نمک) به crypt() عملکرد و محاسبه هش
  • نتیجه را با واکشی مقایسه کنید ssn_hash ارزش
  • اگر مطابقت داشته باشیم ، آنچه را که به دنبالش هستیم پیدا کردیم ، در غیر این صورت ، باید به مورد بعدی و غیره برویم

از آنجا که الگوریتم شرح داده شده نیاز به تکرار بیش از همه موارد موجود دارد ، در حالی که Postgres به هیچ وجه به شاخص ها اعتماد نمی کند ، منطقی است. اگر برای مقایسه ssn_hash با رشته ساده به جای crypt() عملکردی مانند این:

EXPLAIN ANALYSE
SELECT *
FROM users
WHERE ssn_hash = '$2a$06$DQqtgCtvY9GkT3uHpShehuxb3eHD50H6XNxzEyoxZkuDjEt87/6Ce';
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

می بینیم که این بار Postgres از فهرست استفاده می کند:

Index Scan using users_ssn_hashed_idx on users  (cost=0.28..8.30 rows=1 width=138) (actual time=0.015..0.015 rows=0 loops=1)
  Index Cond: (ssn_hash="$2a$06$DQqtgCtvY9GkT3uHpShehuxb3eHD50H6XNxzEyoxZkuDjEt87/6Ce"::text)
Planning Time: 0.448 ms
Execution Time: 0.033 ms
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

همچنین ، به یاد داشته باشید که ما ذکر کردیم که الگوریتم Bcrypt نسبتاً کند است؟ اما اگر 5000 را کند کنیم (تعداد سوابق جدول که داریم) چند برابر کنیم؟ این علت واقعی تخریب عملکرد است که ما در این مدت تحقیق کرده ایم!

ما به سادگی می توانیم با اجرای آن تأیید کنیم:

EXPLAIN ANALYSE
SELECT *
FROM users;
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

خروجی:

Seq Scan on users  (cost=0.00..157.00 rows=5000 width=138) (actual time=0.009..0.441 rows=5001 loops=1)
Planning Time: 0.094 ms
Execution Time: 0.641 ms
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

همانطور که می بینید ، حتی اگر پرس و جو در تمام ردیف ها تکرار شده باشد ، نیازی به انجام هاش کردن bcrypt اصلاً ندارد ، زمان اجرای آن بسیار کم است.

خوب ، بنابراین ما اکنون می دانیم که Bcrypt کسی است که برای عملکرد ضعیف مقصر است. اما آیا این بدان معنی است که Bcrypt فقط یک الگوریتم بد عملکردی است؟

bcrypt: از کجا استفاده کنید و از کجا استفاده نکنید

مشابه هر ابزار دیگر ، BCRYPT مورد استفاده خود را دارد: رمزهای عبور هشدار دهنده. این بدان معنی است که مورد استفاده مورد انتظار برای آن با موردی که در بالا داریم متفاوت است ، زیرا نباید توسط آن جستجو کنیم. بگذارید سناریویی را که می توان از آن استفاده کرد نشان دهم:

CREATE TABLE user_credentials
(
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    login         TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL
);

CREATE INDEX user_credentials_login_idx ON user_credentials (login);
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

و بیایید چند مورد را وارد کنیم:

func main() {
    dbUrl := "postgres://adminU:adminP@localhost:5432/n0rdyblog"
    dbPool, err := pgxpool.New(context.Background(), dbUrl)
    if err != nil {
        panic(err)
    }
    defer dbPool.Close()

    numOfUsers := 5000

    for i := 0; i < numOfUsers; i++ {
        login := randomString(10)
        password := randomString(20)

        dbPool.Exec(context.Background(), "INSERT INTO user_credentials(login, password_hash) VALUES($1, crypt($2, gen_salt('bf')))", login, password)
    }
}

func randomString(length int) string {
    bytes := make([]byte, length)
    _, err := rand.Read(bytes)
    if err != nil {
        panic(err)
    }
    return base64.URLEncoding.EncodeToString(bytes)[:length]
}
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

روشی که اکنون قرار است نمایش داده شود این است:

  • هنگامی که کاربر سعی می کند وارد سیستم شود ، آنها اعتبار خود را به ما منتقل می کنند: ورود به سیستم و رمز عبور
  • در اینجا پرس و جو که ما برای تأیید اعتبار ارائه شده صحیح اجرا می کنیم:
SELECT *
FROM user_credentials
WHERE login = 'ChCpOSGOCx'
  AND password_hash = crypt('ygEUnWIFIBoJ7ZQQqQ3j', password_hash);
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

حتی اگر user_credentials جدول دارای 5000 مورد است ، این پرس و جو بسیار سریع است ، بیایید ببینیم چقدر سریع:

EXPLAIN ANALYSE
SELECT *
FROM user_credentials
WHERE login = 'ChCpOSGOCx'
  AND password_hash = crypt('ygEUnWIFIBoJ7ZQQqQ3j', password_hash);
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

نتایج:

Index Scan using user_credentials_login_idx on user_credentials  (cost=0.28..8.31 rows=1 width=88) (actual time=4.502..4.513 rows=1 loops=1)
  Index Cond: (login = 'ChCpOSGOCx'::text)
"  Filter: (password_hash = crypt('ygEUnWIFIBoJ7ZQQqQ3j'::text, password_hash))"
Planning Time: 0.067 ms
Execution Time: 4.542 ms
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

تفاوت کلیدی بین این سناریو و موردی که با SSNS داشتیم ، این است که در اینجا Postgres به دلیل استفاده از فهرست بندی ها می تواند از فهرست بندی استفاده کند WHERE login = 'ChCpOSGOCx' بخشی ، و سپس آن را فقط برای ردیف برگشت داده شده توسط شاخص محاسبه می کند. از آنجا که ، ما login ستون منحصر به فرد است ، به این معنی است که محاسبه bcrypt فقط 1 بار اتفاق می افتد ، بنابراین O(1) به جای O(n)بشر

اینگونه است که قرار است BCRYPT بدون پرداخت هزینه های با کارایی بسیار بالا ، از امنیت خود بهره ببرد.

خوب ، اما چگونه می توانیم مشکل اصلی را حل کنیم؟

بیایید عملکرد را برطرف کنیم

البته با جابجایی به الگوریتم سریعتر دیگر با نتیجه قابل پیش بینی مانند SHA-256 ، یک فنی آشکار وجود دارد و مسئله عملکرد حل می شود. اما 2 نکته وجود دارد که ارزش بحث و گفتگو دارد:

  • از آنجا که ورودی SHA-256 (یا هر الگوریتم قطعی دیگر) SSN خواهد بود ، که دارای فرمت شناخته شده 10 رقم است ، به این معنی است که هش با حمله بیرحمانه به راحتی می تواند به عنوان 10^10 (به راحتی می تواند ترک شود. 10 میلیارد) گزینه های احتمالی SSN چیزی است که تولید آن برای رایانه های مدرن آسان است

در اینجا کد ساده و بی پروا برای تولید تمام مقادیر هش SSN ممکن است:

const (
    ssnLength = 10
)

func main() {
    start := time.Now()

    // initializes the buffer with ASCII zeros
    current := make([]byte, ssnLength)

    // generates all possible combinations
    generateAllPossibleSSNs(current, 0)

    duration := time.Since(start)
    fmt.Printf("Completed in %v\n", duration)
}

// generateAllPossibleSSNs generates all possible SSNs and hashes them
func generateAllPossibleSSNs(current []byte, position int) {
    if position == ssnLength {
        sha256.Sum256(current)
        return
    }

    // converts 0-9 directly to ASCII bytes ('0' = 48 in ASCII)
    for i := byte(48); i < byte(58); i++ {
        current[position] = i
        generateAllPossibleSSNs(current, position+1)
    }
}
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

30 دقیقه طول کشید تا آن را در Macbook Pro 2019 من اجرا کنید ، که قطعاً یک دستگاه منسوخ از سال 2025 است. اگر مورد استفاده دیگری داشتیم ، به عنوان مثال ، ما نیاز به ذخیره هش از کلیدهای API که از نوع UUID V4 هستند ، بنابراین تصادفی ، SHA-256 می تواند انتخاب خوبی برای چنین سناریویی باشد.

این ما را به نکته دیگری می رساند:

  • چرا ما در وهله اول SSN ها را هش می دهیم؟

هر وقت من دیگر مهندسین نرم افزار را راهنمایی می کنم ، همیشه می گویم که نوشتن کد بخشی آسان از کار ما است و ما باید به جای پرش به حالت برنامه نویسی ، ابتدا هر مشکل و نیاز را تأیید کنیم. در اینجا: ما از هش کردن SSN ها چه چیزی به دست می آوریم؟ آیا این یک الزام حریم خصوصی است یا فقط یک فرض توسط شخصی که “SSN ها حساس هستند ، بنابراین ما نباید مقادیر ساده را برای آنها ذخیره کنیم”؟ آیا این احتمال وجود دارد که ما آنها را در جدول / پایگاه داده دیگری در سیستم خود به روش ساده ذخیره کنیم؟ اگر اینگونه نباشد ، حریم خصوصی و الزامات قانونی برای ارزشهای هشدار چیست؟ همانطور که می بینیم ، بدون نمک ، SSN ها به راحتی حوزه ای می کنند ، اما نمک تصادفی منجر به عملکرد ضعیف می شود.

در بعضی موارد ، به نظر می رسد که ، در واقع ، راه حل به سادگی مانند هش کردن داده ها ، اگر خیلی حساس تلقی نشود. در موارد دیگر ، ما ممکن است نیاز به یک نمک رایج برای همه هش ها داشته باشیم ، که با استفاده از قانون شست گاندالف ذخیره خواهیم کرد:

تصویر

به جای باز نگه داشتن/در هش مانند مثال قبلی ما.

در این حالت ، اگر نمک رایج باشد ، حتی می توانیم در Bcrypt بمانیم ، زیرا پرس و جو با نمک شناخته شده از فهرست بندی استفاده می کند:

EXPLAIN ANALYSE
SELECT *
FROM users
WHERE ssn_hash = crypt('0852285111', '$2a$06$DQqtgCtvY9GkT3uHpShehu');
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

نتیجه این است:

Index Scan using users_ssn_hashed_idx on users  (cost=0.28..8.30 rows=1 width=138) (actual time=0.038..0.038 rows=0 loops=1)
  Index Cond: (ssn_hash="$2a$06$DQqtgCtvY9GkT3uHpShehuxb3eHD50H6XNxzEyoxZkuDjEt87/6Ce"::text)
Planning Time: 3.462 ms
Execution Time: 0.118 ms
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

همانطور که ممکن است به یاد داشته باشید ، ما با 15 ثانیه در هر پرس و جو شروع کردیم و اکنون به 3 میلی ثانیه رسیده ایم که 5000 برابر سریعتر است – یک دستاورد هیجان انگیز.

با این وجود ، اگر یک نمک مشترک داده شود ، چیزی سریعتر مانند SHA-256 ، SHA-3 ، Blake2 یا Blake3 ممکن است گزینه های بهتری باشد. البته ، این نیاز به معیار ، مشاوره با تیم امنیتی شما و بررسی پشتیبانی از پسران برای پرونده خاص شما قبل از تصمیم گیری دارد.

و این همان است ، تیم و کاربران خوشحال هستند ، سیستم دوباره سالم است. ما روز را نجات دادیم و یک یا دو مورد در مورد الگوریتم BCRYPT آموختیم.

این کاملاً سواری بود ، بنابراین از خواندن پست من بسیار سپاسگزارم. من واقعاً امیدوارم که این مفید باشد ، و من شما را در موارد بعدی خود می بینم ، به زودی بیشتر. لذت ببرید! =)

اگر پست را دوست داشتید:

نوشته های مشابه

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

دکمه بازگشت به بالا