Workspace IndexAlgorithms › Day 94

Privacy primitives — ring signatures, nullifiers, and stealth addresses TODO

Algorithms · Day 94 / 100 · F. Cryptography & ZK (Day 82-96)

Concept

The common goal of privacy primitives is separating "proving you have authorization" from "revealing who you are." A ring signature proves that the signer holds the private key for one of a set of public keys, without revealing which one; the strength of the anonymity depends on the size and composition of that set (the anonymity set). A nullifier is a unique tag deterministically derived from a secret value; publishing it prevents double-spending while keeping it unlinkable to the specific deposit or commitment it came from. The same secret always yields the same nullifier, so a second attempt to use it is caught immediately. A stealth address is derived when the sender uses ECDH with the recipient's public meta-address to create a shared secret and derive a one-time receiving address — only the recipient can recognize and spend from that address, so their receiving history doesn't accumulate on-chain under a single address.

On a public ledger, a single address exposes an entire transaction history and balance, making it possible to read a counterparty's position and front-run them — that's a privacy problem that turns straight into financial loss. Understanding privacy design is what lets you decide which information really needs to stay on-chain.

Code & Formula

# 프라이버시 프리미티브 — nullifier(중복 사용 방지)와 스텔스 주소(toy DH),
# 그리고 링 서명(AOS, 1-of-n)으로 "권한 증명"과 "신원 노출"을 분리하는 예시.
# 그룹은 학습용 소수: p=23, q=11(부분군 order), g=2. 실무 곡선 크기가 아니라 예시용.

import hashlib, random

p, q, g = 23, 11, 2

def H(*args) -> int:
    return int(hashlib.sha256("|".join(map(str, args)).encode()).hexdigest(), 16) % q

# --- 1) nullifier: 같은 비밀로는 항상 같은 태그가 나와 중복 사용을 검출 ---
spent = set()
def try_spend(secret):
    tag = H("nullify", secret)
    if tag in spent:
        return False
    spent.add(tag)
    return True

secret = 424242
print("첫 인출:", try_spend(secret))          # True
print("같은 비밀로 재인출 시도:", try_spend(secret))  # False — nullifier 재사용 검출

# --- 2) 스텔스 주소: toy Diffie-Hellman 로 송/수신자가 독립적으로 같은 주소 유도 ---
recv_priv = random.randrange(1, q); recv_pub = pow(g, recv_priv, p)
eph_priv = random.randrange(1, q); eph_pub = pow(g, eph_priv, p)
addr_sender = H("addr", pow(recv_pub, eph_priv, p))
addr_receiver = H("addr", pow(eph_pub, recv_priv, p))
print("스텔스 주소 일치(송신자==수신자 유도):", addr_sender == addr_receiver)

# --- 3) 링 서명(AOS): n명 중 누가 서명했는지 숨긴 채 "그중 하나"임만 증명 ---
def ring_sign(msg, pubs, idx, x):
    n = len(pubs); c = [0] * n; z = [0] * n
    k = random.randrange(1, q)
    i = (idx + 1) % n
    c[i] = H(msg, pow(g, k, p))
    while i != idx:
        z[i] = random.randrange(1, q)
        c[(i + 1) % n] = H(msg, pow(g, z[i], p) * pow(pubs[i], c[i], p) % p)
        i = (i + 1) % n
    z[idx] = (k - c[idx] * x) % q
    return c[0], z

def ring_verify(msg, pubs, c0, z):
    c = c0
    for i in range(len(pubs)):
        c = H(msg, pow(g, z[i], p) * pow(pubs[i], c, p) % p)
    return c == c0

secrets = [random.randrange(1, q) for _ in range(3)]
pubs = [pow(g, x, p) for x in secrets]
c0, z = ring_sign("transfer 10", pubs, 1, secrets[1])  # 실제 서명자는 인덱스 1
print("링 서명 검증(서명자가 3명 중 누구인지는 드러나지 않음):", ring_verify("transfer 10", pubs, c0, z))
print("메시지 변조 시 검증 실패:", not ring_verify("transfer 99", pubs, c0, z))

Exercise

Build a simple deposit/withdraw contract where deposits use a commitment hash and withdrawal is gated by publishing a nullifier so it can only happen once; test that a second withdrawal attempt with the same secret is blocked.

Practical Connection

In a prediction market, exposing the direction of a large position turns it straight into a front-running target, so Verex also needs to decide — from this angle — what order and settlement data to leave as public events versus what to keep off-chain.

If you study this on a given day, add a note link and a ✅ to this line in the source curriculum (docs/knowledge/dev-100-curriculum.md) and this spot will lead straight to the note body. You can also write directly on this page — but regenerating overwrites it, so it's safer to keep anything you want to save as markdown under docs/algorithms/.


한국어

프라이버시 프리미티브 TODO

Algorithms · Day 94 / 100 · F. 암호학·ZK (Day 82–96)

링 서명·nullifier·스텔스 주소

개념

프라이버시 프리미티브의 공통 목표는 "권한이 있음"을 증명하는 일과 "누구인지"를 드러내는 일을 분리하는 것이다. 링 서명은 여러 공개키로 이루어진 집합 중 하나의 비밀키 소유자가 서명했음을 증명하되 어느 것인지는 숨기며, 익명성의 강도는 그 집합(anonymity set)의 크기와 구성에 달린다. nullifier는 비밀값에서 결정적으로 유도한 고유 태그로, 이를 공개해 중복 사용을 막으면서도 어떤 예치·커밋먼트에서 나왔는지는 연결되지 않게 한다. 같은 비밀에서는 항상 같은 nullifier가 나오므로 두 번째 사용 시도는 즉시 검출된다. 스텔스 주소는 송신자가 수신자의 공개 메타주소와 ECDH로 공유 비밀을 만들어 일회성 수신 주소를 유도하는 방식으로, 수신자만 그 주소를 인식하고 지출할 수 있어 온체인상 수신 이력이 한 주소로 모이지 않는다.

공개 원장에서는 주소 하나가 전체 거래 이력과 잔고를 노출해 상대 포지션을 읽고 앞서 거래하는 것이 가능하며, 이는 개인정보 문제이자 곧바로 경제적 손실이다. 프라이버시 설계를 알아야 어느 정보를 굳이 온체인에 남길지 판단할 수 있다.

코드 · 수식

# 프라이버시 프리미티브 — nullifier(중복 사용 방지)와 스텔스 주소(toy DH),
# 그리고 링 서명(AOS, 1-of-n)으로 "권한 증명"과 "신원 노출"을 분리하는 예시.
# 그룹은 학습용 소수: p=23, q=11(부분군 order), g=2. 실무 곡선 크기가 아니라 예시용.

import hashlib, random

p, q, g = 23, 11, 2

def H(*args) -> int:
    return int(hashlib.sha256("|".join(map(str, args)).encode()).hexdigest(), 16) % q

# --- 1) nullifier: 같은 비밀로는 항상 같은 태그가 나와 중복 사용을 검출 ---
spent = set()
def try_spend(secret):
    tag = H("nullify", secret)
    if tag in spent:
        return False
    spent.add(tag)
    return True

secret = 424242
print("첫 인출:", try_spend(secret))          # True
print("같은 비밀로 재인출 시도:", try_spend(secret))  # False — nullifier 재사용 검출

# --- 2) 스텔스 주소: toy Diffie-Hellman 로 송/수신자가 독립적으로 같은 주소 유도 ---
recv_priv = random.randrange(1, q); recv_pub = pow(g, recv_priv, p)
eph_priv = random.randrange(1, q); eph_pub = pow(g, eph_priv, p)
addr_sender = H("addr", pow(recv_pub, eph_priv, p))
addr_receiver = H("addr", pow(eph_pub, recv_priv, p))
print("스텔스 주소 일치(송신자==수신자 유도):", addr_sender == addr_receiver)

# --- 3) 링 서명(AOS): n명 중 누가 서명했는지 숨긴 채 "그중 하나"임만 증명 ---
def ring_sign(msg, pubs, idx, x):
    n = len(pubs); c = [0] * n; z = [0] * n
    k = random.randrange(1, q)
    i = (idx + 1) % n
    c[i] = H(msg, pow(g, k, p))
    while i != idx:
        z[i] = random.randrange(1, q)
        c[(i + 1) % n] = H(msg, pow(g, z[i], p) * pow(pubs[i], c[i], p) % p)
        i = (i + 1) % n
    z[idx] = (k - c[idx] * x) % q
    return c[0], z

def ring_verify(msg, pubs, c0, z):
    c = c0
    for i in range(len(pubs)):
        c = H(msg, pow(g, z[i], p) * pow(pubs[i], c, p) % p)
    return c == c0

secrets = [random.randrange(1, q) for _ in range(3)]
pubs = [pow(g, x, p) for x in secrets]
c0, z = ring_sign("transfer 10", pubs, 1, secrets[1])  # 실제 서명자는 인덱스 1
print("링 서명 검증(서명자가 3명 중 누구인지는 드러나지 않음):", ring_verify("transfer 10", pubs, c0, z))
print("메시지 변조 시 검증 실패:", not ring_verify("transfer 99", pubs, c0, z))

연습

간단한 예치·인출 컨트랙트를 두고 커밋먼트 해시로 예치하고 nullifier 공개로 1회만 인출되게 하는 흐름을 구현해, 같은 비밀로 두 번 인출이 막히는지 테스트하라.

실무 · Verex 연결

예측시장에서는 대형 포지션의 방향이 노출되면 그대로 선행 매매 대상이 되므로, Verex도 주문·정산 데이터 중 무엇을 공개 이벤트로 남기고 무엇을 오프체인에 둘지를 이 관점에서 설계해야 한다.

공부한 날 원본 커리큘럼(docs/knowledge/dev-100-curriculum.md)의 이 줄에 노트 링크와 ✅ 를 붙이면, 이 자리는 노트 본문으로 바로 이어집니다. 노트 없이 이 페이지에 바로 적어도 됩니다 — 다만 다시 생성하면 덮어쓰이므로, 남길 글은 docs/algorithms/ 의 마크다운으로 쓰는 편이 안전합니다.

← 93. 재귀 증명과 증명 집계95. 포스트퀀텀 전환은 암호가 아니라 조정(coordination) 문제 →