@coordination-network/credential 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/dist/credential-message.d.ts +11 -0
- package/dist/credential-message.js +164 -0
- package/dist/credential-signer.d.ts +11 -0
- package/dist/credential-signer.js +17 -0
- package/dist/crypto.d.ts +51 -0
- package/dist/crypto.js +141 -0
- package/dist/decode.d.ts +8 -0
- package/dist/decode.js +27 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +11 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +35 -0
- package/dist/issue-credential.d.ts +4 -0
- package/dist/issue-credential.js +66 -0
- package/dist/issue.d.ts +9 -0
- package/dist/issue.js +65 -0
- package/dist/issuer-set.d.ts +2 -0
- package/dist/issuer-set.js +11 -0
- package/dist/present.d.ts +32 -0
- package/dist/present.js +73 -0
- package/dist/presentation-inputs.d.ts +77 -0
- package/dist/presentation-inputs.js +139 -0
- package/dist/profile.d.ts +32 -0
- package/dist/profile.js +122 -0
- package/dist/registry-client.d.ts +12 -0
- package/dist/registry-client.js +33 -0
- package/dist/resolver.d.ts +37 -0
- package/dist/resolver.js +37 -0
- package/dist/revocation.d.ts +25 -0
- package/dist/revocation.js +72 -0
- package/dist/signer.d.ts +19 -0
- package/dist/signer.js +2 -0
- package/dist/types.d.ts +111 -0
- package/dist/types.js +3 -0
- package/dist/verify.d.ts +29 -0
- package/dist/verify.js +102 -0
- package/dist/witness.d.ts +12 -0
- package/dist/witness.js +62 -0
- package/dist/zk-proof.d.ts +45 -0
- package/dist/zk-proof.js +130 -0
- package/package.json +55 -0
- package/src/credential-message.ts +173 -0
- package/src/credential-signer.ts +24 -0
- package/src/crypto.ts +146 -0
- package/src/decode.ts +25 -0
- package/src/errors.ts +12 -0
- package/src/index.ts +19 -0
- package/src/issue-credential.ts +78 -0
- package/src/issue.ts +83 -0
- package/src/issuer-set.ts +10 -0
- package/src/present.ts +101 -0
- package/src/presentation-inputs.ts +245 -0
- package/src/profile.ts +133 -0
- package/src/registry-client.ts +37 -0
- package/src/resolver.ts +62 -0
- package/src/revocation.ts +81 -0
- package/src/signer.ts +21 -0
- package/src/types.ts +116 -0
- package/src/verify.ts +118 -0
- package/src/witness.ts +69 -0
- package/src/zk-proof.ts +198 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Identity } from "@semaphore-protocol/identity"
|
|
2
|
+
import { bytesToHex, randomBytes } from "@noble/hashes/utils"
|
|
3
|
+
import { EddsaPublicKey, EddsaSignature, StatusClaim } from "./types"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Device-side revocation material (spec 08). r is 32 random bytes, generated
|
|
7
|
+
* on the device, never leaving it. Everything below takes r as Uint8Array,
|
|
8
|
+
* never as a hex string: the two encodings derive different commitments
|
|
9
|
+
* (Semaphore hashes the raw input), and the regression test holds that line.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
function assertSecret(r: Uint8Array): void {
|
|
13
|
+
if (r.length !== 32) {
|
|
14
|
+
throw new Error("revocation secret must be 32 bytes")
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function nonceToBigint(nonce: Uint8Array): bigint {
|
|
19
|
+
if (nonce.length === 0) {
|
|
20
|
+
throw new Error("nonce must not be empty")
|
|
21
|
+
}
|
|
22
|
+
return BigInt(`0x${bytesToHex(nonce)}`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Generates a fresh 32-byte revocation secret r. Device-side only. */
|
|
26
|
+
export function generateRevocationSecret(): Uint8Array {
|
|
27
|
+
return randomBytes(32)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** commitment = new Identity(r).commitment (Semaphore V4). */
|
|
31
|
+
export function deriveRevocationCommitment(r: Uint8Array): bigint {
|
|
32
|
+
assertSecret(r)
|
|
33
|
+
return new Identity(r).commitment
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Signs the issuer's challenge nonce with the Baby Jubjub EdDSA key behind
|
|
38
|
+
* r, proving the device owns the commitment it asks the issuer to embed.
|
|
39
|
+
*/
|
|
40
|
+
export function signCommitmentOwnership(
|
|
41
|
+
r: Uint8Array,
|
|
42
|
+
nonce: Uint8Array
|
|
43
|
+
): { publicKey: EddsaPublicKey; signature: EddsaSignature } {
|
|
44
|
+
assertSecret(r)
|
|
45
|
+
const identity = new Identity(r)
|
|
46
|
+
const signature = identity.signMessage(nonceToBigint(nonce))
|
|
47
|
+
return {
|
|
48
|
+
publicKey: [identity.publicKey[0], identity.publicKey[1]],
|
|
49
|
+
signature: { R8: [signature.R8[0], signature.R8[1]], S: signature.S }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Issuer-side check: verifies the EdDSA signature over the nonce, recomputes
|
|
55
|
+
* the commitment from the public key, and compares it to the claimed one.
|
|
56
|
+
*/
|
|
57
|
+
export function verifyCommitmentOwnership(params: {
|
|
58
|
+
commitment: bigint
|
|
59
|
+
publicKey: EddsaPublicKey
|
|
60
|
+
signature: EddsaSignature
|
|
61
|
+
nonce: Uint8Array
|
|
62
|
+
}): boolean {
|
|
63
|
+
try {
|
|
64
|
+
if (!Identity.verifySignature(nonceToBigint(params.nonce), params.signature, params.publicKey)) {
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
return Identity.generateCommitment(params.publicKey) === params.commitment
|
|
68
|
+
} catch {
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Builds the status.cn_zk_revocation claim for the signed body (spec 07). */
|
|
74
|
+
export function buildStatusClaim(commitment: bigint, revocationRegistry: string): StatusClaim {
|
|
75
|
+
return {
|
|
76
|
+
cn_zk_revocation: {
|
|
77
|
+
revocationRegistry,
|
|
78
|
+
revocationCommitment: commitment.toString(10)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
package/src/signer.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Jwk } from "./types"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Issuer signing key, KMS-shaped. sign receives the JWS signing input in
|
|
5
|
+
* message mode (the service applies SHA-256 itself, never a precomputed
|
|
6
|
+
* digest) and returns the raw 64-byte r||s signature.
|
|
7
|
+
*/
|
|
8
|
+
export interface Signer {
|
|
9
|
+
getPublicKeyJwk(): Promise<Jwk>
|
|
10
|
+
sign(message: Uint8Array): Promise<Uint8Array>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Holder P-256 device key: the secure element in the app, a software key in
|
|
15
|
+
* tests. Same contract as Signer; a separate type because the two keys must
|
|
16
|
+
* never be interchangeable in signatures.
|
|
17
|
+
*/
|
|
18
|
+
export interface DeviceSigner extends Signer {
|
|
19
|
+
/** Signs an already-computed 32-byte digest, without hashing it again. */
|
|
20
|
+
signDigest(digest: Uint8Array): Promise<Uint8Array>
|
|
21
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** Credential and issuance types. */
|
|
2
|
+
|
|
3
|
+
/** A 32-byte value as a 0x-prefixed hex string (e.g. a networkId). */
|
|
4
|
+
export type Hex32 = `0x${string}`
|
|
5
|
+
|
|
6
|
+
/** An EC public key in JWK form (RFC 7517). This profile uses P-256 only. */
|
|
7
|
+
export interface Jwk {
|
|
8
|
+
kty: "EC"
|
|
9
|
+
crv: "P-256"
|
|
10
|
+
x: string
|
|
11
|
+
y: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ApprovalScope {
|
|
15
|
+
/** Programme the approval covers. */
|
|
16
|
+
programme: string
|
|
17
|
+
/** ISO 3166-1 alpha-3 country code. */
|
|
18
|
+
country: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Baby Jubjub EdDSA public key, shaped as Semaphore V4 exposes it. */
|
|
22
|
+
export type EddsaPublicKey = [bigint, bigint]
|
|
23
|
+
|
|
24
|
+
/** Baby Jubjub EdDSA signature, shaped as Semaphore V4 exposes it. */
|
|
25
|
+
export interface EddsaSignature {
|
|
26
|
+
R8: [bigint, bigint]
|
|
27
|
+
S: bigint
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The custom status member inside the signed body (spec 07). The commitment
|
|
32
|
+
* is issuer-attested and visible to WS1 verifiers by design.
|
|
33
|
+
*/
|
|
34
|
+
export interface StatusClaim {
|
|
35
|
+
cn_zk_revocation: {
|
|
36
|
+
/** urn:cn:contract: URN of the on-chain revocation registry. */
|
|
37
|
+
revocationRegistry: string
|
|
38
|
+
revocationCommitment: string
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The reconstructed claim set of a verified credential. */
|
|
43
|
+
export interface ApprovalCredentialClaims {
|
|
44
|
+
vct: string
|
|
45
|
+
iss: string
|
|
46
|
+
sub: string
|
|
47
|
+
jti: string
|
|
48
|
+
nbf: number
|
|
49
|
+
exp: number
|
|
50
|
+
cnf: { jwk: Jwk }
|
|
51
|
+
status: StatusClaim
|
|
52
|
+
approvalScope: ApprovalScope
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface IssuanceRequest {
|
|
56
|
+
issuerNetworkId: Hex32
|
|
57
|
+
subjectNetworkId: Hex32
|
|
58
|
+
approvalScope: ApprovalScope
|
|
59
|
+
validFrom: number
|
|
60
|
+
validUntil: number
|
|
61
|
+
confirmationKey: Jwk
|
|
62
|
+
accountPublicKey: Uint8Array
|
|
63
|
+
nonce: Uint8Array
|
|
64
|
+
devicePopSignature: Uint8Array
|
|
65
|
+
accountPopSignature: Uint8Array
|
|
66
|
+
revocation: {
|
|
67
|
+
commitment: bigint
|
|
68
|
+
publicKey: EddsaPublicKey
|
|
69
|
+
ownershipSignature: EddsaSignature
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface SdJwtIssueParams extends IssuanceRequest {
|
|
74
|
+
revocationRegistry: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ZkCredentialIssueParams extends IssuanceRequest {
|
|
78
|
+
identityPublicKey: EddsaPublicKey
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ZkCredentialBody {
|
|
82
|
+
programmeHash: bigint
|
|
83
|
+
countryCode: number
|
|
84
|
+
notBefore: number
|
|
85
|
+
expiresAt: number
|
|
86
|
+
deviceKeyCommitment: bigint
|
|
87
|
+
holderKeyCommitment: bigint
|
|
88
|
+
revocationCommitment: bigint
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ZkCredential {
|
|
92
|
+
body: ZkCredentialBody
|
|
93
|
+
issuerPublicKey: EddsaPublicKey
|
|
94
|
+
signature: EddsaSignature
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface SerializedZkCredential {
|
|
98
|
+
body: {
|
|
99
|
+
programmeHash: string
|
|
100
|
+
countryCode: number
|
|
101
|
+
notBefore: number
|
|
102
|
+
expiresAt: number
|
|
103
|
+
deviceKeyCommitment: string
|
|
104
|
+
holderKeyCommitment: string
|
|
105
|
+
revocationCommitment: string
|
|
106
|
+
}
|
|
107
|
+
issuerPublicKey: [string, string]
|
|
108
|
+
signature: { R8: [string, string]; S: string }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type VerifyErrorCode =
|
|
112
|
+
"MALFORMED" | "ISSUER_NOT_APPROVED" | "KEY_NOT_FOUND" | "PROOF_INVALID" | "EXPIRED" | "NOT_YET_VALID" | "REVOKED"
|
|
113
|
+
|
|
114
|
+
/** Checks run in spec order (07, verification flow) and the first failure wins. */
|
|
115
|
+
export type VerifyResult =
|
|
116
|
+
{ ok: true; claims: ApprovalCredentialClaims } | { ok: false; code: VerifyErrorCode; message: string }
|
package/src/verify.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { SDJwtVcInstance } from "@sd-jwt/sd-jwt-vc"
|
|
2
|
+
import { base64urlToBytes, createSdJwtVerifier, sdJwtHasher } from "./crypto"
|
|
3
|
+
import { urnToNetworkId, validateClaims } from "./profile"
|
|
4
|
+
import { Resolver } from "./resolver"
|
|
5
|
+
import { ApprovalCredentialClaims, Hex32, Jwk, VerifyErrorCode, VerifyResult } from "./types"
|
|
6
|
+
|
|
7
|
+
export interface VerifyOptions {
|
|
8
|
+
/** Verification time, unix seconds. Defaults to now. */
|
|
9
|
+
currentDate?: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const fail = (code: VerifyErrorCode, message: string): VerifyResult => ({ ok: false, code, message })
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The shared verification pipeline (spec 07, verification flow), check order
|
|
16
|
+
* first-failure-wins: decode/structure -> issuer approved -> key fetch ->
|
|
17
|
+
* signature and disclosure digests -> claims shape -> validity window ->
|
|
18
|
+
* revocation. A disclosure whose digest matches nothing is silently dropped
|
|
19
|
+
* by the library (phase 1 spike), so it surfaces in the claims-shape step as
|
|
20
|
+
* a missing approvalScope member and fails MALFORMED.
|
|
21
|
+
*
|
|
22
|
+
* The library's own nbf/exp check runs against a clamped timestamp so the
|
|
23
|
+
* window verdict (EXPIRED vs NOT_YET_VALID) always comes from our check,
|
|
24
|
+
* after the signature has been verified.
|
|
25
|
+
*/
|
|
26
|
+
async function runPipeline(
|
|
27
|
+
token: string,
|
|
28
|
+
resolver: Resolver,
|
|
29
|
+
now: number,
|
|
30
|
+
verifyWithLibrary: (issuerJwk: Jwk, clampedNow: number) => Promise<Record<string, unknown>>
|
|
31
|
+
): Promise<{ result: VerifyResult; issuerId?: Hex32 }> {
|
|
32
|
+
let payload: Record<string, unknown>
|
|
33
|
+
try {
|
|
34
|
+
const jwt = token.split("~")[0]
|
|
35
|
+
const segments = jwt.split(".")
|
|
36
|
+
if (segments.length !== 3) {
|
|
37
|
+
return { result: fail("MALFORMED", "not a compact SD-JWT") }
|
|
38
|
+
}
|
|
39
|
+
payload = JSON.parse(new TextDecoder().decode(base64urlToBytes(segments[1])))
|
|
40
|
+
if (typeof payload !== "object" || payload === null) {
|
|
41
|
+
return { result: fail("MALFORMED", "JWS payload is not an object") }
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
return { result: fail("MALFORMED", "token could not be decoded") }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const issuerId = typeof payload.iss === "string" ? urnToNetworkId(payload.iss) : null
|
|
48
|
+
if (issuerId === null) {
|
|
49
|
+
return { result: fail("MALFORMED", "iss missing or not a urn:cn:network URN") }
|
|
50
|
+
}
|
|
51
|
+
if (!(await resolver.isApprovedNetworkMember(issuerId))) {
|
|
52
|
+
return {
|
|
53
|
+
result: fail("ISSUER_NOT_APPROVED", `issuer ${payload.iss} is not an approved network member`),
|
|
54
|
+
issuerId
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const issuerJwk = await resolver.getCredentialPublicKey(issuerId)
|
|
58
|
+
if (issuerJwk === null) {
|
|
59
|
+
return { result: fail("KEY_NOT_FOUND", `no credential public key published for ${payload.iss}`), issuerId }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Clamp the library's clock inside the token's window; our own check below decides the verdict.
|
|
63
|
+
let clampedNow = now
|
|
64
|
+
if (typeof payload.exp === "number" && clampedNow > payload.exp) clampedNow = payload.exp
|
|
65
|
+
if (typeof payload.nbf === "number" && clampedNow < payload.nbf) clampedNow = payload.nbf
|
|
66
|
+
|
|
67
|
+
let reconstructed: Record<string, unknown>
|
|
68
|
+
try {
|
|
69
|
+
reconstructed = await verifyWithLibrary(issuerJwk, clampedNow)
|
|
70
|
+
} catch (error) {
|
|
71
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
72
|
+
if (message.includes("not yet valid")) return { result: fail("NOT_YET_VALID", message), issuerId }
|
|
73
|
+
if (message.includes("expired")) return { result: fail("EXPIRED", message), issuerId }
|
|
74
|
+
return { result: fail("PROOF_INVALID", message), issuerId }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const checked = validateClaims(reconstructed)
|
|
78
|
+
if (!checked.ok) {
|
|
79
|
+
return { result: fail("MALFORMED", checked.message), issuerId }
|
|
80
|
+
}
|
|
81
|
+
const claims: ApprovalCredentialClaims = checked.claims
|
|
82
|
+
|
|
83
|
+
if (now < claims.nbf) {
|
|
84
|
+
return { result: fail("NOT_YET_VALID", "credential is not yet within its validity window"), issuerId }
|
|
85
|
+
}
|
|
86
|
+
if (now > claims.exp) {
|
|
87
|
+
return { result: fail("EXPIRED", "credential validity window has passed"), issuerId }
|
|
88
|
+
}
|
|
89
|
+
if (await resolver.isRevoked(BigInt(claims.status.cn_zk_revocation.revocationCommitment))) {
|
|
90
|
+
return { result: fail("REVOKED", "credential commitment is in the revocation registry"), issuerId }
|
|
91
|
+
}
|
|
92
|
+
return { result: { ok: true, claims }, issuerId }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Verifies a compact SD-JWT credential with all its disclosures — the
|
|
97
|
+
* holder's verify-on-receipt and any check that does not involve a live
|
|
98
|
+
* presentation. Key binding is not checked here; that is verifyPresentation.
|
|
99
|
+
*/
|
|
100
|
+
export async function verifySdJwtCredential(
|
|
101
|
+
token: string,
|
|
102
|
+
resolver: Resolver,
|
|
103
|
+
options?: VerifyOptions
|
|
104
|
+
): Promise<VerifyResult> {
|
|
105
|
+
const now = options?.currentDate ?? Math.floor(Date.now() / 1000)
|
|
106
|
+
const { result } = await runPipeline(token, resolver, now, async (issuerJwk, clampedNow) => {
|
|
107
|
+
const sdjwt = new SDJwtVcInstance({
|
|
108
|
+
verifier: createSdJwtVerifier(issuerJwk),
|
|
109
|
+
hasher: sdJwtHasher,
|
|
110
|
+
hashAlg: "sha-256"
|
|
111
|
+
})
|
|
112
|
+
const { payload } = await sdjwt.verify(token, { currentDate: clampedNow })
|
|
113
|
+
return payload
|
|
114
|
+
})
|
|
115
|
+
return result
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export { runPipeline }
|
package/src/witness.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { CompiledCircuit } from "@noir-lang/noir_js"
|
|
2
|
+
import { ZkPresentationInputs } from "./presentation-inputs"
|
|
3
|
+
|
|
4
|
+
type CircuitAbi = CompiledCircuit["abi"]
|
|
5
|
+
type AbiType = CircuitAbi["parameters"][number]["type"]
|
|
6
|
+
|
|
7
|
+
const DECIMAL_RE = /^(0|[1-9][0-9]*)$/
|
|
8
|
+
|
|
9
|
+
function scalarWitness(value: unknown, path: string): string {
|
|
10
|
+
if (typeof value !== "string" || !DECIMAL_RE.test(value)) {
|
|
11
|
+
throw new Error(`zkPresentationInputsToWitness: ${path} must be a canonical decimal string`)
|
|
12
|
+
}
|
|
13
|
+
return value
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function booleanWitness(value: unknown, path: string): string {
|
|
17
|
+
if (typeof value !== "boolean") {
|
|
18
|
+
throw new Error(`zkPresentationInputsToWitness: ${path} must be a boolean`)
|
|
19
|
+
}
|
|
20
|
+
return value ? "1" : "0"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Depth-first ABI walk (spec: field/integer -> decimal string, boolean -> "1"/"0", array/struct recurse). */
|
|
24
|
+
function emit(type: AbiType, value: unknown, path: string, witness: string[]): void {
|
|
25
|
+
switch (type.kind) {
|
|
26
|
+
case "field":
|
|
27
|
+
case "integer":
|
|
28
|
+
witness.push(scalarWitness(value, path))
|
|
29
|
+
return
|
|
30
|
+
case "boolean":
|
|
31
|
+
witness.push(booleanWitness(value, path))
|
|
32
|
+
return
|
|
33
|
+
case "array":
|
|
34
|
+
if (!Array.isArray(value) || value.length !== type.length) {
|
|
35
|
+
throw new Error(`zkPresentationInputsToWitness: ${path} must be an array of length ${type.length}`)
|
|
36
|
+
}
|
|
37
|
+
value.forEach((element, index) => emit(type.type, element, `${path}[${index}]`, witness))
|
|
38
|
+
return
|
|
39
|
+
case "struct": {
|
|
40
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
41
|
+
throw new Error(`zkPresentationInputsToWitness: ${path} must be an object`)
|
|
42
|
+
}
|
|
43
|
+
const fields = value as Record<string, unknown>
|
|
44
|
+
type.fields.forEach((field) => emit(field.type, fields[field.name], `${path}.${field.name}`, witness))
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
default:
|
|
48
|
+
throw new Error(`zkPresentationInputsToWitness: ${path} has unsupported ABI type "${type.kind}"`)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Flattens {@link ZkPresentationInputs} into the ordered `string[]` witness a
|
|
54
|
+
* native Noir prover consumes, walking the compiled circuit's ABI (public and
|
|
55
|
+
* private parameters, in declaration order) rather than the JS object's own
|
|
56
|
+
* key order — a struct field reorder in the circuit changes the ABI and so
|
|
57
|
+
* changes this output, instead of silently desyncing from it.
|
|
58
|
+
*/
|
|
59
|
+
export function zkPresentationInputsToWitness(abi: CircuitAbi, inputs: ZkPresentationInputs): string[] {
|
|
60
|
+
const record: Record<string, unknown> = { ...inputs }
|
|
61
|
+
const witness: string[] = []
|
|
62
|
+
for (const parameter of abi.parameters) {
|
|
63
|
+
if (!(parameter.name in record)) {
|
|
64
|
+
throw new Error(`zkPresentationInputsToWitness: unknown ABI parameter "${parameter.name}"`)
|
|
65
|
+
}
|
|
66
|
+
emit(parameter.type, record[parameter.name], parameter.name, witness)
|
|
67
|
+
}
|
|
68
|
+
return witness
|
|
69
|
+
}
|
package/src/zk-proof.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { BackendOptions, ProofData, UltraHonkBackendOptions } from "@aztec/bb.js"
|
|
2
|
+
import type { CompiledCircuit } from "@noir-lang/noir_js"
|
|
3
|
+
import type { InputMap } from "@noir-lang/noir_js"
|
|
4
|
+
import { countryCode, programmeHash, stringToField } from "./credential-message"
|
|
5
|
+
import { challengeToField, ZkPresentationInputs } from "./presentation-inputs"
|
|
6
|
+
|
|
7
|
+
const PUBLIC_INPUT_NAMES = [
|
|
8
|
+
"current_time",
|
|
9
|
+
"challenge",
|
|
10
|
+
"audience",
|
|
11
|
+
"required_programme_hash",
|
|
12
|
+
"required_country_code"
|
|
13
|
+
] as const
|
|
14
|
+
|
|
15
|
+
type PublicInputName = (typeof PUBLIC_INPUT_NAMES)[number]
|
|
16
|
+
|
|
17
|
+
export type ZkCredentialCircuit = CompiledCircuit
|
|
18
|
+
export type ZkPresentationProof = ProofData
|
|
19
|
+
|
|
20
|
+
export interface ExpectedZkPresentation {
|
|
21
|
+
currentTime: number
|
|
22
|
+
challenge: Uint8Array
|
|
23
|
+
audience: string
|
|
24
|
+
programme: string
|
|
25
|
+
country: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ZkPresentationVerifyResult =
|
|
29
|
+
| { ok: true; approvedIssuerRoot: bigint; revocationRoot: bigint }
|
|
30
|
+
| { ok: false; code: "PUBLIC_INPUT_MISMATCH"; publicInput?: PublicInputName }
|
|
31
|
+
| { ok: false; code: "PROOF_INVALID" }
|
|
32
|
+
|
|
33
|
+
export interface ZkProofSystemOptions {
|
|
34
|
+
backend?: BackendOptions
|
|
35
|
+
proof?: UltraHonkBackendOptions
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface NoirExecutor {
|
|
39
|
+
execute(inputs: InputMap): Promise<{ witness: Uint8Array; returnValue: unknown }>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ProofBackend {
|
|
43
|
+
generateProof(witness: Uint8Array, options?: UltraHonkBackendOptions): Promise<ProofData>
|
|
44
|
+
verifyProof(proof: ProofData, options?: UltraHonkBackendOptions): Promise<boolean>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface DisposableBackend {
|
|
48
|
+
destroy(): Promise<void>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const publicParameterNames = (circuit: CompiledCircuit): string[] =>
|
|
52
|
+
circuit.abi.parameters.filter((parameter) => parameter.visibility === "public").map((parameter) => parameter.name)
|
|
53
|
+
|
|
54
|
+
export const assertZkCredentialCircuitAbi = (circuit: CompiledCircuit): void => {
|
|
55
|
+
const actual = publicParameterNames(circuit)
|
|
56
|
+
if (
|
|
57
|
+
actual.length !== PUBLIC_INPUT_NAMES.length ||
|
|
58
|
+
actual.some((name, index) => name !== PUBLIC_INPUT_NAMES[index])
|
|
59
|
+
) {
|
|
60
|
+
throw new Error(`unexpected credential circuit public inputs: ${actual.join(", ")}`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const returnType = circuit.abi.return_type
|
|
64
|
+
if (
|
|
65
|
+
returnType === null ||
|
|
66
|
+
returnType.visibility !== "public" ||
|
|
67
|
+
returnType.abi_type.kind !== "tuple" ||
|
|
68
|
+
returnType.abi_type.fields.length !== 2 ||
|
|
69
|
+
returnType.abi_type.fields.some((field) => field.kind !== "field")
|
|
70
|
+
) {
|
|
71
|
+
throw new Error("credential circuit must return the proven issuer and revocation roots as public Fields")
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const expectedPublicInputs = (expected: ExpectedZkPresentation): bigint[] => {
|
|
76
|
+
if (!Number.isSafeInteger(expected.currentTime) || expected.currentTime < 0) {
|
|
77
|
+
throw new Error("currentTime must be a non-negative safe integer")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return [
|
|
81
|
+
BigInt(expected.currentTime),
|
|
82
|
+
challengeToField(expected.challenge),
|
|
83
|
+
stringToField(expected.audience),
|
|
84
|
+
programmeHash(expected.programme),
|
|
85
|
+
BigInt(countryCode(expected.country))
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const proofPublicValues = (proof: ZkPresentationProof): bigint[] | null => {
|
|
90
|
+
if (proof.publicInputs.length !== PUBLIC_INPUT_NAMES.length + 2) return null
|
|
91
|
+
try {
|
|
92
|
+
return proof.publicInputs.map(BigInt)
|
|
93
|
+
} catch {
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const publicInputMismatch = (
|
|
99
|
+
proof: ZkPresentationProof,
|
|
100
|
+
expected: ExpectedZkPresentation
|
|
101
|
+
): PublicInputName | undefined | null => {
|
|
102
|
+
const values = proofPublicValues(proof)
|
|
103
|
+
if (values === null) return null
|
|
104
|
+
const actual = values.slice(0, PUBLIC_INPUT_NAMES.length)
|
|
105
|
+
const wanted = expectedPublicInputs(expected)
|
|
106
|
+
const mismatch = actual.findIndex((value, index) => value !== wanted[index])
|
|
107
|
+
return mismatch < 0 ? undefined : PUBLIC_INPUT_NAMES[mismatch]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export class ZkCredentialProofSystem {
|
|
111
|
+
private constructor(
|
|
112
|
+
private readonly noir: NoirExecutor,
|
|
113
|
+
private readonly backend: ProofBackend,
|
|
114
|
+
private readonly api: DisposableBackend,
|
|
115
|
+
private readonly proofOptions?: UltraHonkBackendOptions
|
|
116
|
+
) {}
|
|
117
|
+
|
|
118
|
+
static async create(
|
|
119
|
+
circuit: CompiledCircuit,
|
|
120
|
+
options: ZkProofSystemOptions = {}
|
|
121
|
+
): Promise<ZkCredentialProofSystem> {
|
|
122
|
+
assertZkCredentialCircuitAbi(circuit)
|
|
123
|
+
const [{ Noir }, { Barretenberg, UltraHonkBackend }] = await Promise.all([
|
|
124
|
+
import("@noir-lang/noir_js"),
|
|
125
|
+
import("@aztec/bb.js")
|
|
126
|
+
])
|
|
127
|
+
const api = await Barretenberg.new(options.backend)
|
|
128
|
+
return new ZkCredentialProofSystem(
|
|
129
|
+
new Noir(circuit),
|
|
130
|
+
new UltraHonkBackend(circuit.bytecode, api),
|
|
131
|
+
api,
|
|
132
|
+
options.proof
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async generateProof(inputs: ZkPresentationInputs): Promise<ZkPresentationProof> {
|
|
137
|
+
const { witness } = await this.noir.execute(inputs as unknown as InputMap)
|
|
138
|
+
const proof = await this.backend.generateProof(witness, this.proofOptions)
|
|
139
|
+
return { proof: proof.proof, publicInputs: [...proof.publicInputs] }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async verifyProof(
|
|
143
|
+
proof: ZkPresentationProof,
|
|
144
|
+
expected: ExpectedZkPresentation
|
|
145
|
+
): Promise<ZkPresentationVerifyResult> {
|
|
146
|
+
const mismatch = publicInputMismatch(proof, expected)
|
|
147
|
+
if (mismatch === null) return { ok: false, code: "PUBLIC_INPUT_MISMATCH" }
|
|
148
|
+
if (mismatch !== undefined) return { ok: false, code: "PUBLIC_INPUT_MISMATCH", publicInput: mismatch }
|
|
149
|
+
if (!(proof.proof instanceof Uint8Array) || proof.proof.length === 0 || proof.proof.length % 32 !== 0) {
|
|
150
|
+
return { ok: false, code: "PROOF_INVALID" }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
if (!(await this.backend.verifyProof(proof, this.proofOptions))) {
|
|
155
|
+
return { ok: false, code: "PROOF_INVALID" }
|
|
156
|
+
}
|
|
157
|
+
const publicValues = proofPublicValues(proof)!
|
|
158
|
+
return {
|
|
159
|
+
ok: true,
|
|
160
|
+
approvedIssuerRoot: publicValues[PUBLIC_INPUT_NAMES.length],
|
|
161
|
+
revocationRoot: publicValues[PUBLIC_INPUT_NAMES.length + 1]
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
return { ok: false, code: "PROOF_INVALID" }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async destroy(): Promise<void> {
|
|
169
|
+
await this.api.destroy()
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function generateZkPresentationProof(
|
|
174
|
+
circuit: CompiledCircuit,
|
|
175
|
+
inputs: ZkPresentationInputs,
|
|
176
|
+
options: ZkProofSystemOptions = {}
|
|
177
|
+
): Promise<ZkPresentationProof> {
|
|
178
|
+
const proofSystem = await ZkCredentialProofSystem.create(circuit, options)
|
|
179
|
+
try {
|
|
180
|
+
return await proofSystem.generateProof(inputs)
|
|
181
|
+
} finally {
|
|
182
|
+
await proofSystem.destroy()
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function verifyZkPresentationProof(
|
|
187
|
+
circuit: CompiledCircuit,
|
|
188
|
+
proof: ZkPresentationProof,
|
|
189
|
+
expected: ExpectedZkPresentation,
|
|
190
|
+
options: ZkProofSystemOptions = {}
|
|
191
|
+
): Promise<ZkPresentationVerifyResult> {
|
|
192
|
+
const proofSystem = await ZkCredentialProofSystem.create(circuit, options)
|
|
193
|
+
try {
|
|
194
|
+
return await proofSystem.verifyProof(proof, expected)
|
|
195
|
+
} finally {
|
|
196
|
+
await proofSystem.destroy()
|
|
197
|
+
}
|
|
198
|
+
}
|