@coordination-network/credential 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/dist/credential-message.d.ts +11 -0
- package/dist/credential-message.js +164 -0
- package/dist/credential-signer.d.ts +11 -0
- package/dist/credential-signer.js +17 -0
- package/dist/crypto.d.ts +51 -0
- package/dist/crypto.js +141 -0
- package/dist/decode.d.ts +8 -0
- package/dist/decode.js +27 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +11 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +35 -0
- package/dist/issue-credential.d.ts +4 -0
- package/dist/issue-credential.js +66 -0
- package/dist/issue.d.ts +9 -0
- package/dist/issue.js +65 -0
- package/dist/issuer-set.d.ts +2 -0
- package/dist/issuer-set.js +11 -0
- package/dist/present.d.ts +32 -0
- package/dist/present.js +73 -0
- package/dist/presentation-inputs.d.ts +77 -0
- package/dist/presentation-inputs.js +139 -0
- package/dist/profile.d.ts +32 -0
- package/dist/profile.js +122 -0
- package/dist/registry-client.d.ts +12 -0
- package/dist/registry-client.js +33 -0
- package/dist/resolver.d.ts +37 -0
- package/dist/resolver.js +37 -0
- package/dist/revocation.d.ts +25 -0
- package/dist/revocation.js +72 -0
- package/dist/signer.d.ts +19 -0
- package/dist/signer.js +2 -0
- package/dist/types.d.ts +111 -0
- package/dist/types.js +3 -0
- package/dist/verify.d.ts +29 -0
- package/dist/verify.js +102 -0
- package/dist/witness.d.ts +12 -0
- package/dist/witness.js +62 -0
- package/dist/zk-proof.d.ts +45 -0
- package/dist/zk-proof.js +130 -0
- package/package.json +55 -0
- package/src/credential-message.ts +173 -0
- package/src/credential-signer.ts +24 -0
- package/src/crypto.ts +146 -0
- package/src/decode.ts +25 -0
- package/src/errors.ts +12 -0
- package/src/index.ts +19 -0
- package/src/issue-credential.ts +78 -0
- package/src/issue.ts +83 -0
- package/src/issuer-set.ts +10 -0
- package/src/present.ts +101 -0
- package/src/presentation-inputs.ts +245 -0
- package/src/profile.ts +133 -0
- package/src/registry-client.ts +37 -0
- package/src/resolver.ts +62 -0
- package/src/revocation.ts +81 -0
- package/src/signer.ts +21 -0
- package/src/types.ts +116 -0
- package/src/verify.ts +118 -0
- package/src/witness.ts +69 -0
- package/src/zk-proof.ts +198 -0
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
|
+
}
|
package/src/resolver.ts
ADDED
|
@@ -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
|
+
}
|