Skip to main content

Private data

이 문서에서는 Midnight 컨트랙트에서 데이터를 비공개로 유지하기 위한 몇 가지 전략을 소개합니다. 완전한 목록은 아니지만 시작점으로 활용할 수 있습니다.

가장 중요한 점은, [Historic]MerkleTree 데이터 유형을 제외하면, Compact에서 ledger 연산에 인수로 전달되는 모든 것과 ledger의 모든 읽기/쓰기가 공개적으로 노출된다는 것입니다. 이에 따라 적절히 다루어야 합니다. 공개되는 것은 인수 또는 ledger 값 자체이며, 이를 조작하는 코드가 아닙니다. 예를 들어:

export ledger items: Set<Field>;
export ledger others: MerkleTree<10, Field>;

// `item1`을 공개함
items.insert(item1);
// `f(x)`의 *값*을 공개하지만, `x` 자체를 직접 공개하지는 않음
items.member(f(x));
// 예외: `item2`를 공개하지 *않음*, 하지만 `item2`의 값을 추측하는 사람은
// 이를 확인할 수 있음!
others.insert(item2);

그러나 때로는 공개 상태에서 차폐된 데이터를 참조해야 할 때가 있습니다. 이런 경우 아래 패턴 중 하나가 도움이 됩니다.

Hashes and commitments

데이터를 공개적으로 저장하면서도 차폐 상태를 유지하는 가장 기본적인 방법은 데이터 전체 대신 해시 또는 commitment만 저장하는 것입니다.

Compact의 표준 라이브러리는 이를 위한 두 가지 주요 원시형을 제공합니다:

  • persistentHash: 이진 데이터 해싱을 위한 기본 구성 요소
  • persistentCommit: 모든 Compact 유형에서 commitment를 생성하기 위한 원시형.

둘 다 입력의 해시를 생성하는데, persistentHashBytes<32> 데이터 유형에 한정되고, persistentCommitBytes<32> 무작위 값과 함께 임의의 데이터를 해싱합니다. 해시는 출력으로부터 입력을 역산할 수 없고, 입력 전체를 추측하지 않는 한 입력에 대한 정보도 알아낼 수 없음을 보장합니다. persistentCommit에 추가 무작위성 입력이 있는 이유가 바로 이것입니다. 값 자체를 추측하여 해시 일치 여부를 확인하는 공격을 방지합니다. 이는 선거의 개별 투표처럼 가능한 값의 수가 적을 때 특히 중요합니다.

무작위성의 또 다른 이점은 동일한 값 간의 상관관계를 차단한다는 것입니다. 예를 들어 누군가의 비밀번호를 추측할 수 없더라도, 동일한 해시 값이 두 번 나타나면 같은 값이라는 사실을 알 수 있으며, 이는 누가 상태를 변경했는지에 대한 정보를 의도치 않게 유출할 수 있습니다.

충분한 무작위성을 사용하면 값의 commitment를 실제 값을 드러내지 않고 ledger에 저장할 수 있습니다.

Randomness and rounds in commitments

각 commitment마다 새로운 무작위성을 사용하는 것이 이상적이지만, 동일한 무작위성에 대해 데이터가 절대 같지 않도록 보장할 수 있다면 기존 무작위성을 재사용할 수도 있습니다. 일부 예시 애플리케이션에서 이 방식을 사용하는데, 라운드 카운터와 함께 비밀 키를 무작위성 소스로 재활용하여 라운드 간 연결 불가능성을 보장합니다.

caution

무작위성을 다룰 때는 주의가 필요합니다! 실수하기 쉬우므로, 가능하면 안전한 쪽을 선택하는 것이 좋습니다.

Authenticating with hashes

영지식 증명의 가장 유용한 기능 중 하나는 circuit 내에서 해시만으로 서명을 흉내 낼 수 있다는 것입니다. 비밀 키를 해싱하여 알려진 '공개 키'와 비교하는 것만으로, 컨트랙트는 비밀 키를 아는 사람만 트랜잭션을 진행할 수 있도록 보장할 수 있습니다. 다음은 생성자만 사용할 수 있는 컨트랙트의 예입니다:

import CompactStandardLibrary;

witness secretKey(): Bytes<32>;

export ledger organizer: Bytes<32>;
export ledger restrictedCounter: Counter;
constructor() {
organizer = publicKey(secretKey());
}

export circuit increment(): [] {
assert(organizer == publicKey(secretKey()), "not authorized");
restrictedCounter.increment(1);
}

circuit publicKey(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "some-domain-seperator"), sk]);
}

Making use of Merkle trees

Compact에서 MerkleTree<n, T>HistoricMerkleTree<n, T> 유형으로 제공되는 Merkle 트리는 집합에 포함된 값을 차폐하는 데 매우 유용한 도구입니다. 핵심 기능은 어떤 값인지 밝히지 않으면서도 특정 값이 MerkleTree에 포함되어 있음을 공개적으로 주장할 수 있다는 것입니다.

이는 commitment를 저장하는 Set<Bytes<32>>에서 포함 여부를 테스트하는 것보다 강력합니다. MerkleTree는 어떤 항목의 소속이 증명되는지 자체를 드러내지 않기 때문입니다. 이 속성을 활용하면, 예를 들어 각 연산에서 어떤 키가 인증에 사용되었는지 노출하지 않으면서 비밀 키 집합이 특정 연산을 수행하도록 인가할 수 있습니다.

내부적으로는 circuit이 트리에 삽입된 값까지의 경로에 대한 지식을 증명하고, 이 경로의 해시가 트리의 예상 경로와 일치하는지 확인하는 방식으로 작동합니다.

Compact 표준 라이브러리와 Compact JavaScript 대상 ADT는 이러한 연산을 위한 도구를 제공합니다. 구체적으로 MerkleTreePath<n, T> 유형, merkleTreePathRoot<n, T>() circuit, 그리고 ledger 데이터 유형 명세에 설명된 MerkleTree/HistoricMerkleTree JavaScript 상태 객체의 pathForLeaf()findPathForLeaf() 함수를 참조하세요.

이들을 조합하여 다음과 같이 사용할 수 있습니다:

import CompactStandardLibrary;

export ledger items: MerkleTree<10, Field>;

witness findItem(item: Field): MerkleTreePath<10, Field>;

export circuit insert(item: Field): [] {
items.insert(item);
}

export circuit check(item: Field): [] {
const path = findItem(item);
assert(items.checkRoot(merkleTreePathRoot<10, Field>(path.value)), "path must be valid");
}

findItem 구현:

function findItem(context: WitnessContext, item: bigint): MerkleTreePath<bigint> {
return context.ledger.items.findPathForLeaf(item)!;
}

pathForLeaf는 가능하면 우선적으로 사용하는 것이 좋습니다. 트리의 O(n) 스캔이 필요 없지만, 항목이 원래 어디에 배치되었는지 알고 있어야 합니다.

MerkleTree<n, T>HistoricMerkleTree<n, T>의 차이점은, 후자의 checkRoot가 Merkle 트리의 이전 버전에 대해 생성된 proof도 수용한다는 것입니다. 빈번한 삽입으로 오래된 proof가 무효화되는 상황에서 유용하지만, 항목이 자주 제거되거나 교체되는 경우에는 HistoricMerkleTree가 적합하지 않습니다. 이미 무효화되어야 할 proof가 여전히 유효한 것으로 인정될 수 있기 때문입니다.

The commitment/nullifier pattern

강력한 차폐 패턴 중 하나는 데이터를 두 가지 서로 다른 커밋된 형태("commitment"와 "nullifier")로 유지하는 것입니다. 전자는 Merkle 트리에, 후자는 Set에 보관합니다. 이 방식으로 먼저 Merkle 트리에 항목을 생성한 뒤, 사용 시 그 존재를 증명하고 nullifier를 Set에 추가하여 아직 사용되지 않았음을 주장함으로써 일회용 인증 토큰을 만들 수 있습니다. 이를 통해 어떤 토큰이 사용되었는지 밝히지 않으면서도 토큰의 재사용을 방지합니다. 이것이 Zerocash와 Zswap의 기본 패턴이며, 차폐된 UTXO를 구축하는 데 활용됩니다.

동일한 비밀 데이터에 대해 commitment와 nullifier가 같은 값을 갖지 않도록 도메인 분리자를 사용하는 것이 중요합니다. 또한 nullifier 생성에 비밀 지식을 요구하도록 설계하면(해시를 사용한 인증 섹션 참조), 초기 인가자조차 토큰 사용 여부를 식별할 수 없게 됩니다.

다음은 공개 키가 카운터를 한 번만 증가시킬 수 있도록 인가하는 예입니다:

import CompactStandardLibrary;

witness findAuthPath(pk: Bytes<32>): MerkleTreePath<10, Bytes<32>>;
witness secretKey(): Bytes<32>;

export ledger authorizedCommitments: HistoricMerkleTree<10, Bytes<32>>;
export ledger authorizedNullifiers: Set<Bytes<32>>;
export ledger restrictedCounter: Counter;

export circuit addAuthority(pk: Bytes<32>): [] {
authorizedCommitments.insert(pk);
}

export circuit increment(): [] {
const sk = secretKey();
const authPath = findAuthPath(publicKey(sk));
assert(authorizedCommitments.checkRoot(merkleTreePathRoot<10, Bytes<32>>(authPath)),
"not authorized");
const nul = nullifier(sk);
assert !authorizedNullifiers.member(nul) "already incremented";
authorizedNullifiers.insert(disclose(nul));
restrictedCounter.increment(1);
}

circuit publicKey(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "commitment-domain"), sk]);
}

circuit nullifier(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "nullifier-domain"), sk]);
}