@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,130 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ZkCredentialProofSystem = exports.assertZkCredentialCircuitAbi = void 0;
4
+ exports.generateZkPresentationProof = generateZkPresentationProof;
5
+ exports.verifyZkPresentationProof = verifyZkPresentationProof;
6
+ const credential_message_1 = require("./credential-message");
7
+ const presentation_inputs_1 = require("./presentation-inputs");
8
+ const PUBLIC_INPUT_NAMES = [
9
+ "current_time",
10
+ "challenge",
11
+ "audience",
12
+ "required_programme_hash",
13
+ "required_country_code"
14
+ ];
15
+ const publicParameterNames = (circuit) => circuit.abi.parameters.filter((parameter) => parameter.visibility === "public").map((parameter) => parameter.name);
16
+ const assertZkCredentialCircuitAbi = (circuit) => {
17
+ const actual = publicParameterNames(circuit);
18
+ if (actual.length !== PUBLIC_INPUT_NAMES.length ||
19
+ actual.some((name, index) => name !== PUBLIC_INPUT_NAMES[index])) {
20
+ throw new Error(`unexpected credential circuit public inputs: ${actual.join(", ")}`);
21
+ }
22
+ const returnType = circuit.abi.return_type;
23
+ if (returnType === null ||
24
+ returnType.visibility !== "public" ||
25
+ returnType.abi_type.kind !== "tuple" ||
26
+ returnType.abi_type.fields.length !== 2 ||
27
+ returnType.abi_type.fields.some((field) => field.kind !== "field")) {
28
+ throw new Error("credential circuit must return the proven issuer and revocation roots as public Fields");
29
+ }
30
+ };
31
+ exports.assertZkCredentialCircuitAbi = assertZkCredentialCircuitAbi;
32
+ const expectedPublicInputs = (expected) => {
33
+ if (!Number.isSafeInteger(expected.currentTime) || expected.currentTime < 0) {
34
+ throw new Error("currentTime must be a non-negative safe integer");
35
+ }
36
+ return [
37
+ BigInt(expected.currentTime),
38
+ (0, presentation_inputs_1.challengeToField)(expected.challenge),
39
+ (0, credential_message_1.stringToField)(expected.audience),
40
+ (0, credential_message_1.programmeHash)(expected.programme),
41
+ BigInt((0, credential_message_1.countryCode)(expected.country))
42
+ ];
43
+ };
44
+ const proofPublicValues = (proof) => {
45
+ if (proof.publicInputs.length !== PUBLIC_INPUT_NAMES.length + 2)
46
+ return null;
47
+ try {
48
+ return proof.publicInputs.map(BigInt);
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ };
54
+ const publicInputMismatch = (proof, expected) => {
55
+ const values = proofPublicValues(proof);
56
+ if (values === null)
57
+ return null;
58
+ const actual = values.slice(0, PUBLIC_INPUT_NAMES.length);
59
+ const wanted = expectedPublicInputs(expected);
60
+ const mismatch = actual.findIndex((value, index) => value !== wanted[index]);
61
+ return mismatch < 0 ? undefined : PUBLIC_INPUT_NAMES[mismatch];
62
+ };
63
+ class ZkCredentialProofSystem {
64
+ constructor(noir, backend, api, proofOptions) {
65
+ this.noir = noir;
66
+ this.backend = backend;
67
+ this.api = api;
68
+ this.proofOptions = proofOptions;
69
+ }
70
+ static async create(circuit, options = {}) {
71
+ (0, exports.assertZkCredentialCircuitAbi)(circuit);
72
+ const [{ Noir }, { Barretenberg, UltraHonkBackend }] = await Promise.all([
73
+ import("@noir-lang/noir_js"),
74
+ import("@aztec/bb.js")
75
+ ]);
76
+ const api = await Barretenberg.new(options.backend);
77
+ return new ZkCredentialProofSystem(new Noir(circuit), new UltraHonkBackend(circuit.bytecode, api), api, options.proof);
78
+ }
79
+ async generateProof(inputs) {
80
+ const { witness } = await this.noir.execute(inputs);
81
+ const proof = await this.backend.generateProof(witness, this.proofOptions);
82
+ return { proof: proof.proof, publicInputs: [...proof.publicInputs] };
83
+ }
84
+ async verifyProof(proof, expected) {
85
+ const mismatch = publicInputMismatch(proof, expected);
86
+ if (mismatch === null)
87
+ return { ok: false, code: "PUBLIC_INPUT_MISMATCH" };
88
+ if (mismatch !== undefined)
89
+ return { ok: false, code: "PUBLIC_INPUT_MISMATCH", publicInput: mismatch };
90
+ if (!(proof.proof instanceof Uint8Array) || proof.proof.length === 0 || proof.proof.length % 32 !== 0) {
91
+ return { ok: false, code: "PROOF_INVALID" };
92
+ }
93
+ try {
94
+ if (!(await this.backend.verifyProof(proof, this.proofOptions))) {
95
+ return { ok: false, code: "PROOF_INVALID" };
96
+ }
97
+ const publicValues = proofPublicValues(proof);
98
+ return {
99
+ ok: true,
100
+ approvedIssuerRoot: publicValues[PUBLIC_INPUT_NAMES.length],
101
+ revocationRoot: publicValues[PUBLIC_INPUT_NAMES.length + 1]
102
+ };
103
+ }
104
+ catch {
105
+ return { ok: false, code: "PROOF_INVALID" };
106
+ }
107
+ }
108
+ async destroy() {
109
+ await this.api.destroy();
110
+ }
111
+ }
112
+ exports.ZkCredentialProofSystem = ZkCredentialProofSystem;
113
+ async function generateZkPresentationProof(circuit, inputs, options = {}) {
114
+ const proofSystem = await ZkCredentialProofSystem.create(circuit, options);
115
+ try {
116
+ return await proofSystem.generateProof(inputs);
117
+ }
118
+ finally {
119
+ await proofSystem.destroy();
120
+ }
121
+ }
122
+ async function verifyZkPresentationProof(circuit, proof, expected, options = {}) {
123
+ const proofSystem = await ZkCredentialProofSystem.create(circuit, options);
124
+ try {
125
+ return await proofSystem.verifyProof(proof, expected);
126
+ }
127
+ finally {
128
+ await proofSystem.destroy();
129
+ }
130
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@coordination-network/credential",
3
+ "version": "0.0.1",
4
+ "description": "Verifiable credential SDK for the coordination network: issuance, verification, holder binding and revocation helpers.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/zk-kit/coordination-network.git",
9
+ "directory": "packages/credential"
10
+ },
11
+ "homepage": "https://github.com/zk-kit/coordination-network/tree/main/packages/credential",
12
+ "bugs": "https://github.com/zk-kit/coordination-network/issues",
13
+ "sideEffects": false,
14
+ "main": "dist/index.js",
15
+ "types": "dist/index.d.ts",
16
+ "files": [
17
+ "dist/",
18
+ "src/",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "installConfig": {
26
+ "hoistingLimits": "workspaces"
27
+ },
28
+ "scripts": {
29
+ "build": "tsc -p tsconfig.build.json",
30
+ "test": "jest"
31
+ },
32
+ "devDependencies": {
33
+ "@coordination-network/zk-artifacts": "^0.0.1",
34
+ "@sd-jwt/core": "^0.19.0",
35
+ "@sd-jwt/types": "^0.19.0",
36
+ "@types/jest": "^30.0.0",
37
+ "@types/node": "^24.0.0",
38
+ "jest": "^30.4.2",
39
+ "ts-jest": "^29.4.11",
40
+ "typescript": "^6.0.3"
41
+ },
42
+ "dependencies": {
43
+ "@aztec/bb.js": "5.0.0",
44
+ "@coordination-network/identity": "^0.0.1",
45
+ "@coordination-network/issuer-set": "^0.0.1",
46
+ "@noble/curves": "^1.9.7",
47
+ "@noble/hashes": "^1.8.0",
48
+ "@noir-lang/noir_js": "1.0.0-beta.22",
49
+ "@sd-jwt/decode": "^0.19.0",
50
+ "@sd-jwt/sd-jwt-vc": "^0.19.0",
51
+ "@semaphore-protocol/identity": "^4.14.3",
52
+ "poseidon-lite": "0.3.0",
53
+ "viem": "^2.55.11"
54
+ }
55
+ }
@@ -0,0 +1,173 @@
1
+ import { Identity } from "@semaphore-protocol/identity"
2
+ import { poseidon3 } from "poseidon-lite/poseidon3"
3
+ import { poseidon4 } from "poseidon-lite/poseidon4"
4
+ import { poseidon5 } from "poseidon-lite/poseidon5"
5
+ import { keccak256, stringToHex } from "viem"
6
+ import { bytesToHex } from "@noble/hashes/utils"
7
+ import { jwkToPublicKeyBytes } from "./crypto"
8
+ import { Jwk, SerializedZkCredential, ZkCredential, ZkCredentialBody } from "./types"
9
+
10
+ // keccak256(utf8("urn:cn:credential:v1")) >> 8
11
+ export const CREDENTIAL_DOMAIN = 363566488821926773809509531830640889725124777157045803797738936823559174026n
12
+ export const DEVICE_KEY_DOMAIN = 320507632879070882430325612169297945123468706597652794395401074724051126576n
13
+ const FIELD_MODULUS = 21888242871839275222246405745257275088548364400416034343698204186575808495617n
14
+ export function stringToField(value: string): bigint {
15
+ return BigInt(keccak256(stringToHex(value))) >> 8n
16
+ }
17
+
18
+ export function programmeHash(programme: string): bigint {
19
+ if (programme.length === 0) throw new Error("programme must not be empty")
20
+ return stringToField(programme)
21
+ }
22
+
23
+ export function countryCode(country: string): number {
24
+ if (!/^[A-Z]{3}$/.test(country)) throw new Error("country must be an ISO 3166-1 alpha-3 code")
25
+ return [...country].reduce((value, character) => (value << 8) | character.charCodeAt(0), 0)
26
+ }
27
+
28
+ const bytesToBigint = (value: Uint8Array): bigint => BigInt(`0x${bytesToHex(value)}`)
29
+
30
+ export function deriveDeviceKeyCommitment(publicKey: Jwk): bigint {
31
+ const encoded = jwkToPublicKeyBytes(publicKey)
32
+ const x = encoded.slice(1, 33)
33
+ const y = encoded.slice(33, 65)
34
+
35
+ return poseidon5([
36
+ DEVICE_KEY_DOMAIN,
37
+ bytesToBigint(x.slice(0, 16)),
38
+ bytesToBigint(x.slice(16, 32)),
39
+ bytesToBigint(y.slice(0, 16)),
40
+ bytesToBigint(y.slice(16, 32))
41
+ ])
42
+ }
43
+
44
+ export function zkCredentialCommitment(body: ZkCredentialBody): bigint {
45
+ const policy = poseidon4([
46
+ body.programmeHash,
47
+ BigInt(body.countryCode),
48
+ BigInt(body.notBefore),
49
+ BigInt(body.expiresAt)
50
+ ])
51
+ const keys = poseidon3([body.deviceKeyCommitment, body.holderKeyCommitment, body.revocationCommitment])
52
+
53
+ return poseidon3([CREDENTIAL_DOMAIN, policy, keys])
54
+ }
55
+
56
+ const decimal = (value: unknown, name: string): bigint => {
57
+ if (typeof value !== "string" || !/^(0|[1-9][0-9]*)$/.test(value)) throw new Error(`${name} must be decimal`)
58
+ const parsed = BigInt(value)
59
+ if (parsed >= FIELD_MODULUS) throw new Error(`${name} must fit the BN254 field`)
60
+ return parsed
61
+ }
62
+
63
+ const timestamp = (value: unknown, name: string): number => {
64
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
65
+ throw new Error(`${name} must be a non-negative integer`)
66
+ }
67
+ return value
68
+ }
69
+
70
+ const encodedCountryCode = (value: unknown): number => {
71
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > 0xffffff) {
72
+ throw new Error("credential.body.countryCode must be a three-byte integer")
73
+ }
74
+ return value
75
+ }
76
+
77
+ const record = (value: unknown, name: string): Record<string, unknown> => {
78
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
79
+ throw new Error(`${name} must be an object`)
80
+ }
81
+ return value as Record<string, unknown>
82
+ }
83
+
84
+ const exactKeys = (value: Record<string, unknown>, keys: string[], name: string): void => {
85
+ const actual = Object.keys(value).sort()
86
+ const expected = [...keys].sort()
87
+ if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
88
+ throw new Error(`${name} has unexpected fields`)
89
+ }
90
+ }
91
+
92
+ export function serializeZkCredential(credential: ZkCredential): SerializedZkCredential {
93
+ return {
94
+ body: {
95
+ programmeHash: credential.body.programmeHash.toString(),
96
+ countryCode: credential.body.countryCode,
97
+ notBefore: credential.body.notBefore,
98
+ expiresAt: credential.body.expiresAt,
99
+ deviceKeyCommitment: credential.body.deviceKeyCommitment.toString(),
100
+ holderKeyCommitment: credential.body.holderKeyCommitment.toString(),
101
+ revocationCommitment: credential.body.revocationCommitment.toString()
102
+ },
103
+ issuerPublicKey: credential.issuerPublicKey.map(String) as [string, string],
104
+ signature: {
105
+ R8: credential.signature.R8.map(String) as [string, string],
106
+ S: credential.signature.S.toString()
107
+ }
108
+ }
109
+ }
110
+
111
+ export function parseZkCredential(value: unknown): ZkCredential {
112
+ const credential = record(value, "credential")
113
+ exactKeys(credential, ["body", "issuerPublicKey", "signature"], "credential")
114
+ const body = record(credential.body, "credential.body")
115
+ exactKeys(
116
+ body,
117
+ [
118
+ "programmeHash",
119
+ "countryCode",
120
+ "notBefore",
121
+ "expiresAt",
122
+ "deviceKeyCommitment",
123
+ "holderKeyCommitment",
124
+ "revocationCommitment"
125
+ ],
126
+ "credential.body"
127
+ )
128
+ if (!Array.isArray(credential.issuerPublicKey) || credential.issuerPublicKey.length !== 2) {
129
+ throw new Error("credential.issuerPublicKey must contain two values")
130
+ }
131
+ const signature = record(credential.signature, "credential.signature")
132
+ exactKeys(signature, ["R8", "S"], "credential.signature")
133
+ if (!Array.isArray(signature.R8) || signature.R8.length !== 2) {
134
+ throw new Error("credential.signature.R8 must contain two values")
135
+ }
136
+
137
+ const parsed: ZkCredential = {
138
+ body: {
139
+ programmeHash: decimal(body.programmeHash, "credential.body.programmeHash"),
140
+ countryCode: encodedCountryCode(body.countryCode),
141
+ notBefore: timestamp(body.notBefore, "credential.body.notBefore"),
142
+ expiresAt: timestamp(body.expiresAt, "credential.body.expiresAt"),
143
+ deviceKeyCommitment: decimal(body.deviceKeyCommitment, "credential.body.deviceKeyCommitment"),
144
+ holderKeyCommitment: decimal(body.holderKeyCommitment, "credential.body.holderKeyCommitment"),
145
+ revocationCommitment: decimal(body.revocationCommitment, "credential.body.revocationCommitment")
146
+ },
147
+ issuerPublicKey: [
148
+ decimal(credential.issuerPublicKey[0], "credential.issuerPublicKey[0]"),
149
+ decimal(credential.issuerPublicKey[1], "credential.issuerPublicKey[1]")
150
+ ],
151
+ signature: {
152
+ R8: [
153
+ decimal(signature.R8[0], "credential.signature.R8[0]"),
154
+ decimal(signature.R8[1], "credential.signature.R8[1]")
155
+ ],
156
+ S: decimal(signature.S, "credential.signature.S")
157
+ }
158
+ }
159
+ if (parsed.body.notBefore >= parsed.body.expiresAt) throw new Error("credential validity window is invalid")
160
+ return parsed
161
+ }
162
+
163
+ export function verifyZkCredential(credential: ZkCredential): boolean {
164
+ try {
165
+ return Identity.verifySignature(
166
+ zkCredentialCommitment(credential.body),
167
+ credential.signature,
168
+ credential.issuerPublicKey
169
+ )
170
+ } catch {
171
+ return false
172
+ }
173
+ }
@@ -0,0 +1,24 @@
1
+ import { Identity } from "@semaphore-protocol/identity"
2
+ import { EddsaPublicKey, EddsaSignature } from "./types"
3
+
4
+ export interface CredentialSigner {
5
+ getPublicKey(): Promise<EddsaPublicKey>
6
+ sign(message: bigint): Promise<EddsaSignature>
7
+ }
8
+
9
+ export class LocalCredentialSigner implements CredentialSigner {
10
+ private readonly identity: Identity
11
+
12
+ constructor(secret: string | Uint8Array) {
13
+ this.identity = new Identity(secret)
14
+ }
15
+
16
+ async getPublicKey(): Promise<EddsaPublicKey> {
17
+ return [this.identity.publicKey[0], this.identity.publicKey[1]]
18
+ }
19
+
20
+ async sign(message: bigint): Promise<EddsaSignature> {
21
+ const signature = this.identity.signMessage(message)
22
+ return { R8: [signature.R8[0], signature.R8[1]], S: signature.S }
23
+ }
24
+ }
package/src/crypto.ts ADDED
@@ -0,0 +1,146 @@
1
+ import { p256 } from "@noble/curves/nist.js"
2
+ import { sha256 } from "@noble/hashes/sha2"
3
+ import { bytesToHex, randomBytes } from "@noble/hashes/utils"
4
+ import { base64urlToBytes as strictBase64urlToBytes, bytesToBase64url } from "@coordination-network/identity"
5
+ import { Jwk } from "./types"
6
+ import { DeviceSigner, Signer } from "./signer"
7
+
8
+ /** Bridges the KMS-shaped Signer interface, the noble primitives, and the callback shapes @sd-jwt expects. */
9
+
10
+ const utf8 = (data: string): Uint8Array => new TextEncoder().encode(data)
11
+
12
+ export { bytesToBase64url }
13
+
14
+ /**
15
+ * Padding is optional under RFC 4648 §5 and some clients send it anyway (Python's
16
+ * urlsafe_b64encode, Java's URL encoder), so JWK coordinates are stripped before the strict decode
17
+ * rather than rejected.
18
+ */
19
+ export function base64urlToBytes(encoded: string): Uint8Array {
20
+ const unpadded = encoded.replace(/=+$/, "")
21
+ const invalid = unpadded.match(/[^A-Za-z0-9_-]/)
22
+ if (invalid) throw new Error(`invalid base64url character: ${invalid[0]}`)
23
+ return strictBase64urlToBytes(unpadded)
24
+ }
25
+
26
+ /** Uncompressed SEC1 point (65 bytes, 0x04 || x || y) from a P-256 JWK. */
27
+ export function jwkToPublicKeyBytes(jwk: Jwk): Uint8Array {
28
+ const x = base64urlToBytes(jwk.x)
29
+ const y = base64urlToBytes(jwk.y)
30
+ if (jwk.kty !== "EC" || jwk.crv !== "P-256" || x.length !== 32 || y.length !== 32) {
31
+ throw new Error("expected a P-256 EC JWK with 32-byte coordinates")
32
+ }
33
+ const point = new Uint8Array(65)
34
+ point[0] = 0x04
35
+ point.set(x, 1)
36
+ point.set(y, 33)
37
+ return point
38
+ }
39
+
40
+ /**
41
+ * Registry keys are stored as the compressed SEC1 point (33 bytes, spec 07).
42
+ * Both converters go through the curve, so an off-curve point throws instead
43
+ * of round-tripping garbage.
44
+ */
45
+ export function compressedPointToJwk(point: Uint8Array): Jwk {
46
+ const raw = p256.ProjectivePoint.fromHex(point).toRawBytes(false)
47
+ return {
48
+ kty: "EC",
49
+ crv: "P-256",
50
+ x: bytesToBase64url(raw.slice(1, 33)),
51
+ y: bytesToBase64url(raw.slice(33, 65))
52
+ }
53
+ }
54
+
55
+ export function jwkToCompressedPoint(jwk: Jwk): Uint8Array {
56
+ return p256.ProjectivePoint.fromHex(jwkToPublicKeyBytes(jwk)).toRawBytes(true)
57
+ }
58
+
59
+ /** RFC 7638 JWK thumbprint: SHA-256 over the ordered required members. */
60
+ export function jwkThumbprint(jwk: Jwk): Uint8Array {
61
+ return sha256(JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }))
62
+ }
63
+
64
+ /**
65
+ * ECDSA P-256 verification in message mode. lowS is not enforced on verify:
66
+ * we produce low-s signatures ourselves, but external signers (KMS) may not,
67
+ * and JWS does not require it.
68
+ */
69
+ export function verifyP256(message: Uint8Array, signature: Uint8Array, publicKey: Uint8Array): boolean {
70
+ try {
71
+ return p256.verify(signature, sha256(message), publicKey, { lowS: false })
72
+ } catch {
73
+ return false
74
+ }
75
+ }
76
+
77
+ /** Bridges a Signer to the @sd-jwt signer callback (base64url over utf8 input). */
78
+ export function createSdJwtSigner(signer: Signer): (data: string) => Promise<string> {
79
+ return async (data: string) => bytesToBase64url(await signer.sign(utf8(data)))
80
+ }
81
+
82
+ /** Builds the @sd-jwt verifier callback from a resolved issuer JWK. */
83
+ export function createSdJwtVerifier(jwk: Jwk): (data: string, signature: string) => boolean {
84
+ const publicKey = jwkToPublicKeyBytes(jwk)
85
+ return (data: string, signature: string) => {
86
+ try {
87
+ return verifyP256(utf8(data), base64urlToBytes(signature), publicKey)
88
+ } catch {
89
+ return false
90
+ }
91
+ }
92
+ }
93
+
94
+ /** The sha-256 hasher the @sd-jwt config takes. */
95
+ export function sdJwtHasher(data: string | ArrayBuffer): Uint8Array {
96
+ return sha256(typeof data === "string" ? data : new Uint8Array(data))
97
+ }
98
+
99
+ /** Hex salts for disclosure digests. */
100
+ export function sdJwtSaltGenerator(length: number): string {
101
+ return bytesToHex(randomBytes(length))
102
+ }
103
+
104
+ /** RFC 4122 v4 UUID from CSPRNG bytes — node:crypto is unavailable under React Native. */
105
+ export function randomUuid(): string {
106
+ const bytes = randomBytes(16)
107
+ bytes[6] = (bytes[6] & 0x0f) | 0x40
108
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
109
+ const hex = bytesToHex(bytes)
110
+
111
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
112
+ }
113
+
114
+ /**
115
+ * Software P-256 signer. The issuer demo and every test use it; production
116
+ * issuers replace it with a KMS-backed Signer, apps with a secure-element
117
+ * DeviceSigner. Signs in message mode with low-s, raw 64-byte output —
118
+ * exactly the Signer contract.
119
+ */
120
+ export class LocalP256Signer implements Signer, DeviceSigner {
121
+ private readonly privateKey: Uint8Array
122
+
123
+ constructor(privateKey: Uint8Array) {
124
+ if (privateKey.length !== 32) {
125
+ throw new Error("P-256 private key must be 32 bytes")
126
+ }
127
+ this.privateKey = privateKey
128
+ }
129
+
130
+ static generate(): LocalP256Signer {
131
+ return new LocalP256Signer(p256.utils.randomSecretKey())
132
+ }
133
+
134
+ async getPublicKeyJwk(): Promise<Jwk> {
135
+ return compressedPointToJwk(p256.getPublicKey(this.privateKey, true))
136
+ }
137
+
138
+ async sign(message: Uint8Array): Promise<Uint8Array> {
139
+ return p256.sign(sha256(message), this.privateKey, { lowS: true }).toCompactRawBytes()
140
+ }
141
+
142
+ async signDigest(digest: Uint8Array): Promise<Uint8Array> {
143
+ if (digest.length !== 32) throw new Error("P-256 digest must be 32 bytes")
144
+ return p256.sign(digest, this.privateKey, { lowS: true }).toCompactRawBytes()
145
+ }
146
+ }
package/src/decode.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { decodeSdJwtSync, getClaimsSync } from "@sd-jwt/decode"
2
+ import { sdJwtHasher } from "./crypto"
3
+ import { validateClaims } from "./profile"
4
+ import { ApprovalCredentialClaims } from "./types"
5
+
6
+ /**
7
+ * Decodes a compact SD-JWT and reconstructs the full claim set from its
8
+ * disclosures WITHOUT verifying the signature — for callers that already
9
+ * trust the token, such as the issuer reading back a token it just signed.
10
+ * Untrusted tokens go through verifyCredential/verifyPresentation instead.
11
+ */
12
+ export function decodeSdJwtCredentialClaims(token: string): ApprovalCredentialClaims {
13
+ let reconstructed: unknown
14
+ try {
15
+ const decoded = decodeSdJwtSync(token, sdJwtHasher)
16
+ reconstructed = getClaimsSync(decoded.jwt.payload, decoded.disclosures, sdJwtHasher)
17
+ } catch {
18
+ throw new Error("token is not a decodable SD-JWT")
19
+ }
20
+ const checked = validateClaims(reconstructed)
21
+ if (!checked.ok) {
22
+ throw new Error(checked.message)
23
+ }
24
+ return checked.claims
25
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,12 @@
1
+ export type IssuanceErrorCode =
2
+ "INVALID_PARAMS" | "ISSUER_NOT_APPROVED" | "SUBJECT_MISMATCH" | "BINDING_INVALID" | "COMMITMENT_OWNERSHIP_INVALID"
3
+
4
+ export class IssuanceError extends Error {
5
+ readonly code: IssuanceErrorCode
6
+
7
+ constructor(code: IssuanceErrorCode, message: string) {
8
+ super(message)
9
+ this.name = "IssuanceError"
10
+ this.code = code
11
+ }
12
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ export * from "./errors"
2
+ export * from "./types"
3
+ export * from "./signer"
4
+ export * from "./credential-message"
5
+ export * from "./credential-signer"
6
+ export * from "./issue-credential"
7
+ export * from "./issuer-set"
8
+ export * from "./presentation-inputs"
9
+ export * from "./witness"
10
+ export * from "./zk-proof"
11
+ export * from "./resolver"
12
+ export * from "./registry-client"
13
+ export * from "./crypto"
14
+ export * from "./decode"
15
+ export * from "./profile"
16
+ export * from "./issue"
17
+ export * from "./verify"
18
+ export * from "./present"
19
+ export * from "./revocation"
@@ -0,0 +1,78 @@
1
+ import { InvalidBindingInputError, verifyCredentialBinding } from "@coordination-network/identity"
2
+ import { hexToBytes } from "@noble/hashes/utils"
3
+ import { Identity } from "@semaphore-protocol/identity"
4
+ import { CredentialSigner } from "./credential-signer"
5
+ import { countryCode, deriveDeviceKeyCommitment, programmeHash, zkCredentialCommitment } from "./credential-message"
6
+ import { jwkToPublicKeyBytes } from "./crypto"
7
+ import { IssuanceError } from "./errors"
8
+ import { ApprovalResolver } from "./resolver"
9
+ import { verifyCommitmentOwnership } from "./revocation"
10
+ import { ZkCredential, ZkCredentialIssueParams } from "./types"
11
+
12
+ export async function issueZkCredential(
13
+ params: ZkCredentialIssueParams,
14
+ signer: CredentialSigner,
15
+ approval: ApprovalResolver
16
+ ): Promise<ZkCredential> {
17
+ if (!(await approval.isApprovedNetworkMember(params.issuerNetworkId))) {
18
+ throw new IssuanceError(
19
+ "ISSUER_NOT_APPROVED",
20
+ `issuer ${params.issuerNetworkId} is not an approved network member`
21
+ )
22
+ }
23
+ if (!(params.validFrom < params.validUntil)) {
24
+ throw new IssuanceError("INVALID_PARAMS", "validFrom must be before validUntil")
25
+ }
26
+
27
+ const holderKeyCommitment = Identity.generateCommitment(params.identityPublicKey)
28
+ const holderKeyCommitmentBytes = hexToBytes(holderKeyCommitment.toString(16).padStart(64, "0"))
29
+ let binding: ReturnType<typeof verifyCredentialBinding>
30
+ try {
31
+ binding = verifyCredentialBinding({
32
+ nonce: params.nonce,
33
+ accountPublicKey: params.accountPublicKey,
34
+ confirmationKey: jwkToPublicKeyBytes(params.confirmationKey),
35
+ holderKeyCommitment: holderKeyCommitmentBytes,
36
+ accountSignature: params.accountPopSignature,
37
+ deviceSignature: params.devicePopSignature
38
+ })
39
+ } catch (error) {
40
+ if (error instanceof InvalidBindingInputError) {
41
+ throw new IssuanceError("INVALID_PARAMS", `malformed binding input: ${error.message}`)
42
+ }
43
+ throw error
44
+ }
45
+
46
+ if (binding.networkId !== params.subjectNetworkId.toLowerCase()) {
47
+ throw new IssuanceError("SUBJECT_MISMATCH", "subjectNetworkId does not match the account public key")
48
+ }
49
+ if (!binding.valid) {
50
+ throw new IssuanceError("BINDING_INVALID", "credential binding failed")
51
+ }
52
+
53
+ const ownershipOk = verifyCommitmentOwnership({
54
+ commitment: params.revocation.commitment,
55
+ publicKey: params.revocation.publicKey,
56
+ signature: params.revocation.ownershipSignature,
57
+ nonce: params.nonce
58
+ })
59
+ if (!ownershipOk) {
60
+ throw new IssuanceError("COMMITMENT_OWNERSHIP_INVALID", "revocation commitment ownership proof failed")
61
+ }
62
+
63
+ const body = {
64
+ programmeHash: programmeHash(params.approvalScope.programme),
65
+ countryCode: countryCode(params.approvalScope.country),
66
+ notBefore: params.validFrom,
67
+ expiresAt: params.validUntil,
68
+ deviceKeyCommitment: deriveDeviceKeyCommitment(params.confirmationKey),
69
+ holderKeyCommitment,
70
+ revocationCommitment: params.revocation.commitment
71
+ }
72
+
73
+ return {
74
+ body,
75
+ issuerPublicKey: await signer.getPublicKey(),
76
+ signature: await signer.sign(zkCredentialCommitment(body))
77
+ }
78
+ }