Skip to content

Commit 3de06b9

Browse files
authored
delegation offer over didcomm (#537)
* didcomm delegation offer * address PR review feedback for delegation offer flow * add ISSUE_CREDENTIAL_HANDLER for holder-side credential receipt and ACK * update gitignore * harden delegation offer flow: expiration, slim OOB payload, security checks * test: rejection paths for delegation offer handlers * fix: treat EDV 404 responses as empty find results The EDV server returns HTTP 404 on /query for vaults with no indices yet (e.g. fresh vaults from wrong biometric credentials). ky now surfaces this as 'Request failed with status code 404 Not Found' rather than the legacy 'Not Found' message the universal-wallet catch handles, so biometric authentication failures were leaking raw HTTP errors instead of the domain-level 'Invalid identifier' error. * narrow EDV 404 handling to /query endpoint * require caller to supply OOB invitation goal text * validate delegationPolicy in createDelegationOffer and request handler
1 parent c02f6e2 commit 3de06b9

11 files changed

Lines changed: 1596 additions & 8 deletions

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,6 @@ poc
3232
*.db
3333
reports/
3434
.turbo
35-
packages/react-native/public/*.module.wasm
35+
packages/react-native/public/*.module.wasm
36+
37+
integration-tests/debugging/*
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import {
2+
DELEGATION_REQUEST_HANDLER,
3+
ISSUE_CREDENTIAL_HANDLER,
4+
} from '../packages/core/src/delegation/delegation-offer';
5+
6+
function createStubWallet(offer: any) {
7+
const documents: Record<string, any> = {};
8+
if (offer) {
9+
documents[offer.id] = offer;
10+
}
11+
return {
12+
documents,
13+
sentMessages: [] as any[],
14+
emittedEvents: [] as any[],
15+
getDocumentById: jest.fn(async (id: string) => documents[id]),
16+
addDocument: jest.fn(async (doc: any) => {
17+
documents[doc.id] = doc;
18+
return doc;
19+
}),
20+
updateDocument: jest.fn(async (doc: any) => {
21+
documents[doc.id] = doc;
22+
return doc;
23+
}),
24+
eventManager: {
25+
emit: jest.fn(),
26+
},
27+
};
28+
}
29+
30+
function createStubMessageProvider() {
31+
return {
32+
sendMessage: jest.fn(async () => undefined),
33+
};
34+
}
35+
36+
describe('DELEGATION_REQUEST_HANDLER rejection paths', () => {
37+
const offerId = 'offer-1';
38+
const issuerDID = 'did:test:issuer';
39+
const holderDID = 'did:test:holder';
40+
const attackerDID = 'did:test:attacker';
41+
42+
function buildRequest(overrides: any = {}) {
43+
return {
44+
type: 'https://didcomm.org/issue-credential/3.0/request-credential',
45+
from: holderDID,
46+
to: issuerDID,
47+
body: {
48+
goal_code: 'dock.offer-delegation',
49+
offer_id: offerId,
50+
},
51+
...overrides,
52+
};
53+
}
54+
55+
function buildStoredOffer(overrides: any = {}) {
56+
return {
57+
id: offerId,
58+
type: 'DelegationOffer',
59+
issuerDID,
60+
status: 'sent',
61+
credentialId: 'cred-1',
62+
delegationPolicy: {},
63+
delegationRole: 'role-1',
64+
sentAt: new Date().toISOString(),
65+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
66+
...overrides,
67+
};
68+
}
69+
70+
it('returns silently when no offer is found for the request', async () => {
71+
const wallet = createStubWallet(null);
72+
const messageProvider = createStubMessageProvider();
73+
74+
await DELEGATION_REQUEST_HANDLER.handle(buildRequest(), {
75+
wallet,
76+
messageProvider,
77+
});
78+
79+
expect(messageProvider.sendMessage).not.toHaveBeenCalled();
80+
expect(wallet.updateDocument).not.toHaveBeenCalled();
81+
});
82+
83+
it('rejects requests for offers not in `sent` state', async () => {
84+
const wallet = createStubWallet(buildStoredOffer({status: 'accepted'}));
85+
const messageProvider = createStubMessageProvider();
86+
87+
await DELEGATION_REQUEST_HANDLER.handle(buildRequest(), {
88+
wallet,
89+
messageProvider,
90+
});
91+
92+
expect(messageProvider.sendMessage).not.toHaveBeenCalled();
93+
expect(wallet.updateDocument).not.toHaveBeenCalled();
94+
});
95+
96+
it('rejects requests from senders not in offer.to (array form)', async () => {
97+
const wallet = createStubWallet(
98+
buildStoredOffer({to: [holderDID]}),
99+
);
100+
const messageProvider = createStubMessageProvider();
101+
102+
await DELEGATION_REQUEST_HANDLER.handle(
103+
buildRequest({from: attackerDID}),
104+
{wallet, messageProvider},
105+
);
106+
107+
expect(messageProvider.sendMessage).not.toHaveBeenCalled();
108+
expect(wallet.updateDocument).not.toHaveBeenCalled();
109+
});
110+
111+
it('rejects requests from senders not matching offer.to (string form)', async () => {
112+
const wallet = createStubWallet(buildStoredOffer({to: holderDID}));
113+
const messageProvider = createStubMessageProvider();
114+
115+
await DELEGATION_REQUEST_HANDLER.handle(
116+
buildRequest({from: attackerDID}),
117+
{wallet, messageProvider},
118+
);
119+
120+
expect(messageProvider.sendMessage).not.toHaveBeenCalled();
121+
expect(wallet.updateDocument).not.toHaveBeenCalled();
122+
});
123+
124+
it('rejects expired offers', async () => {
125+
const wallet = createStubWallet(
126+
buildStoredOffer({
127+
expiresAt: new Date(Date.now() - 60_000).toISOString(),
128+
}),
129+
);
130+
const messageProvider = createStubMessageProvider();
131+
132+
await DELEGATION_REQUEST_HANDLER.handle(buildRequest(), {
133+
wallet,
134+
messageProvider,
135+
});
136+
137+
expect(messageProvider.sendMessage).not.toHaveBeenCalled();
138+
expect(wallet.updateDocument).not.toHaveBeenCalled();
139+
});
140+
});
141+
142+
describe('ISSUE_CREDENTIAL_HANDLER rejection paths', () => {
143+
const offerId = 'offer-2';
144+
const issuerDID = 'did:test:issuer';
145+
const holderDID = 'did:test:holder';
146+
const attackerDID = 'did:test:attacker';
147+
148+
function buildIssueMessage(overrides: any = {}) {
149+
return {
150+
id: 'msg-1',
151+
type: 'https://didcomm.org/issue-credential/3.0/issue-credential',
152+
from: issuerDID,
153+
to: [holderDID],
154+
body: {
155+
goal_code: 'dock.offer-delegation',
156+
delegationOfferId: offerId,
157+
credentials: [{id: 'delegated-cred-1', type: ['DelegationCredential']}],
158+
delegationChain: [],
159+
},
160+
...overrides,
161+
};
162+
}
163+
164+
function buildStoredOffer(overrides: any = {}) {
165+
return {
166+
id: offerId,
167+
type: 'DelegationOffer',
168+
issuerDID,
169+
status: 'requested',
170+
...overrides,
171+
};
172+
}
173+
174+
it('returns when delegationOfferId is missing from the message', async () => {
175+
const wallet = createStubWallet(buildStoredOffer());
176+
const messageProvider = createStubMessageProvider();
177+
178+
await ISSUE_CREDENTIAL_HANDLER.handle(
179+
buildIssueMessage({
180+
body: {goal_code: 'dock.offer-delegation', credentials: []},
181+
}),
182+
{wallet, messageProvider},
183+
);
184+
185+
expect(wallet.addDocument).not.toHaveBeenCalled();
186+
expect(messageProvider.sendMessage).not.toHaveBeenCalled();
187+
});
188+
189+
it('returns when no stored offer exists for the delegationOfferId', async () => {
190+
const wallet = createStubWallet(null);
191+
const messageProvider = createStubMessageProvider();
192+
193+
await ISSUE_CREDENTIAL_HANDLER.handle(buildIssueMessage(), {
194+
wallet,
195+
messageProvider,
196+
});
197+
198+
expect(wallet.addDocument).not.toHaveBeenCalled();
199+
expect(messageProvider.sendMessage).not.toHaveBeenCalled();
200+
});
201+
202+
it('rejects credentials from a DID other than the stored issuerDID', async () => {
203+
const wallet = createStubWallet(buildStoredOffer());
204+
const messageProvider = createStubMessageProvider();
205+
206+
await ISSUE_CREDENTIAL_HANDLER.handle(
207+
buildIssueMessage({from: attackerDID}),
208+
{wallet, messageProvider},
209+
);
210+
211+
expect(wallet.addDocument).not.toHaveBeenCalled();
212+
expect(messageProvider.sendMessage).not.toHaveBeenCalled();
213+
expect(wallet.eventManager.emit).not.toHaveBeenCalled();
214+
});
215+
216+
it('returns when the credentials array is empty', async () => {
217+
const wallet = createStubWallet(buildStoredOffer());
218+
const messageProvider = createStubMessageProvider();
219+
220+
await ISSUE_CREDENTIAL_HANDLER.handle(
221+
buildIssueMessage({
222+
body: {
223+
goal_code: 'dock.offer-delegation',
224+
delegationOfferId: offerId,
225+
credentials: [],
226+
delegationChain: [],
227+
},
228+
}),
229+
{wallet, messageProvider},
230+
);
231+
232+
expect(wallet.addDocument).not.toHaveBeenCalled();
233+
expect(messageProvider.sendMessage).not.toHaveBeenCalled();
234+
});
235+
});

0 commit comments

Comments
 (0)