@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
package/src/issue.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { SDJwtVcInstance, SdJwtVcPayload } from "@sd-jwt/sd-jwt-vc"
2
+ import { DisclosureFrame } from "@sd-jwt/types"
3
+ import { InvalidBindingInputError, verifyCredentialBinding } from "@coordination-network/identity"
4
+ import { createSdJwtSigner, jwkToPublicKeyBytes, randomUuid, sdJwtHasher, sdJwtSaltGenerator } from "./crypto"
5
+ import { IssuanceError } from "./errors"
6
+ import { buildPayload, DISCLOSURE_FRAME } from "./profile"
7
+ import { ApprovalResolver } from "./resolver"
8
+ import { verifyCommitmentOwnership } from "./revocation"
9
+ import { Signer } from "./signer"
10
+ import { Hex32, SdJwtIssueParams } from "./types"
11
+
12
+ /**
13
+ * Issues the delegated user approval credential (spec 07, issuance flow).
14
+ * Throws IssuanceError, without signing anything, if the issuer isn't
15
+ * approved or any challenge signature fails.
16
+ */
17
+ export async function issueSdJwtCredential(
18
+ params: SdJwtIssueParams,
19
+ signer: Signer,
20
+ approval: ApprovalResolver
21
+ ): Promise<string> {
22
+ if (!(await approval.isApprovedNetworkMember(params.issuerNetworkId))) {
23
+ throw new IssuanceError(
24
+ "ISSUER_NOT_APPROVED",
25
+ `issuer ${params.issuerNetworkId} is not an approved network member`
26
+ )
27
+ }
28
+ if (!(params.validFrom < params.validUntil)) {
29
+ throw new IssuanceError("INVALID_PARAMS", "validFrom must be before validUntil")
30
+ }
31
+
32
+ let binding: ReturnType<typeof verifyCredentialBinding>
33
+ try {
34
+ binding = verifyCredentialBinding({
35
+ nonce: params.nonce,
36
+ accountPublicKey: params.accountPublicKey,
37
+ confirmationKey: jwkToPublicKeyBytes(params.confirmationKey),
38
+ accountSignature: params.accountPopSignature,
39
+ deviceSignature: params.devicePopSignature
40
+ })
41
+ } catch (error) {
42
+ if (error instanceof InvalidBindingInputError) {
43
+ throw new IssuanceError("INVALID_PARAMS", `malformed binding input: ${error.message}`)
44
+ }
45
+ throw error
46
+ }
47
+
48
+ if (binding.networkId !== params.subjectNetworkId.toLowerCase()) {
49
+ throw new IssuanceError("SUBJECT_MISMATCH", "subjectNetworkId does not match the account public key")
50
+ }
51
+ if (!binding.valid) {
52
+ throw new IssuanceError("BINDING_INVALID", "credential binding failed")
53
+ }
54
+
55
+ const ownershipOk = verifyCommitmentOwnership({
56
+ commitment: params.revocation.commitment,
57
+ publicKey: params.revocation.publicKey,
58
+ signature: params.revocation.ownershipSignature,
59
+ nonce: params.nonce
60
+ })
61
+ if (!ownershipOk) {
62
+ throw new IssuanceError("COMMITMENT_OWNERSHIP_INVALID", "revocation commitment ownership proof failed")
63
+ }
64
+
65
+ const payload = buildPayload(
66
+ { ...params, subjectNetworkId: binding.networkId as Hex32 },
67
+ `urn:uuid:${randomUuid()}`
68
+ )
69
+ const sdjwt = new SDJwtVcInstance({
70
+ signer: createSdJwtSigner(signer),
71
+ signAlg: "ES256",
72
+ hasher: sdJwtHasher,
73
+ hashAlg: "sha-256",
74
+ saltGenerator: sdJwtSaltGenerator
75
+ })
76
+ // Cast: the library types `status` as its own status-list reference, but
77
+ // our custom cn_zk_revocation member is deliberate (spec 07) and the
78
+ // status-list fetcher only triggers on status.status_list.
79
+ return sdjwt.issue(
80
+ { ...payload } as unknown as SdJwtVcPayload,
81
+ DISCLOSURE_FRAME as unknown as DisclosureFrame<SdJwtVcPayload>
82
+ )
83
+ }
@@ -0,0 +1,10 @@
1
+ // The approved-issuer set now lives in @coordination-network/issuer-set, which the ApprovedIssuerSet.sol
2
+ // contract's TypeScript mirror also builds on. Re-exported here so every existing import of this module
3
+ // keeps working unchanged.
4
+ export {
5
+ ApprovedIssuerSet,
6
+ ISSUER_LEAF_DOMAIN,
7
+ ISSUER_SET_MAX_DEPTH,
8
+ issuerLeaf
9
+ } from "@coordination-network/issuer-set"
10
+ export type { IssuerMembershipProof, IssuerSet } from "@coordination-network/issuer-set"
package/src/present.ts ADDED
@@ -0,0 +1,101 @@
1
+ import { SDJwtVcInstance } from "@sd-jwt/sd-jwt-vc"
2
+ import { JwtPayload, PresentationFrame } from "@sd-jwt/types"
3
+ import { createSdJwtSigner, createSdJwtVerifier, sdJwtHasher, sdJwtSaltGenerator } from "./crypto"
4
+ import { Resolver } from "./resolver"
5
+ import { DeviceSigner } from "./signer"
6
+ import { Jwk, VerifyResult } from "./types"
7
+ import { runPipeline } from "./verify"
8
+
9
+ export interface PresentOptions {
10
+ /** Verifier URN, pinned as the KB-JWT audience. */
11
+ audience: string
12
+ /** Verifier-issued nonce, echoed in the KB-JWT. */
13
+ nonce: string
14
+ /** Which approvalScope members to disclose. Both when omitted. */
15
+ disclose?: ("programme" | "country")[]
16
+ }
17
+
18
+ export interface VerifyPresentationOptions {
19
+ /** The verifier's own URN; the KB-JWT audience must match it. */
20
+ audience: string
21
+ /** The nonce this verifier handed out; must be fresh and match. */
22
+ nonce: string
23
+ /** Verification time, unix seconds. Defaults to now. */
24
+ currentDate?: number
25
+ }
26
+
27
+ /**
28
+ * Builds a presentation from a stored credential: the JWS, the selected
29
+ * disclosures, and a KB-JWT signed by the device key binding the verifier's
30
+ * audience, the nonce and the sd_hash of the exact presented token (spec 07,
31
+ * verification flow step 7).
32
+ */
33
+ export async function presentSdJwtCredential(
34
+ token: string,
35
+ deviceSigner: DeviceSigner,
36
+ options: PresentOptions
37
+ ): Promise<string> {
38
+ const disclose = options.disclose ?? ["programme", "country"]
39
+ const frame = { approvalScope: Object.fromEntries(disclose.map((member) => [member, true])) }
40
+ const sdjwt = new SDJwtVcInstance({
41
+ hasher: sdJwtHasher,
42
+ hashAlg: "sha-256",
43
+ saltGenerator: sdJwtSaltGenerator,
44
+ kbSigner: createSdJwtSigner(deviceSigner),
45
+ kbSignAlg: "ES256"
46
+ })
47
+ return sdjwt.present(token, frame as unknown as PresentationFrame<Record<string, unknown>>, {
48
+ kb: {
49
+ payload: {
50
+ aud: options.audience,
51
+ nonce: options.nonce,
52
+ iat: Math.floor(Date.now() / 1000)
53
+ }
54
+ }
55
+ })
56
+ }
57
+
58
+ /**
59
+ * Verifies a presentation: the full verifyCredential pipeline, plus the
60
+ * KB-JWT under the credential's cnf.jwk with nonce, sd_hash (both checked by
61
+ * the library) and audience (checked here) pinned.
62
+ */
63
+ export async function verifySdJwtPresentation(
64
+ presentation: string,
65
+ resolver: Resolver,
66
+ options: VerifyPresentationOptions
67
+ ): Promise<VerifyResult> {
68
+ const now = options.currentDate ?? Math.floor(Date.now() / 1000)
69
+ let audience: string | undefined
70
+
71
+ const { result } = await runPipeline(presentation, resolver, now, async (issuerJwk: Jwk, clampedNow: number) => {
72
+ const sdjwt = new SDJwtVcInstance({
73
+ verifier: createSdJwtVerifier(issuerJwk),
74
+ hasher: sdJwtHasher,
75
+ hashAlg: "sha-256",
76
+ kbVerifier: (data: string, signature: string, payload: JwtPayload) => {
77
+ const cnfJwk = (payload as { cnf?: { jwk?: Jwk } }).cnf?.jwk
78
+ if (!cnfJwk) return false
79
+ try {
80
+ return createSdJwtVerifier(cnfJwk)(data, signature)
81
+ } catch {
82
+ return false
83
+ }
84
+ }
85
+ })
86
+ const { payload, kb } = await sdjwt.verify(presentation, {
87
+ currentDate: clampedNow,
88
+ keyBindingNonce: options.nonce
89
+ })
90
+ audience = kb?.payload.aud
91
+ return payload
92
+ })
93
+
94
+ if (!result.ok) {
95
+ return result
96
+ }
97
+ if (audience !== options.audience) {
98
+ return { ok: false, code: "PROOF_INVALID", message: "KB-JWT audience does not match this verifier" }
99
+ }
100
+ return result
101
+ }
@@ -0,0 +1,245 @@
1
+ import { Identity } from "@semaphore-protocol/identity"
2
+ import { poseidon2 } from "poseidon-lite/poseidon2"
3
+ import { poseidon4 } from "poseidon-lite/poseidon4"
4
+ import { bytesToHex, hexToBytes } from "@noble/hashes/utils"
5
+ import {
6
+ countryCode,
7
+ deriveDeviceKeyCommitment,
8
+ parseZkCredential,
9
+ programmeHash,
10
+ stringToField,
11
+ verifyZkCredential,
12
+ zkCredentialCommitment
13
+ } from "./credential-message"
14
+ import { jwkToPublicKeyBytes } from "./crypto"
15
+ import { issuerLeaf, ISSUER_SET_MAX_DEPTH, IssuerSet } from "./issuer-set"
16
+ import { DeviceSigner } from "./signer"
17
+
18
+ export const REVOCATION_MAX_DEPTH = 20
19
+ // keccak256(utf8("urn:cn:presentation:v1")) >> 8
20
+ export const PRESENTATION_DOMAIN = BigInt("47717037732995119958831198142288202228464361330145547649000821135537239485")
21
+
22
+ interface PaddedPath {
23
+ depth: string
24
+ indices: boolean[]
25
+ siblings: string[]
26
+ }
27
+
28
+ export interface ZkPresentationInputs {
29
+ current_time: string
30
+ challenge: string
31
+ audience: string
32
+ required_programme_hash: string
33
+ required_country_code: string
34
+ credential: {
35
+ programme_hash: string
36
+ country_code: string
37
+ not_before: string
38
+ expires_at: string
39
+ device_key_commitment: string
40
+ holder_key_commitment: string
41
+ revocation_commitment: string
42
+ }
43
+ issuer_pub_x: string
44
+ issuer_pub_y: string
45
+ issuer_sig_r8_x: string
46
+ issuer_sig_r8_y: string
47
+ issuer_sig_s: string
48
+ issuer_depth: string
49
+ issuer_indices: boolean[]
50
+ issuer_siblings: string[]
51
+ device_pub_x: string[]
52
+ device_pub_y: string[]
53
+ device_signature: string[]
54
+ identity_pub_x: string
55
+ identity_pub_y: string
56
+ identity_sig_r8_x: string
57
+ identity_sig_r8_y: string
58
+ identity_sig_s: string
59
+ revocation_pub_x: string
60
+ revocation_pub_y: string
61
+ revocation_sig_r8_x: string
62
+ revocation_sig_r8_y: string
63
+ revocation_sig_s: string
64
+ revocation_low_leaf_value: string
65
+ revocation_low_leaf_next: string
66
+ revocation_depth: string
67
+ revocation_indices: boolean[]
68
+ revocation_siblings: string[]
69
+ }
70
+
71
+ export interface ZkPresentationHolder {
72
+ identitySeed: Uint8Array
73
+ revocationSecret: Uint8Array
74
+ deviceSigner: DeviceSigner
75
+ }
76
+
77
+ export interface IndexedTree {
78
+ root: bigint
79
+ generateProof(value: bigint): {
80
+ proofType: 0 | 1
81
+ root: bigint
82
+ leaf: { value: bigint; nextValue: bigint }
83
+ leafIndex: number
84
+ siblings: bigint[]
85
+ }
86
+ }
87
+
88
+ export interface ZkPresentationChainState {
89
+ approvedIssuers: IssuerSet
90
+ revocations: IndexedTree
91
+ }
92
+
93
+ export interface ZkPresentationRequest {
94
+ challenge: Uint8Array
95
+ audience: string
96
+ currentTime: number
97
+ programme: string
98
+ country: string
99
+ }
100
+
101
+ export const challengeToField = (bytes: Uint8Array): bigint => {
102
+ if (bytes.length !== 31) throw new Error("presentation challenge must be exactly 31 bytes")
103
+ return BigInt(`0x${bytesToHex(bytes)}`)
104
+ }
105
+
106
+ const fieldToBytes = (value: bigint): Uint8Array => hexToBytes(value.toString(16).padStart(64, "0"))
107
+
108
+ const pathIndices = (leafIndex: number, depth: number): boolean[] =>
109
+ Array.from({ length: depth }, (_, index) => ((leafIndex >> index) & 1) === 1)
110
+
111
+ function padPath(leafIndex: number, siblings: bigint[], maxDepth: number, label: string): PaddedPath {
112
+ const depth = siblings.length
113
+ if (depth > maxDepth) {
114
+ throw new Error(`${label} path depth ${depth} exceeds the circuit maximum of ${maxDepth}`)
115
+ }
116
+ const pad = <T>(values: T[], filler: T) => [...values, ...Array(maxDepth - values.length).fill(filler)]
117
+ return {
118
+ depth: depth.toString(),
119
+ indices: pad(pathIndices(leafIndex, depth), false),
120
+ siblings: pad(siblings, 0n).map(String)
121
+ }
122
+ }
123
+
124
+ function issuerPath(issuerSet: IssuerSet, publicKey: [bigint, bigint]): PaddedPath {
125
+ const proof = issuerSet.generateProof(publicKey)
126
+ if (proof.root !== issuerSet.root) throw new Error("issuer proof root does not match the approved issuer set")
127
+
128
+ const padded = padPath(proof.pathIndex, proof.siblings, ISSUER_SET_MAX_DEPTH, "issuer")
129
+
130
+ const depth = proof.siblings.length
131
+ if (!Number.isSafeInteger(proof.pathIndex) || proof.pathIndex < 0 || proof.pathIndex >= 2 ** depth) {
132
+ throw new Error("issuer proof has an invalid path index")
133
+ }
134
+
135
+ let node = issuerLeaf(publicKey)
136
+ for (let level = 0; level < depth; level += 1) {
137
+ node = padded.indices[level]
138
+ ? poseidon2([proof.siblings[level], node])
139
+ : poseidon2([node, proof.siblings[level]])
140
+ }
141
+ if (node !== proof.root) throw new Error("issuer proof is invalid")
142
+
143
+ return padded
144
+ }
145
+
146
+ export async function buildZkPresentationInputs(
147
+ serializedCredential: unknown,
148
+ holder: ZkPresentationHolder,
149
+ chain: ZkPresentationChainState,
150
+ request: ZkPresentationRequest
151
+ ): Promise<ZkPresentationInputs> {
152
+ const credential = parseZkCredential(serializedCredential)
153
+ if (!verifyZkCredential(credential)) throw new Error("credential issuer signature is invalid")
154
+ const { body } = credential
155
+
156
+ const identity = new Identity(holder.identitySeed)
157
+ if (identity.commitment !== body.holderKeyCommitment) {
158
+ throw new Error("identity seed does not open the credential's holder-key commitment")
159
+ }
160
+
161
+ const revocation = new Identity(holder.revocationSecret)
162
+ if (revocation.commitment !== body.revocationCommitment) {
163
+ throw new Error("revocation secret does not open the credential's revocation commitment")
164
+ }
165
+
166
+ const devicePublicKey = await holder.deviceSigner.getPublicKeyJwk()
167
+ if (deriveDeviceKeyCommitment(devicePublicKey) !== body.deviceKeyCommitment) {
168
+ throw new Error("device signer does not open the credential's device-key commitment")
169
+ }
170
+
171
+ const issuer = issuerPath(chain.approvedIssuers, credential.issuerPublicKey)
172
+ const revocationProof =
173
+ chain.revocations.root === 0n
174
+ ? {
175
+ proofType: 1 as const,
176
+ root: 0n,
177
+ leaf: { value: 0n, nextValue: 0n },
178
+ leafIndex: 0,
179
+ siblings: [] as bigint[]
180
+ }
181
+ : chain.revocations.generateProof(body.revocationCommitment)
182
+ if (revocationProof.root !== chain.revocations.root) {
183
+ throw new Error("revocation proof root does not match the supplied revocation tree")
184
+ }
185
+ if (revocationProof.proofType !== 1) throw new Error("credential is revoked")
186
+ const revocationPath = padPath(
187
+ revocationProof.leafIndex,
188
+ revocationProof.siblings,
189
+ REVOCATION_MAX_DEPTH,
190
+ "revocation"
191
+ )
192
+
193
+ const challenge = challengeToField(request.challenge)
194
+ const audience = stringToField(request.audience)
195
+ const credentialCommitment = zkCredentialCommitment(body)
196
+ const sessionMessage = poseidon4([PRESENTATION_DOMAIN, challenge, audience, credentialCommitment])
197
+ const identitySignature = identity.signMessage(sessionMessage)
198
+ const revocationSignature = revocation.signMessage(sessionMessage)
199
+ const deviceSignature = await holder.deviceSigner.signDigest(fieldToBytes(sessionMessage))
200
+ if (deviceSignature.length !== 64) throw new Error("device signer must return a compact 64-byte signature")
201
+
202
+ const encodedDeviceKey = jwkToPublicKeyBytes(devicePublicKey)
203
+ return {
204
+ current_time: String(request.currentTime),
205
+ challenge: String(challenge),
206
+ audience: String(audience),
207
+ required_programme_hash: String(programmeHash(request.programme)),
208
+ required_country_code: String(countryCode(request.country)),
209
+ credential: {
210
+ programme_hash: String(body.programmeHash),
211
+ country_code: String(body.countryCode),
212
+ not_before: String(body.notBefore),
213
+ expires_at: String(body.expiresAt),
214
+ device_key_commitment: String(body.deviceKeyCommitment),
215
+ holder_key_commitment: String(body.holderKeyCommitment),
216
+ revocation_commitment: String(body.revocationCommitment)
217
+ },
218
+ issuer_pub_x: String(credential.issuerPublicKey[0]),
219
+ issuer_pub_y: String(credential.issuerPublicKey[1]),
220
+ issuer_sig_r8_x: String(credential.signature.R8[0]),
221
+ issuer_sig_r8_y: String(credential.signature.R8[1]),
222
+ issuer_sig_s: String(credential.signature.S),
223
+ issuer_depth: issuer.depth,
224
+ issuer_indices: issuer.indices,
225
+ issuer_siblings: issuer.siblings,
226
+ device_pub_x: Array.from(encodedDeviceKey.slice(1, 33), String),
227
+ device_pub_y: Array.from(encodedDeviceKey.slice(33, 65), String),
228
+ device_signature: Array.from(deviceSignature, String),
229
+ identity_pub_x: String(identity.publicKey[0]),
230
+ identity_pub_y: String(identity.publicKey[1]),
231
+ identity_sig_r8_x: String(identitySignature.R8[0]),
232
+ identity_sig_r8_y: String(identitySignature.R8[1]),
233
+ identity_sig_s: String(identitySignature.S),
234
+ revocation_pub_x: String(revocation.publicKey[0]),
235
+ revocation_pub_y: String(revocation.publicKey[1]),
236
+ revocation_sig_r8_x: String(revocationSignature.R8[0]),
237
+ revocation_sig_r8_y: String(revocationSignature.R8[1]),
238
+ revocation_sig_s: String(revocationSignature.S),
239
+ revocation_low_leaf_value: String(revocationProof.leaf.value),
240
+ revocation_low_leaf_next: String(revocationProof.leaf.nextValue),
241
+ revocation_depth: revocationPath.depth,
242
+ revocation_indices: revocationPath.indices,
243
+ revocation_siblings: revocationPath.siblings
244
+ }
245
+ }
package/src/profile.ts ADDED
@@ -0,0 +1,133 @@
1
+ import { ApprovalCredentialClaims, ApprovalScope, Hex32, Jwk, SdJwtIssueParams, StatusClaim } from "./types"
2
+ import { buildStatusClaim } from "./revocation"
3
+
4
+ /**
5
+ * The claims profile of the delegated user approval credential (spec 07):
6
+ * payload construction, the disclosure frame, and the shape validator that
7
+ * backs the MALFORMED verdict.
8
+ */
9
+
10
+ export const APPROVAL_CREDENTIAL_VCT = "urn:cn:credential:approval:v1"
11
+
12
+ /** Only the two approvalScope members are disclosable; everything else is plain. */
13
+ export const DISCLOSURE_FRAME = {
14
+ approvalScope: { _sd: ["programme", "country"] }
15
+ } as const
16
+
17
+ const NETWORK_ID_RE = /^0x[0-9a-f]{64}$/
18
+ const NETWORK_URN_RE = /^urn:cn:network:[0-9a-f]{64}$/
19
+ const CONTRACT_URN_RE = /^urn:cn:contract:.+$/
20
+ const UUID_URN_RE = /^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
21
+
22
+ export function networkIdToUrn(id: Hex32): string {
23
+ const normalized = id.toLowerCase() as Hex32
24
+ if (!NETWORK_ID_RE.test(normalized)) {
25
+ throw new Error("networkId must be a 0x-prefixed 32-byte hex string")
26
+ }
27
+ return `urn:cn:network:${normalized.slice(2)}`
28
+ }
29
+
30
+ export function urnToNetworkId(urn: string): Hex32 | null {
31
+ return NETWORK_URN_RE.test(urn) ? (`0x${urn.slice("urn:cn:network:".length)}` as Hex32) : null
32
+ }
33
+
34
+ /** Assembles the full signed payload; the disclosure frame is applied at issue time. */
35
+ export function buildPayload(params: SdJwtIssueParams, jti: string): ApprovalCredentialClaims {
36
+ return {
37
+ vct: APPROVAL_CREDENTIAL_VCT,
38
+ iss: networkIdToUrn(params.issuerNetworkId),
39
+ sub: networkIdToUrn(params.subjectNetworkId),
40
+ jti,
41
+ nbf: params.validFrom,
42
+ exp: params.validUntil,
43
+ cnf: { jwk: params.confirmationKey },
44
+ status: buildStatusClaim(params.revocation.commitment, params.revocationRegistry),
45
+ approvalScope: { ...params.approvalScope }
46
+ }
47
+ }
48
+
49
+ type ClaimsCheck = { ok: true; claims: ApprovalCredentialClaims } | { ok: false; message: string }
50
+
51
+ const ALLOWED_TOP_LEVEL = new Set(["vct", "iss", "sub", "jti", "nbf", "exp", "cnf", "status", "approvalScope"])
52
+
53
+ function isRecord(value: unknown): value is Record<string, unknown> {
54
+ return typeof value === "object" && value !== null && !Array.isArray(value)
55
+ }
56
+
57
+ function isJwk(value: unknown): value is Jwk {
58
+ if (!isRecord(value)) return false
59
+ const keys = Object.keys(value)
60
+ return (
61
+ keys.length === 4 &&
62
+ value.kty === "EC" &&
63
+ value.crv === "P-256" &&
64
+ typeof value.x === "string" &&
65
+ typeof value.y === "string"
66
+ )
67
+ }
68
+
69
+ function isStatusClaim(value: unknown): value is StatusClaim {
70
+ if (!isRecord(value) || Object.keys(value).length !== 1) return false
71
+ const entry = value.cn_zk_revocation
72
+ if (!isRecord(entry) || Object.keys(entry).length !== 2) return false
73
+ return (
74
+ typeof entry.revocationRegistry === "string" &&
75
+ CONTRACT_URN_RE.test(entry.revocationRegistry) &&
76
+ typeof entry.revocationCommitment === "string" &&
77
+ /^[0-9]+$/.test(entry.revocationCommitment)
78
+ )
79
+ }
80
+
81
+ function isApprovalScope(value: unknown): value is ApprovalScope {
82
+ if (!isRecord(value)) return false
83
+ const keys = Object.keys(value)
84
+ return (
85
+ keys.length === 2 &&
86
+ typeof value.programme === "string" &&
87
+ value.programme.length > 0 &&
88
+ typeof value.country === "string" &&
89
+ /^[A-Z]{3}$/.test(value.country)
90
+ )
91
+ }
92
+
93
+ /**
94
+ * Validates the reconstructed payload of a verified token against the
95
+ * profile. Strict on both sides: a missing required claim and an unknown
96
+ * plain claim both fail (spec 07; a tampered disclosure surfaces here as a
97
+ * missing approvalScope member).
98
+ */
99
+ export function validateClaims(payload: unknown): ClaimsCheck {
100
+ if (!isRecord(payload)) {
101
+ return { ok: false, message: "payload is not an object" }
102
+ }
103
+ for (const key of Object.keys(payload)) {
104
+ if (!ALLOWED_TOP_LEVEL.has(key)) {
105
+ return { ok: false, message: `unknown claim: ${key}` }
106
+ }
107
+ }
108
+ if (payload.vct !== APPROVAL_CREDENTIAL_VCT) {
109
+ return { ok: false, message: "vct missing or not the approval credential type" }
110
+ }
111
+ if (typeof payload.iss !== "string" || urnToNetworkId(payload.iss) === null) {
112
+ return { ok: false, message: "iss missing or not a urn:cn:network URN" }
113
+ }
114
+ if (typeof payload.sub !== "string" || urnToNetworkId(payload.sub) === null) {
115
+ return { ok: false, message: "sub missing or not a urn:cn:network URN" }
116
+ }
117
+ if (typeof payload.jti !== "string" || !UUID_URN_RE.test(payload.jti)) {
118
+ return { ok: false, message: "jti missing or not a urn:uuid URN" }
119
+ }
120
+ if (typeof payload.nbf !== "number" || typeof payload.exp !== "number") {
121
+ return { ok: false, message: "nbf/exp missing or not NumericDate values" }
122
+ }
123
+ if (!isRecord(payload.cnf) || Object.keys(payload.cnf).length !== 1 || !isJwk(payload.cnf.jwk)) {
124
+ return { ok: false, message: "cnf.jwk missing or not a P-256 JWK" }
125
+ }
126
+ if (!isStatusClaim(payload.status)) {
127
+ return { ok: false, message: "status.cn_zk_revocation missing or malformed" }
128
+ }
129
+ if (!isApprovalScope(payload.approvalScope)) {
130
+ return { ok: false, message: "approvalScope missing programme/country" }
131
+ }
132
+ return { ok: true, claims: payload as unknown as ApprovalCredentialClaims }
133
+ }
@@ -0,0 +1,37 @@
1
+ import { createPublicClient, http, PublicClient } from "viem"
2
+ import { Hex32 } from "./types"
3
+
4
+ const REGISTRY_ABI = [
5
+ {
6
+ type: "function",
7
+ name: "isApproved",
8
+ stateMutability: "view",
9
+ inputs: [{ name: "memberNetworkId", type: "bytes32" }],
10
+ outputs: [{ type: "bool" }]
11
+ }
12
+ ] as const
13
+
14
+ /**
15
+ * Chain-backed read of NetworkMemberRegistry.isApproved (spec, network member
16
+ * registry). Satisfies the approval half of Resolver so it can back the
17
+ * issuance and verification governance gates once a registry is deployed.
18
+ */
19
+ export class NetworkMemberRegistryClient {
20
+ private readonly client: PublicClient
21
+
22
+ constructor(
23
+ private readonly registryAddress: `0x${string}`,
24
+ rpcUrl: string
25
+ ) {
26
+ this.client = createPublicClient({ transport: http(rpcUrl) })
27
+ }
28
+
29
+ async isApprovedNetworkMember(id: Hex32): Promise<boolean> {
30
+ return this.client.readContract({
31
+ address: this.registryAddress,
32
+ abi: REGISTRY_ABI,
33
+ functionName: "isApproved",
34
+ args: [id]
35
+ })
36
+ }
37
+ }
@@ -0,0 +1,62 @@
1
+ import { Hex32, Jwk } from "./types"
2
+
3
+ /**
4
+ * Pluggable resolution of urn:cn: identifiers to registry state (spec 07,
5
+ * trust model). In-memory for now; registry contracts back a chain
6
+ * implementation later.
7
+ */
8
+ export interface Resolver {
9
+ isApprovedNetworkMember(id: Hex32): Promise<boolean>
10
+ getCredentialPublicKey(id: Hex32): Promise<Jwk | null>
11
+ isRevoked(commitment: bigint): Promise<boolean>
12
+ }
13
+
14
+ /** The approval half of Resolver — all issuance needs, and all the chain-backed registry client provides. */
15
+ export type ApprovalResolver = Pick<Resolver, "isApprovedNetworkMember">
16
+
17
+ /**
18
+ * Write side of the revocation registry. Separate from Resolver because
19
+ * verifiers never hold it.
20
+ */
21
+ export interface RevocationWriter {
22
+ revoke(commitment: bigint): Promise<void>
23
+ }
24
+
25
+ /**
26
+ * In-memory registry state for tests and the demo issuer service. The seed
27
+ * methods stand in for the one-time on-chain writes (member admission, key
28
+ * publication); the async interface methods mirror the chain reads.
29
+ */
30
+ export class InMemoryResolver implements Resolver, RevocationWriter {
31
+ private readonly approved = new Set<Hex32>()
32
+ private readonly credentialKeys = new Map<Hex32, Jwk>()
33
+ private readonly revoked = new Set<string>()
34
+
35
+ approveMember(id: Hex32): void {
36
+ this.approved.add(id)
37
+ }
38
+
39
+ removeMember(id: Hex32): void {
40
+ this.approved.delete(id)
41
+ }
42
+
43
+ setCredentialPublicKey(id: Hex32, jwk: Jwk): void {
44
+ this.credentialKeys.set(id, jwk)
45
+ }
46
+
47
+ async isApprovedNetworkMember(id: Hex32): Promise<boolean> {
48
+ return this.approved.has(id)
49
+ }
50
+
51
+ async getCredentialPublicKey(id: Hex32): Promise<Jwk | null> {
52
+ return this.credentialKeys.get(id) ?? null
53
+ }
54
+
55
+ async isRevoked(commitment: bigint): Promise<boolean> {
56
+ return this.revoked.has(commitment.toString())
57
+ }
58
+
59
+ async revoke(commitment: bigint): Promise<void> {
60
+ this.revoked.add(commitment.toString())
61
+ }
62
+ }