Skip to content

Commit bb03539

Browse files
committed
Move heartbeat and heartbeatFrequency to event level properties.
1 parent daee488 commit bb03539

8 files changed

Lines changed: 70 additions & 104 deletions

File tree

README.md

Lines changed: 16 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,7 @@ All public functions are exported from the package entry point:
3030
```js
3131
import {
3232
// DID document operations
33-
create, addVm, createEvent, deriveHeartbeatKeyPair,
34-
hashDidKey, setHeartbeatFrequency,
33+
create, addVm, createEvent, deriveHeartbeatKeyPair, hashDidKey,
3534
// CEL operations
3635
createCel, addEvent, getPreviousEventHash, witness,
3736
read, loadFromFile, saveToFile,
@@ -55,7 +54,7 @@ initial signed create event already wrapped in a Cryptographic Event Log.
5554
| Parameter | Type | Description |
5655
|-----------|------|-------------|
5756
| `options.curve` | string | Elliptic curve for key generation. Default: `'P-256'`. |
58-
| `options.heartbeatFrequency` | string | ISO 8601 duration for the required heartbeat interval. Default: `'P10Y'`. |
57+
| `options.heartbeatFrequency` | string | ISO 8601 duration for the required heartbeat interval. Default: `'P1M'`. |
5958

6059
```js
6160
const {keyPair, heartbeatSecret, didDocument, cryptographicEventLog} =
@@ -87,24 +86,25 @@ const {keyPair: authKeyPair, didDocument: updatedDoc} = await addVm({
8786

8887
---
8988

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

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

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.
96+
`heartbeat` hashes and `heartbeatFrequency` are event-level fields — they belong
97+
directly on the event object, not inside the DID document. Both are covered by
98+
the operation proof.
10099

101100
| Parameter | Type | Description |
102101
|-----------|------|-------------|
103102
| `type` | string | Event type: `'update'`, `'heartbeat'`, or `'deactivate'`. |
104103
| `data` | object\|undefined | The DID document for update events; `undefined` for heartbeat and deactivate. |
105104
| `assertionMethod` | KeyPair | The key pair to sign with (from `assertionMethod` in the DID document, or the heartbeat key pair). |
106105
| `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. |
106+
| `heartbeat` | string[]\|undefined | Array of new heartbeat hashes to rotate in (required for heartbeat events). |
107+
| `heartbeatFrequency` | string\|undefined | ISO 8601 duration to change the required heartbeat interval (e.g. `'P1M'`, `'P1D'`). |
108108

109109
```js
110110
const previousEventHash =
@@ -177,26 +177,6 @@ await witness({
177177

178178
---
179179

180-
### `setHeartbeatFrequency({didDocument, heartbeatFrequency})` -> `{didDocument}`
181-
182-
Updates the `heartbeatFrequency` field on a DID document and removes the proof.
183-
The document must be re-signed with `createEvent` before appending an update
184-
event.
185-
186-
| Parameter | Type | Description |
187-
|-----------|------|-------------|
188-
| `didDocument` | object | The current DID document. |
189-
| `heartbeatFrequency` | string | ISO 8601 duration (e.g. `'P3M'`, `'P1Y'`). |
190-
191-
```js
192-
const {didDocument: updatedDoc} = setHeartbeatFrequency({
193-
didDocument,
194-
heartbeatFrequency: 'P3M'
195-
});
196-
```
197-
198-
---
199-
200180
### `deriveHeartbeatKeyPair(masterSecret, index)` -> `Promise<KeyPair>`
201181

202182
Derives an ECDSA P-256 Multikey key pair from a heartbeat master secret and an
@@ -404,18 +384,20 @@ The library implements the `did:cel` DID method, which consists of:
404384
- **Blind witness attestations:** Witness services receive only a SHA3-256 hash
405385
of each event and return `DataIntegrityProof` attestations, providing temporal
406386
anchoring and distributed trust without learning DID document contents.
407-
- **Heartbeat keys:** Each DID document stores SHA3-256 hashes of heartbeat
408-
`did:key:` URIs. A heartbeat operation signs an update with the heartbeat key
409-
(derived via `deriveHeartbeatKeyPair(masterSecret, index)`) and must rotate
410-
out the used hash, replacing it with the hash of the next derived key. Only
411-
the 16-byte master secret is stored; individual keys are derived on demand.
387+
- **Heartbeat keys:** The initial create event carries a `heartbeat` array
388+
(SHA3-256 hashes of heartbeat `did:key:` URIs) and a `heartbeatFrequency`
389+
ISO 8601 duration — both as event-level fields, not inside the DID document.
390+
A heartbeat operation signs an event with the heartbeat key (derived via
391+
`deriveHeartbeatKeyPair(masterSecret, index)`), rotates out the used hash,
392+
and adds the hash of the next derived key on the event itself. Only the
393+
16-byte master secret is stored; individual keys are derived on demand.
412394
- **Encrypted secret storage:** Private keys encrypted with AES-256-GCM using a
413395
scrypt-derived key and stored in YAML format.
414396

415397
## File Structure
416398

417399
- `lib/index.js` - Package entry point; explicit named exports for all public functions
418-
- `lib/didcel.js` - DID document operations: `create`, `addVm`, `createEvent`, `setHeartbeatFrequency`, `hashDidKey`
400+
- `lib/didcel.js` - DID document operations: `create`, `addVm`, `createEvent`, `deriveHeartbeatKeyPair`, `hashDidKey`
419401
- `lib/cel.js` - Cryptographic Event Log: `createCel`, `addEvent`, `getPreviousEventHash`, `witness`, `read`, `loadFromFile`, `saveToFile`
420402
- `lib/secrets.js` - Encrypted key storage: `saveSecrets`, `loadSecrets`
421403
- `lib/witness.js` - HTTP client for witness services

lib/cel.js

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ export async function read({cel, trustedWitnesses = [], versionTime = null}) {
179179
// heartbeat events put rotated hashes on the event itself rather than in the
180180
// DID document, so we must track this state separately
181181
let currentHeartbeat = null;
182+
// effective heartbeatFrequency, updated from event-level field
183+
let currentHeartbeatFrequency = null;
182184
// latest witness timestamp for the previous log entry, used for heartbeat
183185
// frequency checks at each subsequent entry boundary
184186
let prevEntryWitnessTime = null;
@@ -273,26 +275,26 @@ export async function read({cel, trustedWitnesses = [], versionTime = null}) {
273275
}
274276
}
275277

276-
// Snapshot the document state from the previous entry before advancing.
277-
// The heartbeatFrequency check (step 5) must use the frequency that was
278-
// in effect during the gap leading into this entry, not any new frequency
279-
// introduced by this entry's update.
278+
// Snapshot state from the previous entry before advancing.
279+
// The heartbeatFrequency check must use the frequency in effect during the
280+
// gap leading into this entry, not any new frequency this entry introduces.
280281
const prevDidDocument = currentDidDocument;
281-
// Snapshot effective heartbeat array before this entry updates it.
282282
const prevHeartbeat = currentHeartbeat;
283+
const prevHeartbeatFrequency = currentHeartbeatFrequency;
283284

284285
// Track the current DID document for key lookup on stateless events
285286
if(event.operation?.data) {
286287
currentDidDocument = event.operation.data;
287288
}
288289

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.
290+
// Update the effective heartbeat array. Hashes live on the event itself.
292291
if(event.heartbeat) {
293292
currentHeartbeat = event.heartbeat;
294-
} else if(currentDidDocument?.heartbeat) {
295-
currentHeartbeat = currentDidDocument.heartbeat;
293+
}
294+
295+
// Update heartbeatFrequency from the event-level field.
296+
if(event.heartbeatFrequency !== undefined) {
297+
currentHeartbeatFrequency = event.heartbeatFrequency;
296298
}
297299

298300
// Mark the DID as deactivated after processing this entry so that any
@@ -320,7 +322,7 @@ export async function read({cel, trustedWitnesses = [], versionTime = null}) {
320322
try {
321323
const verified = await _verifyOperationProof(
322324
{event, opProof, currentDidDocument: verifyDidDocument,
323-
prevDidDocument: prevDidDocument ?? verifyDidDocument});
325+
prevHeartbeat: prevHeartbeat ?? []});
324326
if(!verified) {
325327
errors.push(`entry ${i}: operation proof invalid`);
326328
}
@@ -403,8 +405,7 @@ export async function read({cel, trustedWitnesses = [], versionTime = null}) {
403405
// Use the frequency from the previous document state so a tightened
404406
// heartbeatFrequency introduced by this entry is not applied retroactively
405407
// to the gap that preceded it.
406-
const heartbeatFrequency =
407-
(prevDidDocument ?? currentDidDocument)?.heartbeatFrequency ?? 'P10Y';
408+
const heartbeatFrequency = prevHeartbeatFrequency ?? 'P1M';
408409
if(i > 0 && prevEntryWitnessTime !== null && entryWitnessTime !== null) {
409410
const freq = moment.duration(heartbeatFrequency);
410411
const elapsed = entryWitnessTime - prevEntryWitnessTime;
@@ -472,7 +473,7 @@ function _isTrustedWitnessProof({wp, trustedWitnesses}) {
472473
* @returns {Promise<boolean>} True if the proof is valid.
473474
*/
474475
async function _verifyOperationProof(
475-
{event, opProof, currentDidDocument, prevDidDocument}) {
476+
{event, opProof, currentDidDocument, prevHeartbeat}) {
476477
const vmRef = opProof.verificationMethod;
477478

478479
// try assertionMethod first; if not found, check heartbeat keys
@@ -488,11 +489,11 @@ async function _verifyOperationProof(
488489
keyController = currentDidDocument.id;
489490
} else if(vmRef.startsWith('did:key:')) {
490491
// heartbeat key path: hash the did:key URI and check it against the
491-
// heartbeat[] of the *previous* document - the update will rotate it out,
492-
// so it is absent from currentDidDocument by the time we verify
492+
// effective heartbeat[] from the *previous* entry — the new entry will
493+
// rotate it out, so it must not be looked up in the current state
493494
const didKeyId = vmRef.split('#')[0];
494495
const hash = await hashDidKey(didKeyId);
495-
const heartbeat = prevDidDocument?.heartbeat ?? [];
496+
const heartbeat = prevHeartbeat ?? [];
496497
if(!heartbeat.includes(hash)) {
497498
throw new Error(
498499
`verification method not found in DID document: ${vmRef}`);

lib/didcel.js

Lines changed: 15 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ const jdl = new JsonLdDocumentLoader();
4343
* console.log(didDocument.id); // did:cel:z...
4444
*/
4545
export async function create(
46-
{curve = 'P-256', heartbeatFrequency = 'P10Y'} = {}) {
46+
{curve = 'P-256', heartbeatFrequency = 'P1M'} = {}) {
4747
// generate a new ECDSA key pair using the specified curve (defaults to P-256)
4848
let keyPair;
4949
try {
@@ -73,14 +73,13 @@ export async function create(
7373
const heartbeatHash = await hashDidKey(heartbeatDidKey);
7474

7575
// create initial DID document structure with assertion method
76+
// heartbeat[] and heartbeatFrequency belong on the event, not the DID document
7677
const didDocument = {
7778
'@context': [
7879
'https://www.w3.org/ns/did/v1.1',
7980
'https://w3id.org/didcel/v1'
8081
],
81-
heartbeatFrequency,
8282
assertionMethod: [publicKey],
83-
heartbeat: [heartbeatHash],
8483
service: [
8584
{
8685
type: 'CelStorageService',
@@ -112,7 +111,12 @@ export async function create(
112111

113112
assertValidDidDocument({didDocument});
114113

115-
const event = {operation: {type: 'create', data: didDocument}};
114+
// heartbeat[] and heartbeatFrequency sit on the event, not the DID document
115+
const event = {
116+
operation: {type: 'create', data: didDocument},
117+
heartbeat: [heartbeatHash],
118+
heartbeatFrequency
119+
};
116120
const signedEvent = await _signEvent({event, signer: keyPair.signer()});
117121

118122
const cryptographicEventLog = celCreate({event: signedEvent});
@@ -229,7 +233,8 @@ export async function addVm({didDocument, verificationRelationship, curve}) {
229233
* });
230234
*/
231235
export async function createEvent(
232-
{type, data, assertionMethod, previousEventHash, heartbeat}) {
236+
{type, data, assertionMethod, previousEventHash, heartbeat,
237+
heartbeatFrequency}) {
233238
const operation = {type};
234239
if(data !== undefined) {
235240
operation.data = data;
@@ -239,34 +244,19 @@ export async function createEvent(
239244
if(previousEventHash !== undefined) {
240245
event.previousEventHash = previousEventHash;
241246
}
242-
// rotated heartbeat hashes sit on the event (not the DID document) so they
243-
// are covered by the operation proof
247+
// heartbeat[] and heartbeatFrequency sit on the event, covered by the proof
244248
if(heartbeat !== undefined) {
245249
event.heartbeat = heartbeat;
246250
}
251+
if(heartbeatFrequency !== undefined) {
252+
event.heartbeatFrequency = heartbeatFrequency;
253+
}
247254
const signedEvent =
248255
await _signEvent({event, signer: assertionMethod.signer()});
249256

250257
return {event: signedEvent};
251258
}
252259

253-
/**
254-
* Sets the heartbeatFrequency on an existing DID document. The proof is
255-
* removed and must be regenerated with createEvent before adding to the CEL.
256-
*
257-
* @param {object} options - Configuration options.
258-
* @param {object} options.didDocument - The DID document to modify.
259-
* @param {string} options.heartbeatFrequency - ISO 8601 duration string
260-
* (e.g. 'P3M', 'P1Y', 'P1D').
261-
* @returns {object} An object containing the updated |didDocument| (no proof).
262-
*/
263-
export function setHeartbeatFrequency({didDocument, heartbeatFrequency}) {
264-
const newDidDocument = structuredClone(didDocument);
265-
newDidDocument.heartbeatFrequency = heartbeatFrequency;
266-
delete newDidDocument.proof;
267-
return {didDocument: newDidDocument};
268-
}
269-
270260
/**
271261
* Computes the base58btc-encoded SHA3-256 multihash of a did:key URI string.
272262
* This is the value stored in the `heartbeat` array of a DID document.
@@ -298,6 +288,5 @@ async function _signEvent({event, signer}) {
298288
}
299289

300290
export default {
301-
addVm, create, createEvent, deriveHeartbeatKeyPair, hashDidKey,
302-
setHeartbeatFrequency
291+
addVm, create, createEvent, deriveHeartbeatKeyPair, hashDidKey
303292
};

lib/index.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ export {
66

77
// didcel.js: DID document creation and management
88
export {
9-
addVm, create, createEvent, deriveHeartbeatKeyPair, hashDidKey,
10-
setHeartbeatFrequency
9+
addVm, create, createEvent, deriveHeartbeatKeyPair, hashDidKey
1110
} from './didcel.js';
1211

1312
// secrets.js: Encrypted private key storage

lib/validate.js

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,7 @@ const SERVICE_ENTRY = {
4343

4444
const DID_DOCUMENT_SCHEMA = {
4545
type: 'object',
46-
required: ['@context', 'id', 'heartbeatFrequency', 'assertionMethod',
47-
'heartbeat', 'service'],
46+
required: ['@context', 'id', 'assertionMethod', 'service'],
4847
properties: {
4948
'@context': {
5049
type: 'array',
@@ -56,7 +55,6 @@ const DID_DOCUMENT_SCHEMA = {
5655
items: {type: 'string'}
5756
},
5857
id: DID_CEL,
59-
heartbeatFrequency: ISO_DURATION,
6058
assertionMethod: {
6159
type: 'array',
6260
items: VERIFICATION_METHOD,
@@ -66,11 +64,6 @@ const DID_DOCUMENT_SCHEMA = {
6664
keyAgreement: {type: 'array', items: VERIFICATION_METHOD},
6765
capabilityDelegation: {type: 'array', items: VERIFICATION_METHOD},
6866
capabilityInvocation: {type: 'array', items: VERIFICATION_METHOD},
69-
heartbeat: {
70-
type: 'array',
71-
items: MULTIBASE_STRING,
72-
minItems: 1
73-
},
7467
service: {
7568
type: 'array',
7669
items: SERVICE_ENTRY,
@@ -132,10 +125,12 @@ const STATELESS_OPERATION = {
132125

133126
const CREATE_EVENT = {
134127
type: 'object',
135-
required: ['operation', 'proof'],
128+
required: ['operation', 'heartbeat', 'heartbeatFrequency', 'proof'],
136129
properties: {
137130
'@context': {},
138131
operation: CREATE_OPERATION,
132+
heartbeat: {type: 'array', items: MULTIBASE_STRING, minItems: 1},
133+
heartbeatFrequency: ISO_DURATION,
139134
proof: DATA_INTEGRITY_PROOF
140135
},
141136
additionalProperties: false
@@ -149,6 +144,7 @@ const NON_CREATE_EVENT = {
149144
previousEventHash: MULTIBASE_STRING,
150145
operation: {oneOf: [UPDATE_OPERATION, STATELESS_OPERATION]},
151146
heartbeat: {type: 'array', items: MULTIBASE_STRING},
147+
heartbeatFrequency: ISO_DURATION,
152148
proof: DATA_INTEGRITY_PROOF
153149
},
154150
additionalProperties: false

tests/mocha/10-create.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,19 @@ describe('create', function() {
2020
expect(didDocument['@context']).to.include('https://www.w3.org/ns/did/v1.1');
2121
expect(didDocument['@context']).to.include('https://w3id.org/didcel/v1');
2222

23-
// heartbeat frequency
24-
expect(didDocument.heartbeatFrequency).to.be.a('string').that.is.not.empty;
25-
2623
// assertionMethod: one embedded key with required fields
2724
expect(didDocument.assertionMethod).to.be.an('array').with.length(1);
2825
const assertionKey = didDocument.assertionMethod[0];
2926
expect(assertionKey.type).to.equal('Multikey');
3027
expect(assertionKey.controller).to.equal(didDocument.id);
3128
expect(assertionKey.publicKeyMultibase).to.be.a('string').that.is.not.empty;
3229

33-
// heartbeat: one base58btc-encoded SHA3-256 multihash of a did:key URI
34-
expect(didDocument.heartbeat).to.be.an('array').with.length(1);
35-
const heartbeatHash = didDocument.heartbeat[0];
30+
// heartbeat and heartbeatFrequency belong on the create event, not the DID document
31+
const createEventEntry = cryptographicEventLog.log[0];
32+
expect(createEventEntry.event.heartbeatFrequency)
33+
.to.be.a('string').that.is.not.empty;
34+
expect(createEventEntry.event.heartbeat).to.be.an('array').with.length(1);
35+
const heartbeatHash = createEventEntry.event.heartbeat[0];
3636
expect(heartbeatHash).to.be.a('string').that.matches(/^z/);
3737

3838
// heartbeatSecret: 16-byte KDF master secret returned to caller for storage

tests/mocha/40-heartbeat.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,13 @@ describe('heartbeat', function() {
8989
async () => {
9090
const {cryptographicEventLog} = await runHeartbeat();
9191

92-
const createDoc = cryptographicEventLog.log[0].event.operation.data;
92+
const createEntry = cryptographicEventLog.log[0].event;
9393
const heartbeatEvent = cryptographicEventLog.log[1].event;
9494

9595
// rotated hashes live on the event itself
9696
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]);
97+
// the new hash must differ from the one in the original create event
98+
expect(heartbeatEvent.heartbeat[0]).to.not.equal(createEntry.heartbeat[0]);
9999
// the heartbeat event has no operation.data (stateless)
100100
expect(heartbeatEvent.operation.data).to.be.undefined;
101101
});

0 commit comments

Comments
 (0)