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
검증 스크립트 전문 보기 (179줄)
/**
 * 퍼즐 원본 데이터를 검증하고 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 body = {
  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,
};

/* generatedAt 은 "데이터가 바뀐 시각" 이지 "빌드를 돌린 시각" 이 아니다.
 * 빌드마다 새로 찍으면 커밋되는 data/puzzles.json 이 매번 더러워지고,
 * sitemap 의 lastmod 도 아무것도 안 바뀐 날 전부 갱신된 것처럼 보인다
 * (app/sitemap.ts 가 피하려던 바로 그 잡음이다).
 * 그래서 나머지 내용이 이전과 같으면 이전 시각을 그대로 쓴다. */
const outPath = join(root, 'data/puzzles.json');
const sameAsBefore = (prev) =>
  prev && typeof prev.generatedAt === 'string' &&
  JSON.stringify({ ...prev, generatedAt: undefined }) ===
  JSON.stringify({ ...body, generatedAt: undefined });

let generatedAt = new Date().toISOString();
try {
  const prev = JSON.parse(readFileSync(outPath, 'utf8'));
  if (sameAsBefore(prev)) generatedAt = prev.generatedAt;
} catch {
  // 파일이 없거나 깨졌으면 지금 시각으로 새로 찍는다
}

const out = { generatedAt, ...body };

writeFileSync(outPath, 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})`);

그런데 그 검증은 라이브러리를 믿고 있습니다

위 스크립트는 @noble/secp256k1 로 개인키에서 공개키를 다시 만들어 대조합니다. 좋은 라이브러리지만, 여기에 구멍이 하나 있습니다 — 그 라이브러리가 틀렸다면 검증도 같이 틀립니다. “우리를 믿지 마세요” 라고 해 놓고 남을 믿으라고 하는 셈입니다.

그래서 타원곡선 산술을 BigInt 로 처음부터 다시 짰습니다. 유한체 나눗셈도, 점 덧셈도, 배가도, 스칼라 곱도 직접 구현했고 라이브러리를 한 줄도 쓰지 않았습니다. 그리고 개인키가 공개된 퍼즐 83개 전부를 두 구현으로 각각 유도해 대조합니다.

두 구현은 서로를 참조하지 않습니다. 그러니 둘 다 틀리려면 서로 다른 코드가 같은 방식으로 틀려야 합니다. 곡선 산술이 스스로 일관적인지도 함께 확인합니다 — (a+b)G = aG + bG 인지, n·G 가 무한원점인지 같은 것들입니다.

이 방식은 Arnaud Brousseau 의 keys.deconstructed 에서 가져왔습니다. 그가 keys.lol 을 의존성 0으로 재구현한 것과 같은 동기입니다. 다만 코드는 옮기지 않고 새로 썼습니다 — 그쪽 점 덧셈에는 대칭점 분기가 죽어 있는 버그가 있습니다.

독립 구현으로 대조
83
개인키가 공개된 전부
불일치
0
직접 구현 = 노블 = 저장된 값
이것도 직접 돌려보세요

빌드할 때마다 자동으로 돌고, 하나라도 어긋나면 빌드가 실패합니다.

교차검증 실행
node scripts/crosscheck.mjs

# 곡선 산술이 스스로 일관적인가
#   ✓ G + (-G) = 무한원점
#   ✓ (a+b)G = aG + bG
#   ✓ n·G = 무한원점 (군의 위수)
#
# 퍼즐 83개를 두 구현으로 각각 유도해 대조
#   ✓ 83개 전부 일치 (직접 구현 = 노블 = 저장된 값)
#
# ✓ 교차검증 통과
교차검증 스크립트 전문 보기 (195줄)
/**
 * 노블 라이브러리를 믿지 않기 위한 두 번째 구현.
 *
 * 이 사이트는 @noble/secp256k1 로 개인키가 공개된 퍼즐의 개인키 -> 주소를 재유도해서
 * 데이터가 맞는지 확인한다(scripts/build-data.mjs). 그런데 그 검증은
 * "노블이 맞다" 를 전제로 한다. 노블이 틀렸다면 검증도 같이 틀린다.
 *
 * 그래서 여기서는 타원곡선 산술을 BigInt 로 처음부터 다시 짠다.
 * 라이브러리를 한 줄도 쓰지 않는다 — 유한체 나눗셈도, 점 덧셈도, 배가도.
 * 그리고 개인키가 있는 83개 전부에서 노블과 같은 공개키가 나오는지 대조한다.
 * (나머지 77개는 개인키가 없어서 유도할 대상 자체가 없다.)
 *
 * 두 구현이 독립이므로, 둘 다 틀리려면 서로 다른 코드가 같은 방식으로
 * 틀려야 한다. 그건 훨씬 있을 법하지 않은 일이다.
 *
 * 참고: Arnaud Brousseau 의 keys.deconstructed 가 같은 동기로 쓰였다.
 * 다만 그 구현의 점 덧셈에는 대칭점 분기가 죽어 있는 버그가 있어
 * (this.y.eq(this.y) 는 언제나 참) 코드를 가져오지 않고 새로 썼다.
 */
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';

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

/* ────────────────────────── 유한체 ──────────────────────────
 * 비트코인 곡선이 사는 소수체. p = 2^256 - 2^32 - 977
 * (부동소수점을 거치지 않고 BigInt 로만 쓴다)               */
const P = 2n ** 256n - 2n ** 32n - 977n;
const N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;

const mod = (a) => ((a % P) + P) % P;

/** a^e mod P — 제곱-곱셈법. BigInt 의 ** 는 지수가 크면 터진다. */
function powmod(a, e) {
  let r = 1n, b = mod(a);
  while (e > 0n) {
    if (e & 1n) r = (r * b) % P;
    b = (b * b) % P;
    e >>= 1n;
  }
  return r;
}

/** 1/a mod P — 페르마의 소정리: p 가 소수면 a^(p-2) ≡ 1/a */
const inv = (a) => powmod(a, P - 2n);

/* ──────────────────── 타원곡선 y² = x³ + 7 ────────────────────
 * 점은 {x, y} 이고 무한원점(항등원)은 null 로 둔다.          */

/** 이 점이 정말 곡선 위에 있는가 */
function onCurve(pt) {
  if (pt === null) return true;
  return mod(pt.y * pt.y) === mod(pt.x * pt.x * pt.x + 7n);
}

function double(pt) {
  if (pt === null) return null;
  if (pt.y === 0n) return null;              // 접선이 수직 -> 무한원점
  const s = mod(3n * pt.x * pt.x * inv(2n * pt.y));
  const x = mod(s * s - 2n * pt.x);
  return { x, y: mod(s * (pt.x - x) - pt.y) };
}

function add(a, b) {
  if (a === null) return b;                  // 0 + B = B
  if (b === null) return a;                  // A + 0 = A
  if (a.x === b.x) {
    // 여기가 브루소 구현이 틀린 자리다. y 가 다르면 서로 대칭점이므로 합은 0.
    if (mod(a.y + b.y) === 0n) return null;
    return double(a);                        // 같은 점이면 배가
  }
  const s = mod((b.y - a.y) * inv(b.x - a.x));
  const x = mod(s * s - a.x - b.x);
  return { x, y: mod(s * (a.x - x) - a.y) };
}

/** k·G — 아래에서 위로 훑는 배가-덧셈 */
function multiply(k, pt) {
  let r = null, cur = pt, n = k;
  while (n > 0n) {
    if (n & 1n) r = add(r, cur);
    cur = double(cur);
    n >>= 1n;
  }
  return r;
}

const G = {
  x: 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n,
  y: 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n,
};

const hex64 = (n) => n.toString(16).padStart(64, '0');

/** 개인키 -> 압축 공개키 hex. 라이브러리 없이. */
function derivePubkey(k) {
  const pt = multiply(k, G);
  if (pt === null) throw new Error('무한원점이 나왔다');
  if (!onCurve(pt)) throw new Error('곡선 위의 점이 아니다');
  return (pt.y % 2n === 0n ? '02' : '03') + hex64(pt.x);
}

/* ──────────────────────────── 검사 ──────────────────────────── */
let fail = 0;
const ok = (name, cond, extra = '') => {
  if (cond) console.log(`  ✓ ${name}${extra ? '  ' + extra : ''}`);
  else { console.log(`  ✗ ${name}${extra ? '  ' + extra : ''}`); fail++; }
};

console.log('\n곡선 산술이 스스로 일관적인가');
ok('G 가 곡선 위에 있다', onCurve(G));
ok('2G 가 곡선 위에 있다', onCurve(double(G)));
ok('G + G = 2G', (() => { const a = add(G, G), b = double(G); return a.x === b.x && a.y === b.y; })());
ok('G + (-G) = 무한원점', add(G, { x: G.x, y: mod(-G.y) }) === null);
ok('3G = 2G + G = G + 2G', (() => {
  const a = add(double(G), G), b = add(G, double(G));
  return a.x === b.x && a.y === b.y;
})());
ok('(a+b)G = aG + bG', (() => {
  const a = 1234567n, b = 7654321n;
  const l = multiply(a + b, G), r = add(multiply(a, G), multiply(b, G));
  return l.x === r.x && l.y === r.y;
})());
ok('n·G = 무한원점 (군의 위수)', multiply(N, G) === null);

// 공개된 표준 벡터. https://en.bitcoin.it/wiki/Secp256k1
console.log('\n표준 시험값과 대조');
ok('1·G 의 x 좌표', hex64(multiply(1n, G).x) === '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798');
ok('2·G 의 x 좌표', hex64(multiply(2n, G).x) === 'c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5');
ok('3·G 의 x 좌표', hex64(multiply(3n, G).x) === 'f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9');
// SHA-256 과 RIPEMD-160 은 노블 것을 쓰므로, 공개된 시험값으로 따로 확인한다
const h = (b) => Buffer.from(b).toString('hex');
ok('SHA-256("abc")', h(sha256(Buffer.from('abc'))) === 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');
ok('RIPEMD-160("abc")', h(ripemd160(Buffer.from('abc'))) === '8eb208f7e05d987a9b044a8e98c6b087f15a0bfc');

/* ── 본론: 160개 퍼즐을 두 구현으로 각각 유도해 대조 ── */
const data = JSON.parse(readFileSync(join(root, 'data/puzzles.json'), 'utf8'));
const withKey = data.puzzles.filter((p) => p.privateKey);

console.log(`\n퍼즐 ${withKey.length}개를 두 구현으로 각각 유도해 대조`);
const t0 = process.hrtime.bigint();
let mismatch = [];
for (const p of withKey) {
  const k = BigInt('0x' + p.privateKey);
  const mine = derivePubkey(k);
  const noble = secp.etc.bytesToHex(secp.getPublicKey(hexToBytes(hex64(k)), true));
  if (mine !== noble || mine !== p.publicKey) {
    mismatch.push({ n: p.n, mine, noble, stored: p.publicKey });
  }
}
const ms = Number(process.hrtime.bigint() - t0) / 1e6;

ok(`${withKey.length}개 전부 일치 (직접 구현 = 노블 = 저장된 값)`, mismatch.length === 0,
   `${ms.toFixed(0)}ms`);
for (const m of mismatch) {
  console.log(`      #${m.n}\n        직접 ${m.mine}\n        노블 ${m.noble}\n        저장 ${m.stored}`);
}

// 공개키만 공개된 미해결 퍼즐: 저장된 공개키가 곡선 위의 점인지라도 확인한다
const pubOnly = data.puzzles.filter((p) => p.publicKey && !p.privateKey);
let bad = 0;
for (const p of pubOnly) {
  const x = BigInt('0x' + p.publicKey.slice(2));
  const y2 = mod(x * x * x + 7n);
  // 제곱근이 존재하는가 — 오일러 판정법
  if (powmod(y2, (P - 1n) / 2n) !== 1n) bad++;
}
ok(`미해결 중 공개키 공개된 ${pubOnly.length}개가 전부 곡선 위의 점`, bad === 0);

function hexToBytes(s) {
  const o = new Uint8Array(s.length / 2);
  for (let i = 0; i < o.length; i++) o[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
  return o;
}

// /verify 페이지가 이 스크립트 전문을 보여준다. 런타임에 파일을 읽으면
// 파일시스템 없는 환경에서 죽으므로 지금 박아둔다.
// 커밋되는 파일이므로 실행마다 달라지는 값(측정 시간 같은)은 넣지 않는다.
// 걸린 시간은 위 콘솔 출력에만 남긴다.
writeFileSync(
  join(root, 'data/crosscheck-source.json'),
  JSON.stringify({
    path: 'scripts/crosscheck.mjs',
    source: readFileSync(fileURLToPath(import.meta.url), 'utf8'),
    checked: withKey.length,
  }, null, 2) + '\n',
);

console.log(fail === 0 ? '\n✓ 교차검증 통과 — 독립된 두 구현이 같은 답을 냅니다\n' : `\n✗ ${fail}개 실패\n`);
process.exit(fail === 0 ? 0 : 1);
주장 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에 주소를 넣으면 이 블록이 자동으로 살아납니다.

남이 먼저 한 실험6.8년째 그대로

Arnaud Brousseau 는 2019년에 keys.lol 을 검증하려고 이 방법을 먼저 썼습니다. 그 사이트가 표시하는 키 중 하나에 0.001 BTC 를 넣고, 사이트가 몰래 쓸어가는지 지켜본 것입니다.

1A2zNEFyMAfmZdy4yeXUoaPdt5Kns6vu3s
0.00100000 BTC2019-12-28에 심음 · 출금 0건

6.8년이 지나도록 움직이지 않았습니다. 이건 우리 사이트에 대한 증거가 아니라 이 방법이 실제로 작동한다는 증거입니다 — 그리고 시간이 쌓일수록 강해진다는 것도요. 우리 것은 이제 시작입니다.

Arnaud Brousseau 의 원문 →
다른 사이트를 판별할 때도 쓰세요

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

마지막

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

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

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

돕기

이 사이트는 광고를 받지 않습니다

그래서 수익이 없습니다. 쓰시다가 도움이 됐다면 아래 둘 중 하나로 보태 주시면 됩니다. 둘 다 링크와 글자뿐입니다 — 누르기 전에는 아무 일도 일어나지 않고, 누구를 추적하지도 않습니다.

후원 — 비트코인
후원 비트코인 주소 QR 코드
bc1q6cgvpy6v6dkqawhad3km5n3nq0fjzt5qtc9cyz

받는 주소는 바꿔치기의 표적입니다. 보내기 전에 QR 과 아래 글자가 서로 같은지 맞춰 보세요. 둘 다 같은 커밋에서 나오므로, 한쪽만 손댄 페이지라면 그 자리에서 어긋납니다.

하드웨어 지갑

키를 종이에만 적어 두셨다면 다음 단계는 하드웨어 지갑입니다. 저희가 직접 운영하는 스토어라, 남의 제휴 링크를 붙인 것이 아닙니다. 여기서 사시면 이 사이트를 굴리는 데 보탬이 됩니다.

스토어 보기