@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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +67 -0
  3. package/dist/credential-message.d.ts +11 -0
  4. package/dist/credential-message.js +164 -0
  5. package/dist/credential-signer.d.ts +11 -0
  6. package/dist/credential-signer.js +17 -0
  7. package/dist/crypto.d.ts +51 -0
  8. package/dist/crypto.js +141 -0
  9. package/dist/decode.d.ts +8 -0
  10. package/dist/decode.js +27 -0
  11. package/dist/errors.d.ts +5 -0
  12. package/dist/errors.js +11 -0
  13. package/dist/index.d.ts +19 -0
  14. package/dist/index.js +35 -0
  15. package/dist/issue-credential.d.ts +4 -0
  16. package/dist/issue-credential.js +66 -0
  17. package/dist/issue.d.ts +9 -0
  18. package/dist/issue.js +65 -0
  19. package/dist/issuer-set.d.ts +2 -0
  20. package/dist/issuer-set.js +11 -0
  21. package/dist/present.d.ts +32 -0
  22. package/dist/present.js +73 -0
  23. package/dist/presentation-inputs.d.ts +77 -0
  24. package/dist/presentation-inputs.js +139 -0
  25. package/dist/profile.d.ts +32 -0
  26. package/dist/profile.js +122 -0
  27. package/dist/registry-client.d.ts +12 -0
  28. package/dist/registry-client.js +33 -0
  29. package/dist/resolver.d.ts +37 -0
  30. package/dist/resolver.js +37 -0
  31. package/dist/revocation.d.ts +25 -0
  32. package/dist/revocation.js +72 -0
  33. package/dist/signer.d.ts +19 -0
  34. package/dist/signer.js +2 -0
  35. package/dist/types.d.ts +111 -0
  36. package/dist/types.js +3 -0
  37. package/dist/verify.d.ts +29 -0
  38. package/dist/verify.js +102 -0
  39. package/dist/witness.d.ts +12 -0
  40. package/dist/witness.js +62 -0
  41. package/dist/zk-proof.d.ts +45 -0
  42. package/dist/zk-proof.js +130 -0
  43. package/package.json +55 -0
  44. package/src/credential-message.ts +173 -0
  45. package/src/credential-signer.ts +24 -0
  46. package/src/crypto.ts +146 -0
  47. package/src/decode.ts +25 -0
  48. package/src/errors.ts +12 -0
  49. package/src/index.ts +19 -0
  50. package/src/issue-credential.ts +78 -0
  51. package/src/issue.ts +83 -0
  52. package/src/issuer-set.ts +10 -0
  53. package/src/present.ts +101 -0
  54. package/src/presentation-inputs.ts +245 -0
  55. package/src/profile.ts +133 -0
  56. package/src/registry-client.ts +37 -0
  57. package/src/resolver.ts +62 -0
  58. package/src/revocation.ts +81 -0
  59. package/src/signer.ts +21 -0
  60. package/src/types.ts +116 -0
  61. package/src/verify.ts +118 -0
  62. package/src/witness.ts +69 -0
  63. 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
+ }
@@ -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
+ }