From fa9a8a3faa8235d25fa707211db653f786325610 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Tue, 30 Jun 2026 09:47:31 -0700 Subject: [PATCH 1/2] fix: asn1 choice wrapping --- src/lib/certificates.ts | 97 +++++++++++++++++-- src/services/kyc/utils/generate-kyc-schema.ts | 9 +- 2 files changed, 94 insertions(+), 12 deletions(-) diff --git a/src/lib/certificates.ts b/src/lib/certificates.ts index a9e7b999..a2cb45ee 100644 --- a/src/lib/certificates.ts +++ b/src/lib/certificates.ts @@ -293,6 +293,69 @@ function unwrapContextTagsFromSchema(schema: ASN1.Schema): ASN1.Schema { return(schema); } +/** + * Determine whether a schema node is an ASN.1 CHOICE. + */ +function isChoiceSchema(schema: ASN1.Schema): boolean { + return(typeof schema === 'object' && schema !== null && 'choice' in schema); +} + +/** + * Produce a legacy-compatible schema in which CHOICE fields are encoded without + * the positional context-tag wrapper that newer certificates add. + */ +function toLegacyChoiceSchema(schema: ASN1.Schema): ASN1.Schema { + if (typeof schema !== 'object' || schema === null) { + return(schema); + } + if (Array.isArray(schema)) { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return(schema.map(toLegacyChoiceSchema) as unknown as ASN1.Schema); + } + + if ('choice' in schema) { + if (Array.isArray(schema.choice)) { + return({ choice: schema.choice.map(toLegacyChoiceSchema) }); + } + + const mappedChoice: { [key: string]: ASN1.Schema } = {}; + for (const [key, value] of Object.entries(schema.choice)) { + mappedChoice[key] = toLegacyChoiceSchema(value); + } + + return({ choice: mappedChoice }); + } + + if ('sequenceOf' in schema) { + return({ sequenceOf: toLegacyChoiceSchema(schema.sequenceOf) }); + } + if ('optional' in schema) { + return({ optional: toLegacyChoiceSchema(schema.optional) }); + } + if ('type' in schema && schema.type === 'context') { + // Strip the positional wrapper when it wraps a CHOICE to restore the bare form + if (isChoiceSchema(schema.contains)) { + return(toLegacyChoiceSchema(schema.contains)); + } + + return({ ...schema, contains: toLegacyChoiceSchema(schema.contains) }); + } + if ('type' in schema && schema.type === 'struct') { + const legacyContains: { [key: string]: ASN1.Schema } = {}; + for (const [fieldName, fieldSchema] of Object.entries(schema.contains)) { + legacyContains[fieldName] = toLegacyChoiceSchema(fieldSchema); + } + + return({ + type: 'struct', + fieldNames: schema.fieldNames, + contains: legacyContains + }); + } + + return(schema); +} + /** * Fallback decoder for entityType attribute from old certificates. * Transforms raw ASN1 into the expected EntityType structure. @@ -375,22 +438,36 @@ async function decodeAttribute(name: NAM // @ts-expect-error decodedASN1 = new ASN1.BufferStorageASN1(value, schema).getASN1(); } catch (firstError) { - // Fallback: try with backwards-compatible schema (context tags stripped) - // This supports old certificates encoded before context tags were added - try { - // Special handling for entityType - if (name === 'entityType') { + // Special handling for entityType: positional raw walk for pre-wrapper certificates + if (name === 'entityType') { + try { const candidate = decodeEntityTypeFallback(value, principals); return(asAttributeValue(name, candidate)); + } catch { + throw(firstError); } + } - const backwardsCompatSchema = unwrapContextTagsFromSchema(schema); + /* + * Fall back to historical wire formats, most-recent first: + * 1. CHOICE fields encoded bare, before the positional wrapper was added + * 2. All context tags stripped, before any positional wrappers existed + */ + try { + const legacyChoiceSchema = toLegacyChoiceSchema(schema); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - decodedASN1 = new ASN1.BufferStorageASN1(value, backwardsCompatSchema).getASN1(); - usedSchema = backwardsCompatSchema; + decodedASN1 = new ASN1.BufferStorageASN1(value, legacyChoiceSchema).getASN1(); + usedSchema = legacyChoiceSchema; } catch { - // If both fail, throw the original error - throw(firstError); + try { + const backwardsCompatSchema = unwrapContextTagsFromSchema(schema); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + decodedASN1 = new ASN1.BufferStorageASN1(value, backwardsCompatSchema).getASN1(); + usedSchema = backwardsCompatSchema; + } catch { + // If every format fails, throw the original error + throw(firstError); + } } } diff --git a/src/services/kyc/utils/generate-kyc-schema.ts b/src/services/kyc/utils/generate-kyc-schema.ts index 592f2f20..e51d5c73 100755 --- a/src/services/kyc/utils/generate-kyc-schema.ts +++ b/src/services/kyc/utils/generate-kyc-schema.ts @@ -478,8 +478,13 @@ function genSequenceSchema(typeName: string, fields: { [key: string]: { type: st const baseSchema = fieldInfo.schema; const isOptional = fieldInfo.optional; - const isChoiceField = fieldInfo.isChoice; - if (hasAnyOptionalField && !isChoiceField) { + /* + * CHOICE fields are wrapped in a positional context tag just like every + * other field. Leaving them bare lets a CHOICE alternative's own context + * tag collide with a sibling field's positional tag, which makes the + * SEQUENCE ambiguous to a tag-driven decoder. + */ + if (hasAnyOptionalField) { const wrappedInContext = `{ type: 'context', kind: 'explicit', value: ${index}, contains: ${baseSchema} }`; if (isOptional) { return(`\t\t${fname}: { optional: ${wrappedInContext} }`); From a778e8723d360c85286d9c22bccec56195ed5ea8 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Tue, 30 Jun 2026 18:36:52 -0700 Subject: [PATCH 2/2] fix(certificates): backward compat --- src/lib/certificates.legacy.test.ts | 77 +++++++++++++++++++++++++++++ src/lib/certificates.ts | 29 +++++------ 2 files changed, 92 insertions(+), 14 deletions(-) create mode 100644 src/lib/certificates.legacy.test.ts diff --git a/src/lib/certificates.legacy.test.ts b/src/lib/certificates.legacy.test.ts new file mode 100644 index 00000000..7bd0b000 --- /dev/null +++ b/src/lib/certificates.legacy.test.ts @@ -0,0 +1,77 @@ +import { test, expect } from 'vitest'; +import * as Certificates from './certificates.js'; +import * as KeetaNetClient from '@keetanetwork/keetanet-client'; + +/* + * Backward-compatibility regression: this certificate was signed before CHOICE + * fields gained the positional context-tag wrapper, so entityType's schemeName + * is encoded bare while its sibling id stays context-tagged. Signed with the + * seed below (ED25519) and + * entityType { person: [{ id: '123-45-6789', schemeName: 'SSN' }] }. + */ +const legacySeed = 'B0B0B0B0B0B0B0B0B0B0B0B0B0B0B0B0B0B0B0B0B0B0B0B0B0B0B0B0B0B0B0B0'; + +const legacyCertPEM = `-----BEGIN CERTIFICATE----- +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 +-----END CERTIFICATE-----`; + +test('legacy bare-CHOICE entityType still decodes', async function() { + const account = KeetaNetClient.lib.Account.fromSeed(legacySeed, 1, KeetaNetClient.lib.Account.AccountKeyAlgorithm.ED25519); + const certificate = new Certificates.Certificate(legacyCertPEM, { subjectKey: account, moment: null }); + + const entityType = await certificate.getAttributeValue('entityType'); + expect(entityType.person?.[0]?.schemeName, 'bare CHOICE schemeName decodes').toBe('SSN'); + expect(entityType.person?.[0]?.id, 'context-tagged sibling id decodes').toBe('123-45-6789'); +}); diff --git a/src/lib/certificates.ts b/src/lib/certificates.ts index a2cb45ee..84dd8a93 100644 --- a/src/lib/certificates.ts +++ b/src/lib/certificates.ts @@ -438,20 +438,9 @@ async function decodeAttribute(name: NAM // @ts-expect-error decodedASN1 = new ASN1.BufferStorageASN1(value, schema).getASN1(); } catch (firstError) { - // Special handling for entityType: positional raw walk for pre-wrapper certificates - if (name === 'entityType') { - try { - const candidate = decodeEntityTypeFallback(value, principals); - return(asAttributeValue(name, candidate)); - } catch { - throw(firstError); - } - } - /* - * Fall back to historical wire formats, most-recent first: - * 1. CHOICE fields encoded bare, before the positional wrapper was added - * 2. All context tags stripped, before any positional wrappers existed + * Certificates signed before CHOICE fields gained their positional wrapper + * encode those fields bare; retry with the pre-wrapper schema */ try { const legacyChoiceSchema = toLegacyChoiceSchema(schema); @@ -459,13 +448,25 @@ async function decodeAttribute(name: NAM decodedASN1 = new ASN1.BufferStorageASN1(value, legacyChoiceSchema).getASN1(); usedSchema = legacyChoiceSchema; } catch { + /* + * Fallback: try with backwards-compatible schema (context tags stripped) + * This supports old certificates encoded before context tags were added + */ try { + /* + * Special handling for entityType + */ + if (name === 'entityType') { + const candidate = decodeEntityTypeFallback(value, principals); + return(asAttributeValue(name, candidate)); + } + const backwardsCompatSchema = unwrapContextTagsFromSchema(schema); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment decodedASN1 = new ASN1.BufferStorageASN1(value, backwardsCompatSchema).getASN1(); usedSchema = backwardsCompatSchema; } catch { - // If every format fails, throw the original error + // If both fail, throw the original error throw(firstError); } }