@mblaney/holster 1.0.23 → 1.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mblaney/holster",
3
- "version": "1.0.23",
3
+ "version": "1.0.25",
4
4
  "description": "Holster is a service that allows synchronising data between devices using Node, Deno, Bun or the browser.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/src/get.js CHANGED
@@ -24,16 +24,26 @@ const Get = (lex, graph) => {
24
24
 
25
25
  node[key] = graph[soul][key]
26
26
  node._[">"][key] = graph[soul]._[">"][key]
27
- if (graph[soul]._["s"] && graph[soul]._["s"][key]) {
28
- signatures[key] = graph[soul]._["s"][key]
27
+ const state = graph[soul]._[">"][key]
28
+ if (graph[soul]._["s"]) {
29
+ if (graph[soul]._["s"][state]) {
30
+ signatures[state] = graph[soul]._["s"][state]
31
+ } else if (graph[soul]._["s"][key]) {
32
+ signatures[key] = graph[soul]._["s"][key]
33
+ }
29
34
  }
30
35
  } else {
31
36
  for (const key of Object.keys(graph[soul])) {
32
37
  if (match(lex["."], key)) {
33
38
  node[key] = graph[soul][key]
34
39
  node._[">"][key] = graph[soul]._[">"][key]
35
- if (graph[soul]._["s"] && graph[soul]._["s"][key]) {
36
- signatures[key] = graph[soul]._["s"][key]
40
+ const state = graph[soul]._[">"][key]
41
+ if (graph[soul]._["s"]) {
42
+ if (graph[soul]._["s"][state]) {
43
+ signatures[state] = graph[soul]._["s"][state]
44
+ } else if (graph[soul]._["s"][key]) {
45
+ signatures[key] = graph[soul]._["s"][key]
46
+ }
37
47
  }
38
48
  }
39
49
  }
package/src/ham.js CHANGED
@@ -7,12 +7,20 @@ const MAX_GRAPH_SIZE = 10000
7
7
 
8
8
  // state and value are the incoming changes.
9
9
  // currentState and currentValue are the current graph data.
10
- const Ham = (state, currentState, value, currentValue) => {
10
+ const Ham = (state, currentState, value, currentValue, signed = false) => {
11
11
  if (state < currentState) return {historical: true}
12
12
 
13
13
  if (state > currentState) return {incoming: true}
14
14
 
15
- // state is equal to currentState, lexically compare to resolve conflict.
15
+ // state is equal to currentState
16
+ // If using signed timestamps then reject conflicting values because the
17
+ // owner can only create one value per timestamp.
18
+ if (signed && value !== currentValue) {
19
+ console.log(`Signed timestamp ${state}: reject conflicting value`)
20
+ return {historical: true}
21
+ }
22
+
23
+ // Lexically compare to resolve conflict (for unsigned or matching values)
16
24
  if (typeof value !== "string") {
17
25
  value = JSON.stringify(value) || ""
18
26
  }
@@ -44,6 +52,7 @@ Ham.mix = async (change, graph, secure, listen) => {
44
52
  const now = {}
45
53
  const defer = {}
46
54
  const validProperties = new Map() // Track valid properties per soul
55
+ const validTimestamps = new Map() // Track valid timestamps per soul
47
56
  let wait = 0
48
57
 
49
58
  for (const soul of Object.keys(change)) {
@@ -56,7 +65,7 @@ Ham.mix = async (change, graph, secure, listen) => {
56
65
  if (!node || !node._) continue
57
66
 
58
67
  const pub = node[utils.userPublicKey]
59
- // If per-property signatures and public key are provided then always verify.
68
+ // If timestamp signatures and public key are provided then always verify
60
69
  if (node._["s"] && pub) verify = true
61
70
 
62
71
  // Special case if soul starts with "~". Node must be system data ie,
@@ -77,21 +86,39 @@ Ham.mix = async (change, graph, secure, listen) => {
77
86
  }
78
87
  }
79
88
  if (verify) {
80
- // Per-property signatures stored in node._["s"]
89
+ // Signature verification: check node._["s"] which can contain:
90
+ // 1. Per-property signatures (legacy): keys are property names
91
+ // 2. Timestamp signatures (new): keys are numeric timestamps
81
92
  if (!pub || !node._) {
82
93
  continue
83
94
  }
84
95
 
85
- // Verify properties individually, returns array of valid property names
86
- const validProps = await SEA.verifyProperties(node, pub)
96
+ const signedTimestamps = new Set()
97
+ const signedProperties = new Set()
98
+ for (const [key, sig] of Object.entries(node._["s"] || {})) {
99
+ const asNumber = Number(key)
100
+ if (!isNaN(asNumber) && asNumber > 0) {
101
+ const isValid = await SEA.verifyTimestamp(asNumber, sig, pub)
102
+ if (isValid) {
103
+ signedTimestamps.add(asNumber)
104
+ // Add all properties with this timestamp to signed set
105
+ for (const [prop, ts] of Object.entries(node._[">"] || {})) {
106
+ if (ts === asNumber) {
107
+ signedProperties.add(prop)
108
+ }
109
+ }
110
+ }
111
+ }
112
+ }
87
113
 
88
114
  // If no properties verified, skip this update
89
- if (validProps.length === 0) {
115
+ if (signedProperties.size === 0) {
90
116
  continue
91
117
  }
92
118
 
93
- // Track valid properties for this soul
94
- validProperties.set(soul, new Set(validProps))
119
+ // Track signed properties and timestamps for this soul
120
+ validProperties.set(soul, signedProperties)
121
+ validTimestamps.set(soul, signedTimestamps)
95
122
  }
96
123
 
97
124
  for (const key of Object.keys(node)) {
@@ -100,7 +127,6 @@ Ham.mix = async (change, graph, secure, listen) => {
100
127
  // Skip metadata fields (including userSignature for old data)
101
128
  if (key === utils.userSignature || key === utils.userPublicKey) continue
102
129
 
103
- // If this soul had verification, only process properties that have valid signatures
104
130
  // Properties without signatures in this update are left unchanged
105
131
  if (validProperties.has(soul) && !validProperties.get(soul).has(key)) {
106
132
  continue
@@ -129,11 +155,18 @@ Ham.mix = async (change, graph, secure, listen) => {
129
155
  }
130
156
  defer[soul][key] = value
131
157
  defer[soul]._[">"][key] = state
132
- if (node._["s"] && node._["s"][key]) {
133
- defer[soul]._["s"][key] = node._["s"][key]
158
+ if (node._["s"]) {
159
+ if (node._["s"][state]) {
160
+ defer[soul]._["s"][state] = node._["s"][state]
161
+ } else if (node._["s"][key]) {
162
+ defer[soul]._["s"][key] = node._["s"][key]
163
+ }
134
164
  }
135
165
  } else {
136
- const result = Ham(state, currentState, value, currentValue)
166
+ // Check if this property has a signed timestamp
167
+ const isSigned =
168
+ validTimestamps.has(soul) && validTimestamps.get(soul).has(state)
169
+ const result = Ham(state, currentState, value, currentValue, isSigned)
137
170
  if (result.incoming) {
138
171
  if (!now[soul]) {
139
172
  now[soul] = {_: {"#": soul, ">": {}, s: {}}}
@@ -143,8 +176,13 @@ Ham.mix = async (change, graph, secure, listen) => {
143
176
  }
144
177
  graph[soul][key] = now[soul][key] = value
145
178
  graph[soul]._[">"][key] = now[soul]._[">"][key] = state
146
- if (node._["s"] && node._["s"][key]) {
147
- graph[soul]._["s"][key] = now[soul]._["s"][key] = node._["s"][key]
179
+ if (node._["s"]) {
180
+ if (node._["s"][state]) {
181
+ graph[soul]._["s"][state] = now[soul]._["s"][state] =
182
+ node._["s"][state]
183
+ } else if (node._["s"][key]) {
184
+ graph[soul]._["s"][key] = now[soul]._["s"][key] = node._["s"][key]
185
+ }
148
186
  }
149
187
  // Call event listeners for update on key, mix is called before
150
188
  // put has finished so wait for what could be multiple nested
@@ -163,11 +201,6 @@ Ham.mix = async (change, graph, secure, listen) => {
163
201
  }
164
202
  }
165
203
 
166
- if (verify && nodeWait !== 0 && now[soul]) {
167
- // Secure updates can't be split, so move now to deferred as well.
168
- Object.assign(defer[soul], now[soul])
169
- delete now[soul]
170
- }
171
204
  // Call event listeners for update on soul.
172
205
  if (updated && listen[soul]) {
173
206
  setTimeout(() => {
package/src/holster.js CHANGED
@@ -97,9 +97,10 @@ const Holster = opt => {
97
97
 
98
98
  const graph = async (soul, data, userctx, cb) => {
99
99
  if (userctx) {
100
- // Sign each property individually
101
- const propertySignatures = await SEA.signProperties(data, userctx)
102
- return utils.graph(soul, data, propertySignatures, userctx.pub)
100
+ // Sign the timestamp
101
+ const timestamp = Date.now()
102
+ const sig = await SEA.signTimestamp(timestamp, userctx)
103
+ return utils.graph(soul, data, sig, userctx.pub, timestamp)
103
104
  }
104
105
 
105
106
  if (opt.secure) {
@@ -270,7 +271,7 @@ const Holster = opt => {
270
271
  // Top level keys are added to a root node so their values don't need
271
272
  // to be objects.
272
273
  ctxid = utils.text.random()
273
- allctx.set(ctxid, {chain: [{item: key, soul: "root"}], cb: cb})
274
+ allctx.set(ctxid, {chain: [{item: String(key), soul: "root"}], cb: cb})
274
275
  if (!cb) return api(ctxid)
275
276
 
276
277
  const _done = done(ctxid)
@@ -325,7 +326,7 @@ const Holster = opt => {
325
326
 
326
327
  // Push the key to the context as it needs a soul lookup.
327
328
  // (null is used to call the api with updated context)
328
- if (key !== null) ctx.chain.push({item: key, soul: null})
329
+ if (key !== null) ctx.chain.push({item: String(key), soul: null})
329
330
  if (!ctx.cb) return api(ctxid)
330
331
 
331
332
  // When there's a callback need to resolve the context first.
@@ -446,81 +447,92 @@ const Holster = opt => {
446
447
 
447
448
  // Otherwise put the data using the keys returned in result.
448
449
  // Need to check if a rel has already been added on the current node.
449
- wire.get({"#": soul, ".": item}, async msg => {
450
- if (msg.err) {
451
- _ack(`error getting ${soul}.${item}: ${msg.err}`)
452
- return
453
- }
450
+ // Use attemptRead to retry if data hasn't propagated yet.
451
+ const attemptRead = (retries = 0) => {
452
+ wire.get({"#": soul, ".": item}, async msg => {
453
+ if (msg.err) {
454
+ _ack(`error getting ${soul}.${item}: ${msg.err}`)
455
+ return
456
+ }
454
457
 
455
- let node = msg.put && msg.put[soul]
456
- const current = node && node[item]
457
- const id = utils.rel.is(current)
458
- if (!id) {
459
- // The current rel doesn't exist, so add it first.
460
- if (!node) node = {}
461
- node[item] = utils.rel.ify(utils.text.random())
462
- const g = await graph(soul, node, ctx.user, _ack)
463
- if (g === null) return
464
-
465
- wire.put(g, err => {
466
- if (err) {
467
- _ack(`error putting ${item} on ${soul}: ${err}`)
468
- } else {
469
- const _ctxid = utils.text.random()
470
- const chain = [{item: item, soul: soul}]
471
- // Pass on the previous context's callback and user flag here.
472
- allctx.set(_ctxid, {chain: chain, user: ctx.user, cb: ctx.cb})
473
- api(_ctxid).put(data)
474
- }
475
- })
476
- return
477
- }
458
+ let node = msg.put && msg.put[soul]
459
+ const current = node && node[item]
478
460
 
479
- const update = []
480
- for (const key of result) {
481
- const err = await new Promise(res => {
482
- if (utils.obj.is(data[key]) && !utils.rel.is(data[key])) {
483
- // Use the current rel as the context for nested objects.
484
- const _ctxid = utils.text.random()
485
- const chain = [{item: key, soul: id}]
486
- allctx.set(_ctxid, {chain: chain, user: ctx.user})
487
- api(_ctxid).put(data[key], res)
488
- } else {
489
- // Group the rest of the updates for put below.
490
- update.push(key)
491
- res(null)
492
- }
493
- })
494
- if (err) {
495
- _ack(err, ctxid)
461
+ // If we didn't find the item but still have retries, retry
462
+ if (!current && retries < 5) {
463
+ await new Promise(r => setTimeout(r, 50))
464
+ return attemptRead(retries + 1)
465
+ }
466
+
467
+ const id = utils.rel.is(current)
468
+ if (!id) {
469
+ // The current rel doesn't exist, so add it first.
470
+ if (!node) node = {}
471
+ node[item] = utils.rel.ify(utils.text.random())
472
+ const g = await graph(soul, node, ctx.user, _ack)
473
+ if (g === null) return
474
+
475
+ wire.put(g, err => {
476
+ if (err) {
477
+ _ack(`error putting ${item} on ${soul}: ${err}`)
478
+ } else {
479
+ const _ctxid = utils.text.random()
480
+ const chain = [{item: item, soul: soul}]
481
+ // Pass on the previous context's callback and user flag here.
482
+ allctx.set(_ctxid, {chain: chain, user: ctx.user, cb: ctx.cb})
483
+ api(_ctxid).put(data)
484
+ }
485
+ })
496
486
  return
497
487
  }
498
- }
499
488
 
500
- if (update.length === 0) {
501
- _ack(null)
502
- return
503
- }
489
+ const update = []
490
+ for (const key of result) {
491
+ const err = await new Promise(res => {
492
+ if (utils.obj.is(data[key]) && !utils.rel.is(data[key])) {
493
+ // Use the current rel as the context for nested objects.
494
+ const _ctxid = utils.text.random()
495
+ const chain = [{item: key, soul: id}]
496
+ allctx.set(_ctxid, {chain: chain, user: ctx.user})
497
+ api(_ctxid).put(data[key], res)
498
+ } else {
499
+ // Group the rest of the updates for put below.
500
+ update.push(key)
501
+ res(null)
502
+ }
503
+ })
504
+ if (err) {
505
+ _ack(err, ctxid)
506
+ return
507
+ }
508
+ }
504
509
 
505
- // The nested objects created above will also have rels on the parent
506
- // object, so fetch the node so the rest of the updates can be added.
507
- wire.get({"#": id}, async msg => {
508
- if (msg.err) {
509
- _ack(`error getting ${id}: ${msg.err}`)
510
+ if (update.length === 0) {
511
+ _ack(null)
510
512
  return
511
513
  }
512
514
 
513
- let node = msg.put && msg.put[id]
514
- if (!node) node = {}
515
- update.forEach(key => {
516
- node[key] = data[key]
517
- })
518
- const g = await graph(id, node, ctx.user, _ack)
519
- if (g === null) return
515
+ // The nested objects created above will also have rels on the parent
516
+ // object, so fetch the node so the rest of the updates can be added.
517
+ wire.get({"#": id}, async msg => {
518
+ if (msg.err) {
519
+ _ack(`error getting ${id}: ${msg.err}`)
520
+ return
521
+ }
520
522
 
521
- wire.put(g, _ack)
523
+ let node = msg.put && msg.put[id]
524
+ if (!node) node = {}
525
+ update.forEach(key => {
526
+ node[key] = data[key]
527
+ })
528
+ const g = await graph(id, node, ctx.user, _ack)
529
+ if (g === null) return
530
+
531
+ wire.put(g, _ack)
532
+ })
522
533
  })
523
- })
534
+ }
535
+ attemptRead()
524
536
  },
525
537
  on: (lex, cb, _get, _opt) => {
526
538
  if (typeof lex === "function") {
@@ -557,8 +569,8 @@ const Holster = opt => {
557
569
  const current = msg.put && msg.put[soul] && msg.put[soul][item]
558
570
  const id = utils.rel.is(current)
559
571
  if (id) {
560
- // It's a rel, read the related node. It might not be in the graph
561
- // yet when the listener fires, so we retry with delays.
572
+ // It's a rel, read the related node. It might not be in the
573
+ // graph yet when the listener fires, so we retry with delays.
562
574
  const attemptRead = async (retries = 0) => {
563
575
  const data = await new Promise(res => {
564
576
  const _ctxid = utils.text.random()
@@ -587,8 +599,16 @@ const Holster = opt => {
587
599
  // queueing. Initially register the soul without _get, then update if
588
600
  // it's a rel.
589
601
  let initialLex
590
- if (lex) initialLex = utils.obj.put(lex, "#", soul)
591
- else initialLex = {"#": soul, ".": item}
602
+ if (lex) {
603
+ initialLex = utils.obj.put(lex, "#", soul)
604
+ // Normalize numeric property filters to strings for wire operations
605
+ // Only convert if it's a number, not if it's a complex filter object
606
+ if (initialLex["."] != null && typeof initialLex["."] === "number") {
607
+ initialLex = {...initialLex, ".": String(initialLex["."])}
608
+ }
609
+ } else {
610
+ initialLex = {"#": soul, ".": item}
611
+ }
592
612
  wire.on(initialLex, map.get(cb), false, _opt)
593
613
 
594
614
  // Check if item is a rel and update listener if needed.
@@ -609,7 +629,7 @@ const Holster = opt => {
609
629
  wire.off(initialLex, map.get(cb))
610
630
  // Then add listener on the related node
611
631
  let relLex
612
- if (lex) relLex = utils.obj.put(lex, "#", id)
632
+ if (lex) relLex = utils.obj.put(initialLex, "#", id)
613
633
  else relLex = {"#": id, ".": null}
614
634
  wire.on(relLex, map.get(cb), _get, _opt)
615
635
  } else if (_get) {
@@ -691,7 +711,7 @@ const Holster = opt => {
691
711
  }
692
712
 
693
713
  ctxid = utils.text.random()
694
- const chain = [{item: key, soul: "~" + pub}]
714
+ const chain = [{item: String(key), soul: "~" + pub}]
695
715
  allctx.set(ctxid, {chain: chain, user: user.is, cb: cb})
696
716
  if (!cb) return api(ctxid)
697
717
 
package/src/sea.js CHANGED
@@ -135,59 +135,6 @@ const SEA = {
135
135
  if (cb) cb(null)
136
136
  return null
137
137
  },
138
- // Verify individual property signatures stored in node._["s"]
139
- // Returns array of valid property names
140
- verifyProperties: async (node, pub, cb) => {
141
- if (!pub || !node) {
142
- if (cb) cb([])
143
- return []
144
- }
145
-
146
- const key = await utils.subtle.importKey(
147
- "jwk",
148
- utils.jwk(pub),
149
- {name: "ECDSA", namedCurve: "P-256"},
150
- false,
151
- ["verify"],
152
- )
153
-
154
- const alg = {name: "ECDSA", hash: {name: "SHA-256"}}
155
- const valid = []
156
- const propertySignatures = (node._ && node._["s"]) || {}
157
-
158
- // Verify each property individually
159
- for (const k of Object.keys(node).sort()) {
160
- if (k === "_" || k == userPublicKey) continue
161
-
162
- const propSig = propertySignatures[k]
163
- if (!propSig) {
164
- console.log(`warning: property '${k}' missing signature`)
165
- continue
166
- }
167
-
168
- try {
169
- const hash = await utils.sha256(node[k])
170
- const sig = new Uint8Array(SafeBuffer.from(propSig, "base64"))
171
-
172
- const isValid = await utils.subtle.verify(
173
- alg,
174
- key,
175
- sig,
176
- new Uint8Array(hash),
177
- )
178
- if (isValid) {
179
- valid.push(k)
180
- } else {
181
- console.log(`warning: property '${k}' signature verification failed`)
182
- }
183
- } catch (err) {
184
- console.log(`warning: property '${k}' error verifying: ${err.message}`)
185
- }
186
- }
187
-
188
- if (cb) cb(valid)
189
- return valid
190
- },
191
138
  sign: async (data, pair, cb) => {
192
139
  if (!pair || !pair.pub || !pair.priv) {
193
140
  if (cb) cb(null)
@@ -225,36 +172,71 @@ const SEA = {
225
172
  if (cb) cb(signed)
226
173
  return signed
227
174
  },
228
- // Sign individual properties for per-property verification
229
- signProperties: async (data, pair, cb) => {
175
+ // Sign a timestamp to prove ownership of an update
176
+ signTimestamp: async (timestamp, pair, cb) => {
230
177
  if (!pair || !pair.pub || !pair.priv) {
231
178
  if (cb) cb(null)
232
179
  return null
233
180
  }
234
181
 
235
- const check = utils.parse(data)
236
- const propertySignatures = {}
182
+ // Convert timestamp to string for consistent hashing
183
+ const timestampStr = timestamp.toString()
184
+ const hash = await utils.sha256(timestampStr)
237
185
  const jwk = utils.jwk(pair.pub, pair.priv)
238
186
  const alg = {name: "ECDSA", namedCurve: "P-256"}
239
- const key = await utils.subtle.importKey("jwk", jwk, alg, false, ["sign"])
240
187
 
241
- // Sign each property individually
242
- for (const k of Object.keys(check).sort()) {
243
- if (k !== "_" && k != userPublicKey && k != userSignature) {
244
- const hash = await utils.sha256(check[k])
245
- const sig = await utils.subtle.sign(
188
+ const sig = await utils.subtle
189
+ .importKey("jwk", jwk, alg, false, ["sign"])
190
+ .then(key =>
191
+ utils.subtle.sign(
246
192
  {name: "ECDSA", hash: {name: "SHA-256"}},
247
193
  key,
248
194
  new Uint8Array(hash),
249
- )
250
- propertySignatures[k] = SafeBuffer.from(sig, "binary").toString(
251
- "base64",
252
- )
253
- }
195
+ ),
196
+ )
197
+
198
+ const signature = SafeBuffer.from(sig, "binary").toString("base64")
199
+
200
+ if (cb) cb(signature)
201
+ return signature
202
+ },
203
+ // Verify a timestamp signature to confirm ownership of update
204
+ verifyTimestamp: async (timestamp, signature, publicKey, cb) => {
205
+ if (!publicKey || !timestamp || !signature) {
206
+ if (cb) cb(false)
207
+ return false
254
208
  }
255
209
 
256
- if (cb) cb(propertySignatures)
257
- return propertySignatures
210
+ try {
211
+ // Convert timestamp to string for consistent hashing
212
+ const timestampStr = timestamp.toString()
213
+ const hash = await utils.sha256(timestampStr)
214
+
215
+ const key = await utils.subtle.importKey(
216
+ "jwk",
217
+ utils.jwk(publicKey),
218
+ {name: "ECDSA", namedCurve: "P-256"},
219
+ false,
220
+ ["verify"],
221
+ )
222
+
223
+ const sig = new Uint8Array(SafeBuffer.from(signature, "base64"))
224
+ const alg = {name: "ECDSA", hash: {name: "SHA-256"}}
225
+
226
+ const isValid = await utils.subtle.verify(
227
+ alg,
228
+ key,
229
+ sig,
230
+ new Uint8Array(hash),
231
+ )
232
+
233
+ if (cb) cb(isValid)
234
+ return isValid
235
+ } catch (err) {
236
+ console.log(`error verifying timestamp signature: ${err.message}`)
237
+ if (cb) cb(false)
238
+ return false
239
+ }
258
240
  },
259
241
  work: async (data, salt, cb) => {
260
242
  if (typeof salt === "function") {
package/src/store.js CHANGED
@@ -233,6 +233,11 @@ const Store = opt => {
233
233
  node._[">"][key] = value[1]
234
234
  // If signature is present, store it in _["s"]
235
235
  if (value.length === 3 && value[2]) {
236
+ const state = value[1]
237
+ // Store signature under both state and key for compatibility
238
+ signatures[state] = value[2]
239
+ // TODO: Can probably remove the following when old data is no
240
+ // longer a problem.
236
241
  signatures[key] = value[2]
237
242
  }
238
243
  }
@@ -286,8 +291,7 @@ const Store = opt => {
286
291
  count++
287
292
  let value = node[key]
288
293
  let state = node._[">"][key]
289
- let sig = node._["s"] && node._["s"][key]
290
- // Only pass signature if it exists, otherwise just [value, state]
294
+ let sig = node._["s"] && node._["s"][state]
291
295
  let data = sig ? [value, state, sig] : [value, state]
292
296
  radisk(soul + enq + key, data, ack)
293
297
  })