@coordination-network/credential 0.0.1 → 0.0.2

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/src/decode.ts DELETED
@@ -1,25 +0,0 @@
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/issue.ts DELETED
@@ -1,83 +0,0 @@
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
- }
package/src/present.ts DELETED
@@ -1,101 +0,0 @@
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
- }
package/src/profile.ts DELETED
@@ -1,133 +0,0 @@
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
- }
package/src/verify.ts DELETED
@@ -1,118 +0,0 @@
1
- import { SDJwtVcInstance } from "@sd-jwt/sd-jwt-vc"
2
- import { base64urlToBytes, createSdJwtVerifier, sdJwtHasher } from "./crypto"
3
- import { urnToNetworkId, validateClaims } from "./profile"
4
- import { Resolver } from "./resolver"
5
- import { ApprovalCredentialClaims, Hex32, Jwk, VerifyErrorCode, VerifyResult } from "./types"
6
-
7
- export interface VerifyOptions {
8
- /** Verification time, unix seconds. Defaults to now. */
9
- currentDate?: number
10
- }
11
-
12
- const fail = (code: VerifyErrorCode, message: string): VerifyResult => ({ ok: false, code, message })
13
-
14
- /**
15
- * The shared verification pipeline (spec 07, verification flow), check order
16
- * first-failure-wins: decode/structure -> issuer approved -> key fetch ->
17
- * signature and disclosure digests -> claims shape -> validity window ->
18
- * revocation. A disclosure whose digest matches nothing is silently dropped
19
- * by the library (phase 1 spike), so it surfaces in the claims-shape step as
20
- * a missing approvalScope member and fails MALFORMED.
21
- *
22
- * The library's own nbf/exp check runs against a clamped timestamp so the
23
- * window verdict (EXPIRED vs NOT_YET_VALID) always comes from our check,
24
- * after the signature has been verified.
25
- */
26
- async function runPipeline(
27
- token: string,
28
- resolver: Resolver,
29
- now: number,
30
- verifyWithLibrary: (issuerJwk: Jwk, clampedNow: number) => Promise<Record<string, unknown>>
31
- ): Promise<{ result: VerifyResult; issuerId?: Hex32 }> {
32
- let payload: Record<string, unknown>
33
- try {
34
- const jwt = token.split("~")[0]
35
- const segments = jwt.split(".")
36
- if (segments.length !== 3) {
37
- return { result: fail("MALFORMED", "not a compact SD-JWT") }
38
- }
39
- payload = JSON.parse(new TextDecoder().decode(base64urlToBytes(segments[1])))
40
- if (typeof payload !== "object" || payload === null) {
41
- return { result: fail("MALFORMED", "JWS payload is not an object") }
42
- }
43
- } catch {
44
- return { result: fail("MALFORMED", "token could not be decoded") }
45
- }
46
-
47
- const issuerId = typeof payload.iss === "string" ? urnToNetworkId(payload.iss) : null
48
- if (issuerId === null) {
49
- return { result: fail("MALFORMED", "iss missing or not a urn:cn:network URN") }
50
- }
51
- if (!(await resolver.isApprovedNetworkMember(issuerId))) {
52
- return {
53
- result: fail("ISSUER_NOT_APPROVED", `issuer ${payload.iss} is not an approved network member`),
54
- issuerId
55
- }
56
- }
57
- const issuerJwk = await resolver.getCredentialPublicKey(issuerId)
58
- if (issuerJwk === null) {
59
- return { result: fail("KEY_NOT_FOUND", `no credential public key published for ${payload.iss}`), issuerId }
60
- }
61
-
62
- // Clamp the library's clock inside the token's window; our own check below decides the verdict.
63
- let clampedNow = now
64
- if (typeof payload.exp === "number" && clampedNow > payload.exp) clampedNow = payload.exp
65
- if (typeof payload.nbf === "number" && clampedNow < payload.nbf) clampedNow = payload.nbf
66
-
67
- let reconstructed: Record<string, unknown>
68
- try {
69
- reconstructed = await verifyWithLibrary(issuerJwk, clampedNow)
70
- } catch (error) {
71
- const message = error instanceof Error ? error.message : String(error)
72
- if (message.includes("not yet valid")) return { result: fail("NOT_YET_VALID", message), issuerId }
73
- if (message.includes("expired")) return { result: fail("EXPIRED", message), issuerId }
74
- return { result: fail("PROOF_INVALID", message), issuerId }
75
- }
76
-
77
- const checked = validateClaims(reconstructed)
78
- if (!checked.ok) {
79
- return { result: fail("MALFORMED", checked.message), issuerId }
80
- }
81
- const claims: ApprovalCredentialClaims = checked.claims
82
-
83
- if (now < claims.nbf) {
84
- return { result: fail("NOT_YET_VALID", "credential is not yet within its validity window"), issuerId }
85
- }
86
- if (now > claims.exp) {
87
- return { result: fail("EXPIRED", "credential validity window has passed"), issuerId }
88
- }
89
- if (await resolver.isRevoked(BigInt(claims.status.cn_zk_revocation.revocationCommitment))) {
90
- return { result: fail("REVOKED", "credential commitment is in the revocation registry"), issuerId }
91
- }
92
- return { result: { ok: true, claims }, issuerId }
93
- }
94
-
95
- /**
96
- * Verifies a compact SD-JWT credential with all its disclosures — the
97
- * holder's verify-on-receipt and any check that does not involve a live
98
- * presentation. Key binding is not checked here; that is verifyPresentation.
99
- */
100
- export async function verifySdJwtCredential(
101
- token: string,
102
- resolver: Resolver,
103
- options?: VerifyOptions
104
- ): Promise<VerifyResult> {
105
- const now = options?.currentDate ?? Math.floor(Date.now() / 1000)
106
- const { result } = await runPipeline(token, resolver, now, async (issuerJwk, clampedNow) => {
107
- const sdjwt = new SDJwtVcInstance({
108
- verifier: createSdJwtVerifier(issuerJwk),
109
- hasher: sdJwtHasher,
110
- hashAlg: "sha-256"
111
- })
112
- const { payload } = await sdjwt.verify(token, { currentDate: clampedNow })
113
- return payload
114
- })
115
- return result
116
- }
117
-
118
- export { runPipeline }