Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions packages/w3c-vc/src/lib/presentation/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,39 @@ const assertDefined = <T>(value: T | undefined, message: string): T => {
return value;
};

/**
* The modern fixtures carry a TransferableRecords credentialStatus, which cannot be
* presented. `credentialStatus` is a mandatory pointer so it survives derivation — tests
* about anything other than transferable records need it gone before signing.
*/
const presentableV2 = () => {
const { credentialStatus: _transferableRecord, ...rest } = modernCredentialV2_0 as Record<
string,
unknown
>;
return rest;
};

/**
* Signs a credential and derives it (selective disclosure) so it is a
* verifiable, holder-presentable credential.
*
* The shared modern fixtures carry a TransferableRecords credentialStatus, which is NOT
* presentable — ownership of a transferable record lives on-chain. It is dropped before
* signing rather than relied on being derived away: `credentialStatus` is a mandatory
* pointer, so it survives derivation and the presentation would be rejected. The dedicated
* `rejects TransferableRecords credentials` block below keeps it, via makeSignedCredential.
*/
const makeDerivedCredential = async (
credential: object,
keyPair: object,
cryptosuite: 'ecdsa-sd-2023' | 'bbs-2023',
): Promise<SignedVerifiableCredential> => {
const { credentialStatus: _transferableRecord, ...presentable } = credential as Record<
string,
unknown
>;
credential = presentable;
const signed = await signCredential(credential as never, keyPair as never, cryptosuite);
if (signed.error) throw new Error(`sign failed: ${signed.error}`);
const derived = await deriveCredential(
Expand Down Expand Up @@ -416,7 +440,8 @@ describe('Verifiable Presentation', () => {

beforeAll(async () => {
// The modern credential fixtures carry a TransferableRecords credentialStatus;
// a base (non-derived) signature retains it.
// a base (non-derived) signature retains it. This block NEEDS it, so it uses the
// fixture as-is rather than presentableV2().
transferableRecordVc = await makeSignedCredential(
{
...modernCredentialV2_0,
Expand Down Expand Up @@ -556,7 +581,7 @@ describe('Verifiable Presentation', () => {
// modernCredentialV2_0 has validUntil 2029; reveal it, then treat "now" as 2030.
const signed = await signCredential(
{
...modernCredentialV2_0,
...presentableV2(),
issuer: ECDSA_DID_KEY_ISSUER,
validFrom: '2024-04-01T12:19:52Z',
},
Expand All @@ -582,7 +607,7 @@ describe('Verifiable Presentation', () => {
// Build a VP whose embedded credential expires in 2021, created in 2020 (so creation passes).
const signed = await signCredential(
{
...modernCredentialV2_0,
...presentableV2(),
issuer: ECDSA_DID_KEY_ISSUER,
validFrom: '2020-01-01T00:00:00Z',
validUntil: '2021-01-01T00:00:00Z',
Expand Down
79 changes: 79 additions & 0 deletions packages/w3c-vc/src/lib/w3c-vc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,85 @@ describe('W3C Verifiable Credentials', () => {
expect(verificationResult.error).toBeUndefined();
});

describe('fields a holder must not be able to withhold', () => {
// Selective disclosure lets the holder drop any non-mandatory statement while the
// credential still verifies, so anything that limits whether a credential may
// still be used has to be mandatory at issuance. These derivations deliberately
// ask for none of those fields; they must come back anyway.
const expiryField = dateField === 'validFrom' ? 'validUntil' : 'expirationDate';
const revealOnlyOneSubjectField = ['/credentialSubject/billOfLadingName'];

it(`keeps ${dateField === 'validFrom' ? 'validUntil' : 'expirationDate'} through an unrelated derivation`, async () => {
const testCredential = {
...credential,
[dateField]: dateValue,
[expiryField]: '2029-12-03T12:19:52Z',
};

const signedCredential = await signCredential(testCredential, keyPair, cryptosuite);
expect(signedCredential.error).toBeUndefined();

const derivedCredential = await deriveCredential(
signedCredential.signed,
revealOnlyOneSubjectField,
);
expect(derivedCredential.error).toBeUndefined();
// Without this, an expired credential could be presented with no expiry at all.
expect(derivedCredential.derived?.[expiryField]).toBe('2029-12-03T12:19:52Z');

const verificationResult = await verifyCredential(derivedCredential.derived);
expect(verificationResult.verified).toBe(true);
});

it('keeps credentialStatus through an unrelated derivation', async () => {
const testCredential = {
...credential,
'@context': [...credential['@context'], 'https://w3id.org/vc/status-list/2021/v1'],
[dateField]: dateValue,
credentialStatus: {
id: 'https://trustvc.github.io/did/credentials/statuslist/1#10',
type: 'StatusList2021Entry',
statusPurpose: 'revocation',
statusListIndex: '10',
statusListCredential: 'https://trustvc.github.io/did/credentials/statuslist/1',
},
};

const signedCredential = await signCredential(testCredential, keyPair, cryptosuite);
expect(signedCredential.error).toBeUndefined();

const derivedCredential = await deriveCredential(
signedCredential.signed,
revealOnlyOneSubjectField,
);
expect(derivedCredential.error).toBeUndefined();
// Without this, a revoked credential could be presented with the entry removed.
expect(derivedCredential.derived?.credentialStatus).toBeDefined();

const verificationResult = await verifyCredential(derivedCredential.derived);
expect(verificationResult.verified).toBe(true);
});

it('leaves a credential carrying neither field unaffected', async () => {
// The shared fixture already has an expiry, so strip both fields to test the
// case where there is nothing extra to force.
const testCredential = { ...credential, [dateField]: dateValue };
delete (testCredential as Record<string, unknown>)[expiryField];
delete (testCredential as Record<string, unknown>).credentialStatus;

const signedCredential = await signCredential(testCredential, keyPair, cryptosuite);
expect(signedCredential.error).toBeUndefined();

const derivedCredential = await deriveCredential(
signedCredential.signed,
revealOnlyOneSubjectField,
);
expect(derivedCredential.error).toBeUndefined();
expect(derivedCredential.derived?.[expiryField]).toBeUndefined();
expect(derivedCredential.derived?.credentialStatus).toBeUndefined();
});
});

it('should automatically include entire credentialSubject when no properties selected', async () => {
const testCredential = { ...credential, [dateField]: dateValue };

Expand Down
37 changes: 35 additions & 2 deletions packages/w3c-vc/src/lib/w3c-vc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,20 +281,53 @@ export const signCredential = async (
const firstContext = credential['@context'][0];
const isV2 = firstContext === CredentialContextVersion.v2;

// Core mandatory pointers for fields required for credential validity
// Core mandatory pointers for fields required for credential validity.
//
// These are forced rather than left to the caller because selective disclosure lets
// the HOLDER omit any statement the issuer did not mark mandatory, and the credential
// still verifies. Anything constraining whether a credential may still be used has to
// be here or a holder can derive it away: a revoked credential presented with no
// `credentialStatus`, or an expired one with no expiry, verifies clean. The removal is
// undetectable — the derived proof records nothing about what was withheld, and
// dropping the then-unused `@context` entry leaves the canonical RDF, and so the
// signature, intact. Verification cannot catch it; issuance is the only defence.
const coreMandatoryPointers = ['/issuer'];

// Add date field pointer based on credential version
// Add date field pointers based on credential version. Both ends of the validity
// window belong here — forcing only the start would leave the expiry strippable.
if (isV2) {
// For v2.0, validFrom is optional but if present should be mandatory for consistency
if (credential.validFrom) {
coreMandatoryPointers.push('/validFrom');
}
if (credential.validUntil) {
coreMandatoryPointers.push('/validUntil');
}
} else {
// For v1.1, issuanceDate is required
coreMandatoryPointers.push('/issuanceDate');
if (credential.expirationDate) {
coreMandatoryPointers.push('/expirationDate');
}
}

// A revocation entry a holder can withhold is not a revocation entry.
if (credential.credentialStatus) {
coreMandatoryPointers.push('/credentialStatus');
}

// BEFORE ENFORCING A NEW FIELD DURING VERIFICATION, ADD IT HERE FIRST.
//
// `credentialSchema` and `termsOfUse` are deliberately absent: nothing reads them
// today, so forcing them would only widen what a derivation discloses. But the moment
// a verifier starts acting on one, a holder can delete it and the check is silently
// skipped rather than failed — and that cannot be fixed retroactively, because
// mandatory pointers are fixed at issuance. Credentials signed before the pointer is
// added stay strippable forever and have to be reissued.
//
// `evidence`, `renderMethod` and `qrCode` are informational or display-only, and
// withholding them is a legitimate holder choice; they should stay optional.

// Combine core mandatory pointers with user-provided ones, ensuring core fields are always included
const userMandatoryPointers = options?.mandatoryPointers || [];
const mandatoryPointers = [
Expand Down
Loading