@private.me/xcontinuity 2.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/AGENTS.md +123 -0
- package/LICENSE.md +26 -0
- package/MIGRATING.md +77 -0
- package/README.md +601 -0
- package/dist/adjudicator.d.ts +75 -0
- package/dist/adjudicator.js +184 -0
- package/dist/cascade.d.ts +157 -0
- package/dist/cascade.js +323 -0
- package/dist/chronicle.d.ts +76 -0
- package/dist/chronicle.js +173 -0
- package/dist/cjs/adjudicator.js +189 -0
- package/dist/cjs/cascade.js +328 -0
- package/dist/cjs/chronicle.js +178 -0
- package/dist/cjs/enforcement.js +108 -0
- package/dist/cjs/errors.js +72 -0
- package/dist/cjs/index.js +108 -0
- package/dist/cjs/memory-runtime.js +129 -0
- package/dist/cjs/memory-session.js +134 -0
- package/dist/cjs/mission.js +178 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/provenance.js +192 -0
- package/dist/cjs/ratification.js +322 -0
- package/dist/cjs/reverse-xorida.js +506 -0
- package/dist/cjs/session.js +273 -0
- package/dist/cjs/state-serializer.js +300 -0
- package/dist/cjs/store-memory.js +33 -0
- package/dist/cjs/trust.js +133 -0
- package/dist/cjs/types.js +59 -0
- package/dist/enforcement.d.ts +40 -0
- package/dist/enforcement.js +104 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.js +68 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +43 -0
- package/dist/memory-runtime.d.ts +36 -0
- package/dist/memory-runtime.js +125 -0
- package/dist/memory-session.d.ts +38 -0
- package/dist/memory-session.js +97 -0
- package/dist/mission.d.ts +68 -0
- package/dist/mission.js +172 -0
- package/dist/provenance.d.ts +54 -0
- package/dist/provenance.js +182 -0
- package/dist/ratification.d.ts +113 -0
- package/dist/ratification.js +317 -0
- package/dist/reverse-xorida.d.ts +174 -0
- package/dist/reverse-xorida.js +490 -0
- package/dist/session.d.ts +102 -0
- package/dist/session.js +269 -0
- package/dist/state-serializer.d.ts +37 -0
- package/dist/state-serializer.js +294 -0
- package/dist/store-memory.d.ts +18 -0
- package/dist/store-memory.js +29 -0
- package/dist/trust.d.ts +76 -0
- package/dist/trust.js +121 -0
- package/dist/types.d.ts +320 -0
- package/dist/types.js +56 -0
- package/llms.txt +43 -0
- package/package.json +125 -0
- package/share1.dat +0 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @private.me/xcontinuity — Session Memory Layer
|
|
3
|
+
*
|
|
4
|
+
* Orchestration layer over serializer + reverse-xorida + store.
|
|
5
|
+
* Maintains lastSplitState for incremental updates.
|
|
6
|
+
*/
|
|
7
|
+
import type { Result } from '@private.me/shared';
|
|
8
|
+
import type { AgentState, StateMetadata, StateSnapshot, SplitState, SplitConfig, StateStore } from './types.js';
|
|
9
|
+
import type { ContinuityError } from './errors.js';
|
|
10
|
+
export declare class SessionMemory {
|
|
11
|
+
private lastSplitState;
|
|
12
|
+
private lastSnapshot;
|
|
13
|
+
private readonly store;
|
|
14
|
+
private readonly splitConfig;
|
|
15
|
+
constructor(store: StateStore, splitConfig?: SplitConfig);
|
|
16
|
+
/**
|
|
17
|
+
* Save agent state: serialize, split (incremental if possible), persist to store.
|
|
18
|
+
*
|
|
19
|
+
* @param state - Agent state to persist
|
|
20
|
+
* @param metadata - State metadata
|
|
21
|
+
* @returns The state snapshot (with stateId for later retrieval)
|
|
22
|
+
*/
|
|
23
|
+
save(state: AgentState, metadata: StateMetadata): Promise<Result<StateSnapshot, ContinuityError>>;
|
|
24
|
+
/**
|
|
25
|
+
* Load agent state from store by stateId.
|
|
26
|
+
* If no stateId provided, uses the last saved state.
|
|
27
|
+
*
|
|
28
|
+
* @param stateId - Optional state ID to load (defaults to last saved)
|
|
29
|
+
* @returns Reconstructed AgentState
|
|
30
|
+
*/
|
|
31
|
+
load(stateId?: string): Promise<Result<AgentState, ContinuityError>>;
|
|
32
|
+
/** Get the last split state (for inspection/testing). */
|
|
33
|
+
getLastSplitState(): SplitState | null;
|
|
34
|
+
/** Get the last snapshot (for inspection/testing). */
|
|
35
|
+
getLastSnapshot(): StateSnapshot | null;
|
|
36
|
+
/** Reset internal tracking state. */
|
|
37
|
+
reset(): void;
|
|
38
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @private.me/xcontinuity — Session Memory Layer
|
|
3
|
+
*
|
|
4
|
+
* Orchestration layer over serializer + reverse-xorida + store.
|
|
5
|
+
* Maintains lastSplitState for incremental updates.
|
|
6
|
+
*/
|
|
7
|
+
import { ok, err } from '@private.me/shared';
|
|
8
|
+
import { DEFAULT_SPLIT_CONFIG } from './types.js';
|
|
9
|
+
import { continuityError } from './errors.js';
|
|
10
|
+
import { serializeState, deserializeState } from './state-serializer.js';
|
|
11
|
+
import { splitState, reconstructState, incrementalUpdate } from './reverse-xorida.js';
|
|
12
|
+
export class SessionMemory {
|
|
13
|
+
lastSplitState = null;
|
|
14
|
+
lastSnapshot = null;
|
|
15
|
+
store;
|
|
16
|
+
splitConfig;
|
|
17
|
+
constructor(store, splitConfig = DEFAULT_SPLIT_CONFIG) {
|
|
18
|
+
this.store = store;
|
|
19
|
+
this.splitConfig = splitConfig;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Save agent state: serialize, split (incremental if possible), persist to store.
|
|
23
|
+
*
|
|
24
|
+
* @param state - Agent state to persist
|
|
25
|
+
* @param metadata - State metadata
|
|
26
|
+
* @returns The state snapshot (with stateId for later retrieval)
|
|
27
|
+
*/
|
|
28
|
+
async save(state, metadata) {
|
|
29
|
+
// Serialize
|
|
30
|
+
const serResult = await serializeState(state, metadata);
|
|
31
|
+
if (!serResult.ok)
|
|
32
|
+
return serResult;
|
|
33
|
+
const snapshot = serResult.value;
|
|
34
|
+
// Split (incremental if we have a previous split)
|
|
35
|
+
let splitResult;
|
|
36
|
+
if (this.lastSplitState && this.lastSnapshot) {
|
|
37
|
+
splitResult = await incrementalUpdate(this.lastSplitState, this.lastSnapshot, snapshot);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
splitResult = await splitState(snapshot, this.splitConfig);
|
|
41
|
+
}
|
|
42
|
+
if (!splitResult.ok)
|
|
43
|
+
return splitResult;
|
|
44
|
+
// Persist to store
|
|
45
|
+
await this.store.putShares(splitResult.value);
|
|
46
|
+
// Update tracking
|
|
47
|
+
this.lastSplitState = splitResult.value;
|
|
48
|
+
this.lastSnapshot = snapshot;
|
|
49
|
+
return ok(snapshot);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Load agent state from store by stateId.
|
|
53
|
+
* If no stateId provided, uses the last saved state.
|
|
54
|
+
*
|
|
55
|
+
* @param stateId - Optional state ID to load (defaults to last saved)
|
|
56
|
+
* @returns Reconstructed AgentState
|
|
57
|
+
*/
|
|
58
|
+
async load(stateId) {
|
|
59
|
+
const targetId = stateId ?? this.lastSplitState?.stateId;
|
|
60
|
+
if (!targetId) {
|
|
61
|
+
return err(continuityError('NO_SNAPSHOTS', 'No state ID provided and no previous save'));
|
|
62
|
+
}
|
|
63
|
+
const splitState = await this.store.getShares(targetId);
|
|
64
|
+
if (!splitState) {
|
|
65
|
+
return err(continuityError('SNAPSHOT_NOT_FOUND', `State ${targetId} not found in store`));
|
|
66
|
+
}
|
|
67
|
+
// Reconstruct from shares
|
|
68
|
+
const reconResult = await reconstructState(splitState.shares);
|
|
69
|
+
if (!reconResult.ok)
|
|
70
|
+
return reconResult;
|
|
71
|
+
// Deserialize the snapshot
|
|
72
|
+
const snapshot = {
|
|
73
|
+
stateId: reconResult.value.stateId,
|
|
74
|
+
metadata: splitState.metadata,
|
|
75
|
+
serializedBytes: reconResult.value.serializedBytes,
|
|
76
|
+
checksum: new Uint8Array(0), // Will be recomputed during deserialization
|
|
77
|
+
};
|
|
78
|
+
// Compute fresh checksum for verification
|
|
79
|
+
const { computeChecksum } = await import('./state-serializer.js');
|
|
80
|
+
const freshChecksum = await computeChecksum(reconResult.value.serializedBytes);
|
|
81
|
+
const verifiedSnapshot = { ...snapshot, checksum: freshChecksum };
|
|
82
|
+
return deserializeState(verifiedSnapshot);
|
|
83
|
+
}
|
|
84
|
+
/** Get the last split state (for inspection/testing). */
|
|
85
|
+
getLastSplitState() {
|
|
86
|
+
return this.lastSplitState;
|
|
87
|
+
}
|
|
88
|
+
/** Get the last snapshot (for inspection/testing). */
|
|
89
|
+
getLastSnapshot() {
|
|
90
|
+
return this.lastSnapshot;
|
|
91
|
+
}
|
|
92
|
+
/** Reset internal tracking state. */
|
|
93
|
+
reset() {
|
|
94
|
+
this.lastSplitState = null;
|
|
95
|
+
this.lastSnapshot = null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @private.me/xcontinuity — Mission (Human-Anchored Goals)
|
|
3
|
+
*
|
|
4
|
+
* Ensures AI agents operate within human-defined boundaries.
|
|
5
|
+
*
|
|
6
|
+
* Components:
|
|
7
|
+
* MissionAuthority — validates mission signatures, checks expiry/scope
|
|
8
|
+
* MissionGuard — pre-write check against active mission constraints
|
|
9
|
+
* AlignmentAdjudicator — wraps any Adjudicator, adding mission checks
|
|
10
|
+
*/
|
|
11
|
+
import type { Result } from '@private.me/shared';
|
|
12
|
+
import type { MissionRecord, HardConstraint, ProposedAction, ConstraintResult, MemoryEntry, AdjudicatorResult } from './types.js';
|
|
13
|
+
import type { ContinuityError } from './errors.js';
|
|
14
|
+
import type { Adjudicator } from './adjudicator.js';
|
|
15
|
+
/**
|
|
16
|
+
* Validates mission records and checks authority.
|
|
17
|
+
*/
|
|
18
|
+
export declare class MissionAuthority {
|
|
19
|
+
private activeMission;
|
|
20
|
+
/**
|
|
21
|
+
* Load a mission record after verifying its signature and validity.
|
|
22
|
+
*/
|
|
23
|
+
loadMission(mission: MissionRecord): Promise<Result<void, ContinuityError>>;
|
|
24
|
+
/**
|
|
25
|
+
* Load a mission without signature verification (for testing or trusted sources).
|
|
26
|
+
*/
|
|
27
|
+
loadTrustedMission(mission: MissionRecord): void;
|
|
28
|
+
/** Get the active mission. */
|
|
29
|
+
getActiveMission(): MissionRecord | null;
|
|
30
|
+
/** Clear the active mission. */
|
|
31
|
+
clearMission(): void;
|
|
32
|
+
/**
|
|
33
|
+
* Check if an action falls within the active mission's scopes.
|
|
34
|
+
*/
|
|
35
|
+
isInScope(action: ProposedAction): Result<boolean, ContinuityError>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Pre-write guard that evaluates actions against mission constraints.
|
|
39
|
+
*/
|
|
40
|
+
export declare class MissionGuard {
|
|
41
|
+
private readonly authority;
|
|
42
|
+
private readonly constraints;
|
|
43
|
+
constructor(authority: MissionAuthority);
|
|
44
|
+
/** Add a hard constraint. */
|
|
45
|
+
addConstraint(constraint: HardConstraint): void;
|
|
46
|
+
/** Remove a constraint by ID. */
|
|
47
|
+
removeConstraint(constraintId: string): boolean;
|
|
48
|
+
/** Get all active constraints. */
|
|
49
|
+
getConstraints(): readonly HardConstraint[];
|
|
50
|
+
/**
|
|
51
|
+
* Evaluate a proposed action against all constraints and mission scope.
|
|
52
|
+
*
|
|
53
|
+
* @returns ConstraintResult — allowed:true if all pass, or first violation
|
|
54
|
+
*/
|
|
55
|
+
evaluate(action: ProposedAction): Result<ConstraintResult, ContinuityError>;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Adjudicator wrapper that adds mission constraint checking.
|
|
59
|
+
*
|
|
60
|
+
* Delegates resolution to the inner adjudicator, but rejects
|
|
61
|
+
* any winning entry that violates active mission constraints.
|
|
62
|
+
*/
|
|
63
|
+
export declare class AlignmentAdjudicator implements Adjudicator {
|
|
64
|
+
private readonly inner;
|
|
65
|
+
private readonly guard;
|
|
66
|
+
constructor(inner: Adjudicator, guard: MissionGuard);
|
|
67
|
+
resolve(key: string, candidates: readonly MemoryEntry[]): Result<AdjudicatorResult, ContinuityError>;
|
|
68
|
+
}
|
package/dist/mission.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @private.me/xcontinuity — Mission (Human-Anchored Goals)
|
|
3
|
+
*
|
|
4
|
+
* Ensures AI agents operate within human-defined boundaries.
|
|
5
|
+
*
|
|
6
|
+
* Components:
|
|
7
|
+
* MissionAuthority — validates mission signatures, checks expiry/scope
|
|
8
|
+
* MissionGuard — pre-write check against active mission constraints
|
|
9
|
+
* AlignmentAdjudicator — wraps any Adjudicator, adding mission checks
|
|
10
|
+
*/
|
|
11
|
+
import { ok, err } from '@private.me/shared';
|
|
12
|
+
import { continuityError } from './errors.js';
|
|
13
|
+
import { authorRefToPublicKey } from './provenance.js';
|
|
14
|
+
/**
|
|
15
|
+
* Validates mission records and checks authority.
|
|
16
|
+
*/
|
|
17
|
+
export class MissionAuthority {
|
|
18
|
+
activeMission = null;
|
|
19
|
+
/**
|
|
20
|
+
* Load a mission record after verifying its signature and validity.
|
|
21
|
+
*/
|
|
22
|
+
async loadMission(mission) {
|
|
23
|
+
// Check expiry
|
|
24
|
+
if (mission.expiresAt !== undefined && Date.now() > mission.expiresAt) {
|
|
25
|
+
return err(continuityError('AUTHORITY_EXPIRED', `Mission ${mission.missionId} has expired`));
|
|
26
|
+
}
|
|
27
|
+
// Verify signature
|
|
28
|
+
try {
|
|
29
|
+
const publicKey = await authorRefToPublicKey(mission.authority);
|
|
30
|
+
const encoder = new TextEncoder();
|
|
31
|
+
const missionBytes = encoder.encode(canonicalMissionString(mission));
|
|
32
|
+
const valid = await crypto.subtle.verify('Ed25519', publicKey, mission.signature.buffer, missionBytes.buffer);
|
|
33
|
+
if (!valid) {
|
|
34
|
+
return err(continuityError('INVALID_MISSION_SIGNATURE', `Mission ${mission.missionId} signature invalid`));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
return err(continuityError('INVALID_MISSION_SIGNATURE', `Signature verification failed: ${e instanceof Error ? e.message : String(e)}`));
|
|
39
|
+
}
|
|
40
|
+
this.activeMission = mission;
|
|
41
|
+
return ok(undefined);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Load a mission without signature verification (for testing or trusted sources).
|
|
45
|
+
*/
|
|
46
|
+
loadTrustedMission(mission) {
|
|
47
|
+
this.activeMission = mission;
|
|
48
|
+
}
|
|
49
|
+
/** Get the active mission. */
|
|
50
|
+
getActiveMission() {
|
|
51
|
+
return this.activeMission;
|
|
52
|
+
}
|
|
53
|
+
/** Clear the active mission. */
|
|
54
|
+
clearMission() {
|
|
55
|
+
this.activeMission = null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Check if an action falls within the active mission's scopes.
|
|
59
|
+
*/
|
|
60
|
+
isInScope(action) {
|
|
61
|
+
if (!this.activeMission) {
|
|
62
|
+
return err(continuityError('NO_ACTIVE_MISSION', 'No mission loaded'));
|
|
63
|
+
}
|
|
64
|
+
// Check expiry
|
|
65
|
+
if (this.activeMission.expiresAt !== undefined && Date.now() > this.activeMission.expiresAt) {
|
|
66
|
+
return err(continuityError('AUTHORITY_EXPIRED', `Mission ${this.activeMission.missionId} has expired`));
|
|
67
|
+
}
|
|
68
|
+
// Empty scopes = all actions allowed
|
|
69
|
+
if (this.activeMission.scopes.length === 0) {
|
|
70
|
+
return ok(true);
|
|
71
|
+
}
|
|
72
|
+
// Check if action type or key matches any scope
|
|
73
|
+
const actionScope = `${action.type}:${action.key}`;
|
|
74
|
+
const inScope = this.activeMission.scopes.some(scope => actionScope.startsWith(scope) || action.type === scope || scope === '*');
|
|
75
|
+
return ok(inScope);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Pre-write guard that evaluates actions against mission constraints.
|
|
80
|
+
*/
|
|
81
|
+
export class MissionGuard {
|
|
82
|
+
authority;
|
|
83
|
+
constraints = [];
|
|
84
|
+
constructor(authority) {
|
|
85
|
+
this.authority = authority;
|
|
86
|
+
}
|
|
87
|
+
/** Add a hard constraint. */
|
|
88
|
+
addConstraint(constraint) {
|
|
89
|
+
this.constraints.push(constraint);
|
|
90
|
+
}
|
|
91
|
+
/** Remove a constraint by ID. */
|
|
92
|
+
removeConstraint(constraintId) {
|
|
93
|
+
const idx = this.constraints.findIndex(c => c.constraintId === constraintId);
|
|
94
|
+
if (idx === -1)
|
|
95
|
+
return false;
|
|
96
|
+
this.constraints.splice(idx, 1);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
/** Get all active constraints. */
|
|
100
|
+
getConstraints() {
|
|
101
|
+
return this.constraints.slice();
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Evaluate a proposed action against all constraints and mission scope.
|
|
105
|
+
*
|
|
106
|
+
* @returns ConstraintResult — allowed:true if all pass, or first violation
|
|
107
|
+
*/
|
|
108
|
+
evaluate(action) {
|
|
109
|
+
// Check mission scope first
|
|
110
|
+
const scopeResult = this.authority.isInScope(action);
|
|
111
|
+
if (!scopeResult.ok)
|
|
112
|
+
return scopeResult;
|
|
113
|
+
if (!scopeResult.value) {
|
|
114
|
+
return ok({
|
|
115
|
+
allowed: false,
|
|
116
|
+
reason: `Action "${action.type}:${action.key}" is outside mission scope`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
// Evaluate each constraint
|
|
120
|
+
for (const constraint of this.constraints) {
|
|
121
|
+
const result = constraint.evaluate(action);
|
|
122
|
+
if (!result.allowed) {
|
|
123
|
+
return ok(result);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return ok({ allowed: true });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Adjudicator wrapper that adds mission constraint checking.
|
|
131
|
+
*
|
|
132
|
+
* Delegates resolution to the inner adjudicator, but rejects
|
|
133
|
+
* any winning entry that violates active mission constraints.
|
|
134
|
+
*/
|
|
135
|
+
export class AlignmentAdjudicator {
|
|
136
|
+
inner;
|
|
137
|
+
guard;
|
|
138
|
+
constructor(inner, guard) {
|
|
139
|
+
this.inner = inner;
|
|
140
|
+
this.guard = guard;
|
|
141
|
+
}
|
|
142
|
+
resolve(key, candidates) {
|
|
143
|
+
const result = this.inner.resolve(key, candidates);
|
|
144
|
+
if (!result.ok)
|
|
145
|
+
return result;
|
|
146
|
+
// Check if winning entry passes mission constraints
|
|
147
|
+
const action = {
|
|
148
|
+
author: result.value.winner.provenance?.author ?? '',
|
|
149
|
+
type: 'write',
|
|
150
|
+
key,
|
|
151
|
+
value: result.value.winner.value,
|
|
152
|
+
};
|
|
153
|
+
const guardResult = this.guard.evaluate(action);
|
|
154
|
+
if (!guardResult.ok)
|
|
155
|
+
return guardResult;
|
|
156
|
+
if (!guardResult.value.allowed) {
|
|
157
|
+
return err(continuityError('CONSTRAINT_VIOLATION', `Winner for "${key}" violates constraint: ${guardResult.value.reason}`));
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/* ── Internal helpers ── */
|
|
163
|
+
function canonicalMissionString(mission) {
|
|
164
|
+
return [
|
|
165
|
+
mission.missionId,
|
|
166
|
+
mission.goal,
|
|
167
|
+
mission.authority,
|
|
168
|
+
String(mission.issuedAt),
|
|
169
|
+
String(mission.expiresAt ?? ''),
|
|
170
|
+
[...mission.scopes].sort().join(','),
|
|
171
|
+
].join('\0');
|
|
172
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @private.me/xcontinuity — Provenance (Ed25519 Signed Entries)
|
|
3
|
+
*
|
|
4
|
+
* Cryptographic provenance for chronicle entries using Ed25519 signatures
|
|
5
|
+
* via the Web Crypto API. Each entry can be signed by an agent, creating
|
|
6
|
+
* a verifiable chain of authorship and integrity.
|
|
7
|
+
*
|
|
8
|
+
* Uses native Web Crypto EdDSA (Node.js 16.9+, modern browsers).
|
|
9
|
+
* Zero external dependencies.
|
|
10
|
+
*/
|
|
11
|
+
import type { Result } from '@private.me/shared';
|
|
12
|
+
import type { AuthorRef, MemoryEntry, ProvenanceRecord, StateValue } from './types.js';
|
|
13
|
+
import type { ContinuityError } from './errors.js';
|
|
14
|
+
/** Ed25519 key pair for signing and verification. */
|
|
15
|
+
export interface Ed25519KeyPair {
|
|
16
|
+
readonly publicKey: CryptoKey;
|
|
17
|
+
readonly privateKey: CryptoKey;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Generate a new Ed25519 key pair for provenance signing.
|
|
21
|
+
*/
|
|
22
|
+
export declare function generateSigningKeyPair(): Promise<Ed25519KeyPair>;
|
|
23
|
+
/**
|
|
24
|
+
* Export the public key as an AuthorRef (base64url-encoded 32-byte key).
|
|
25
|
+
*/
|
|
26
|
+
export declare function publicKeyToAuthorRef(publicKey: CryptoKey): Promise<AuthorRef>;
|
|
27
|
+
/**
|
|
28
|
+
* Import an AuthorRef back to a CryptoKey for verification.
|
|
29
|
+
*/
|
|
30
|
+
export declare function authorRefToPublicKey(authorRef: AuthorRef): Promise<CryptoKey>;
|
|
31
|
+
/**
|
|
32
|
+
* Produce canonical bytes for a memory entry, suitable for signing.
|
|
33
|
+
*
|
|
34
|
+
* Deterministic serialization: sorted keys, UTF-8 encoded.
|
|
35
|
+
* Format: `key\0type\0valueBytes\0timestamp`
|
|
36
|
+
*/
|
|
37
|
+
export declare function canonicalEntryBytes(key: string, value: StateValue, timestamp: number, parentHash?: Uint8Array): Uint8Array;
|
|
38
|
+
/**
|
|
39
|
+
* Sign a memory entry, producing a ProvenanceRecord.
|
|
40
|
+
*/
|
|
41
|
+
export declare function signEntry(key: string, value: StateValue, privateKey: CryptoKey, publicKey: CryptoKey, parentHash?: Uint8Array): Promise<Result<ProvenanceRecord, ContinuityError>>;
|
|
42
|
+
/**
|
|
43
|
+
* Verify a provenance record against the entry data and author's public key.
|
|
44
|
+
*/
|
|
45
|
+
export declare function verifyEntry(key: string, value: StateValue, provenance: ProvenanceRecord, publicKey: CryptoKey): Promise<Result<boolean, ContinuityError>>;
|
|
46
|
+
/**
|
|
47
|
+
* Compute the SHA-256 hash of a provenance record for chain linking.
|
|
48
|
+
*/
|
|
49
|
+
export declare function hashProvenance(provenance: ProvenanceRecord): Promise<Uint8Array>;
|
|
50
|
+
/**
|
|
51
|
+
* Verify a parent hash chain link: the parentHash in the current entry
|
|
52
|
+
* must equal the hash of the previous entry's provenance.
|
|
53
|
+
*/
|
|
54
|
+
export declare function verifyChainLink(current: MemoryEntry, previous: MemoryEntry): Promise<Result<boolean, ContinuityError>>;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @private.me/xcontinuity — Provenance (Ed25519 Signed Entries)
|
|
3
|
+
*
|
|
4
|
+
* Cryptographic provenance for chronicle entries using Ed25519 signatures
|
|
5
|
+
* via the Web Crypto API. Each entry can be signed by an agent, creating
|
|
6
|
+
* a verifiable chain of authorship and integrity.
|
|
7
|
+
*
|
|
8
|
+
* Uses native Web Crypto EdDSA (Node.js 16.9+, modern browsers).
|
|
9
|
+
* Zero external dependencies.
|
|
10
|
+
*/
|
|
11
|
+
import { ok, err } from '@private.me/shared';
|
|
12
|
+
import { continuityError } from './errors.js';
|
|
13
|
+
/**
|
|
14
|
+
* Generate a new Ed25519 key pair for provenance signing.
|
|
15
|
+
*/
|
|
16
|
+
export async function generateSigningKeyPair() {
|
|
17
|
+
const keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']);
|
|
18
|
+
return {
|
|
19
|
+
publicKey: keyPair.publicKey,
|
|
20
|
+
privateKey: keyPair.privateKey,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Export the public key as an AuthorRef (base64url-encoded 32-byte key).
|
|
25
|
+
*/
|
|
26
|
+
export async function publicKeyToAuthorRef(publicKey) {
|
|
27
|
+
const raw = await crypto.subtle.exportKey('raw', publicKey);
|
|
28
|
+
return uint8ToBase64Url(new Uint8Array(raw));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Import an AuthorRef back to a CryptoKey for verification.
|
|
32
|
+
*/
|
|
33
|
+
export async function authorRefToPublicKey(authorRef) {
|
|
34
|
+
const raw = base64UrlToUint8(authorRef);
|
|
35
|
+
return crypto.subtle.importKey('raw', raw.buffer, 'Ed25519', true, ['verify']);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Produce canonical bytes for a memory entry, suitable for signing.
|
|
39
|
+
*
|
|
40
|
+
* Deterministic serialization: sorted keys, UTF-8 encoded.
|
|
41
|
+
* Format: `key\0type\0valueBytes\0timestamp`
|
|
42
|
+
*/
|
|
43
|
+
export function canonicalEntryBytes(key, value, timestamp, parentHash) {
|
|
44
|
+
const encoder = new TextEncoder();
|
|
45
|
+
const keyBytes = encoder.encode(key);
|
|
46
|
+
const valueStr = canonicalValueString(value);
|
|
47
|
+
const valueBytes = encoder.encode(valueStr);
|
|
48
|
+
const tsBytes = encoder.encode(String(timestamp));
|
|
49
|
+
// Total: key + \0 + value + \0 + timestamp [+ \0 + parentHash]
|
|
50
|
+
const parentLen = parentHash ? 1 + parentHash.length : 0;
|
|
51
|
+
const total = keyBytes.length + 1 + valueBytes.length + 1 + tsBytes.length + parentLen;
|
|
52
|
+
const buf = new Uint8Array(total);
|
|
53
|
+
let offset = 0;
|
|
54
|
+
buf.set(keyBytes, offset);
|
|
55
|
+
offset += keyBytes.length;
|
|
56
|
+
buf[offset++] = 0;
|
|
57
|
+
buf.set(valueBytes, offset);
|
|
58
|
+
offset += valueBytes.length;
|
|
59
|
+
buf[offset++] = 0;
|
|
60
|
+
buf.set(tsBytes, offset);
|
|
61
|
+
offset += tsBytes.length;
|
|
62
|
+
if (parentHash) {
|
|
63
|
+
buf[offset++] = 0;
|
|
64
|
+
buf.set(parentHash, offset);
|
|
65
|
+
}
|
|
66
|
+
return buf;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Sign a memory entry, producing a ProvenanceRecord.
|
|
70
|
+
*/
|
|
71
|
+
export async function signEntry(key, value, privateKey, publicKey, parentHash) {
|
|
72
|
+
try {
|
|
73
|
+
const timestamp = Date.now();
|
|
74
|
+
const canonical = canonicalEntryBytes(key, value, timestamp, parentHash);
|
|
75
|
+
const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', privateKey, canonical.buffer));
|
|
76
|
+
const author = await publicKeyToAuthorRef(publicKey);
|
|
77
|
+
return ok({
|
|
78
|
+
author,
|
|
79
|
+
timestamp,
|
|
80
|
+
signature,
|
|
81
|
+
parentHash,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
return err(continuityError('INVALID_SIGNATURE', `Failed to sign entry: ${e instanceof Error ? e.message : String(e)}`));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Verify a provenance record against the entry data and author's public key.
|
|
90
|
+
*/
|
|
91
|
+
export async function verifyEntry(key, value, provenance, publicKey) {
|
|
92
|
+
try {
|
|
93
|
+
if (!provenance.author) {
|
|
94
|
+
return err(continuityError('MISSING_AUTHOR', 'Provenance record has no author'));
|
|
95
|
+
}
|
|
96
|
+
const canonical = canonicalEntryBytes(key, value, provenance.timestamp, provenance.parentHash);
|
|
97
|
+
const valid = await crypto.subtle.verify('Ed25519', publicKey, provenance.signature.buffer, canonical.buffer);
|
|
98
|
+
return ok(valid);
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
return err(continuityError('INVALID_SIGNATURE', `Verification failed: ${e instanceof Error ? e.message : String(e)}`));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Compute the SHA-256 hash of a provenance record for chain linking.
|
|
106
|
+
*/
|
|
107
|
+
export async function hashProvenance(provenance) {
|
|
108
|
+
const encoder = new TextEncoder();
|
|
109
|
+
const authorBytes = encoder.encode(provenance.author);
|
|
110
|
+
const tsBytes = encoder.encode(String(provenance.timestamp));
|
|
111
|
+
const parentLen = provenance.parentHash ? provenance.parentHash.length : 0;
|
|
112
|
+
const total = authorBytes.length + 1 + tsBytes.length + 1 + provenance.signature.length + parentLen;
|
|
113
|
+
const buf = new Uint8Array(total);
|
|
114
|
+
let offset = 0;
|
|
115
|
+
buf.set(authorBytes, offset);
|
|
116
|
+
offset += authorBytes.length;
|
|
117
|
+
buf[offset++] = 0;
|
|
118
|
+
buf.set(tsBytes, offset);
|
|
119
|
+
offset += tsBytes.length;
|
|
120
|
+
buf[offset++] = 0;
|
|
121
|
+
buf.set(provenance.signature, offset);
|
|
122
|
+
offset += provenance.signature.length;
|
|
123
|
+
if (provenance.parentHash) {
|
|
124
|
+
buf.set(provenance.parentHash, offset);
|
|
125
|
+
}
|
|
126
|
+
const hash = await crypto.subtle.digest('SHA-256', buf.buffer);
|
|
127
|
+
return new Uint8Array(hash);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Verify a parent hash chain link: the parentHash in the current entry
|
|
131
|
+
* must equal the hash of the previous entry's provenance.
|
|
132
|
+
*/
|
|
133
|
+
export async function verifyChainLink(current, previous) {
|
|
134
|
+
if (!current.provenance?.parentHash) {
|
|
135
|
+
return ok(true); // No chain link to verify (root entry)
|
|
136
|
+
}
|
|
137
|
+
if (!previous.provenance) {
|
|
138
|
+
return err(continuityError('HASH_CHAIN_BREAK', `Previous entry "${previous.key}" has no provenance to hash`));
|
|
139
|
+
}
|
|
140
|
+
const expectedHash = await hashProvenance(previous.provenance);
|
|
141
|
+
const matches = constantTimeEqual(current.provenance.parentHash, expectedHash);
|
|
142
|
+
if (!matches) {
|
|
143
|
+
return err(continuityError('HASH_CHAIN_BREAK', `Parent hash mismatch for entry "${current.key}"`));
|
|
144
|
+
}
|
|
145
|
+
return ok(true);
|
|
146
|
+
}
|
|
147
|
+
/* ── Internal helpers ── */
|
|
148
|
+
function canonicalValueString(value) {
|
|
149
|
+
if (value === null)
|
|
150
|
+
return 'null';
|
|
151
|
+
if (value instanceof Uint8Array)
|
|
152
|
+
return `bytes:${uint8ToBase64Url(value)}`;
|
|
153
|
+
if (typeof value === 'object')
|
|
154
|
+
return JSON.stringify(value, Object.keys(value).sort());
|
|
155
|
+
return String(value);
|
|
156
|
+
}
|
|
157
|
+
function uint8ToBase64Url(data) {
|
|
158
|
+
let binary = '';
|
|
159
|
+
for (let i = 0; i < data.length; i++) {
|
|
160
|
+
binary += String.fromCharCode(data[i]);
|
|
161
|
+
}
|
|
162
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
163
|
+
}
|
|
164
|
+
function base64UrlToUint8(b64url) {
|
|
165
|
+
const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
166
|
+
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
|
|
167
|
+
const binary = atob(padded);
|
|
168
|
+
const bytes = new Uint8Array(binary.length);
|
|
169
|
+
for (let i = 0; i < binary.length; i++) {
|
|
170
|
+
bytes[i] = binary.charCodeAt(i);
|
|
171
|
+
}
|
|
172
|
+
return bytes;
|
|
173
|
+
}
|
|
174
|
+
function constantTimeEqual(a, b) {
|
|
175
|
+
if (a.length !== b.length)
|
|
176
|
+
return false;
|
|
177
|
+
let diff = 0;
|
|
178
|
+
for (let i = 0; i < a.length; i++) {
|
|
179
|
+
diff |= a[i] ^ b[i];
|
|
180
|
+
}
|
|
181
|
+
return diff === 0;
|
|
182
|
+
}
|