NEEDLESPACE
투명성

우리를 믿지 마세요.

개인키를 다루는 사이트에서 “안전합니다”라는 말은 아무 가치가 없습니다. 그 말을 하는 사이트일수록 위험하고요. 그래서 이 페이지에는 우리 주장을 당신이 직접 깨보는 방법만 적었습니다.

주장 4개 · 검증 방법 4개계정 없음추적기 없음
주장 1

“퍼즐 데이터를 우리가 지어내지 않았습니다”

퍼즐 표에 적힌 주소·범위·개인키는 외부에서 가져온 텍스트 파일입니다. 옮겨 적는 과정에서 틀릴 수도 있고, 원본이 틀렸을 수도 있습니다. 그래서 그 파일을 그대로 쓰지 않습니다. 빌드할 때마다 아래 검증을 전부 통과해야만 사이트가 만들어집니다.

  1. 160행이 다 있고 행 번호 = 퍼즐 번호인가
  2. 범위가 정확히 [2n-1, 2n−1]인가
  3. 해결된 퍼즐의 개인키가 그 범위 안에 있는가
  4. 그 개인키에서 secp256k1로 공개키를 유도하면 원본 공개키와 같은가
  5. 거기서 HASH160·Base58Check를 거치면 원본 주소가 나오는가

하나라도 어긋나면 process.exit(1)로 빌드가 실패합니다. 지금 보고 있는 페이지가 존재한다는 것 자체가 160개 전부 통과했다는 뜻입니다.

검증한 행
160
전량
키 재유도
83
해결된 퍼즐 전부
직접 돌려보기

저장소를 받아서 같은 스크립트를 실행하면 같은 결과가 나와야 합니다.

검증 스크립트 실행
node scripts/build-data.mjs

# ✓ 검증 통과
#   전체 160 / 해결 83 / 미해결 77
#   미해결 중 공개키 노출: 140, 145, 150, 155, 160
#   남은 상금 합계: 903 BTC
검증 스크립트 전문 보기 (159줄)
/**
 * 퍼즐 원본 데이터를 검증하고 data/puzzles.json 으로 만든다.
 *
 * 원본(data/raw/puzzles.txt)은 외부에서 가져온 것이라 그대로 믿지 않는다.
 * 여기서 하는 검증:
 *   1. 160행이 전부 있고 행 번호 = 퍼즐 번호인지
 *   2. 범위가 정확히 [2^(n-1), 2^n - 1] 인지
 *   3. 해결된 퍼즐: 개인키 -> 압축 공개키 -> 주소 가 원본 주소와 일치하는지
 *   4. 개인키가 실제로 그 범위 안에 들어있는지
 *   5. 공개키가 공개된 퍼즐: 공개키 -> 주소 가 원본 주소와 일치하는지
 * 하나라도 틀리면 빌드를 실패시킨다.
 */
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import * as secp from '@noble/secp256k1';
import { sha256 } from '@noble/hashes/sha2.js';
import { ripemd160 } from '@noble/hashes/legacy.js';
import { base58check } from '@scure/base';

const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const NOT_SOLVED = 'NOT SOLVED';

const b58c = base58check(sha256);

/** 압축 공개키(hex) -> P2PKH 주소 */
function pubkeyToAddress(pubHex) {
  const h160 = ripemd160(sha256(hexToBytes(pubHex)));
  const payload = new Uint8Array(21);
  payload[0] = 0x00; // mainnet P2PKH
  payload.set(h160, 1);
  return b58c.encode(payload);
}

function hexToBytes(hex) {
  const clean = hex.length % 2 ? '0' + hex : hex;
  const out = new Uint8Array(clean.length / 2);
  for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
  return out;
}

/** 개인키(hex, 64자) -> 압축 공개키(hex) */
function privToPub(privHex) {
  return secp.etc.bytesToHex(secp.getPublicKey(hexToBytes(privHex.padStart(64, '0')), true));
}

// ---------------------------------------------------------------- 파싱
const raw = readFileSync(join(root, 'data/raw/puzzles.txt'), 'utf8');
const lines = raw.split('\n').filter((l) => l.trim());
const errors = [];

if (lines.length !== 160) errors.push(`행 수가 160이 아님: ${lines.length}`);

const puzzles = lines.map((line, idx) => {
  const n = idx + 1;
  const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
  if (cells.length !== 5) {
    errors.push(`#${n}: 컬럼 수가 5가 아님 (${cells.length})`);
    return null;
  }
  const [range, address, status, pub, priv] = cells;
  const [loHex, hiHex] = range.split(':');

  const lo = BigInt('0x' + loHex);
  const hi = BigInt('0x' + hiHex);

  // 2. 범위 검증
  const expLo = n === 1 ? 1n : 1n << BigInt(n - 1);
  const expHi = (1n << BigInt(n)) - 1n;
  if (lo !== expLo || hi !== expHi) {
    errors.push(`#${n}: 범위 불일치 — 원본 ${loHex}:${hiHex}, 기대 ${expLo.toString(16)}:${expHi.toString(16)}`);
  }

  const solved = status === 'SOLVED';
  const hasPub = pub !== NOT_SOLVED;
  const privateKey = priv !== NOT_SOLVED ? priv.toLowerCase() : null;

  // 3 & 4. 해결된 퍼즐 검증
  let publicKey = hasPub ? pub.toLowerCase() : null;
  if (privateKey) {
    const k = BigInt('0x' + privateKey);
    if (k < lo || k > hi) {
      errors.push(`#${n}: 개인키가 범위 밖 (${privateKey})`);
    }
    const derivedPub = privToPub(privateKey);
    if (publicKey && derivedPub !== publicKey) {
      errors.push(`#${n}: 개인키에서 유도한 공개키가 원본과 다름`);
    }
    publicKey = derivedPub;
    const derivedAddr = pubkeyToAddress(derivedPub);
    if (derivedAddr !== address) {
      errors.push(`#${n}: 개인키 -> 주소 불일치 — 유도 ${derivedAddr}, 원본 ${address}`);
    }
  } else if (publicKey) {
    // 5. 공개키만 있는 경우
    const derivedAddr = pubkeyToAddress(publicKey);
    if (derivedAddr !== address) {
      errors.push(`#${n}: 공개키 -> 주소 불일치 — 유도 ${derivedAddr}, 원본 ${address}`);
    }
  }

  // 2023-04-16 증액 이후 상금 규칙: 미해결 퍼즐은 n/10 BTC.
  // 해결된 퍼즐은 이미 인출되어 0이므로, 참고용 "당초 상금"만 계산해 둔다.
  const prize = n / 10;

  return {
    n,
    address,
    rangeStart: '0x' + lo.toString(16),
    rangeEnd: '0x' + hi.toString(16),
    bits: n,
    solved,
    // 공개키 노출 = Kangaroo/BSGS 로 O(2^(n/2)) 공격 가능
    pubkeyExposed: hasPub,
    publicKey,
    privateKey,
    prizeBtc: prize,
  };
}).filter(Boolean);

if (errors.length) {
  console.error('\n✗ 데이터 검증 실패\n');
  for (const e of errors) console.error('  ' + e);
  process.exit(1);
}

const solved = puzzles.filter((p) => p.solved);
const unsolved = puzzles.filter((p) => !p.solved);
const attackable = unsolved.filter((p) => p.pubkeyExposed);

const out = {
  generatedAt: new Date().toISOString(),
  source: 'https://github.com/roadhero/Bitcoin-Puzzle-Info (BTC-Solved-Unsolved.txt) — 전 항목 secp256k1 재유도 검증 완료',
  totals: {
    all: puzzles.length,
    solved: solved.length,
    unsolved: unsolved.length,
    pubkeyExposedUnsolved: attackable.length,
    remainingPrizeBtc: Number(unsolved.reduce((s, p) => s + p.prizeBtc, 0).toFixed(1)),
  },
  puzzles,
};

writeFileSync(join(root, 'data/puzzles.json'), JSON.stringify(out, null, 2) + '\n');

// /verify 페이지가 이 스크립트 전문을 보여준다. 런타임에 파일을 읽으면
// 파일시스템 없는 환경(Cloudflare Workers 등)에서 죽으므로 지금 박아둔다.
const selfPath = fileURLToPath(import.meta.url);
writeFileSync(
  join(root, 'data/build-data-source.json'),
  JSON.stringify({ path: 'scripts/build-data.mjs', source: readFileSync(selfPath, 'utf8') }, null, 2) + '\n',
);

console.log('✓ 검증 통과');
console.log(`  전체 ${out.totals.all} / 해결 ${out.totals.solved} / 미해결 ${out.totals.unsolved}`);
console.log(`  미해결 중 공개키 노출: ${attackable.map((p) => p.n).join(', ')}`);
console.log(`  남은 상금 합계: ${out.totals.remainingPrizeBtc} BTC`);
console.log(`  가장 쉬운 미해결: #${unsolved[0].n} (2^${unsolved[0].n - 1} ~ 2^${unsolved[0].n})`);
주장 2

“계산 결과가 맞는지 직접 확인해 보세요”

아래 도구에 개인키를 넣으면 공개키와 주소를 유도해서 보여줍니다. 이 사이트의 어떤 퍼즐 페이지를 열어서 거기 적힌 개인키를 복사해 넣어보세요. 같은 주소가 나와야 합니다. 안 나오면 우리가 틀린 겁니다.

반대로도 됩니다. 다른 사이트나 포럼에서 본 “퍼즐 #NN의 키는 이거다”라는 주장을 여기 넣어보면 진위가 바로 갈립니다.

이 입력창의 정체

개인키를 받는 입력창은 그 자체로 위험합니다. 다른 사이트에서 이런 걸 보면 일단 의심하세요. 여기가 안전하다고 주장하는 대신, 확인하는 법을 알려드립니다 — F12 → 네트워크 탭을 열고 키를 입력해보세요. 요청이 하나도 안 생깁니다.

여기 넣은 값은 어디로도 가지 않습니다. 계산은 전부 이 탭에서 일어납니다. 그래도 실제로 쓰는 지갑의 키는 넣지 마세요 — 습관을 만들면 언젠가 다른 사이트에서 넣게 됩니다.

정규화된 개인키1비트

0000000000000000000000000000000000000000000000000000000000000001

WIF (압축)

KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn

공개키 (압축)

0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798

주소 (압축)

1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH

공개키 (비압축)

0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8

주소 (비압축)

1EHNa6Q4Jz2uvNExL497mE43ikXhwF6kZm

주장 3

“어떤 키도 서버로 보내지 않습니다”

말로는 증명이 안 되니, 확인 방법 세 가지를 적어둡니다.

가장 쉬운 방법 — 네트워크 탭

F12로 개발자 도구를 열고 네트워크 탭을 켠 뒤, 탐색기를 몇 분 돌려보세요. 수백만 개의 키가 생성되는 동안 새로운 요청이 0건이어야 합니다.

확실한 방법 — 인터넷 끊기

페이지를 완전히 불러온 뒤 Wi-Fi를 끄고 탐색기를 돌려보세요. 여전히 작동합니다. 서버가 필요했다면 여기서 멈춥니다.

가장 확실한 방법 — 소스 읽기

탐색 로직은 Web Worker 파일 하나에 다 들어 있습니다.postMessage로 메인 스레드에 넘기는 건 시도 횟수·경과 시간·현재 위치뿐이고, 적중했을 때만 그 키가 넘어옵니다. 그것도 화면에 띄우기 위해서고, 네트워크로 나가는 코드는 파일 전체에 없습니다.

워커가 밖으로 내보내는 전부
self.postMessage({
  type: "progress",
  tried,          // 시도 횟수
  elapsedMs,      // 경과 시간
  cursor,         // 현재 위치 (hex)
});

// 적중했을 때만 (메인 스레드로만, 네트워크 아님)
self.postMessage({
  type: "found",
  privateKeyHex, h160, compressed, tried, elapsedMs,
});
구조적으로도 그렇습니다
  • 계정도 로그인도 없습니다. 당신을 식별할 방법 자체가 없습니다.
  • 애널리틱스·광고·추적 스크립트를 넣지 않았습니다.
  • 퍼즐 페이지의 잔액만 서버에서 조회하고, 그건 공개된 퍼즐 주소지 당신 것이 아닙니다.
주장 4

“몰래 쓸어가지 않습니다” — 허니팟

앞의 세 가지는 “우리가 보내지 않는다”는 증명입니다. 하지만 더 강한 질문이 남습니다 — 우리가 몰래 훔치고 있지 않다는 걸 어떻게 아는가?

여기에 대한 고전적인 답이 허니팟입니다. Arnaud Brousseau가 keys.lol을 검증할 때 쓴 방법이기도 합니다: 그 사이트에 노출되는 주소에 소액을 넣어두고, 몇 년간 없어지는지 지켜본 것. 3년 넘게 그대로였고, 그게 그 사이트에 대한 가장 설득력 있는 증거가 됐습니다.

같은 걸 우리 자신에게 겁니다. 규칙은 이렇습니다:

  1. 이 사이트가 만들어내는 주소 중 일부에 실제로 비트코인을 넣습니다.
  2. 그 주소와 잔액을 여기에 공개하고, 온체인에서 실시간으로 읽어옵니다.
  3. 우리가 몰래 키를 로깅해서 쓸어간다면, 이 숫자가 0이 됩니다.
  4. 당신이 직접 블록 익스플로러에서 확인할 수 있습니다.

이게 완벽한 증명은 아닙니다. 우리가 허니팟만 피해서 훔칠 수도 있으니까요. 하지만 “믿어주세요”보다는 훨씬 낫고, 시간이 쌓일수록 강해집니다.

설치 전

아직 허니팟을 심지 않았습니다. 있는 척하지 않겠습니다 — 자금을 넣는 순간 이 자리에 주소와 잔액이 실시간으로 표시되고, 그때부터 시계가 돌기 시작합니다.

운영자용 메모: data/honeypots.json entries에 주소를 넣으면 이 블록이 자동으로 살아납니다.

다른 사이트를 판별할 때도 쓰세요

이 방법은 우리한테만 쓰는 게 아닙니다. 어떤 키 생성 사이트든, 거기서 나온 주소에 소액을 넣고 며칠 지켜보면 됩니다. 없어지면 답이 나온 거고, 안 없어져도 최소한 즉시 쓸어가는 곳은 아닙니다.

마지막

여전히 의심스럽다면, 그게 맞습니다

암호화폐 근처에서 “개인키”라는 단어를 쓰는 사이트를 처음부터 신뢰하면 안 됩니다. 이 페이지의 목적은 우리를 믿게 만드는 게 아니라, 무엇을 어떻게 확인해야 하는지 알려드리는 것입니다. 그 기준을 익히고 나면 다른 사이트도 판별할 수 있게 됩니다 — 그게 더 중요합니다.

검증하다가 우리가 틀린 걸 찾으면 알려주세요. 고치고, 무엇이 틀렸었는지 여기에 적겠습니다.