@coordination-network/credential 0.0.1 → 0.1.0
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/README.md +17 -45
- package/dist/credential-message.d.ts +5 -1
- package/dist/credential-message.js +17 -1
- package/dist/crypto.d.ts +3 -11
- package/dist/crypto.js +1 -31
- package/dist/errors.d.ts +1 -1
- package/dist/holder-ownership.d.ts +12 -0
- package/dist/holder-ownership.js +26 -0
- package/dist/index.d.ts +1 -5
- package/dist/index.js +1 -5
- package/dist/issue-credential.d.ts +5 -1
- package/dist/issue-credential.js +24 -2
- package/dist/revocation.d.ts +6 -5
- package/dist/revocation.js +9 -12
- package/dist/signer.d.ts +2 -13
- package/dist/types.d.ts +2 -38
- package/dist/zk-proof.d.ts +10 -0
- package/dist/zk-proof.js +14 -3
- package/package.json +6 -9
- package/src/credential-message.ts +22 -2
- package/src/crypto.ts +3 -34
- package/src/errors.ts +6 -1
- package/src/holder-ownership.ts +30 -0
- package/src/index.ts +1 -5
- package/src/issue-credential.ts +43 -6
- package/src/revocation.ts +10 -14
- package/src/signer.ts +2 -14
- package/src/types.ts +2 -36
- package/src/zk-proof.ts +19 -3
- package/dist/decode.d.ts +0 -8
- package/dist/decode.js +0 -27
- package/dist/issue.d.ts +0 -9
- package/dist/issue.js +0 -65
- package/dist/present.d.ts +0 -32
- package/dist/present.js +0 -73
- package/dist/profile.d.ts +0 -32
- package/dist/profile.js +0 -122
- package/dist/verify.d.ts +0 -29
- package/dist/verify.js +0 -102
- package/src/decode.ts +0 -25
- package/src/issue.ts +0 -83
- package/src/present.ts +0 -101
- package/src/profile.ts +0 -133
- package/src/verify.ts +0 -118
|
@@ -2,13 +2,17 @@ import { Identity } from "@semaphore-protocol/identity"
|
|
|
2
2
|
import { poseidon3 } from "poseidon-lite/poseidon3"
|
|
3
3
|
import { poseidon4 } from "poseidon-lite/poseidon4"
|
|
4
4
|
import { poseidon5 } from "poseidon-lite/poseidon5"
|
|
5
|
-
import { keccak256, stringToHex } from "viem"
|
|
5
|
+
import { concatHex, hexToBytes, keccak256, stringToHex, toHex } from "viem"
|
|
6
6
|
import { bytesToHex } from "@noble/hashes/utils"
|
|
7
7
|
import { jwkToPublicKeyBytes } from "./crypto"
|
|
8
|
-
import { Jwk, SerializedZkCredential, ZkCredential, ZkCredentialBody } from "./types"
|
|
8
|
+
import { ApprovalScope, Hex32, Jwk, SerializedZkCredential, ZkCredential, ZkCredentialBody } from "./types"
|
|
9
9
|
|
|
10
10
|
// keccak256(utf8("urn:cn:credential:v1")) >> 8
|
|
11
11
|
export const CREDENTIAL_DOMAIN = 363566488821926773809509531830640889725124777157045803797738936823559174026n
|
|
12
|
+
export const ISSUANCE_REQUEST_DOMAIN = "urn:cn:issuance-request:v1"
|
|
13
|
+
// Android Key Attestation challenge for the device signing key, as UTF-8 bytes. Fixed because the key is
|
|
14
|
+
// attested once, at generation; the device signature over the issuer nonce supplies freshness.
|
|
15
|
+
export const DEVICE_ATTESTATION_CHALLENGE = "urn:cn:device-attestation:v1"
|
|
12
16
|
export const DEVICE_KEY_DOMAIN = 320507632879070882430325612169297945123468706597652794395401074724051126576n
|
|
13
17
|
const FIELD_MODULUS = 21888242871839275222246405745257275088548364400416034343698204186575808495617n
|
|
14
18
|
export function stringToField(value: string): bigint {
|
|
@@ -25,6 +29,22 @@ export function countryCode(country: string): number {
|
|
|
25
29
|
return [...country].reduce((value, character) => (value << 8) | character.charCodeAt(0), 0)
|
|
26
30
|
}
|
|
27
31
|
|
|
32
|
+
/** What the device signature commits to besides the holder key: the issuer and the scope shown at consent. */
|
|
33
|
+
export function issuanceRequestCommitment(issuerNetworkId: Hex32, approvalScope: ApprovalScope): Uint8Array {
|
|
34
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(issuerNetworkId)) throw new Error("issuerNetworkId must be 32 bytes")
|
|
35
|
+
|
|
36
|
+
return hexToBytes(
|
|
37
|
+
keccak256(
|
|
38
|
+
concatHex([
|
|
39
|
+
stringToHex(ISSUANCE_REQUEST_DOMAIN),
|
|
40
|
+
issuerNetworkId,
|
|
41
|
+
toHex(programmeHash(approvalScope.programme), { size: 32 }),
|
|
42
|
+
toHex(countryCode(approvalScope.country), { size: 32 })
|
|
43
|
+
])
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
28
48
|
const bytesToBigint = (value: Uint8Array): bigint => BigInt(`0x${bytesToHex(value)}`)
|
|
29
49
|
|
|
30
50
|
export function deriveDeviceKeyCommitment(publicKey: Jwk): bigint {
|
package/src/crypto.ts
CHANGED
|
@@ -3,11 +3,7 @@ import { sha256 } from "@noble/hashes/sha2"
|
|
|
3
3
|
import { bytesToHex, randomBytes } from "@noble/hashes/utils"
|
|
4
4
|
import { base64urlToBytes as strictBase64urlToBytes, bytesToBase64url } from "@coordination-network/identity"
|
|
5
5
|
import { Jwk } from "./types"
|
|
6
|
-
import { DeviceSigner
|
|
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)
|
|
6
|
+
import { DeviceSigner } from "./signer"
|
|
11
7
|
|
|
12
8
|
export { bytesToBase64url }
|
|
13
9
|
|
|
@@ -74,33 +70,6 @@ export function verifyP256(message: Uint8Array, signature: Uint8Array, publicKey
|
|
|
74
70
|
}
|
|
75
71
|
}
|
|
76
72
|
|
|
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
73
|
/** RFC 4122 v4 UUID from CSPRNG bytes — node:crypto is unavailable under React Native. */
|
|
105
74
|
export function randomUuid(): string {
|
|
106
75
|
const bytes = randomBytes(16)
|
|
@@ -115,9 +84,9 @@ export function randomUuid(): string {
|
|
|
115
84
|
* Software P-256 signer. The issuer demo and every test use it; production
|
|
116
85
|
* issuers replace it with a KMS-backed Signer, apps with a secure-element
|
|
117
86
|
* DeviceSigner. Signs in message mode with low-s, raw 64-byte output —
|
|
118
|
-
* exactly the
|
|
87
|
+
* exactly the DeviceSigner contract.
|
|
119
88
|
*/
|
|
120
|
-
export class LocalP256Signer implements
|
|
89
|
+
export class LocalP256Signer implements DeviceSigner {
|
|
121
90
|
private readonly privateKey: Uint8Array
|
|
122
91
|
|
|
123
92
|
constructor(privateKey: Uint8Array) {
|
package/src/errors.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export type IssuanceErrorCode =
|
|
2
|
-
|
|
2
|
+
| "INVALID_PARAMS"
|
|
3
|
+
| "ISSUER_NOT_APPROVED"
|
|
4
|
+
| "SUBJECT_MISMATCH"
|
|
5
|
+
| "BINDING_INVALID"
|
|
6
|
+
| "HOLDER_KEY_OWNERSHIP_INVALID"
|
|
7
|
+
| "COMMITMENT_OWNERSHIP_INVALID"
|
|
3
8
|
|
|
4
9
|
export class IssuanceError extends Error {
|
|
5
10
|
readonly code: IssuanceErrorCode
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Identity } from "@semaphore-protocol/identity"
|
|
2
|
+
import { nonceToBigint } from "./revocation"
|
|
3
|
+
import type { EddsaPublicKey, EddsaSignature } from "./types"
|
|
4
|
+
|
|
5
|
+
/** Proves possession of the holder identity key bound into a credential request. */
|
|
6
|
+
export function signHolderKeyOwnership(
|
|
7
|
+
identitySeed: Uint8Array,
|
|
8
|
+
nonce: Uint8Array
|
|
9
|
+
): { publicKey: EddsaPublicKey; signature: EddsaSignature } {
|
|
10
|
+
if (identitySeed.length !== 32) throw new Error("identity seed must be 32 bytes")
|
|
11
|
+
const identity = new Identity(identitySeed)
|
|
12
|
+
const signature = identity.signMessage(nonceToBigint(nonce))
|
|
13
|
+
return {
|
|
14
|
+
publicKey: [identity.publicKey[0], identity.publicKey[1]],
|
|
15
|
+
signature: { R8: [signature.R8[0], signature.R8[1]], S: signature.S }
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Verifies that the requestor controls the supplied Baby Jubjub holder public key. */
|
|
20
|
+
export function verifyHolderKeyOwnership(params: {
|
|
21
|
+
publicKey: EddsaPublicKey
|
|
22
|
+
signature: EddsaSignature
|
|
23
|
+
nonce: Uint8Array
|
|
24
|
+
}): boolean {
|
|
25
|
+
try {
|
|
26
|
+
return Identity.verifySignature(nonceToBigint(params.nonce), params.signature, params.publicKey)
|
|
27
|
+
} catch {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -11,9 +11,5 @@ export * from "./zk-proof"
|
|
|
11
11
|
export * from "./resolver"
|
|
12
12
|
export * from "./registry-client"
|
|
13
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
14
|
export * from "./revocation"
|
|
15
|
+
export * from "./holder-ownership"
|
package/src/issue-credential.ts
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
import { InvalidBindingInputError, verifyCredentialBinding } from "@coordination-network/identity"
|
|
2
|
+
import { inPrimeOrderSubgroup } from "@coordination-network/issuer-set"
|
|
2
3
|
import { hexToBytes } from "@noble/hashes/utils"
|
|
3
4
|
import { Identity } from "@semaphore-protocol/identity"
|
|
4
5
|
import { CredentialSigner } from "./credential-signer"
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
countryCode,
|
|
8
|
+
deriveDeviceKeyCommitment,
|
|
9
|
+
issuanceRequestCommitment,
|
|
10
|
+
programmeHash,
|
|
11
|
+
zkCredentialCommitment
|
|
12
|
+
} from "./credential-message"
|
|
6
13
|
import { jwkToPublicKeyBytes } from "./crypto"
|
|
7
14
|
import { IssuanceError } from "./errors"
|
|
8
15
|
import { ApprovalResolver } from "./resolver"
|
|
9
16
|
import { verifyCommitmentOwnership } from "./revocation"
|
|
10
|
-
import {
|
|
17
|
+
import { verifyHolderKeyOwnership } from "./holder-ownership"
|
|
18
|
+
import { ZkCredential, ZkCredentialBody, ZkCredentialIssueParams } from "./types"
|
|
11
19
|
|
|
12
|
-
|
|
20
|
+
/** Validates an issuance request without signing it. */
|
|
21
|
+
export async function validateZkCredentialIssue(
|
|
13
22
|
params: ZkCredentialIssueParams,
|
|
14
|
-
signer: CredentialSigner,
|
|
15
23
|
approval: ApprovalResolver
|
|
16
|
-
): Promise<
|
|
24
|
+
): Promise<ZkCredentialBody> {
|
|
17
25
|
if (!(await approval.isApprovedNetworkMember(params.issuerNetworkId))) {
|
|
18
26
|
throw new IssuanceError(
|
|
19
27
|
"ISSUER_NOT_APPROVED",
|
|
@@ -24,6 +32,10 @@ export async function issueZkCredential(
|
|
|
24
32
|
throw new IssuanceError("INVALID_PARAMS", "validFrom must be before validUntil")
|
|
25
33
|
}
|
|
26
34
|
|
|
35
|
+
if (!inPrimeOrderSubgroup(params.identityPublicKey)) {
|
|
36
|
+
throw new IssuanceError("INVALID_PARAMS", "identity public key is not in the prime-order subgroup")
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
const holderKeyCommitment = Identity.generateCommitment(params.identityPublicKey)
|
|
28
40
|
const holderKeyCommitmentBytes = hexToBytes(holderKeyCommitment.toString(16).padStart(64, "0"))
|
|
29
41
|
let binding: ReturnType<typeof verifyCredentialBinding>
|
|
@@ -33,6 +45,7 @@ export async function issueZkCredential(
|
|
|
33
45
|
accountPublicKey: params.accountPublicKey,
|
|
34
46
|
confirmationKey: jwkToPublicKeyBytes(params.confirmationKey),
|
|
35
47
|
holderKeyCommitment: holderKeyCommitmentBytes,
|
|
48
|
+
requestCommitment: issuanceRequestCommitment(params.issuerNetworkId, params.approvalScope),
|
|
36
49
|
accountSignature: params.accountPopSignature,
|
|
37
50
|
deviceSignature: params.devicePopSignature
|
|
38
51
|
})
|
|
@@ -50,6 +63,16 @@ export async function issueZkCredential(
|
|
|
50
63
|
throw new IssuanceError("BINDING_INVALID", "credential binding failed")
|
|
51
64
|
}
|
|
52
65
|
|
|
66
|
+
if (
|
|
67
|
+
!verifyHolderKeyOwnership({
|
|
68
|
+
publicKey: params.identityPublicKey,
|
|
69
|
+
signature: params.identityOwnershipSignature,
|
|
70
|
+
nonce: params.nonce
|
|
71
|
+
})
|
|
72
|
+
) {
|
|
73
|
+
throw new IssuanceError("HOLDER_KEY_OWNERSHIP_INVALID", "holder key ownership proof failed")
|
|
74
|
+
}
|
|
75
|
+
|
|
53
76
|
const ownershipOk = verifyCommitmentOwnership({
|
|
54
77
|
commitment: params.revocation.commitment,
|
|
55
78
|
publicKey: params.revocation.publicKey,
|
|
@@ -60,7 +83,7 @@ export async function issueZkCredential(
|
|
|
60
83
|
throw new IssuanceError("COMMITMENT_OWNERSHIP_INVALID", "revocation commitment ownership proof failed")
|
|
61
84
|
}
|
|
62
85
|
|
|
63
|
-
|
|
86
|
+
return {
|
|
64
87
|
programmeHash: programmeHash(params.approvalScope.programme),
|
|
65
88
|
countryCode: countryCode(params.approvalScope.country),
|
|
66
89
|
notBefore: params.validFrom,
|
|
@@ -69,10 +92,24 @@ export async function issueZkCredential(
|
|
|
69
92
|
holderKeyCommitment,
|
|
70
93
|
revocationCommitment: params.revocation.commitment
|
|
71
94
|
}
|
|
95
|
+
}
|
|
72
96
|
|
|
97
|
+
/** Signs a request that has already passed {@link validateZkCredentialIssue}. */
|
|
98
|
+
export async function signValidatedZkCredential(
|
|
99
|
+
body: ZkCredentialBody,
|
|
100
|
+
signer: CredentialSigner
|
|
101
|
+
): Promise<ZkCredential> {
|
|
73
102
|
return {
|
|
74
103
|
body,
|
|
75
104
|
issuerPublicKey: await signer.getPublicKey(),
|
|
76
105
|
signature: await signer.sign(zkCredentialCommitment(body))
|
|
77
106
|
}
|
|
78
107
|
}
|
|
108
|
+
|
|
109
|
+
export async function issueZkCredential(
|
|
110
|
+
params: ZkCredentialIssueParams,
|
|
111
|
+
signer: CredentialSigner,
|
|
112
|
+
approval: ApprovalResolver
|
|
113
|
+
): Promise<ZkCredential> {
|
|
114
|
+
return signValidatedZkCredential(await validateZkCredentialIssue(params, approval), signer)
|
|
115
|
+
}
|
package/src/revocation.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { inPrimeOrderSubgroup } from "@coordination-network/issuer-set"
|
|
1
2
|
import { Identity } from "@semaphore-protocol/identity"
|
|
2
3
|
import { bytesToHex, randomBytes } from "@noble/hashes/utils"
|
|
3
|
-
import { EddsaPublicKey, EddsaSignature
|
|
4
|
+
import { EddsaPublicKey, EddsaSignature } from "./types"
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Device-side revocation material (spec 08). r is 32 random bytes, generated
|
|
@@ -15,7 +16,7 @@ function assertSecret(r: Uint8Array): void {
|
|
|
15
16
|
}
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
function nonceToBigint(nonce: Uint8Array): bigint {
|
|
19
|
+
export function nonceToBigint(nonce: Uint8Array): bigint {
|
|
19
20
|
if (nonce.length === 0) {
|
|
20
21
|
throw new Error("nonce must not be empty")
|
|
21
22
|
}
|
|
@@ -51,8 +52,10 @@ export function signCommitmentOwnership(
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
/**
|
|
54
|
-
* Issuer-side check:
|
|
55
|
-
* the commitment from the public key, and
|
|
55
|
+
* Issuer-side check: requires a prime-order public key, verifies the EdDSA
|
|
56
|
+
* signature over the nonce, recomputes the commitment from the public key, and
|
|
57
|
+
* compares it to the claimed one. A low-order key is refused because its
|
|
58
|
+
* signatures need no secret.
|
|
56
59
|
*/
|
|
57
60
|
export function verifyCommitmentOwnership(params: {
|
|
58
61
|
commitment: bigint
|
|
@@ -61,6 +64,9 @@ export function verifyCommitmentOwnership(params: {
|
|
|
61
64
|
nonce: Uint8Array
|
|
62
65
|
}): boolean {
|
|
63
66
|
try {
|
|
67
|
+
if (!inPrimeOrderSubgroup(params.publicKey)) {
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
64
70
|
if (!Identity.verifySignature(nonceToBigint(params.nonce), params.signature, params.publicKey)) {
|
|
65
71
|
return false
|
|
66
72
|
}
|
|
@@ -69,13 +75,3 @@ export function verifyCommitmentOwnership(params: {
|
|
|
69
75
|
return false
|
|
70
76
|
}
|
|
71
77
|
}
|
|
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
CHANGED
|
@@ -1,21 +1,9 @@
|
|
|
1
1
|
import { Jwk } from "./types"
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
|
|
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 {
|
|
3
|
+
/** Holder P-256 device key backed by a secure element in production. */
|
|
4
|
+
export interface DeviceSigner {
|
|
9
5
|
getPublicKeyJwk(): Promise<Jwk>
|
|
10
6
|
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
7
|
/** Signs an already-computed 32-byte digest, without hashing it again. */
|
|
20
8
|
signDigest(digest: Uint8Array): Promise<Uint8Array>
|
|
21
9
|
}
|
package/src/types.ts
CHANGED
|
@@ -27,31 +27,6 @@ export interface EddsaSignature {
|
|
|
27
27
|
S: bigint
|
|
28
28
|
}
|
|
29
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
30
|
export interface IssuanceRequest {
|
|
56
31
|
issuerNetworkId: Hex32
|
|
57
32
|
subjectNetworkId: Hex32
|
|
@@ -70,12 +45,10 @@ export interface IssuanceRequest {
|
|
|
70
45
|
}
|
|
71
46
|
}
|
|
72
47
|
|
|
73
|
-
export interface SdJwtIssueParams extends IssuanceRequest {
|
|
74
|
-
revocationRegistry: string
|
|
75
|
-
}
|
|
76
|
-
|
|
77
48
|
export interface ZkCredentialIssueParams extends IssuanceRequest {
|
|
78
49
|
identityPublicKey: EddsaPublicKey
|
|
50
|
+
/** Signature over the issuer challenge proving control of `identityPublicKey`. */
|
|
51
|
+
identityOwnershipSignature: EddsaSignature
|
|
79
52
|
}
|
|
80
53
|
|
|
81
54
|
export interface ZkCredentialBody {
|
|
@@ -107,10 +80,3 @@ export interface SerializedZkCredential {
|
|
|
107
80
|
issuerPublicKey: [string, string]
|
|
108
81
|
signature: { R8: [string, string]; S: string }
|
|
109
82
|
}
|
|
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/zk-proof.ts
CHANGED
|
@@ -25,6 +25,12 @@ export interface ExpectedZkPresentation {
|
|
|
25
25
|
country: string
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* `approvedIssuerRoot` and `revocationRoot` are the proof's public outputs and are
|
|
30
|
+
* not checked here. Check them against the registries before accepting the presentation:
|
|
31
|
+
* the issuer root against the approved-issuer set, the revocation root with the revocation
|
|
32
|
+
* registry's `isValidRoot`.
|
|
33
|
+
*/
|
|
28
34
|
export type ZkPresentationVerifyResult =
|
|
29
35
|
| { ok: true; approvedIssuerRoot: bigint; revocationRoot: bigint }
|
|
30
36
|
| { ok: false; code: "PUBLIC_INPUT_MISMATCH"; publicInput?: PublicInputName }
|
|
@@ -134,9 +140,15 @@ export class ZkCredentialProofSystem {
|
|
|
134
140
|
}
|
|
135
141
|
|
|
136
142
|
async generateProof(inputs: ZkPresentationInputs): Promise<ZkPresentationProof> {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
143
|
+
try {
|
|
144
|
+
const { witness } = await this.noir.execute(inputs as unknown as InputMap)
|
|
145
|
+
const proof = await this.backend.generateProof(witness, this.proofOptions)
|
|
146
|
+
return { proof: proof.proof, publicInputs: [...proof.publicInputs] }
|
|
147
|
+
} catch {
|
|
148
|
+
// The circuit's assertion text distinguishes "not yet valid" from "expired".
|
|
149
|
+
// Both outcomes must be indistinguishable to the verifier that chose currentTime.
|
|
150
|
+
throw new Error("presentation proof failed")
|
|
151
|
+
}
|
|
140
152
|
}
|
|
141
153
|
|
|
142
154
|
async verifyProof(
|
|
@@ -183,6 +195,10 @@ export async function generateZkPresentationProof(
|
|
|
183
195
|
}
|
|
184
196
|
}
|
|
185
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Verifies a presentation proof against the expected public inputs and returns the roots
|
|
200
|
+
* from the proof. Check those roots against the registries; see {@link ZkPresentationVerifyResult}.
|
|
201
|
+
*/
|
|
186
202
|
export async function verifyZkPresentationProof(
|
|
187
203
|
circuit: CompiledCircuit,
|
|
188
204
|
proof: ZkPresentationProof,
|
package/dist/decode.d.ts
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import { ApprovalCredentialClaims } from "./types";
|
|
2
|
-
/**
|
|
3
|
-
* Decodes a compact SD-JWT and reconstructs the full claim set from its
|
|
4
|
-
* disclosures WITHOUT verifying the signature — for callers that already
|
|
5
|
-
* trust the token, such as the issuer reading back a token it just signed.
|
|
6
|
-
* Untrusted tokens go through verifyCredential/verifyPresentation instead.
|
|
7
|
-
*/
|
|
8
|
-
export declare function decodeSdJwtCredentialClaims(token: string): ApprovalCredentialClaims;
|
package/dist/decode.js
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.decodeSdJwtCredentialClaims = decodeSdJwtCredentialClaims;
|
|
4
|
-
const decode_1 = require("@sd-jwt/decode");
|
|
5
|
-
const crypto_1 = require("./crypto");
|
|
6
|
-
const profile_1 = require("./profile");
|
|
7
|
-
/**
|
|
8
|
-
* Decodes a compact SD-JWT and reconstructs the full claim set from its
|
|
9
|
-
* disclosures WITHOUT verifying the signature — for callers that already
|
|
10
|
-
* trust the token, such as the issuer reading back a token it just signed.
|
|
11
|
-
* Untrusted tokens go through verifyCredential/verifyPresentation instead.
|
|
12
|
-
*/
|
|
13
|
-
function decodeSdJwtCredentialClaims(token) {
|
|
14
|
-
let reconstructed;
|
|
15
|
-
try {
|
|
16
|
-
const decoded = (0, decode_1.decodeSdJwtSync)(token, crypto_1.sdJwtHasher);
|
|
17
|
-
reconstructed = (0, decode_1.getClaimsSync)(decoded.jwt.payload, decoded.disclosures, crypto_1.sdJwtHasher);
|
|
18
|
-
}
|
|
19
|
-
catch {
|
|
20
|
-
throw new Error("token is not a decodable SD-JWT");
|
|
21
|
-
}
|
|
22
|
-
const checked = (0, profile_1.validateClaims)(reconstructed);
|
|
23
|
-
if (!checked.ok) {
|
|
24
|
-
throw new Error(checked.message);
|
|
25
|
-
}
|
|
26
|
-
return checked.claims;
|
|
27
|
-
}
|
package/dist/issue.d.ts
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { ApprovalResolver } from "./resolver";
|
|
2
|
-
import { Signer } from "./signer";
|
|
3
|
-
import { SdJwtIssueParams } from "./types";
|
|
4
|
-
/**
|
|
5
|
-
* Issues the delegated user approval credential (spec 07, issuance flow).
|
|
6
|
-
* Throws IssuanceError, without signing anything, if the issuer isn't
|
|
7
|
-
* approved or any challenge signature fails.
|
|
8
|
-
*/
|
|
9
|
-
export declare function issueSdJwtCredential(params: SdJwtIssueParams, signer: Signer, approval: ApprovalResolver): Promise<string>;
|
package/dist/issue.js
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.issueSdJwtCredential = issueSdJwtCredential;
|
|
4
|
-
const sd_jwt_vc_1 = require("@sd-jwt/sd-jwt-vc");
|
|
5
|
-
const identity_1 = require("@coordination-network/identity");
|
|
6
|
-
const crypto_1 = require("./crypto");
|
|
7
|
-
const errors_1 = require("./errors");
|
|
8
|
-
const profile_1 = require("./profile");
|
|
9
|
-
const revocation_1 = require("./revocation");
|
|
10
|
-
/**
|
|
11
|
-
* Issues the delegated user approval credential (spec 07, issuance flow).
|
|
12
|
-
* Throws IssuanceError, without signing anything, if the issuer isn't
|
|
13
|
-
* approved or any challenge signature fails.
|
|
14
|
-
*/
|
|
15
|
-
async function issueSdJwtCredential(params, signer, approval) {
|
|
16
|
-
if (!(await approval.isApprovedNetworkMember(params.issuerNetworkId))) {
|
|
17
|
-
throw new errors_1.IssuanceError("ISSUER_NOT_APPROVED", `issuer ${params.issuerNetworkId} is not an approved network member`);
|
|
18
|
-
}
|
|
19
|
-
if (!(params.validFrom < params.validUntil)) {
|
|
20
|
-
throw new errors_1.IssuanceError("INVALID_PARAMS", "validFrom must be before validUntil");
|
|
21
|
-
}
|
|
22
|
-
let binding;
|
|
23
|
-
try {
|
|
24
|
-
binding = (0, identity_1.verifyCredentialBinding)({
|
|
25
|
-
nonce: params.nonce,
|
|
26
|
-
accountPublicKey: params.accountPublicKey,
|
|
27
|
-
confirmationKey: (0, crypto_1.jwkToPublicKeyBytes)(params.confirmationKey),
|
|
28
|
-
accountSignature: params.accountPopSignature,
|
|
29
|
-
deviceSignature: params.devicePopSignature
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
catch (error) {
|
|
33
|
-
if (error instanceof identity_1.InvalidBindingInputError) {
|
|
34
|
-
throw new errors_1.IssuanceError("INVALID_PARAMS", `malformed binding input: ${error.message}`);
|
|
35
|
-
}
|
|
36
|
-
throw error;
|
|
37
|
-
}
|
|
38
|
-
if (binding.networkId !== params.subjectNetworkId.toLowerCase()) {
|
|
39
|
-
throw new errors_1.IssuanceError("SUBJECT_MISMATCH", "subjectNetworkId does not match the account public key");
|
|
40
|
-
}
|
|
41
|
-
if (!binding.valid) {
|
|
42
|
-
throw new errors_1.IssuanceError("BINDING_INVALID", "credential binding failed");
|
|
43
|
-
}
|
|
44
|
-
const ownershipOk = (0, revocation_1.verifyCommitmentOwnership)({
|
|
45
|
-
commitment: params.revocation.commitment,
|
|
46
|
-
publicKey: params.revocation.publicKey,
|
|
47
|
-
signature: params.revocation.ownershipSignature,
|
|
48
|
-
nonce: params.nonce
|
|
49
|
-
});
|
|
50
|
-
if (!ownershipOk) {
|
|
51
|
-
throw new errors_1.IssuanceError("COMMITMENT_OWNERSHIP_INVALID", "revocation commitment ownership proof failed");
|
|
52
|
-
}
|
|
53
|
-
const payload = (0, profile_1.buildPayload)({ ...params, subjectNetworkId: binding.networkId }, `urn:uuid:${(0, crypto_1.randomUuid)()}`);
|
|
54
|
-
const sdjwt = new sd_jwt_vc_1.SDJwtVcInstance({
|
|
55
|
-
signer: (0, crypto_1.createSdJwtSigner)(signer),
|
|
56
|
-
signAlg: "ES256",
|
|
57
|
-
hasher: crypto_1.sdJwtHasher,
|
|
58
|
-
hashAlg: "sha-256",
|
|
59
|
-
saltGenerator: crypto_1.sdJwtSaltGenerator
|
|
60
|
-
});
|
|
61
|
-
// Cast: the library types `status` as its own status-list reference, but
|
|
62
|
-
// our custom cn_zk_revocation member is deliberate (spec 07) and the
|
|
63
|
-
// status-list fetcher only triggers on status.status_list.
|
|
64
|
-
return sdjwt.issue({ ...payload }, profile_1.DISCLOSURE_FRAME);
|
|
65
|
-
}
|
package/dist/present.d.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { Resolver } from "./resolver";
|
|
2
|
-
import { DeviceSigner } from "./signer";
|
|
3
|
-
import { VerifyResult } from "./types";
|
|
4
|
-
export interface PresentOptions {
|
|
5
|
-
/** Verifier URN, pinned as the KB-JWT audience. */
|
|
6
|
-
audience: string;
|
|
7
|
-
/** Verifier-issued nonce, echoed in the KB-JWT. */
|
|
8
|
-
nonce: string;
|
|
9
|
-
/** Which approvalScope members to disclose. Both when omitted. */
|
|
10
|
-
disclose?: ("programme" | "country")[];
|
|
11
|
-
}
|
|
12
|
-
export interface VerifyPresentationOptions {
|
|
13
|
-
/** The verifier's own URN; the KB-JWT audience must match it. */
|
|
14
|
-
audience: string;
|
|
15
|
-
/** The nonce this verifier handed out; must be fresh and match. */
|
|
16
|
-
nonce: string;
|
|
17
|
-
/** Verification time, unix seconds. Defaults to now. */
|
|
18
|
-
currentDate?: number;
|
|
19
|
-
}
|
|
20
|
-
/**
|
|
21
|
-
* Builds a presentation from a stored credential: the JWS, the selected
|
|
22
|
-
* disclosures, and a KB-JWT signed by the device key binding the verifier's
|
|
23
|
-
* audience, the nonce and the sd_hash of the exact presented token (spec 07,
|
|
24
|
-
* verification flow step 7).
|
|
25
|
-
*/
|
|
26
|
-
export declare function presentSdJwtCredential(token: string, deviceSigner: DeviceSigner, options: PresentOptions): Promise<string>;
|
|
27
|
-
/**
|
|
28
|
-
* Verifies a presentation: the full verifyCredential pipeline, plus the
|
|
29
|
-
* KB-JWT under the credential's cnf.jwk with nonce, sd_hash (both checked by
|
|
30
|
-
* the library) and audience (checked here) pinned.
|
|
31
|
-
*/
|
|
32
|
-
export declare function verifySdJwtPresentation(presentation: string, resolver: Resolver, options: VerifyPresentationOptions): Promise<VerifyResult>;
|
package/dist/present.js
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.presentSdJwtCredential = presentSdJwtCredential;
|
|
4
|
-
exports.verifySdJwtPresentation = verifySdJwtPresentation;
|
|
5
|
-
const sd_jwt_vc_1 = require("@sd-jwt/sd-jwt-vc");
|
|
6
|
-
const crypto_1 = require("./crypto");
|
|
7
|
-
const verify_1 = require("./verify");
|
|
8
|
-
/**
|
|
9
|
-
* Builds a presentation from a stored credential: the JWS, the selected
|
|
10
|
-
* disclosures, and a KB-JWT signed by the device key binding the verifier's
|
|
11
|
-
* audience, the nonce and the sd_hash of the exact presented token (spec 07,
|
|
12
|
-
* verification flow step 7).
|
|
13
|
-
*/
|
|
14
|
-
async function presentSdJwtCredential(token, deviceSigner, options) {
|
|
15
|
-
const disclose = options.disclose ?? ["programme", "country"];
|
|
16
|
-
const frame = { approvalScope: Object.fromEntries(disclose.map((member) => [member, true])) };
|
|
17
|
-
const sdjwt = new sd_jwt_vc_1.SDJwtVcInstance({
|
|
18
|
-
hasher: crypto_1.sdJwtHasher,
|
|
19
|
-
hashAlg: "sha-256",
|
|
20
|
-
saltGenerator: crypto_1.sdJwtSaltGenerator,
|
|
21
|
-
kbSigner: (0, crypto_1.createSdJwtSigner)(deviceSigner),
|
|
22
|
-
kbSignAlg: "ES256"
|
|
23
|
-
});
|
|
24
|
-
return sdjwt.present(token, frame, {
|
|
25
|
-
kb: {
|
|
26
|
-
payload: {
|
|
27
|
-
aud: options.audience,
|
|
28
|
-
nonce: options.nonce,
|
|
29
|
-
iat: Math.floor(Date.now() / 1000)
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
/**
|
|
35
|
-
* Verifies a presentation: the full verifyCredential pipeline, plus the
|
|
36
|
-
* KB-JWT under the credential's cnf.jwk with nonce, sd_hash (both checked by
|
|
37
|
-
* the library) and audience (checked here) pinned.
|
|
38
|
-
*/
|
|
39
|
-
async function verifySdJwtPresentation(presentation, resolver, options) {
|
|
40
|
-
const now = options.currentDate ?? Math.floor(Date.now() / 1000);
|
|
41
|
-
let audience;
|
|
42
|
-
const { result } = await (0, verify_1.runPipeline)(presentation, resolver, now, async (issuerJwk, clampedNow) => {
|
|
43
|
-
const sdjwt = new sd_jwt_vc_1.SDJwtVcInstance({
|
|
44
|
-
verifier: (0, crypto_1.createSdJwtVerifier)(issuerJwk),
|
|
45
|
-
hasher: crypto_1.sdJwtHasher,
|
|
46
|
-
hashAlg: "sha-256",
|
|
47
|
-
kbVerifier: (data, signature, payload) => {
|
|
48
|
-
const cnfJwk = payload.cnf?.jwk;
|
|
49
|
-
if (!cnfJwk)
|
|
50
|
-
return false;
|
|
51
|
-
try {
|
|
52
|
-
return (0, crypto_1.createSdJwtVerifier)(cnfJwk)(data, signature);
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
return false;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
});
|
|
59
|
-
const { payload, kb } = await sdjwt.verify(presentation, {
|
|
60
|
-
currentDate: clampedNow,
|
|
61
|
-
keyBindingNonce: options.nonce
|
|
62
|
-
});
|
|
63
|
-
audience = kb?.payload.aud;
|
|
64
|
-
return payload;
|
|
65
|
-
});
|
|
66
|
-
if (!result.ok) {
|
|
67
|
-
return result;
|
|
68
|
-
}
|
|
69
|
-
if (audience !== options.audience) {
|
|
70
|
-
return { ok: false, code: "PROOF_INVALID", message: "KB-JWT audience does not match this verifier" };
|
|
71
|
-
}
|
|
72
|
-
return result;
|
|
73
|
-
}
|