Skip to content

Commit daee488

Browse files
committed
Move heartbeat declaration to event.
1 parent ae4bd0a commit daee488

5 files changed

Lines changed: 63 additions & 27 deletions

File tree

README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,19 +87,24 @@ const {keyPair: authKeyPair, didDocument: updatedDoc} = await addVm({
8787

8888
---
8989

90-
### `createEvent({type, data, assertionMethod, previousEventHash})` -> `Promise<{event}>`
90+
### `createEvent({type, data, assertionMethod, previousEventHash, [heartbeat]})` -> `Promise<{event}>`
9191

9292
Creates a signed event of the given type using the provided assertion method key.
9393
Use this for `'update'`, `'heartbeat'`, and `'deactivate'` events after the
9494
initial create. Always call `getPreviousEventHash()` first and pass the result
9595
as `previousEventHash` so the hash is covered by the operation proof.
9696

97+
For `'heartbeat'` events, pass the rotated heartbeat hashes as `heartbeat`
98+
rather than embedding them in a DID document. The hashes are placed directly
99+
on the event object and covered by the proof.
100+
97101
| Parameter | Type | Description |
98102
|-----------|------|-------------|
99103
| `type` | string | Event type: `'update'`, `'heartbeat'`, or `'deactivate'`. |
100104
| `data` | object\|undefined | The DID document for update events; `undefined` for heartbeat and deactivate. |
101105
| `assertionMethod` | KeyPair | The key pair to sign with (from `assertionMethod` in the DID document, or the heartbeat key pair). |
102106
| `previousEventHash` | string | Base58btc SHA3-256 hash of the previous event from `getPreviousEventHash()`. |
107+
| `heartbeat` | string[]\|undefined | For heartbeat events: array of new heartbeat hashes to rotate in. |
103108

104109
```js
105110
const previousEventHash =
@@ -110,6 +115,18 @@ const {event} = await createEvent({
110115
assertionMethod: keyPair,
111116
previousEventHash
112117
});
118+
119+
// heartbeat event — rotated hashes on the event, no DID document copy
120+
const hbKeyPair = await deriveHeartbeatKeyPair(heartbeatSecret, 0);
121+
const nextKeyPair = await deriveHeartbeatKeyPair(heartbeatSecret, 1);
122+
const nextExported = await nextKeyPair.export({publicKey: true, includeContext: false});
123+
const nextHash = await hashDidKey(`did:key:${nextExported.publicKeyMultibase}`);
124+
const {event: hbEvent} = await createEvent({
125+
type: 'heartbeat',
126+
assertionMethod: hbKeyPair,
127+
previousEventHash,
128+
heartbeat: [nextHash]
129+
});
113130
```
114131

115132
---

lib/cel.js

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,10 @@ export async function addEvent({cel, event}) {
175175
export async function read({cel, trustedWitnesses = [], versionTime = null}) {
176176
const errors = [];
177177
let currentDidDocument = null;
178+
// effective heartbeat array, updated independently of currentDidDocument:
179+
// heartbeat events put rotated hashes on the event itself rather than in the
180+
// DID document, so we must track this state separately
181+
let currentHeartbeat = null;
178182
// latest witness timestamp for the previous log entry, used for heartbeat
179183
// frequency checks at each subsequent entry boundary
180184
let prevEntryWitnessTime = null;
@@ -274,12 +278,23 @@ export async function read({cel, trustedWitnesses = [], versionTime = null}) {
274278
// in effect during the gap leading into this entry, not any new frequency
275279
// introduced by this entry's update.
276280
const prevDidDocument = currentDidDocument;
281+
// Snapshot effective heartbeat array before this entry updates it.
282+
const prevHeartbeat = currentHeartbeat;
277283

278284
// Track the current DID document for key lookup on stateless events
279285
if(event.operation?.data) {
280286
currentDidDocument = event.operation.data;
281287
}
282288

289+
// Update the effective heartbeat array. For heartbeat events the rotated
290+
// hashes live on the event itself; for create/update they come from the
291+
// DID document. This must stay in sync with the rotation check below.
292+
if(event.heartbeat) {
293+
currentHeartbeat = event.heartbeat;
294+
} else if(currentDidDocument?.heartbeat) {
295+
currentHeartbeat = currentDidDocument.heartbeat;
296+
}
297+
283298
// Mark the DID as deactivated after processing this entry so that any
284299
// subsequent entries are rejected at the top of the next iteration.
285300
if(event.operation?.type === 'deactivate') {
@@ -353,22 +368,23 @@ export async function read({cel, trustedWitnesses = [], versionTime = null}) {
353368
}
354369

355370
// 5. If the operation was signed by a heartbeat key, verify that the new
356-
// DID document no longer contains that heartbeat hash (it must be rotated
357-
// out) and contains at least one new heartbeat hash.
358-
if(opProof && currentDidDocument) {
371+
// heartbeat array no longer contains the used hash (rotated out) and
372+
// contains at least one new hash. The new array comes from event.heartbeat
373+
// for heartbeat events, or from the DID document for update events.
374+
if(opProof) {
359375
const vmRef = opProof.verificationMethod;
360376
if(vmRef?.startsWith('did:key:')) {
361377
const didKeyId = vmRef.split('#')[0];
362378
const usedHash = await hashDidKey(didKeyId);
363-
const prevHeartbeat = prevDidDocument?.heartbeat ?? [];
364-
const newHeartbeat = currentDidDocument?.heartbeat ?? [];
365-
if(prevHeartbeat.includes(usedHash)) {
379+
const oldHeartbeat = prevHeartbeat ?? prevDidDocument?.heartbeat ?? [];
380+
const newHeartbeat = currentHeartbeat ?? [];
381+
if(oldHeartbeat.includes(usedHash)) {
366382
if(newHeartbeat.includes(usedHash)) {
367383
errors.push(
368384
`entry ${i}: heartbeat key used without rotating its hash - ` +
369385
`${usedHash} must be removed from heartbeat[]`);
370386
}
371-
if(newHeartbeat.length < prevHeartbeat.length) {
387+
if(newHeartbeat.length < oldHeartbeat.length) {
372388
errors.push(
373389
`entry ${i}: heartbeat key rotation must add a new heartbeat ` +
374390
`hash to replace the consumed one`);

lib/didcel.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ export async function addVm({didDocument, verificationRelationship, curve}) {
229229
* });
230230
*/
231231
export async function createEvent(
232-
{type, data, assertionMethod, previousEventHash}) {
232+
{type, data, assertionMethod, previousEventHash, heartbeat}) {
233233
const operation = {type};
234234
if(data !== undefined) {
235235
operation.data = data;
@@ -239,6 +239,11 @@ export async function createEvent(
239239
if(previousEventHash !== undefined) {
240240
event.previousEventHash = previousEventHash;
241241
}
242+
// rotated heartbeat hashes sit on the event (not the DID document) so they
243+
// are covered by the operation proof
244+
if(heartbeat !== undefined) {
245+
event.heartbeat = heartbeat;
246+
}
242247
const signedEvent =
243248
await _signEvent({event, signer: assertionMethod.signer()});
244249

lib/validate.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ const NON_CREATE_EVENT = {
148148
'@context': {},
149149
previousEventHash: MULTIBASE_STRING,
150150
operation: {oneOf: [UPDATE_OPERATION, STATELESS_OPERATION]},
151+
heartbeat: {type: 'array', items: MULTIBASE_STRING},
151152
proof: DATA_INTEGRITY_PROOF
152153
},
153154
additionalProperties: false

tests/mocha/40-heartbeat.js

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {TEST_WITNESSES} from './helpers.js';
1111
const {expect} = chai;
1212

1313
async function runHeartbeat() {
14-
const {heartbeatSecret, didDocument, cryptographicEventLog} = await create();
14+
const {heartbeatSecret, cryptographicEventLog} = await create();
1515

1616
await witness({cel: cryptographicEventLog, witnesses: TEST_WITNESSES});
1717

@@ -25,18 +25,13 @@ async function runHeartbeat() {
2525
const nextHeartbeatHash =
2626
await hashDidKey(`did:key:${nextExported.publicKeyMultibase}`);
2727

28-
// build updated DID document: remove key 0 hash, add key 1 hash
29-
const updatedDoc = structuredClone(didDocument);
30-
updatedDoc.heartbeat = [nextHeartbeatHash];
31-
delete updatedDoc.proof;
32-
3328
const previousEventHash =
3429
await getPreviousEventHash({cel: cryptographicEventLog});
3530
const {event: hbEvent} = await createEvent({
36-
type: 'update',
37-
data: updatedDoc,
31+
type: 'heartbeat',
3832
assertionMethod: hbKeyPair,
39-
previousEventHash
33+
previousEventHash,
34+
heartbeat: [nextHeartbeatHash]
4035
});
4136
await addEvent({cel: cryptographicEventLog, event: hbEvent});
4237

@@ -59,8 +54,8 @@ describe('heartbeat', function() {
5954
const {cryptographicEventLog} = await runHeartbeat();
6055

6156
const heartbeatEntry = cryptographicEventLog.log[1];
62-
expect(heartbeatEntry.event.operation).to.have.property('type', 'update');
63-
expect(heartbeatEntry.event.operation.data).to.be.an('object');
57+
expect(heartbeatEntry.event.operation).to.have.property('type', 'heartbeat');
58+
expect(heartbeatEntry.event.operation.data).to.be.undefined;
6459
});
6560

6661
it('should hash-link heartbeat event to the witnessed create event',
@@ -90,16 +85,18 @@ describe('heartbeat', function() {
9085
expect(vm).to.be.a('string').that.matches(/^did:key:/);
9186
});
9287

93-
it('should rotate the heartbeat hash in the updated document',
88+
it('should rotate the heartbeat hash onto the event, not the DID document',
9489
async () => {
9590
const {cryptographicEventLog} = await runHeartbeat();
9691

97-
const createDoc =
98-
cryptographicEventLog.log[0].event.operation.data;
99-
const updateDoc =
100-
cryptographicEventLog.log[1].event.operation.data;
92+
const createDoc = cryptographicEventLog.log[0].event.operation.data;
93+
const heartbeatEvent = cryptographicEventLog.log[1].event;
10194

102-
// the hash in the updated document must differ from the original
103-
expect(updateDoc.heartbeat[0]).to.not.equal(createDoc.heartbeat[0]);
95+
// rotated hashes live on the event itself
96+
expect(heartbeatEvent.heartbeat).to.be.an('array').with.length(1);
97+
// the new hash must differ from the one in the original DID document
98+
expect(heartbeatEvent.heartbeat[0]).to.not.equal(createDoc.heartbeat[0]);
99+
// the heartbeat event has no operation.data (stateless)
100+
expect(heartbeatEvent.operation.data).to.be.undefined;
104101
});
105102
});

0 commit comments

Comments
 (0)