dacument 1.0.1 → 1.1.0

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/README.md CHANGED
@@ -19,10 +19,16 @@ yarn add dacument
19
19
 
20
20
  ```ts
21
21
  import { generateNonce } from "bytecodec";
22
+ import { generateSignPair } from "zeyra";
22
23
  import { Dacument } from "dacument";
23
24
 
24
25
  const actorId = generateNonce(); // 256-bit base64url id
25
- Dacument.setActorId(actorId);
26
+ const actorKeys = await generateSignPair();
27
+ await Dacument.setActorInfo({
28
+ id: actorId,
29
+ privateKeyJwk: actorKeys.signingJwk,
30
+ publicKeyJwk: actorKeys.verificationJwk,
31
+ });
26
32
 
27
33
  const schema = Dacument.schema({
28
34
  title: Dacument.register({ jsType: "string", regex: /^[a-z ]+$/i }),
@@ -93,9 +99,11 @@ doc.acl.setRole("user-viewer", "viewer");
93
99
  await doc.flush();
94
100
  ```
95
101
 
96
- Before any schema/load/create, call `Dacument.setActorId()` once per process.
97
- The actor id must be a 256-bit base64url string (e.g. `bytecodec.generateNonce()`).
98
- Subsequent calls are ignored.
102
+ Before any schema/load/create, call `await Dacument.setActorInfo(...)` once per
103
+ process. The actor id must be a 256-bit base64url string (e.g.
104
+ `bytecodec` libarys `generateNonce()`), and the actor key pair must be ES256 (P-256).
105
+ Subsequent calls are ignored. On first merge, Dacument auto-attaches the
106
+ actor's `publicKeyJwk` to its own ACL entry (if missing).
99
107
 
100
108
  Each actor signs with the role key they were given (owner/manager/editor). Load
101
109
  with the highest role key you have; viewers load without a key.
@@ -123,7 +131,12 @@ without snapshotting.
123
131
  To add a new replica, share a snapshot and load it:
124
132
 
125
133
  ```ts
126
- Dacument.setActorId(bobId);
134
+ const bobKeys = await generateSignPair();
135
+ await Dacument.setActorInfo({
136
+ id: bobId,
137
+ privateKeyJwk: bobKeys.signingJwk,
138
+ publicKeyJwk: bobKeys.verificationJwk,
139
+ });
127
140
  const bob = await Dacument.load({
128
141
  schema,
129
142
  roleKey: bobKey.privateKey,
@@ -139,10 +152,20 @@ Snapshots do not include schema or schema ids; callers must supply the schema on
139
152
  - `doc.addEventListener("merge", handler)` emits `{ actor, target, method, data }`.
140
153
  - `doc.addEventListener("error", handler)` emits signing/verification errors.
141
154
  - `doc.addEventListener("revoked", handler)` fires when the current actor is revoked.
155
+ - `doc.selfRevoke()` emits a signed ACL op that revokes the current actor.
142
156
  - `await doc.flush()` waits for pending signatures so all local ops are emitted.
143
157
  - `doc.snapshot()` returns a loadable op log (`{ docId, roleKeys, ops }`).
158
+ - `await doc.verifyActorIntegrity(...)` verifies per-actor signatures on demand.
144
159
  - Revoked actors cannot snapshot; reads are masked to initial values.
145
160
 
161
+ ## Actor identity (cold path)
162
+
163
+ Every op may include an `actorSig` (detached ES256 signature over the op token).
164
+ Merges ignore `actorSig` by default to keep the hot path fast. When you need
165
+ attribution or forensic checks, call `verifyActorIntegrity()` with a token,
166
+ ops list, or snapshot. It verifies `actorSig` against the actor's `publicKeyJwk`
167
+ from the ACL at the op stamp and returns a summary plus failures.
168
+
146
169
  ## Garbage collection
147
170
 
148
171
  Dacument tracks per-actor `ack` ops and compacts tombstones once all non-revoked
@@ -11,6 +11,7 @@ export declare class AclLog {
11
11
  roleAt(actorId: string, stamp: AclAssignment["stamp"]): Role;
12
12
  currentRole(actorId: string): Role;
13
13
  currentEntry(actorId: string): AclAssignment | null;
14
+ publicKeyAt(actorId: string, stamp: AclAssignment["stamp"]): JsonWebKey | null;
14
15
  knownActors(): string[];
15
16
  private insert;
16
17
  }
@@ -55,6 +55,19 @@ export class AclLog {
55
55
  currentEntry(actorId) {
56
56
  return this.currentByActor.get(actorId) ?? null;
57
57
  }
58
+ publicKeyAt(actorId, stamp) {
59
+ const list = this.nodesByActor.get(actorId);
60
+ if (!list || list.length === 0)
61
+ return null;
62
+ for (let index = list.length - 1; index >= 0; index--) {
63
+ const entry = list[index];
64
+ if (compareHLC(entry.stamp, stamp) > 0)
65
+ continue;
66
+ if (entry.publicKeyJwk)
67
+ return entry.publicKeyJwk;
68
+ }
69
+ return null;
70
+ }
58
71
  knownActors() {
59
72
  return [...this.currentByActor.keys()];
60
73
  }
@@ -1,9 +1,15 @@
1
- import { type AclAssignment, type DacumentEventMap, type DocFieldAccess, type DocSnapshot, type RoleKeys, type RolePublicKeys, type SchemaDefinition, type SchemaId, type SignedOp, type Role, array, map, record, register, set, text } from "./types.js";
1
+ import { type AclAssignment, type ActorInfo, type DacumentEventMap, type DocFieldAccess, type DocSnapshot, type RoleKeys, type RolePublicKeys, type SchemaDefinition, type SchemaId, type SignedOp, type VerificationResult, type VerifyActorIntegrityOptions, type Role, array, map, record, register, set, text } from "./types.js";
2
2
  export declare class Dacument<S extends SchemaDefinition> {
3
- private static actorId?;
4
- static setActorId(actorId: string): void;
5
- private static requireActorId;
3
+ private static actorInfo?;
4
+ private static actorSigner?;
5
+ static setActorInfo(info: ActorInfo): Promise<void>;
6
+ private static requireActorInfo;
7
+ private static requireActorSigner;
8
+ private static signActorToken;
6
9
  private static isValidActorId;
10
+ private static assertActorKeyJwk;
11
+ private static assertActorPrivateKey;
12
+ private static assertActorPublicKey;
7
13
  static schema: <Schema extends SchemaDefinition>(schema: Schema) => Schema;
8
14
  static register: typeof register;
9
15
  static text: typeof text;
@@ -38,6 +44,8 @@ export declare class Dacument<S extends SchemaDefinition> {
38
44
  private readonly opLog;
39
45
  private readonly opTokens;
40
46
  private readonly verifiedOps;
47
+ private readonly opIndexByToken;
48
+ private readonly actorSigByToken;
41
49
  private readonly appliedTokens;
42
50
  private currentRole;
43
51
  private readonly revokedCrdtByField;
@@ -49,8 +57,10 @@ export declare class Dacument<S extends SchemaDefinition> {
49
57
  private readonly ackByActor;
50
58
  private suppressMerge;
51
59
  private ackScheduled;
60
+ private actorKeyPublishPending;
52
61
  private lastGcBarrier;
53
62
  private snapshotFieldValues;
63
+ private recordActorSig;
54
64
  readonly acl: {
55
65
  setRole: (actorId: string, role: Role) => void;
56
66
  getRole: (actorId: string) => Role;
@@ -68,11 +78,14 @@ export declare class Dacument<S extends SchemaDefinition> {
68
78
  removeEventListener<K extends keyof DacumentEventMap>(type: K, listener: (event: DacumentEventMap[K]) => void): void;
69
79
  flush(): Promise<void>;
70
80
  snapshot(): DocSnapshot;
81
+ selfRevoke(): void;
82
+ verifyActorIntegrity(options?: VerifyActorIntegrityOptions): Promise<VerificationResult>;
71
83
  merge(input: SignedOp | SignedOp[] | string | string[]): Promise<{
72
84
  accepted: SignedOp[];
73
85
  rejected: number;
74
86
  }>;
75
87
  private rebuildFromVerified;
88
+ private maybePublishActorKey;
76
89
  private ack;
77
90
  private scheduleAck;
78
91
  private computeGcBarrier;
@@ -102,6 +115,7 @@ export declare class Dacument<S extends SchemaDefinition> {
102
115
  private commitRecordMutation;
103
116
  private capturePatches;
104
117
  private queueLocalOp;
118
+ private queueActorOp;
105
119
  private applyRemotePayload;
106
120
  private applyAclPayload;
107
121
  private applyRegisterPayload;
@@ -1,5 +1,5 @@
1
1
  import { Bytes, generateNonce } from "bytecodec";
2
- import { generateSignPair } from "zeyra";
2
+ import { SigningAgent, generateSignPair } from "zeyra";
3
3
  import { v7 as uuidv7 } from "uuid";
4
4
  import { CRArray } from "../CRArray/class.js";
5
5
  import { CRMap } from "../CRMap/class.js";
@@ -9,7 +9,7 @@ import { CRSet } from "../CRSet/class.js";
9
9
  import { CRText } from "../CRText/class.js";
10
10
  import { AclLog } from "./acl.js";
11
11
  import { HLC, compareHLC } from "./clock.js";
12
- import { decodeToken, encodeToken, signToken, verifyToken } from "./crypto.js";
12
+ import { decodeToken, encodeToken, signToken, validateActorKeyPair, verifyDetached, verifyToken, } from "./crypto.js";
13
13
  import { array, map, record, register, set, text, isJsValue, isValueOfType, schemaIdInput, } from "./types.js";
14
14
  const TOKEN_TYP = "DACOP";
15
15
  function nowSeconds() {
@@ -35,6 +35,15 @@ function stableKey(value) {
35
35
  }
36
36
  return JSON.stringify(value);
37
37
  }
38
+ function normalizeJwk(jwk) {
39
+ const entries = Object.entries(jwk).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
40
+ return JSON.stringify(Object.fromEntries(entries));
41
+ }
42
+ function jwkEquals(left, right) {
43
+ if (!left || !right)
44
+ return false;
45
+ return normalizeJwk(left) === normalizeJwk(right);
46
+ }
38
47
  function isDagNode(node) {
39
48
  if (!isObject(node))
40
49
  return false;
@@ -55,6 +64,13 @@ function isAclPatch(value) {
55
64
  return false;
56
65
  if (typeof value.role !== "string")
57
66
  return false;
67
+ if ("publicKeyJwk" in value && value.publicKeyJwk !== undefined) {
68
+ if (!isObject(value.publicKeyJwk))
69
+ return false;
70
+ const jwk = value.publicKeyJwk;
71
+ if (jwk.kty && jwk.kty !== "EC")
72
+ return false;
73
+ }
58
74
  return true;
59
75
  }
60
76
  function isAckPatch(value) {
@@ -99,7 +115,7 @@ function createEmptyField(crdt) {
99
115
  function roleNeedsKey(role) {
100
116
  return role === "owner" || role === "manager" || role === "editor";
101
117
  }
102
- function parseSignerRole(kid, issuer) {
118
+ function parseSignerKind(kid, issuer) {
103
119
  if (!kid)
104
120
  return null;
105
121
  const [kidIssuer, role] = kid.split(":");
@@ -107,6 +123,8 @@ function parseSignerRole(kid, issuer) {
107
123
  return null;
108
124
  if (role === "owner" || role === "manager" || role === "editor")
109
125
  return role;
126
+ if (role === "actor")
127
+ return "actor";
110
128
  return null;
111
129
  }
112
130
  async function generateRoleKeys() {
@@ -127,18 +145,33 @@ function toPublicRoleKeys(roleKeys) {
127
145
  };
128
146
  }
129
147
  export class Dacument {
130
- static actorId;
131
- static setActorId(actorId) {
132
- if (Dacument.actorId)
148
+ static actorInfo;
149
+ static actorSigner;
150
+ static async setActorInfo(info) {
151
+ if (Dacument.actorInfo)
133
152
  return;
134
- if (!Dacument.isValidActorId(actorId))
135
- throw new Error("Dacument.setActorId: actorId must be 256-bit base64url");
136
- Dacument.actorId = actorId;
137
- }
138
- static requireActorId() {
139
- if (!Dacument.actorId)
140
- throw new Error("Dacument: actorId not set; call Dacument.setActorId()");
141
- return Dacument.actorId;
153
+ if (!Dacument.isValidActorId(info.id))
154
+ throw new Error("Dacument.setActorInfo: id must be 256-bit base64url");
155
+ Dacument.assertActorPrivateKey(info.privateKeyJwk);
156
+ Dacument.assertActorPublicKey(info.publicKeyJwk);
157
+ await validateActorKeyPair(info.privateKeyJwk, info.publicKeyJwk);
158
+ Dacument.actorInfo = info;
159
+ Dacument.actorSigner = new SigningAgent(info.privateKeyJwk);
160
+ }
161
+ static requireActorInfo() {
162
+ if (!Dacument.actorInfo)
163
+ throw new Error("Dacument: actor info not set; call Dacument.setActorInfo()");
164
+ return Dacument.actorInfo;
165
+ }
166
+ static requireActorSigner() {
167
+ if (!Dacument.actorSigner)
168
+ throw new Error("Dacument: actor info not set; call Dacument.setActorInfo()");
169
+ return Dacument.actorSigner;
170
+ }
171
+ static async signActorToken(token) {
172
+ const signer = Dacument.requireActorSigner();
173
+ const signature = await signer.sign(Bytes.fromString(token));
174
+ return Bytes.toBase64UrlString(signature);
142
175
  }
143
176
  static isValidActorId(actorId) {
144
177
  if (typeof actorId !== "string")
@@ -151,8 +184,26 @@ export class Dacument {
151
184
  return false;
152
185
  }
153
186
  }
187
+ static assertActorKeyJwk(jwk, label) {
188
+ if (!jwk || typeof jwk !== "object")
189
+ throw new Error(`Dacument.setActorInfo: ${label} must be a JWK object`);
190
+ if (jwk.kty !== "EC")
191
+ throw new Error(`Dacument.setActorInfo: ${label} must be EC (P-256)`);
192
+ if (jwk.crv && jwk.crv !== "P-256")
193
+ throw new Error(`Dacument.setActorInfo: ${label} must use P-256`);
194
+ if (jwk.alg && jwk.alg !== "ES256")
195
+ throw new Error(`Dacument.setActorInfo: ${label} must use ES256`);
196
+ }
197
+ static assertActorPrivateKey(jwk) {
198
+ Dacument.assertActorKeyJwk(jwk, "privateKeyJwk");
199
+ if (!jwk.d)
200
+ throw new Error("Dacument.setActorInfo: privateKeyJwk must include 'd'");
201
+ }
202
+ static assertActorPublicKey(jwk) {
203
+ Dacument.assertActorKeyJwk(jwk, "publicKeyJwk");
204
+ }
154
205
  static schema = (schema) => {
155
- Dacument.requireActorId();
206
+ Dacument.requireActorInfo();
156
207
  return schema;
157
208
  };
158
209
  static register = register;
@@ -173,7 +224,8 @@ export class Dacument {
173
224
  return Bytes.toBase64UrlString(new Uint8Array(digest));
174
225
  }
175
226
  static async create(params) {
176
- const ownerId = Dacument.requireActorId();
227
+ const ownerInfo = Dacument.requireActorInfo();
228
+ const ownerId = ownerInfo.id;
177
229
  const docId = params.docId ?? generateNonce();
178
230
  const schemaId = await Dacument.computeSchemaId(params.schema);
179
231
  const roleKeys = await generateRoleKeys();
@@ -198,7 +250,8 @@ export class Dacument {
198
250
  };
199
251
  const sign = async (payload) => {
200
252
  const token = await signToken(roleKeys.owner.privateKey, header, payload);
201
- ops.push({ token });
253
+ const actorSig = await Dacument.signActorToken(token);
254
+ ops.push({ token, actorSig });
202
255
  };
203
256
  await sign({
204
257
  iss: ownerId,
@@ -211,6 +264,7 @@ export class Dacument {
211
264
  id: uuidv7(),
212
265
  target: ownerId,
213
266
  role: "owner",
267
+ publicKeyJwk: ownerInfo.publicKeyJwk,
214
268
  },
215
269
  });
216
270
  for (const [field, schema] of Object.entries(params.schema)) {
@@ -390,7 +444,7 @@ export class Dacument {
390
444
  return { docId, schemaId, roleKeys, snapshot };
391
445
  }
392
446
  static async load(params) {
393
- const actorId = Dacument.requireActorId();
447
+ const actorId = Dacument.requireActorInfo().id;
394
448
  const schemaId = await Dacument.computeSchemaId(params.schema);
395
449
  const doc = new Dacument({
396
450
  schema: params.schema,
@@ -414,6 +468,8 @@ export class Dacument {
414
468
  opLog = [];
415
469
  opTokens = new Set();
416
470
  verifiedOps = new Map();
471
+ opIndexByToken = new Map();
472
+ actorSigByToken = new Map();
417
473
  appliedTokens = new Set();
418
474
  currentRole;
419
475
  revokedCrdtByField = new Map();
@@ -425,6 +481,7 @@ export class Dacument {
425
481
  ackByActor = new Map();
426
482
  suppressMerge = false;
427
483
  ackScheduled = false;
484
+ actorKeyPublishPending = false;
428
485
  lastGcBarrier = null;
429
486
  snapshotFieldValues() {
430
487
  const values = new Map();
@@ -432,9 +489,20 @@ export class Dacument {
432
489
  values.set(key, this.fieldValue(key));
433
490
  return values;
434
491
  }
492
+ recordActorSig(token, actorSig) {
493
+ if (!actorSig || this.actorSigByToken.has(token))
494
+ return;
495
+ this.actorSigByToken.set(token, actorSig);
496
+ const index = this.opIndexByToken.get(token);
497
+ if (index === undefined)
498
+ return;
499
+ const entry = this.opLog[index];
500
+ if (!entry.actorSig)
501
+ entry.actorSig = actorSig;
502
+ }
435
503
  acl;
436
504
  constructor(params) {
437
- const actorId = Dacument.requireActorId();
505
+ const actorId = Dacument.requireActorInfo().id;
438
506
  this.schema = params.schema;
439
507
  this.schemaId = params.schemaId;
440
508
  this.docId = params.docId;
@@ -522,10 +590,104 @@ export class Dacument {
522
590
  snapshot() {
523
591
  if (this.isRevoked())
524
592
  throw new Error("Dacument: revoked actors cannot snapshot");
593
+ const ops = this.opLog.map((op) => {
594
+ const actorSig = this.actorSigByToken.get(op.token);
595
+ return actorSig ? { token: op.token, actorSig } : { token: op.token };
596
+ });
525
597
  return {
526
598
  docId: this.docId,
527
599
  roleKeys: this.roleKeys,
528
- ops: this.opLog.slice(),
600
+ ops,
601
+ };
602
+ }
603
+ selfRevoke() {
604
+ const stamp = this.clock.next();
605
+ const role = this.aclLog.roleAt(this.actorId, stamp);
606
+ if (role === "revoked")
607
+ return;
608
+ const actorInfo = Dacument.requireActorInfo();
609
+ const entry = this.aclLog.currentEntry(this.actorId);
610
+ const patch = {
611
+ id: uuidv7(),
612
+ target: this.actorId,
613
+ role: "revoked",
614
+ };
615
+ if (!entry?.publicKeyJwk)
616
+ patch.publicKeyJwk = actorInfo.publicKeyJwk;
617
+ const payload = {
618
+ iss: this.actorId,
619
+ sub: this.docId,
620
+ iat: nowSeconds(),
621
+ stamp,
622
+ kind: "acl.set",
623
+ schema: this.schemaId,
624
+ patch,
625
+ };
626
+ if (roleNeedsKey(role) && this.roleKey) {
627
+ this.queueLocalOp(payload, role);
628
+ return;
629
+ }
630
+ this.queueActorOp(payload);
631
+ }
632
+ async verifyActorIntegrity(options = {}) {
633
+ const input = options.token !== undefined
634
+ ? [options.token]
635
+ : options.ops ?? options.snapshot?.ops ?? this.opLog;
636
+ let verified = 0;
637
+ let failed = 0;
638
+ let missing = 0;
639
+ const failures = [];
640
+ for (let index = 0; index < input.length; index++) {
641
+ const item = input[index];
642
+ const token = typeof item === "string" ? item : item.token;
643
+ const actorSig = typeof item === "string"
644
+ ? this.actorSigByToken.get(token)
645
+ : item.actorSig ?? this.actorSigByToken.get(token);
646
+ const decoded = decodeToken(token);
647
+ if (!decoded) {
648
+ failed++;
649
+ failures.push({ index, reason: "invalid token" });
650
+ continue;
651
+ }
652
+ const payload = decoded.payload;
653
+ if (!this.isValidPayload(payload)) {
654
+ failed++;
655
+ failures.push({ index, reason: "invalid payload" });
656
+ continue;
657
+ }
658
+ if (!actorSig) {
659
+ missing++;
660
+ continue;
661
+ }
662
+ const publicKey = this.aclLog.publicKeyAt(payload.iss, payload.stamp);
663
+ if (!publicKey) {
664
+ missing++;
665
+ continue;
666
+ }
667
+ try {
668
+ const ok = await verifyDetached(publicKey, token, actorSig);
669
+ if (!ok) {
670
+ failed++;
671
+ failures.push({ index, reason: "actor signature mismatch" });
672
+ continue;
673
+ }
674
+ }
675
+ catch (error) {
676
+ failed++;
677
+ failures.push({
678
+ index,
679
+ reason: error instanceof Error ? error.message : "actor signature error",
680
+ });
681
+ continue;
682
+ }
683
+ verified++;
684
+ }
685
+ return {
686
+ ok: failed === 0,
687
+ verified,
688
+ failed,
689
+ missing,
690
+ failures,
529
691
  };
530
692
  }
531
693
  async merge(input) {
@@ -538,6 +700,7 @@ export class Dacument {
538
700
  let diffStamp = null;
539
701
  for (const item of tokens) {
540
702
  const token = typeof item === "string" ? item : item.token;
703
+ const actorSig = typeof item === "string" ? undefined : item.actorSig;
541
704
  const decoded = decodeToken(token);
542
705
  if (!decoded) {
543
706
  rejected++;
@@ -569,23 +732,66 @@ export class Dacument {
569
732
  stored = { payload, signerRole: null };
570
733
  }
571
734
  else {
572
- const signerRole = parseSignerRole(decoded.header.kid, payload.iss);
573
- if (!signerRole) {
735
+ const signerKind = parseSignerKind(decoded.header.kid, payload.iss);
736
+ if (!signerKind) {
574
737
  rejected++;
575
738
  continue;
576
739
  }
577
- const publicKey = this.roleKeys[signerRole];
578
- const verified = await verifyToken(publicKey, token, TOKEN_TYP);
579
- if (!verified) {
580
- rejected++;
581
- continue;
740
+ if (signerKind === "actor") {
741
+ if (payload.kind !== "acl.set") {
742
+ rejected++;
743
+ continue;
744
+ }
745
+ const patch = isAclPatch(payload.patch) ? payload.patch : null;
746
+ if (!patch || patch.target !== payload.iss) {
747
+ rejected++;
748
+ continue;
749
+ }
750
+ const wantsSelfRevoke = patch.role === "revoked";
751
+ const wantsKeyAttach = Boolean(patch.publicKeyJwk);
752
+ if (!wantsSelfRevoke && !wantsKeyAttach) {
753
+ rejected++;
754
+ continue;
755
+ }
756
+ const existingKey = this.aclLog.publicKeyAt(payload.iss, payload.stamp);
757
+ if (existingKey &&
758
+ patch.publicKeyJwk &&
759
+ !jwkEquals(existingKey, patch.publicKeyJwk)) {
760
+ rejected++;
761
+ continue;
762
+ }
763
+ const publicKey = existingKey ?? patch.publicKeyJwk;
764
+ if (!publicKey) {
765
+ rejected++;
766
+ continue;
767
+ }
768
+ const verified = await verifyToken(publicKey, token, TOKEN_TYP);
769
+ if (!verified) {
770
+ rejected++;
771
+ continue;
772
+ }
773
+ stored = { payload, signerRole: "actor" };
774
+ }
775
+ else {
776
+ const publicKey = this.roleKeys[signerKind];
777
+ const verified = await verifyToken(publicKey, token, TOKEN_TYP);
778
+ if (!verified) {
779
+ rejected++;
780
+ continue;
781
+ }
782
+ stored = { payload, signerRole: signerKind };
582
783
  }
583
- stored = { payload, signerRole };
584
784
  }
585
785
  this.verifiedOps.set(token, stored);
586
786
  if (!this.opTokens.has(token)) {
587
787
  this.opTokens.add(token);
588
- this.opLog.push({ token });
788
+ const opEntry = { token };
789
+ if (typeof actorSig === "string") {
790
+ opEntry.actorSig = actorSig;
791
+ this.actorSigByToken.set(token, actorSig);
792
+ }
793
+ this.opLog.push(opEntry);
794
+ this.opIndexByToken.set(token, this.opLog.length - 1);
589
795
  }
590
796
  sawNewToken = true;
591
797
  if (payload.kind === "acl.set") {
@@ -595,6 +801,7 @@ export class Dacument {
595
801
  }
596
802
  }
597
803
  }
804
+ this.recordActorSig(token, actorSig);
598
805
  decodedOps.push({
599
806
  token,
600
807
  payload: stored.payload,
@@ -605,7 +812,7 @@ export class Dacument {
605
812
  let appliedNonAck = false;
606
813
  if (sawNewToken) {
607
814
  const beforeValues = this.isRevoked() ? undefined : this.snapshotFieldValues();
608
- const result = this.rebuildFromVerified(new Set(this.appliedTokens), {
815
+ const result = await this.rebuildFromVerified(new Set(this.appliedTokens), {
609
816
  beforeValues,
610
817
  diffActor: diffActor ?? this.actorId,
611
818
  });
@@ -627,9 +834,10 @@ export class Dacument {
627
834
  if (appliedNonAck)
628
835
  this.scheduleAck();
629
836
  this.maybeGc();
837
+ this.maybePublishActorKey();
630
838
  return { accepted, rejected };
631
839
  }
632
- rebuildFromVerified(previousApplied, options) {
840
+ async rebuildFromVerified(previousApplied, options) {
633
841
  const invalidated = new Set(previousApplied);
634
842
  let appliedNonAck = false;
635
843
  this.aclLog.reset();
@@ -658,21 +866,49 @@ export class Dacument {
658
866
  for (const { token, payload, signerRole } of ops) {
659
867
  let allowed = false;
660
868
  if (payload.kind === "acl.set") {
661
- if (!signerRole)
869
+ const patch = isAclPatch(payload.patch) ? payload.patch : null;
870
+ if (!patch)
662
871
  continue;
663
872
  if (this.aclLog.isEmpty() &&
664
- isAclPatch(payload.patch) &&
665
- payload.patch.role === "owner" &&
666
- payload.patch.target === payload.iss &&
873
+ patch.role === "owner" &&
874
+ patch.target === payload.iss &&
667
875
  signerRole === "owner") {
668
876
  allowed = true;
669
877
  }
670
878
  else {
671
879
  const roleAt = this.aclLog.roleAt(payload.iss, payload.stamp);
672
- if (roleAt === signerRole &&
673
- isAclPatch(payload.patch) &&
674
- this.canWriteAclTarget(signerRole, payload.patch.role, payload.patch.target, payload.stamp))
675
- allowed = true;
880
+ const isSelfRevoke = patch.target === payload.iss && patch.role === "revoked";
881
+ const targetKey = this.aclLog.publicKeyAt(patch.target, payload.stamp);
882
+ const isSelfKeyUpdate = patch.target === payload.iss &&
883
+ patch.publicKeyJwk &&
884
+ patch.role === roleAt &&
885
+ roleAt !== "revoked" &&
886
+ (!targetKey || jwkEquals(targetKey, patch.publicKeyJwk));
887
+ if (patch.publicKeyJwk &&
888
+ targetKey &&
889
+ !jwkEquals(targetKey, patch.publicKeyJwk)) {
890
+ continue;
891
+ }
892
+ if (isSelfRevoke) {
893
+ if (signerRole === "actor") {
894
+ allowed = true;
895
+ }
896
+ else if (signerRole && roleAt === signerRole) {
897
+ allowed = true;
898
+ }
899
+ }
900
+ else if (signerRole === "actor") {
901
+ if (isSelfKeyUpdate)
902
+ allowed = true;
903
+ }
904
+ else if (signerRole && roleAt === signerRole) {
905
+ if (this.canWriteAclTarget(signerRole, patch.role, patch.target, payload.stamp)) {
906
+ allowed = true;
907
+ }
908
+ else if (isSelfKeyUpdate) {
909
+ allowed = true;
910
+ }
911
+ }
676
912
  }
677
913
  }
678
914
  else {
@@ -715,6 +951,39 @@ export class Dacument {
715
951
  }
716
952
  return { appliedNonAck };
717
953
  }
954
+ maybePublishActorKey() {
955
+ const entry = this.aclLog.currentEntry(this.actorId);
956
+ if (entry?.publicKeyJwk) {
957
+ this.actorKeyPublishPending = false;
958
+ return;
959
+ }
960
+ if (this.actorKeyPublishPending)
961
+ return;
962
+ if (this.isRevoked())
963
+ return;
964
+ if (!entry)
965
+ return;
966
+ const actorInfo = Dacument.requireActorInfo();
967
+ const stamp = this.clock.next();
968
+ const payload = {
969
+ iss: this.actorId,
970
+ sub: this.docId,
971
+ iat: nowSeconds(),
972
+ stamp,
973
+ kind: "acl.set",
974
+ schema: this.schemaId,
975
+ patch: {
976
+ id: uuidv7(),
977
+ target: this.actorId,
978
+ role: entry.role,
979
+ publicKeyJwk: actorInfo.publicKeyJwk,
980
+ },
981
+ };
982
+ this.actorKeyPublishPending = true;
983
+ this.queueActorOp(payload, () => {
984
+ this.actorKeyPublishPending = false;
985
+ });
986
+ }
718
987
  ack() {
719
988
  const stamp = this.clock.next();
720
989
  const role = this.aclLog.roleAt(this.actorId, stamp);
@@ -1405,14 +1674,35 @@ export class Dacument {
1405
1674
  throw new Error("Dacument: missing role private key");
1406
1675
  const header = { alg: "ES256", typ: TOKEN_TYP, kid: `${payload.iss}:${role}` };
1407
1676
  const promise = signToken(this.roleKey, header, payload)
1408
- .then((token) => {
1409
- const op = { token };
1677
+ .then(async (token) => {
1678
+ const actorSig = await Dacument.signActorToken(token);
1679
+ const op = { token, actorSig };
1410
1680
  this.emitEvent("change", { type: "change", ops: [op] });
1411
1681
  })
1412
1682
  .catch((error) => this.emitError(error instanceof Error ? error : new Error(String(error))));
1413
1683
  this.pending.add(promise);
1414
1684
  promise.finally(() => this.pending.delete(promise));
1415
1685
  }
1686
+ queueActorOp(payload, onError) {
1687
+ const actorInfo = Dacument.requireActorInfo();
1688
+ const header = {
1689
+ alg: "ES256",
1690
+ typ: TOKEN_TYP,
1691
+ kid: `${payload.iss}:actor`,
1692
+ };
1693
+ const promise = signToken(actorInfo.privateKeyJwk, header, payload)
1694
+ .then(async (token) => {
1695
+ const actorSig = await Dacument.signActorToken(token);
1696
+ const op = { token, actorSig };
1697
+ this.emitEvent("change", { type: "change", ops: [op] });
1698
+ })
1699
+ .catch((error) => {
1700
+ onError?.();
1701
+ this.emitError(error instanceof Error ? error : new Error(String(error)));
1702
+ });
1703
+ this.pending.add(promise);
1704
+ promise.finally(() => this.pending.delete(promise));
1705
+ }
1416
1706
  applyRemotePayload(payload, signerRole) {
1417
1707
  this.clock.observe(payload.stamp);
1418
1708
  if (payload.kind === "ack") {
@@ -1421,11 +1711,15 @@ export class Dacument {
1421
1711
  this.ackByActor.set(payload.iss, payload.patch.seen);
1422
1712
  return true;
1423
1713
  }
1424
- if (!signerRole)
1425
- return false;
1426
1714
  if (payload.kind === "acl.set") {
1715
+ if (!signerRole)
1716
+ return false;
1717
+ if (signerRole === "actor")
1718
+ return this.applyAclPayload(payload, null, { skipAuth: true });
1427
1719
  return this.applyAclPayload(payload, signerRole);
1428
1720
  }
1721
+ if (!signerRole || signerRole === "actor")
1722
+ return false;
1429
1723
  if (!payload.field)
1430
1724
  return false;
1431
1725
  const state = this.fields.get(payload.field);
@@ -1444,18 +1738,23 @@ export class Dacument {
1444
1738
  return false;
1445
1739
  }
1446
1740
  }
1447
- applyAclPayload(payload, signerRole) {
1741
+ applyAclPayload(payload, signerRole, options) {
1448
1742
  if (!isAclPatch(payload.patch))
1449
1743
  return false;
1450
1744
  const patch = payload.patch;
1451
- if (!this.canWriteAclTarget(signerRole, patch.role, patch.target, payload.stamp))
1452
- return false;
1745
+ if (!options?.skipAuth) {
1746
+ if (!signerRole)
1747
+ return false;
1748
+ if (!this.canWriteAclTarget(signerRole, patch.role, patch.target, payload.stamp))
1749
+ return false;
1750
+ }
1453
1751
  const assignment = {
1454
1752
  id: patch.id,
1455
1753
  actorId: patch.target,
1456
1754
  role: patch.role,
1457
1755
  stamp: payload.stamp,
1458
1756
  by: payload.iss,
1757
+ publicKeyJwk: patch.publicKeyJwk,
1459
1758
  };
1460
1759
  const accepted = this.aclLog.merge(assignment);
1461
1760
  if (accepted.length)
@@ -23,4 +23,7 @@ export declare function verifyToken(publicJwk: JsonWebKey, token: string, expect
23
23
  header: SignedHeader;
24
24
  payload: unknown;
25
25
  } | false>;
26
+ export declare function validateActorKeyPair(privateJwk: JsonWebKey, publicJwk: JsonWebKey): Promise<void>;
27
+ export declare function signDetached(privateJwk: JsonWebKey, payload: string): Promise<string>;
28
+ export declare function verifyDetached(publicJwk: JsonWebKey, payload: string, signatureB64: string): Promise<boolean>;
26
29
  export {};
@@ -1,5 +1,6 @@
1
1
  import { Bytes } from "bytecodec";
2
2
  import { SigningAgent, VerificationAgent } from "zeyra";
3
+ const ACTOR_CHALLENGE = Bytes.fromString("dacument-actor-verify");
3
4
  function stableStringify(value) {
4
5
  if (value === null || typeof value !== "object")
5
6
  return JSON.stringify(value);
@@ -16,6 +17,11 @@ function decodePart(part) {
16
17
  const json = Bytes.toString(bytes);
17
18
  return JSON.parse(json);
18
19
  }
20
+ function toArrayBuffer(bytes) {
21
+ const buffer = new ArrayBuffer(bytes.byteLength);
22
+ new Uint8Array(buffer).set(bytes);
23
+ return buffer;
24
+ }
19
25
  export async function signToken(privateJwk, header, payload) {
20
26
  const headerJson = stableStringify(header);
21
27
  const payloadJson = stableStringify(payload);
@@ -59,6 +65,24 @@ export async function verifyToken(publicJwk, token, expectedTyp) {
59
65
  const verifier = new VerificationAgent(publicJwk);
60
66
  const signingInput = Bytes.fromString(`${headerB64}.${payloadB64}`);
61
67
  const signatureBytes = new Uint8Array(signature);
62
- const ok = await verifier.verify(signingInput, signatureBytes.buffer);
68
+ const ok = await verifier.verify(signingInput, toArrayBuffer(signatureBytes));
63
69
  return ok ? { header, payload } : false;
64
70
  }
71
+ export async function validateActorKeyPair(privateJwk, publicJwk) {
72
+ const signer = new SigningAgent(privateJwk);
73
+ const signatureBytes = new Uint8Array(await signer.sign(ACTOR_CHALLENGE));
74
+ const verifier = new VerificationAgent(publicJwk);
75
+ const ok = await verifier.verify(ACTOR_CHALLENGE, toArrayBuffer(signatureBytes));
76
+ if (!ok)
77
+ throw new Error("Dacument.setActorInfo: publicKeyJwk does not match privateKeyJwk");
78
+ }
79
+ export async function signDetached(privateJwk, payload) {
80
+ const signer = new SigningAgent(privateJwk);
81
+ const signature = await signer.sign(Bytes.fromString(payload));
82
+ return Bytes.toBase64UrlString(signature);
83
+ }
84
+ export async function verifyDetached(publicJwk, payload, signatureB64) {
85
+ const verifier = new VerificationAgent(publicJwk);
86
+ const signatureBytes = Bytes.fromBase64UrlString(signatureB64);
87
+ return verifier.verify(Bytes.fromString(payload), toArrayBuffer(signatureBytes));
88
+ }
@@ -27,6 +27,11 @@ export type RolePublicKeys = {
27
27
  manager: JsonWebKey;
28
28
  editor: JsonWebKey;
29
29
  };
30
+ export type ActorInfo = {
31
+ id: string;
32
+ privateKeyJwk: JsonWebKey;
33
+ publicKeyJwk: JsonWebKey;
34
+ };
30
35
  export type RegisterSchema<T extends JsTypeName = JsTypeName> = {
31
36
  crdt: "register";
32
37
  jsType: T;
@@ -77,6 +82,7 @@ export type OpPayload = {
77
82
  };
78
83
  export type SignedOp = {
79
84
  token: string;
85
+ actorSig?: string;
80
86
  };
81
87
  export type DacumentChangeEvent = {
82
88
  type: "change";
@@ -112,6 +118,23 @@ export type AclAssignment = {
112
118
  role: Role;
113
119
  stamp: HLCStamp;
114
120
  by: string;
121
+ publicKeyJwk?: JsonWebKey;
122
+ };
123
+ export type VerifyActorIntegrityOptions = {
124
+ token?: string | SignedOp;
125
+ ops?: Array<string | SignedOp>;
126
+ snapshot?: DocSnapshot;
127
+ };
128
+ export type VerificationFailure = {
129
+ index: number;
130
+ reason: string;
131
+ };
132
+ export type VerificationResult = {
133
+ ok: boolean;
134
+ verified: number;
135
+ failed: number;
136
+ missing: number;
137
+ failures: VerificationFailure[];
115
138
  };
116
139
  export type DocSnapshot = {
117
140
  docId: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dacument",
3
- "version": "1.0.1",
4
- "description": "Schema-driven CRDT document with signed ops and role-based ACLs.",
3
+ "version": "1.1.0",
4
+ "description": "Schema-driven CRDT document with signed ops, role-based ACLs, and optional per-actor verification.",
5
5
  "keywords": [
6
6
  "crdt",
7
7
  "document",