cojson 0.0.1 → 0.0.2

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/src/crypto.ts ADDED
@@ -0,0 +1,297 @@
1
+ import { ed25519, x25519 } from "@noble/curves/ed25519";
2
+ import { xsalsa20_poly1305, xsalsa20 } from "@noble/ciphers/salsa";
3
+ import { JsonValue } from "./jsonValue";
4
+ import { base58, base64url } from "@scure/base";
5
+ import { default as stableStringify } from "fast-json-stable-stringify";
6
+ import { blake3 } from "@noble/hashes/blake3";
7
+ import { randomBytes } from "@noble/ciphers/webcrypto/utils";
8
+ import { RawCoValueID, SessionID, TransactionID } from "./coValue";
9
+
10
+ export type SignatorySecret = `signatorySecret_z${string}`;
11
+ export type SignatoryID = `signatory_z${string}`;
12
+ export type Signature = `signature_z${string}`;
13
+
14
+ export type RecipientSecret = `recipientSecret_z${string}`;
15
+ export type RecipientID = `recipient_z${string}`;
16
+ export type Sealed<T> = `sealed_U${string}` & { __type: T };
17
+
18
+ const textEncoder = new TextEncoder();
19
+ const textDecoder = new TextDecoder();
20
+
21
+ export function newRandomSignatory(): SignatorySecret {
22
+ return `signatorySecret_z${base58.encode(
23
+ ed25519.utils.randomPrivateKey()
24
+ )}`;
25
+ }
26
+
27
+ export function getSignatoryID(secret: SignatorySecret): SignatoryID {
28
+ return `signatory_z${base58.encode(
29
+ ed25519.getPublicKey(
30
+ base58.decode(secret.substring("signatorySecret_z".length))
31
+ )
32
+ )}`;
33
+ }
34
+
35
+ export function sign(secret: SignatorySecret, message: JsonValue): Signature {
36
+ const signature = ed25519.sign(
37
+ textEncoder.encode(stableStringify(message)),
38
+ base58.decode(secret.substring("signatorySecret_z".length))
39
+ );
40
+ return `signature_z${base58.encode(signature)}`;
41
+ }
42
+
43
+ export function verify(
44
+ signature: Signature,
45
+ message: JsonValue,
46
+ id: SignatoryID
47
+ ): boolean {
48
+ return ed25519.verify(
49
+ base58.decode(signature.substring("signature_z".length)),
50
+ textEncoder.encode(stableStringify(message)),
51
+ base58.decode(id.substring("signatory_z".length))
52
+ );
53
+ }
54
+
55
+ export function newRandomRecipient(): RecipientSecret {
56
+ return `recipientSecret_z${base58.encode(x25519.utils.randomPrivateKey())}`;
57
+ }
58
+
59
+ export function getRecipientID(secret: RecipientSecret): RecipientID {
60
+ return `recipient_z${base58.encode(
61
+ x25519.getPublicKey(
62
+ base58.decode(secret.substring("recipientSecret_z".length))
63
+ )
64
+ )}`;
65
+ }
66
+
67
+ export type SealedSet<T> = {
68
+ [recipient: RecipientID]: Sealed<T>;
69
+ };
70
+
71
+ export function seal<T extends JsonValue>(
72
+ message: T,
73
+ from: RecipientSecret,
74
+ to: Set<RecipientID>,
75
+ nOnceMaterial: { in: RawCoValueID; tx: TransactionID }
76
+ ): SealedSet<T> {
77
+ const nOnce = blake3(
78
+ textEncoder.encode(stableStringify(nOnceMaterial))
79
+ ).slice(0, 24);
80
+
81
+ const recipientsSorted = Array.from(to).sort();
82
+ const recipientPubs = recipientsSorted.map((recipient) => {
83
+ return base58.decode(recipient.substring("recipient_z".length));
84
+ });
85
+ const senderPriv = base58.decode(
86
+ from.substring("recipientSecret_z".length)
87
+ );
88
+
89
+ const plaintext = textEncoder.encode(stableStringify(message));
90
+
91
+ const sealedSet: SealedSet<T> = {};
92
+
93
+ for (let i = 0; i < recipientsSorted.length; i++) {
94
+ const recipient = recipientsSorted[i]!;
95
+ const sharedSecret = x25519.getSharedSecret(
96
+ senderPriv,
97
+ recipientPubs[i]!
98
+ );
99
+
100
+ const sealedBytes = xsalsa20_poly1305(sharedSecret, nOnce).encrypt(
101
+ plaintext
102
+ );
103
+
104
+ sealedSet[recipient] = `sealed_U${base64url.encode(
105
+ sealedBytes
106
+ )}` as Sealed<T>;
107
+ }
108
+
109
+ return sealedSet;
110
+ }
111
+
112
+ export function openAs<T extends JsonValue>(
113
+ sealedSet: SealedSet<T>,
114
+ recipient: RecipientSecret,
115
+ from: RecipientID,
116
+ nOnceMaterial: { in: RawCoValueID; tx: TransactionID }
117
+ ): T | undefined {
118
+ const nOnce = blake3(
119
+ textEncoder.encode(stableStringify(nOnceMaterial))
120
+ ).slice(0, 24);
121
+
122
+ const recipientPriv = base58.decode(
123
+ recipient.substring("recipientSecret_z".length)
124
+ );
125
+
126
+ const senderPub = base58.decode(from.substring("recipient_z".length));
127
+
128
+ const sealed = sealedSet[getRecipientID(recipient)];
129
+
130
+ if (!sealed) {
131
+ return undefined;
132
+ }
133
+
134
+ const sealedBytes = base64url.decode(sealed.substring("sealed_U".length));
135
+
136
+ const sharedSecret = x25519.getSharedSecret(recipientPriv, senderPub);
137
+
138
+ const plaintext = xsalsa20_poly1305(sharedSecret, nOnce).decrypt(
139
+ sealedBytes
140
+ );
141
+
142
+ try {
143
+ return JSON.parse(textDecoder.decode(plaintext));
144
+ } catch (e) {
145
+ console.error("Failed to decrypt/parse sealed message", e);
146
+ return undefined;
147
+ }
148
+ }
149
+
150
+ export type Hash = `hash_z${string}`;
151
+
152
+ export function secureHash(value: JsonValue): Hash {
153
+ return `hash_z${base58.encode(
154
+ blake3(textEncoder.encode(stableStringify(value)))
155
+ )}`;
156
+ }
157
+
158
+ export class StreamingHash {
159
+ state: ReturnType<typeof blake3.create>;
160
+
161
+ constructor(fromClone?: ReturnType<typeof blake3.create>) {
162
+ this.state = fromClone || blake3.create({});
163
+ }
164
+
165
+ update(value: JsonValue) {
166
+ this.state.update(textEncoder.encode(stableStringify(value)));
167
+ }
168
+
169
+ digest(): Hash {
170
+ const hash = this.state.digest();
171
+ return `hash_z${base58.encode(hash)}`;
172
+ }
173
+
174
+ clone(): StreamingHash {
175
+ return new StreamingHash(this.state.clone());
176
+ }
177
+ }
178
+
179
+ export type ShortHash = `shortHash_z${string}`;
180
+
181
+ export function shortHash(value: JsonValue): ShortHash {
182
+ return `shortHash_z${base58.encode(
183
+ blake3(textEncoder.encode(stableStringify(value))).slice(0, 19)
184
+ )}`;
185
+ }
186
+
187
+ export type Encrypted<
188
+ T extends JsonValue,
189
+ N extends JsonValue
190
+ > = `encrypted_U${string}` & { __type: T; __nOnceMaterial: N };
191
+
192
+ export type KeySecret = `keySecret_z${string}`;
193
+ export type KeyID = `key_z${string}`;
194
+
195
+ export function newRandomKeySecret(): { secret: KeySecret; id: KeyID } {
196
+ return {
197
+ secret: `keySecret_z${base58.encode(randomBytes(32))}`,
198
+ id: `key_z${base58.encode(randomBytes(12))}`,
199
+ };
200
+ }
201
+
202
+ function encrypt<T extends JsonValue, N extends JsonValue>(
203
+ value: T,
204
+ keySecret: KeySecret,
205
+ nOnceMaterial: N
206
+ ): Encrypted<T, N> {
207
+ const keySecretBytes = base58.decode(
208
+ keySecret.substring("keySecret_z".length)
209
+ );
210
+ const nOnce = blake3(
211
+ textEncoder.encode(stableStringify(nOnceMaterial))
212
+ ).slice(0, 24);
213
+
214
+ const plaintext = textEncoder.encode(stableStringify(value));
215
+ const ciphertext = xsalsa20(keySecretBytes, nOnce, plaintext);
216
+ return `encrypted_U${base64url.encode(ciphertext)}` as Encrypted<T, N>;
217
+ }
218
+
219
+ export function encryptForTransaction<T extends JsonValue>(
220
+ value: T,
221
+ keySecret: KeySecret,
222
+ nOnceMaterial: { in: RawCoValueID; tx: TransactionID }
223
+ ): Encrypted<T, { in: RawCoValueID; tx: TransactionID }> {
224
+ return encrypt(value, keySecret, nOnceMaterial);
225
+ }
226
+
227
+ export function sealKeySecret(keys: {
228
+ toSeal: { id: KeyID; secret: KeySecret };
229
+ sealing: { id: KeyID; secret: KeySecret };
230
+ }): {
231
+ sealed: KeyID;
232
+ sealing: KeyID;
233
+ encrypted: Encrypted<KeySecret, { sealed: KeyID; sealing: KeyID }>;
234
+ } {
235
+ const nOnceMaterial = {
236
+ sealed: keys.toSeal.id,
237
+ sealing: keys.sealing.id,
238
+ };
239
+
240
+ return {
241
+ sealed: keys.toSeal.id,
242
+ sealing: keys.sealing.id,
243
+ encrypted: encrypt(
244
+ keys.toSeal.secret,
245
+ keys.sealing.secret,
246
+ nOnceMaterial
247
+ ),
248
+ };
249
+ }
250
+
251
+ function decrypt<T extends JsonValue, N extends JsonValue>(
252
+ encrypted: Encrypted<T, N>,
253
+ keySecret: KeySecret,
254
+ nOnceMaterial: N
255
+ ): T | undefined {
256
+ const keySecretBytes = base58.decode(
257
+ keySecret.substring("keySecret_z".length)
258
+ );
259
+ const nOnce = blake3(
260
+ textEncoder.encode(stableStringify(nOnceMaterial))
261
+ ).slice(0, 24);
262
+
263
+ const ciphertext = base64url.decode(
264
+ encrypted.substring("encrypted_U".length)
265
+ );
266
+ const plaintext = xsalsa20(keySecretBytes, nOnce, ciphertext);
267
+
268
+ try {
269
+ return JSON.parse(textDecoder.decode(plaintext));
270
+ } catch (e) {
271
+ return undefined;
272
+ }
273
+ }
274
+
275
+ export function decryptForTransaction<T extends JsonValue>(
276
+ encrypted: Encrypted<T, { in: RawCoValueID; tx: TransactionID }>,
277
+ keySecret: KeySecret,
278
+ nOnceMaterial: { in: RawCoValueID; tx: TransactionID }
279
+ ): T | undefined {
280
+ return decrypt(encrypted, keySecret, nOnceMaterial);
281
+ }
282
+
283
+ export function unsealKeySecret(
284
+ sealedInfo: {
285
+ sealed: KeyID;
286
+ sealing: KeyID;
287
+ encrypted: Encrypted<KeySecret, { sealed: KeyID; sealing: KeyID }>;
288
+ },
289
+ sealingSecret: KeySecret
290
+ ): KeySecret | undefined {
291
+ const nOnceMaterial = {
292
+ sealed: sealedInfo.sealed,
293
+ sealing: sealedInfo.sealing,
294
+ };
295
+
296
+ return decrypt(sealedInfo.encrypted, sealingSecret, nOnceMaterial);
297
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { ContentType } from "./contentType";
2
+ import { JsonValue } from "./jsonValue";
3
+ import { CoValue } from "./coValue";
4
+ import { LocalNode } from "./node";
5
+
6
+ type Value = JsonValue | ContentType;
7
+
8
+ export {
9
+ JsonValue,
10
+ ContentType,
11
+ Value,
12
+ LocalNode,
13
+ CoValue
14
+ }
@@ -0,0 +1,6 @@
1
+ import { CoValueID, ContentType } from "./contentType";
2
+
3
+ export type JsonAtom = string | number | boolean | null;
4
+ export type JsonValue = JsonAtom | JsonArray | JsonObject | CoValueID<ContentType>;
5
+ export type JsonArray = JsonValue[];
6
+ export type JsonObject = { [key: string]: JsonValue; };
package/src/node.ts ADDED
@@ -0,0 +1,193 @@
1
+ import { newRandomKeySecret, seal } from "./crypto";
2
+ import {
3
+ RawCoValueID,
4
+ CoValue,
5
+ AgentCredential,
6
+ AgentID,
7
+ SessionID,
8
+ Agent,
9
+ getAgent,
10
+ getAgentID,
11
+ getAgentCoValueHeader,
12
+ CoValueHeader,
13
+ agentIDfromSessionID,
14
+ newRandomAgentCredential,
15
+ } from "./coValue";
16
+ import { Team, expectTeamContent } from "./permissions";
17
+ import { SyncManager } from "./sync";
18
+
19
+ export class LocalNode {
20
+ coValues: { [key: RawCoValueID]: CoValueState } = {};
21
+ agentCredential: AgentCredential;
22
+ agentID: AgentID;
23
+ ownSessionID: SessionID;
24
+ sync = new SyncManager(this);
25
+
26
+ constructor(agentCredential: AgentCredential, ownSessionID: SessionID) {
27
+ this.agentCredential = agentCredential;
28
+ const agent = getAgent(agentCredential);
29
+ const agentID = getAgentID(agent);
30
+ this.agentID = agentID;
31
+ this.ownSessionID = ownSessionID;
32
+
33
+ const agentCoValue = new CoValue(getAgentCoValueHeader(agent), this);
34
+ this.coValues[agentCoValue.id] = {
35
+ state: "loaded",
36
+ coValue: agentCoValue,
37
+ };
38
+ }
39
+
40
+ createCoValue(header: CoValueHeader): CoValue {
41
+ const coValue = new CoValue(header, this);
42
+ this.coValues[coValue.id] = { state: "loaded", coValue: coValue };
43
+
44
+ void this.sync.syncCoValue(coValue);
45
+
46
+ return coValue;
47
+ }
48
+
49
+ loadCoValue(id: RawCoValueID): Promise<CoValue> {
50
+ let entry = this.coValues[id];
51
+ if (!entry) {
52
+ entry = newLoadingState();
53
+
54
+ this.coValues[id] = entry;
55
+
56
+ this.sync.loadFromPeers(id);
57
+ }
58
+ if (entry.state === "loaded") {
59
+ return Promise.resolve(entry.coValue);
60
+ }
61
+ return entry.done;
62
+ }
63
+
64
+ expectCoValueLoaded(id: RawCoValueID, expectation?: string): CoValue {
65
+ const entry = this.coValues[id];
66
+ if (!entry) {
67
+ throw new Error(
68
+ `${expectation ? expectation + ": " : ""}Unknown CoValue ${id}`
69
+ );
70
+ }
71
+ if (entry.state === "loading") {
72
+ throw new Error(
73
+ `${
74
+ expectation ? expectation + ": " : ""
75
+ }CoValue ${id} not yet loaded`
76
+ );
77
+ }
78
+ return entry.coValue;
79
+ }
80
+
81
+ createAgent(publicNickname: string): AgentCredential {
82
+ const agentCredential = newRandomAgentCredential(publicNickname);
83
+
84
+ this.createCoValue(getAgentCoValueHeader(getAgent(agentCredential)));
85
+
86
+ return agentCredential;
87
+ }
88
+
89
+ expectAgentLoaded(id: AgentID, expectation?: string): Agent {
90
+ const coValue = this.expectCoValueLoaded(
91
+ id,
92
+ expectation
93
+ );
94
+
95
+ if (coValue.header.type !== "comap" || coValue.header.ruleset.type !== "agent") {
96
+ throw new Error(
97
+ `${
98
+ expectation ? expectation + ": " : ""
99
+ }CoValue ${id} is not an agent`
100
+ );
101
+ }
102
+
103
+ return {
104
+ recipientID: coValue.header.ruleset.initialRecipientID,
105
+ signatoryID: coValue.header.ruleset.initialSignatoryID,
106
+ publicNickname: coValue.header.publicNickname?.replace("agent-", ""),
107
+ }
108
+ }
109
+
110
+ createTeam(): Team {
111
+ const teamCoValue = this.createCoValue({
112
+ type: "comap",
113
+ ruleset: { type: "team", initialAdmin: this.agentID },
114
+ meta: null,
115
+ publicNickname: "team",
116
+ });
117
+
118
+ let teamContent = expectTeamContent(teamCoValue.getCurrentContent());
119
+
120
+ teamContent = teamContent.edit((editable) => {
121
+ editable.set(this.agentID, "admin", "trusting");
122
+
123
+ const readKey = newRandomKeySecret();
124
+ const revelation = seal(
125
+ readKey.secret,
126
+ this.agentCredential.recipientSecret,
127
+ new Set([getAgent(this.agentCredential).recipientID]),
128
+ {
129
+ in: teamCoValue.id,
130
+ tx: teamCoValue.nextTransactionID(),
131
+ }
132
+ );
133
+
134
+ editable.set(
135
+ "readKey",
136
+ { keyID: readKey.id, revelation },
137
+ "trusting"
138
+ );
139
+ });
140
+
141
+ return new Team(teamContent, this);
142
+ }
143
+
144
+ testWithDifferentCredentials(
145
+ agentCredential: AgentCredential,
146
+ ownSessionID: SessionID
147
+ ): LocalNode {
148
+ const newNode = new LocalNode(agentCredential, ownSessionID);
149
+
150
+ newNode.coValues = Object.fromEntries(
151
+ Object.entries(this.coValues)
152
+ .map(([id, entry]) => {
153
+ if (entry.state === "loading") {
154
+ return undefined;
155
+ }
156
+
157
+ const newCoValue = new CoValue(
158
+ entry.coValue.header,
159
+ newNode
160
+ );
161
+
162
+ newCoValue.sessions = entry.coValue.sessions;
163
+
164
+ return [id, { state: "loaded", coValue: newCoValue }];
165
+ })
166
+ .filter((x): x is Exclude<typeof x, undefined> => !!x)
167
+ );
168
+
169
+ return newNode;
170
+ }
171
+ }
172
+
173
+ type CoValueState =
174
+ | {
175
+ state: "loading";
176
+ done: Promise<CoValue>;
177
+ resolve: (coValue: CoValue) => void;
178
+ }
179
+ | { state: "loaded"; coValue: CoValue };
180
+
181
+ export function newLoadingState(): CoValueState {
182
+ let resolve: (coValue: CoValue) => void;
183
+
184
+ const promise = new Promise<CoValue>((r) => {
185
+ resolve = r;
186
+ });
187
+
188
+ return {
189
+ state: "loading",
190
+ done: promise,
191
+ resolve: resolve!,
192
+ };
193
+ }