diff --git a/package.json b/package.json index 34c8737..9e98a16 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "changeset-version": "pnpm changeset version && pnpm style:fix" }, "dependencies": { - "buffer": "^6.0.3" + "buffer": "^6.0.3", + "pako": "^2.1.0" }, "devDependencies": { "@biomejs/biome": "^1.9.4", @@ -40,6 +41,7 @@ "@panva/hkdf": "^1.2.1", "@peculiar/x509": "^1.12.3", "@types/node": "^20.14.11", + "@types/pako": "^2.0.3", "jose": "^5.9.3", "tsup": "^8.3.5", "typescript": "^5.6.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ddd2a9..744d25a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: buffer: specifier: ^6.0.3 version: 6.0.3 + pako: + specifier: ^2.1.0 + version: 2.1.0 devDependencies: '@biomejs/biome': specifier: ^1.9.4 @@ -33,6 +36,9 @@ importers: '@types/node': specifier: ^20.14.11 version: 20.17.6 + '@types/pako': + specifier: ^2.0.3 + version: 2.0.3 jose: specifier: ^5.9.3 version: 5.9.6 @@ -728,6 +734,9 @@ packages: '@types/node@20.17.6': resolution: {integrity: sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ==} + '@types/pako@2.0.3': + resolution: {integrity: sha512-bq0hMV9opAcrmE0Byyo0fY3Ew4tgOevJmQ9grUhpXQhYfyLJ1Kqg3P33JT5fdbT2AjeAjR51zqqVjAL/HMkx7Q==} + '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} @@ -1159,6 +1168,9 @@ packages: package-manager-detector@0.2.4: resolution: {integrity: sha512-H/OUu9/zUfP89z1APcBf2X8Us0tt8dUK4lUmKqz12QNXif3DxAs1/YqjGtcutZi1zQqeNQRWr9C+EbQnnvSSFA==} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2151,6 +2163,8 @@ snapshots: dependencies: undici-types: 6.19.8 + '@types/pako@2.0.3': {} + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -2581,6 +2595,8 @@ snapshots: package-manager-detector@0.2.4: {} + pako@2.1.0: {} + path-exists@4.0.0: {} path-key@3.1.1: {} diff --git a/src/cose/index.ts b/src/cose/index.ts index 4a20ea5..c70033c 100644 --- a/src/cose/index.ts +++ b/src/cose/index.ts @@ -3,3 +3,4 @@ export * from './mac0' export * from './sign1' export * from './error' export * from './key' +export * from './type' diff --git a/src/cose/type.ts b/src/cose/type.ts new file mode 100644 index 0000000..e9c6179 --- /dev/null +++ b/src/cose/type.ts @@ -0,0 +1,24 @@ +export enum CoseStructureType { + Sign = 'sign', + Sign1 = 'sign1', + Encrypt = 'encrypt', + Encrypt0 = 'encrypt0', + Mac = 'mac', + Mac0 = 'mac0', +} +export enum CoseStructureTag { + Sign = 98, + Sign1 = 18, + Encrypt = 96, + Encrypt0 = 16, + Mac = 97, + Mac0 = 17, +} +export const CoseTypeToTag: Record = { + [CoseStructureType.Sign]: CoseStructureTag.Sign, + [CoseStructureType.Sign1]: CoseStructureTag.Sign1, + [CoseStructureType.Encrypt]: CoseStructureTag.Encrypt, + [CoseStructureType.Encrypt0]: CoseStructureTag.Encrypt0, + [CoseStructureType.Mac]: CoseStructureTag.Mac, + [CoseStructureType.Mac0]: CoseStructureTag.Mac0, +} diff --git a/src/credential-status/error.ts b/src/credential-status/error.ts new file mode 100644 index 0000000..af648a1 --- /dev/null +++ b/src/credential-status/error.ts @@ -0,0 +1,9 @@ +// biome-ignore format: +class StatusListError extends Error { constructor(message: string = new.target.name) { super(message) } } + +export class InvalidStatusListFormatError extends StatusListError {} +export class InvalidStatusListBitsError extends StatusListError { + constructor(bits: number, allowedBits: readonly number[]) { + super(`Invalid bits per entry: ${bits}. Allowed values are ${allowedBits.join(', ')}.`) + } +} diff --git a/src/credential-status/index.ts b/src/credential-status/index.ts new file mode 100644 index 0000000..a3bf3e3 --- /dev/null +++ b/src/credential-status/index.ts @@ -0,0 +1,3 @@ +export * from './status-array' +export * from './status-list' +export * from './status-token' diff --git a/src/credential-status/status-array.ts b/src/credential-status/status-array.ts new file mode 100644 index 0000000..4543236 --- /dev/null +++ b/src/credential-status/status-array.ts @@ -0,0 +1,56 @@ +import * as zlib from 'pako' + +const arraySize = 1024 +export const allowedBitsPerEntry = [1, 2, 4, 8] as const +export type AllowedBitsPerEntry = (typeof allowedBitsPerEntry)[number] + +export class StatusArray { + private readonly _bitsPerEntry: AllowedBitsPerEntry + private readonly statusBitMask: number + private readonly data: Uint8Array + + constructor(bitsPerEntry: AllowedBitsPerEntry, byteArr?: Uint8Array) { + if (!allowedBitsPerEntry.includes(bitsPerEntry)) { + throw new Error(`Only bits ${allowedBitsPerEntry.join(', ')} per entry are allowed.`) + } + + this._bitsPerEntry = bitsPerEntry + this.statusBitMask = (1 << bitsPerEntry) - 1 + this.data = byteArr ? byteArr : new Uint8Array(arraySize) + } + + private computeByteAndOffset(index: number): { byteIndex: number; bitOffset: number } { + const byteIndex = Math.floor((index * this._bitsPerEntry) / 8) + const bitOffset = (index * this._bitsPerEntry) % 8 + + return { byteIndex, bitOffset } + } + + get bitsPerEntry(): AllowedBitsPerEntry { + return this._bitsPerEntry + } + + set(index: number, status: number): void { + if (status < 0 || status > this.statusBitMask) { + throw new Error(`Invalid status: ${status}. Must be between 0 and ${this.statusBitMask}.`) + } + + const { byteIndex, bitOffset } = this.computeByteAndOffset(index) + + // Clear current bits + this.data[byteIndex] &= ~(this.statusBitMask << bitOffset) + + // Set new status bits + this.data[byteIndex] |= (status & this.statusBitMask) << bitOffset + } + + get(index: number): number { + const { byteIndex, bitOffset } = this.computeByteAndOffset(index) + + return (this.data[byteIndex] >> bitOffset) & this.statusBitMask + } + + compress(): Uint8Array { + return zlib.deflate(this.data) + } +} diff --git a/src/credential-status/status-list.ts b/src/credential-status/status-list.ts new file mode 100644 index 0000000..853d565 --- /dev/null +++ b/src/credential-status/status-list.ts @@ -0,0 +1,56 @@ +import * as zlib from 'pako' +import { cborDecode, cborEncode } from '../cbor' +import { InvalidStatusListBitsError, InvalidStatusListFormatError } from './error' +import { type AllowedBitsPerEntry, StatusArray, allowedBitsPerEntry } from './status-array' + +export interface CborStatusListOptions { + statusArray: StatusArray + aggregationUri?: string +} + +export interface CborStatusList { + bits: AllowedBitsPerEntry + lst: Uint8Array + aggregation_uri?: string +} + +export class StatusList { + static buildCborStatusList(options: CborStatusListOptions): Uint8Array { + const compressed = options.statusArray.compress() + + const statusList: CborStatusList = { + bits: options.statusArray.bitsPerEntry, + lst: compressed, + } + + if (options.aggregationUri) { + statusList.aggregation_uri = options.aggregationUri + } + return cborEncode(statusList) + } + + static verifyStatus(cborStatusList: Uint8Array, index: number, expectedStatus: number): boolean { + const decoded = cborDecode(cborStatusList) + if (!(decoded instanceof Map)) { + throw new Error('Decoded CBOR data is not a Map.') + } + + const statusList: CborStatusList = { + bits: decoded.get('bits') as AllowedBitsPerEntry, + lst: decoded.get('lst') as Uint8Array, + aggregation_uri: decoded.get('aggregation_uri') as string | undefined, + } + const { bits, lst } = statusList + + if (!statusList || !lst || !bits) { + throw new InvalidStatusListFormatError() + } + if (!allowedBitsPerEntry.includes(bits)) { + throw new InvalidStatusListBitsError(bits, allowedBitsPerEntry) + } + + const statusArray = new StatusArray(bits, zlib.inflate(lst)) + const actualStatus = statusArray.get(index) + return actualStatus === expectedStatus + } +} diff --git a/src/credential-status/status-token.ts b/src/credential-status/status-token.ts new file mode 100644 index 0000000..5caad22 --- /dev/null +++ b/src/credential-status/status-token.ts @@ -0,0 +1,166 @@ +import { cborDecode, cborEncode } from '../cbor' +import { Tag } from '../cbor/cbor-x' +import type { MdocContext } from '../context' +import type { CoseKey } from '../cose' +import { CoseStructureType, CoseTypeToTag, Mac0, Sign1 } from '../cose' +import { CWT, CwtProtectedHeaders } from '../cwt' +import { dateToSeconds } from '../utils' +import type { StatusArray } from './status-array' +import { StatusList } from './status-list' + +export interface CwtStatusTokenOptions { + mdocContext: Pick + statusListUri: string + claimsSet: { + statusArray: StatusArray + aggregationUri?: string + expirationTime?: number + timeToLive?: number + } + type: CoseStructureType.Sign1 | CoseStructureType.Mac0 + key: CoseKey +} + +export interface CwtStatusTokenVerifyOptions { + mdocContext: Pick + token: Uint8Array + key?: CoseKey +} + +export interface CwtStatusVerifyOptions extends CwtStatusTokenVerifyOptions { + index: number + expectedStatus: number +} + +enum CwtStatusListClaims { + Sub = 2, + Exp = 4, + Iat = 6, + Sli = 65533, // Status List + Ttl = 65534, +} + +const CWT_STATUS_LIST_HEADER_TYPE = 'application/statuslist+cwt' + +export class CwtStatusToken { + static async sign(options: CwtStatusTokenOptions): Promise { + const cwt = new CWT() + cwt.setHeaders({ + protected: { + [CwtProtectedHeaders.Typ]: CWT_STATUS_LIST_HEADER_TYPE, + }, + }) + + const claims: { [key: number]: string | number | Uint8Array } = { + [CwtStatusListClaims.Sub]: options.statusListUri, + [CwtStatusListClaims.Iat]: dateToSeconds(), + [CwtStatusListClaims.Sli]: StatusList.buildCborStatusList({ + statusArray: options.claimsSet.statusArray, + aggregationUri: options.claimsSet.aggregationUri, + }), + } + if (options.claimsSet.expirationTime) { + claims[CwtStatusListClaims.Exp] = options.claimsSet.expirationTime + } + if (options.claimsSet.timeToLive) { + claims[CwtStatusListClaims.Ttl] = options.claimsSet.timeToLive + } + + cwt.setClaims(claims) + return cborEncode( + new Tag( + await cwt.create({ type: options.type, key: options.key, mdocContext: options.mdocContext }), + CoseTypeToTag[options.type] + ) + ) + } + + static async verifyStatusToken(options: CwtStatusTokenVerifyOptions): Promise { + const cwt = cborDecode(options.token) as Sign1 | Mac0 + + const type = cwt.protectedHeaders.headers?.get(String(CwtProtectedHeaders.Typ)) + if (!type || type !== CWT_STATUS_LIST_HEADER_TYPE) { + throw new Error('CWT status token does not have the correct type in protected headers') + } + + if (!cwt.payload) { + throw new Error('CWT status token does not contain claims') + } + const claims = cborDecode(cwt.payload) as Map + // Todo: Check if is the same as the one used to fetch the token + if (!claims.has(String(CwtStatusListClaims.Sub))) { + throw new Error('CWT status token does not contain status list URI') + } + if (!claims.has(String(CwtStatusListClaims.Iat))) { + throw new Error('CWT status token does not contain issued at claim') + } + if (!claims.has(String(CwtStatusListClaims.Sli))) { + throw new Error('CWT status token does not contain status list') + } + + const expirationTime = claims.get(String(CwtStatusListClaims.Exp)) + if (expirationTime && typeof expirationTime === 'number' && expirationTime < dateToSeconds()) { + throw new Error('CWT status token has expired') + } + + let coseType: CoseStructureType + if (cwt instanceof Sign1) { + coseType = CoseStructureType.Sign1 + } else if (cwt instanceof Mac0) { + coseType = CoseStructureType.Mac0 + } else { + throw new Error('Unsupported CWT structure type. Supported values are sign1 and mac0') + } + const validSignature = await CWT.verify({ + type: coseType, + token: options.token, + key: options.key, + mdocContext: options.mdocContext, + }) + if (!validSignature) { + throw new Error('Invalid signature for CWT status token') + } + + return cwt + } + + static async verifyStatus(options: CwtStatusVerifyOptions): Promise { + const cwt = await CwtStatusToken.verifyStatusToken(options) + if (!cwt.payload) { + throw new Error('CWT status token does not contain claims') + } + + const claims = cborDecode(cwt.payload) as Map + const statusList = claims.get(String(CwtStatusListClaims.Sli)) + return StatusList.verifyStatus(statusList as Uint8Array, options.index, options.expectedStatus) + } + + static async fetchStatusList(statusListUri: string, timeoutMs = 5000): Promise { + if (!statusListUri.startsWith('https://')) { + throw new Error(`Status list URI must be HTTPS: ${statusListUri}`) + } + + const abortController = new AbortController() + const timeout = setTimeout(() => { + abortController.abort() + }, timeoutMs) + try { + const response = await fetch(statusListUri, { + signal: abortController.signal as NonNullable, + headers: { + Accept: CWT_STATUS_LIST_HEADER_TYPE, + }, + }) + const buffer = await response.arrayBuffer() + clearTimeout(timeout) + return new Uint8Array(buffer) + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Fetch operation timed out for status list URI: ${statusListUri}`) + } + throw new Error( + `Error fetching status list from ${statusListUri}: ${error instanceof Error ? error.message : String(error)}` + ) + } + } +} diff --git a/src/cwt/index.ts b/src/cwt/index.ts new file mode 100644 index 0000000..0799f3e --- /dev/null +++ b/src/cwt/index.ts @@ -0,0 +1,132 @@ +import { cborDecode, cborEncode } from '../cbor' +import type { MdocContext } from '../context' +import { + type CoseKey, + Mac0, + type Mac0Options, + type Mac0Structure, + Sign1, + type Sign1Options, + type Sign1Structure, +} from '../cose' +import { CoseStructureType } from '../cose' + +type Header = { + protected?: Record + unprotected?: Record +} + +type CwtOptions = { + mdocContext: Pick + type: CoseStructureType.Sign1 | CoseStructureType.Mac0 + key: CoseKey +} + +export interface CwtVerifyOptions { + mdocContext: Pick + type: CoseStructureType + token: Uint8Array + key?: CoseKey +} + +export enum CwtProtectedHeaders { + Typ = 16, +} + +enum CwtStandardClaims { + Iss = 1, + Sub = 2, + Aud = 3, + Exp = 4, + Nbf = 5, + Iat = 6, + Cti = 7, +} + +export class CWT { + private claimsSet: Record = {} + private headers: Header = {} + + setIss(iss: string): void { + this.claimsSet[CwtStandardClaims.Iss] = iss + } + setSub(sub: string): void { + this.claimsSet[CwtStandardClaims.Sub] = sub + } + setAud(aud: string): void { + this.claimsSet[CwtStandardClaims.Aud] = aud + } + setExp(exp: number): void { + this.claimsSet[CwtStandardClaims.Exp] = exp + } + setNbf(nbf: number): void { + this.claimsSet[CwtStandardClaims.Nbf] = nbf + } + setIat(iat: number): void { + this.claimsSet[CwtStandardClaims.Iat] = iat + } + setCti(cti: Uint8Array): void { + this.claimsSet[CwtStandardClaims.Cti] = cti + } + + setClaims(claims: Record): void { + this.claimsSet = claims + } + + setHeaders(headers: Header): void { + this.headers = headers + } + + async create({ type, key, mdocContext }: CwtOptions): Promise { + switch (type) { + case CoseStructureType.Sign1: { + const sign1Options: Sign1Options = { + protectedHeaders: this.headers.protected ? cborEncode(this.headers.protected) : undefined, + unprotectedHeaders: this.headers.unprotected ? new Map(Object.entries(this.headers.unprotected)) : undefined, + payload: this.claimsSet ? cborEncode(this.claimsSet) : null, + } + + const sign1 = new Sign1(sign1Options) + await sign1.addSignature({ signingKey: key }, { cose: mdocContext.cose }) + return sign1.encodedStructure() + } + case CoseStructureType.Mac0: { + if (!this.headers.protected || !this.headers.unprotected) { + throw new Error('Protected and unprotected headers must be defined for MAC0') + } + const mac0Options: Mac0Options = { + protectedHeaders: this.headers.protected ? cborEncode(this.headers.protected) : undefined, + unprotectedHeaders: this.headers.unprotected ? new Map(Object.entries(this.headers.unprotected)) : undefined, + payload: this.claimsSet ? cborEncode(this.claimsSet) : null, + } + + const mac0 = new Mac0(mac0Options) + // Todo: Implement MAC0 signing logic + // await mac0.addTag({ privateKey: key, ephemeralKey: key, sessionTranscript: new SessionTranscript({ handover: new QrHandover() }) }, mdocContext); + // return mac0.encodedStructure(); + throw new Error('MAC0 is not yet implemented') + } + default: + throw new Error(`${type} is not yet implemented`) + } + } + + static async verify({ type, token, key, mdocContext }: CwtVerifyOptions): Promise { + const cwt = cborDecode(token) as Sign1 | Mac0 + switch (type) { + case CoseStructureType.Sign1: { + const sign1Options: Sign1Options = { + protectedHeaders: cwt.protectedHeaders, + unprotectedHeaders: cwt.unprotectedHeaders, + payload: cwt.payload, + signature: (cwt as Sign1).signature, + } + const sign1 = new Sign1(sign1Options) + return await sign1.verify({ key }, mdocContext) + } + default: + // Todo: Implement verification for MAC0 + throw new Error(`${type} is not yet implemented for verification`) + } + } +} diff --git a/src/index.ts b/src/index.ts index 3887586..f1b3f04 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,9 @@ export * from './mdoc' export * from './cose' export * from './utils' +export * from './cwt' +export * from './credential-status' + export * from './holder' export * from './verifier' export * from './issuer' diff --git a/src/mdoc/builders/issuer-signed-builder.ts b/src/mdoc/builders/issuer-signed-builder.ts index 02d7c6c..8adce25 100644 --- a/src/mdoc/builders/issuer-signed-builder.ts +++ b/src/mdoc/builders/issuer-signed-builder.ts @@ -19,7 +19,10 @@ import { IssuerSigned, IssuerSignedItem, MobileSecurityObject, + type MobileSecurityObjectOptions, type Namespace, + StatusInfo, + type StatusInfoOptions, ValidityInfo, type ValidityInfoOptions, ValueDigests, @@ -86,6 +89,7 @@ export class IssuerSignedBuilder { validityInfo: ValidityInfo | ValidityInfoOptions deviceKeyInfo: DeviceKeyInfo | DeviceKeyInfoOptions certificate: Uint8Array + statusList?: StatusInfoOptions }): Promise { const validityInfo = options.validityInfo instanceof ValidityInfo ? options.validityInfo : new ValidityInfo(options.validityInfo) @@ -93,13 +97,17 @@ export class IssuerSignedBuilder { const deviceKeyInfo = options.deviceKeyInfo instanceof DeviceKeyInfo ? options.deviceKeyInfo : new DeviceKeyInfo(options.deviceKeyInfo) - const mso = new MobileSecurityObject({ + const payload: MobileSecurityObjectOptions = { docType: this.docType, validityInfo, digestAlgorithm: options.digestAlgorithm, deviceKeyInfo, valueDigests: await this.convertIssuerNamespacesIntoValueDigests(options.digestAlgorithm), - }) + } + if (options.statusList) { + payload.status = new StatusInfo(options.statusList) + } + const mso = new MobileSecurityObject(payload) const protectedHeaders = new ProtectedHeaders({ protectedHeaders: new Map([[Header.Algorithm, options.algorithm]]), diff --git a/src/mdoc/models/index.ts b/src/mdoc/models/index.ts index fa0f047..a18f921 100644 --- a/src/mdoc/models/index.ts +++ b/src/mdoc/models/index.ts @@ -40,6 +40,7 @@ export * from './nfc-options' export * from './oidc' export * from './pex-limit-disclosure' export * from './presentation-definition' +export * from './status-info' export * from './protocol-info' export * from './qr-handover' export * from './reader-auth' diff --git a/src/mdoc/models/mobile-security-object.ts b/src/mdoc/models/mobile-security-object.ts index dccd074..6166336 100644 --- a/src/mdoc/models/mobile-security-object.ts +++ b/src/mdoc/models/mobile-security-object.ts @@ -2,6 +2,7 @@ import { type CborDecodeOptions, CborStructure, cborDecode } from '../../cbor' import type { DigestAlgorithm } from '../../cose' import { DeviceKeyInfo, type DeviceKeyInfoStructure } from './device-key-info' import type { DocType } from './doctype' +import { StatusInfo, type StatusInfoStructure } from './status-info' import { ValidityInfo, type ValidityInfoStructure } from './validity-info' import { ValueDigests, type ValueDigestsStructure } from './value-digests' @@ -12,6 +13,7 @@ export type MobileSecurityObjectStructure = { valueDigests: ValueDigestsStructure deviceKeyInfo: DeviceKeyInfoStructure validityInfo: ValidityInfoStructure + status?: StatusInfoStructure } export type MobileSecurityObjectOptions = { @@ -21,6 +23,7 @@ export type MobileSecurityObjectOptions = { valueDigests: ValueDigests validityInfo: ValidityInfo deviceKeyInfo: DeviceKeyInfo + status?: StatusInfo } export class MobileSecurityObject extends CborStructure { @@ -30,6 +33,7 @@ export class MobileSecurityObject extends CborStructure { public validityInfo: ValidityInfo public valueDigests: ValueDigests public deviceKeyInfo: DeviceKeyInfo + public status?: StatusInfo public constructor(options: MobileSecurityObjectOptions) { super() @@ -39,10 +43,11 @@ export class MobileSecurityObject extends CborStructure { this.validityInfo = options.validityInfo this.valueDigests = options.valueDigests this.deviceKeyInfo = options.deviceKeyInfo + this.status = options.status } public encodedStructure(): MobileSecurityObjectStructure { - return { + const structure: MobileSecurityObjectStructure = { version: this.version, digestAlgorithm: this.digestAlgorithm, valueDigests: this.valueDigests.encodedStructure(), @@ -50,6 +55,10 @@ export class MobileSecurityObject extends CborStructure { docType: this.docType, validityInfo: this.validityInfo.encodedStructure(), } + if (this.status) { + structure.status = this.status.encodedStructure() + } + return structure } public static override fromEncodedStructure( @@ -61,14 +70,18 @@ export class MobileSecurityObject extends CborStructure { structure = Object.fromEntries(encodedStructure.entries()) as MobileSecurityObjectStructure } - return new MobileSecurityObject({ + const mobileSecurityObject: MobileSecurityObjectOptions = { version: structure.version, digestAlgorithm: structure.digestAlgorithm as DigestAlgorithm, docType: structure.docType, validityInfo: ValidityInfo.fromEncodedStructure(structure.validityInfo), valueDigests: ValueDigests.fromEncodedStructure(structure.valueDigests), deviceKeyInfo: DeviceKeyInfo.fromEncodedStructure(structure.deviceKeyInfo), - }) + } + if (structure.status) { + mobileSecurityObject.status = StatusInfo.fromEncodedStructure(structure.status) + } + return new MobileSecurityObject(mobileSecurityObject) } public static override decode(bytes: Uint8Array, options?: CborDecodeOptions): MobileSecurityObject { diff --git a/src/mdoc/models/status-info.ts b/src/mdoc/models/status-info.ts new file mode 100644 index 0000000..4e55152 --- /dev/null +++ b/src/mdoc/models/status-info.ts @@ -0,0 +1,45 @@ +export type StatusInfoStructure = { + status_list: StatusInfoOptions +} + +export type StatusInfoOptions = { + idx: number + uri: string +} + +export class StatusInfo { + public statusList: StatusInfoOptions + + public constructor(statusInfo: StatusInfoOptions) { + this.statusList = { + idx: statusInfo.idx, + uri: statusInfo.uri, + } + } + + public encodedStructure(): StatusInfoStructure { + return { + status_list: this.statusList, + } + } + + public static fromEncodedStructure(encodedStructure: StatusInfoStructure): StatusInfo { + let structure = encodedStructure as StatusInfoStructure + if (structure instanceof Map) { + structure = Object.fromEntries(structure.entries()) as StatusInfoStructure + } + + let statusList = structure.status_list as StatusInfoOptions + if (statusList instanceof Map) { + statusList = Object.fromEntries(statusList.entries()) as StatusInfoOptions + } + if (!('idx' in statusList) || !('uri' in statusList)) { + throw new Error('Invalid status list structure') + } + + return new StatusInfo({ + idx: statusList.idx, + uri: statusList.uri, + }) + } +} diff --git a/src/utils/transformers.ts b/src/utils/transformers.ts index 19c0c97..387d529 100644 --- a/src/utils/transformers.ts +++ b/src/utils/transformers.ts @@ -33,3 +33,5 @@ export const compareBytes = (lhs: Uint8Array, rhs: Uint8Array) => { if (lhs.byteLength !== rhs.byteLength) return false return lhs.every((b, i) => b === rhs[i]) } + +export const dateToSeconds = (date?: Date): number => Math.floor((date?.getTime() ?? Date.now()) / 1000) diff --git a/tests/builders/issuer-signed-builder.test.ts b/tests/builders/issuer-signed-builder.test.ts index 5b486cf..d9709a3 100644 --- a/tests/builders/issuer-signed-builder.test.ts +++ b/tests/builders/issuer-signed-builder.test.ts @@ -1,6 +1,14 @@ import { X509Certificate } from '@peculiar/x509' import { describe, expect, test } from 'vitest' -import { CoseKey, DateOnly, type IssuerSigned, SignatureAlgorithm } from '../../src' +import { + CoseKey, + CoseStructureType, + CwtStatusToken, + DateOnly, + type IssuerSigned, + SignatureAlgorithm, + StatusArray, +} from '../../src' import { IssuerSignedBuilder } from '../../src/mdoc/builders/issuer-signed-builder' import { mdocContext } from '../context' import { DEVICE_JWK, ISSUER_CERTIFICATE, ISSUER_PRIVATE_KEY_JWK } from '../issuing/config' @@ -29,7 +37,7 @@ const claims = { ], } -describe('issuer signed builder', () => { +describe('issuer signed builder', async () => { let issuerSigned: IssuerSigned let issuerSignedEncoded: Uint8Array @@ -39,6 +47,18 @@ describe('issuer signed builder', () => { const validUntil = new Date(signed) validUntil.setFullYear(signed.getFullYear() + 30) + const statusArray = new StatusArray(1) + statusArray.set(0, 0) + const statusToken = await CwtStatusToken.sign({ + mdocContext, + statusListUri: 'https://status.example.com/status-list', + claimsSet: { + statusArray, + }, + type: CoseStructureType.Sign1, + key: CoseKey.fromJwk(ISSUER_PRIVATE_KEY_JWK), + }) + test('correctly instantiate an issuer signed object', async () => { const issuerSignedBuilder = new IssuerSignedBuilder('org.iso.18013.5.1.mDL', mdocContext).addIssuerNamespace( 'org.iso.18013.5.1', @@ -51,6 +71,7 @@ describe('issuer signed builder', () => { digestAlgorithm: 'SHA-256', deviceKeyInfo: { deviceKey: CoseKey.fromJwk(DEVICE_JWK) }, validityInfo: { signed, validFrom, validUntil }, + statusList: { idx: 0, uri: 'https://status.example.com/status-list' }, }) issuerSignedEncoded = issuerSigned.encode() @@ -84,6 +105,23 @@ describe('issuer signed builder', () => { expect(validityInfo.expectedUpdate).toBeUndefined() }) + test('verify status info', async () => { + const { status } = issuerSigned.issuerAuth.mobileSecurityObject + expect(status).toBeDefined() + + if (status) { + expect( + await CwtStatusToken.verifyStatus({ + mdocContext, + key: CoseKey.fromJwk(ISSUER_PRIVATE_KEY_JWK), + token: statusToken, + index: status.statusList.idx, + expectedStatus: 0, + }) + ).toBeTruthy() + } + }) + test('set correct digest algorithm', () => { const { digestAlgorithm } = issuerSigned.issuerAuth.mobileSecurityObject expect(digestAlgorithm).toEqual('SHA-256') diff --git a/tests/credential-status/cred-status.test.ts b/tests/credential-status/cred-status.test.ts new file mode 100644 index 0000000..029ab86 --- /dev/null +++ b/tests/credential-status/cred-status.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'vitest' +import { CoseKey, CwtStatusToken, StatusArray } from '../../src' +import { CoseStructureType } from '../../src/cose' +import { mdocContext } from '../context' +import { ISSUER_PRIVATE_KEY_JWK } from '../issuing/config' + +describe('CWTStatusToken', () => { + test('should create and verify a CWTStatusToken with a StatusArray', async () => { + const statusArray = new StatusArray(2) + + statusArray.set(0, 2) + statusArray.set(1, 3) + expect(statusArray.get(0)).toBe(2) + expect(statusArray.get(1)).toBe(3) + + const cwtStatusToken = await CwtStatusToken.sign({ + mdocContext, + statusListUri: 'https://example.com/status-list', + claimsSet: { statusArray }, + type: CoseStructureType.Sign1, + key: CoseKey.fromJwk(ISSUER_PRIVATE_KEY_JWK), + }) + const verify = await CwtStatusToken.verifyStatus({ + mdocContext, + token: cwtStatusToken, + key: CoseKey.fromJwk(ISSUER_PRIVATE_KEY_JWK), + index: 0, + expectedStatus: 2, + }) + + expect(verify).toBeTruthy() + }) +}) diff --git a/tsconfig.json b/tsconfig.json index e97c047..056aab3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "strict": true, "skipLibCheck": true, "noEmitOnError": true, - "lib": ["ES2020"], + "lib": ["ES2020", "DOM"], "types": [], "esModuleInterop": true, "allowSyntheticDefaultImports": true