[Security] bcrypt 정리

🔐 bcrypt란?

bcrypt는 1999년에 Niels ProvosDavid Mazieres가 개발한 암호화 해시 함수로, Blowfish 대칭 키 암호화 알고리즘을 기반으로 만들어졌습니다. 주로 웹 애플리케이션에서 사용자의 비밀번호를 안전하게 저장하기 위해 사용됩니다.

✅ 주요 특징

1. Salt 내장

  • bcrypt는 자동으로 salt를 랜덤 생성하여 포함시킵니다.
  • salt는 같은 비밀번호도 다른 해시 결과를 만들게 하여 무작위성을 증가시키고 레인보우 테이블 공격을 방어합니다.

2. 비용 증가 (Cost factor)

  • cost 또는 work factor는 해시 계산에 걸리는 연산 횟수(또는 시간)를 조절합니다.
  • 예: cost 10이면 2¹⁰ = 1024번의 해시 계산을 수행합니다.
  • 이를 통해 컴퓨터 성능이 높아져도 쉽게 크래킹되지 않도록 시간적 방어 장치를 제공합니다.

3. 느린 연산

  • 빠른 해시 함수(SHA-256 등)는 brute-force 공격에 더 취약한 반면, bcrypt는 의도적으로 느리게 설계되어 공격자를 어렵게 만듭니다.

4. 결과에 버전과 cost 정보 포함

$2b$10$Qcfksu5KwUepA.zVDkkw4Oco0z14Kz2jgfD6sdoGieQfztY1KaIhK
└┬┘└┬┘└─────── Salt ────────┘└─────── Hashed result ───────┘
 │  │
 │  └── Cost factor (2의 10제곱 = 1024번 해싱)
 └────── 버전 (2a, 2b, 2y 등)
  • 2b → bcrypt 알고리즘 버전
  • 10 → cost factor (해시 반복 횟수 조절)
  • 그 뒤 22자 → salt (Qcfksu5KwUepA.zVDkkw4O)
  • 나머지 → 실제 해시된 비밀번호

⚠️ 주의할 점

  • bcrypt는 암호화(encryption)가 아닌 해싱(hash)입니다. 즉, 원래 값을 복호화할 수 없습니다.
  • 사용자가 로그인할 때는 입력된 비밀번호를 같은 방식으로 해시하고, 저장된 해시와 비교(compare)하여 확인합니다.
  • 비밀번호가 노출되었을 경우, 단방향이기 때문에 해시만으로는 원래 값을 알 수 없습니다.

💻 Mac에서 간단히 CLI로 해시를 생성하는 방법

🔹 Homebrew로 bcrypt 설치

brew install bcrypt

🔹 비밀번호 해싱

echo "yourPasswordHere" | bcrypt
  • 예시 출력:
$2a$06$D9f2gKT8iJ1Lg/YhH9QpneCn7vD0Qx7xDdwU5k7AlhOPKfMyVPMta

💻 node.js 예제 코드

🔹 프로젝트 폴더 생성 후 bcrypt 설치

mkdir bcrypt-demo && cd bcrypt-demo
npm init -y
npm install bcrypt

✅ bcrypt-hash.js: 해시 생성 코드

const bcrypt = require('bcrypt');

// 커맨드라인에서 입력받은 비밀번호
const [,, password] = process.argv;

// 유효성 검사
if (!password) {
  console.error("❗ 사용법: node bcrypt-hash.js <비밀번호>");
  process.exit(1);
}

const saltRounds = 10; // 기본 비용 계수 (2^10 = 1024번 해시 반복)

// 해시 생성
bcrypt.hash(password, saltRounds, (err, hash) => {
  if (err) {
    console.error("❌ 에러:", err.message);
    process.exit(1);
  }

  console.log("🔐 bcrypt 해시 결과:");
  console.log(hash);
});

✅ 사용 방법

테스트 예시:

node bcrypt-hash.js mySecretPassword

✅ bcrypt-test.js: 해시 검증 코드

const bcrypt = require('bcrypt');

// 커맨드라인 인자 받아오기
const [,, inputPassword, hashedPassword] = process.argv;

// 유효성 검사
if (!inputPassword || !hashedPassword) {
  console.error("❗ 사용법: node bcrypt-test.js <암호> <해시>");
  process.exit(1);
}

// 비밀번호 검증
bcrypt.compare(inputPassword, hashedPassword, (err, result) => {
  if (err) {
    console.error("❌ 에러 발생:", err.message);
    process.exit(1);
  }

  if (result) {
    console.log("✅ 비밀번호가 일치합니다!");
  } else {
    console.log("❌ 비밀번호가 일치하지 않습니다.");
  }
});

✅ 사용 방법

테스트 예시:

node bcrypt-test.js mySecretPassword '$2b$10$WnV2Kk1aR8...'

[Ethereum] 트랜잭션 해시로부터 Public key 및 주소 얻기

Ethereum의 경우 버전에 따라 트랜잭션에 gasPrice를 쓸 지, maxFeePerGas/maxPriorityFeePerGas 를 쓸 지가 정해진다. https://ethereum.stackexchange.com/questions/147692/how-to-get-public-key-by-the-transaction-hash 를 참고.

주소를 얻을 때, Public key에서 앞의 한 바이트(0x04)를 빼고, keccak256을 돌리는 부분에 유의하자.

const { ethers } = require("hardhat");

// 트랜잭션 서명 값
// const r = "0xe680637b83a1dd102364503bd77979b87c92ba651132a4df8e839af69c20af95";
// const s = "0x65becfa32384747adb05f543397baaf23d1c55fa28d0c9a38924912dae6df28f";
// const v = 12266684226873;
// const chainId = 6133342113419;

// 트랜잭션 해시
const txHash = "0xc7159866fb5b94d291e8624bdc731b7a6d46533b398065de7aa7dd1af7b6aa36";
const ethermainHash = "0xef120deb6ff2e516ad44724d220c0cd73166d169a2a0b0f66dfb5613d2d6169c"
const sepoliaHash = "0xfe4ddad4cae9ea353fc91441ee5db6c70bd5468673d907926be92dd4b8a63f63"

// 실제 트랜잭션에 사용된 private key. 아무거나 넣자.
const privateKey = "0x0000000000000000000000000000000000000000000000000000000000000001";

// Web3 프로바이더 설정 (Hardhat 로컬 노드 사용 예시)
const provider = ethers.provider;

// 서명된 트랜잭션 정보 가져오기
async function getPublicKeyFromTransactionHash(provider, txHash) {
  // Fetch the transaction using the transaction hash and provier
  const tx = await provider.getTransaction(txHash);

  console.log(tx);

  // Extract the all the relevant fields from the transaction (We need all of them)
  const unsignedTx = {
    gasPrice: tx.gasPrice,
    gasLimit: tx.gasLimit,
    value: tx.value,
    nonce: tx.nonce,
    data: tx.data,
    chainId: tx.chainId,
    to: tx.to,
    type: tx.type,
    // maxFeePerGas: tx.maxFeePerGas,
    // maxPriorityFeePerGas: tx.maxPriorityFeePerGas,
  };

  // Serializing tx without the signature
  const serializedTx = ethers.utils.serializeTransaction(unsignedTx);

  // Extract the signature (v, r, s) from the transaction
  const { v, r, s } = tx;

  // Join splitted signature
  const signature = ethers.utils.joinSignature({ v, r, s });

  const recoveredPublicKey = ethers.utils.recoverPublicKey(
    ethers.utils.keccak256(serializedTx),
    signature,
  );
  console.log("recoveredPublicKey:", recoveredPublicKey);
  const keccak = ethers.utils.keccak256("0x" + recoveredPublicKey.slice(4))
  console.log("keccak256 of recoveredPublicKey:", keccak, ethers.utils.keccak256("0x" + recoveredPublicKey.slice(4)));

  // Recover the address or public key with (replace recoverAddress by recoverPublicKey) associated with the transaction
  return ethers.utils.recoverAddress(
    ethers.utils.keccak256(serializedTx),
    signature
  );
}

async function main() {
  const address = await getPublicKeyFromTransactionHash(provider, txHash);

  console.log("Address:", address);
}

main()
.then(() => process.exit(0))
.catch((error) => {
    console.error(error);
    process.exit(1);
});