@quo-systems/quo 0.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/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +91 -0
- package/SPEC.md +1595 -0
- package/package.json +64 -0
- package/src/being/being.ts +92 -0
- package/src/being/digest.ts +31 -0
- package/src/being/index.ts +7 -0
- package/src/being/silence.ts +14 -0
- package/src/being/types.ts +88 -0
- package/src/conformance/assert.ts +67 -0
- package/src/conformance/beings.ts +129 -0
- package/src/conformance/estate.ts +339 -0
- package/src/conformance/index.ts +516 -0
- package/src/conformance/reach.ts +83 -0
- package/src/conformance/store.ts +109 -0
- package/src/harbor/core.ts +178 -0
- package/src/harbor/dial.ts +61 -0
- package/src/harbor/index.ts +11 -0
- package/src/harbor/memory.ts +74 -0
- package/src/harbor/reach.ts +189 -0
- package/src/harbor/store.ts +69 -0
- package/src/ward/allowance.ts +74 -0
- package/src/ward/arithmetic.ts +161 -0
- package/src/ward/cells.ts +90 -0
- package/src/ward/door.ts +102 -0
- package/src/ward/ground.ts +35 -0
- package/src/ward/heirs.ts +87 -0
- package/src/ward/index.ts +14 -0
- package/src/ward/owner.ts +122 -0
- package/src/ward/partition.ts +123 -0
- package/src/ward/seal.ts +133 -0
- package/src/ward/stance.ts +264 -0
- package/src/ward/ward.ts +209 -0
- package/vectors/arithmetic.json +110 -0
- package/vectors/framing.json +71 -0
- package/vectors/wire.json +48 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The store: keeping wards by name. A ward is three things and the store
|
|
3
|
+
// keeps all three under one name: the seed, the partition and the ward
|
|
4
|
+
// record, which says where the class bodies come from and which being is
|
|
5
|
+
// the user's. Plus the directory's hints, which are the harbor's and
|
|
6
|
+
// survive a restart. The store reads nothing it keeps; the partition is
|
|
7
|
+
// values the ward wrote, and the seed is bytes only the ward derives from.
|
|
8
|
+
// Every terrain has one: files on a disk, IndexedDB in a tab, an object's
|
|
9
|
+
// storage on the edge; the memory store below is the library's own, for a
|
|
10
|
+
// harbor core with no device under it. `src/conformance/store.ts` is what
|
|
11
|
+
// every one of them passes.
|
|
12
|
+
export type WardRecord = { pk: string; code: string; user: string };
|
|
13
|
+
export type Kept = { seed: Uint8Array; partition: Record<string, unknown>; record: WardRecord };
|
|
14
|
+
|
|
15
|
+
export type Store = {
|
|
16
|
+
list(): Promise<string[]>;
|
|
17
|
+
load(name: string): Promise<Kept | undefined>;
|
|
18
|
+
// Keep all three under the name; a name already kept is refused.
|
|
19
|
+
put(name: string, kept: Kept): Promise<void>;
|
|
20
|
+
// The partition back, after a call; the record back, after a boot learned
|
|
21
|
+
// the pk. A name not kept is nothing, on both.
|
|
22
|
+
save(name: string, partition: Record<string, unknown>): Promise<void>;
|
|
23
|
+
record(name: string, record: WardRecord): Promise<void>;
|
|
24
|
+
// Take a ward out: the first move of a migration. What was kept comes back.
|
|
25
|
+
take(name: string): Promise<Kept | undefined>;
|
|
26
|
+
hints(): Promise<Record<string, string>>;
|
|
27
|
+
hint(pk: string, url: string): Promise<void>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// The partition is values only, and the ward hands it out through a guard,
|
|
31
|
+
// so a store takes a copy of the values the way a file would: through JSON.
|
|
32
|
+
export const values = (p: Record<string, unknown>): Record<string, unknown> => JSON.parse(JSON.stringify(p)) as Record<string, unknown>;
|
|
33
|
+
|
|
34
|
+
// The store as a map, one process, nothing kept past it.
|
|
35
|
+
export class MemoryStore implements Store {
|
|
36
|
+
readonly rows = new Map<string, Kept>();
|
|
37
|
+
readonly reach = new Map<string, string>();
|
|
38
|
+
|
|
39
|
+
async list(): Promise<string[]> {
|
|
40
|
+
return [...this.rows.keys()];
|
|
41
|
+
}
|
|
42
|
+
async load(name: string): Promise<Kept | undefined> {
|
|
43
|
+
const row = this.rows.get(name);
|
|
44
|
+
return row && { seed: new Uint8Array(row.seed), partition: values(row.partition), record: { ...row.record } };
|
|
45
|
+
}
|
|
46
|
+
async put(name: string, kept: Kept): Promise<void> {
|
|
47
|
+
if (this.rows.has(name)) throw new Error(`ward ${name} already exists here`);
|
|
48
|
+
this.rows.set(name, { seed: new Uint8Array(kept.seed), partition: values(kept.partition), record: { ...kept.record } });
|
|
49
|
+
}
|
|
50
|
+
async save(name: string, partition: Record<string, unknown>): Promise<void> {
|
|
51
|
+
const row = this.rows.get(name);
|
|
52
|
+
if (row) row.partition = values(partition);
|
|
53
|
+
}
|
|
54
|
+
async record(name: string, record: WardRecord): Promise<void> {
|
|
55
|
+
const row = this.rows.get(name);
|
|
56
|
+
if (row) row.record = { ...record };
|
|
57
|
+
}
|
|
58
|
+
async take(name: string): Promise<Kept | undefined> {
|
|
59
|
+
const kept = await this.load(name);
|
|
60
|
+
this.rows.delete(name);
|
|
61
|
+
return kept;
|
|
62
|
+
}
|
|
63
|
+
async hints(): Promise<Record<string, string>> {
|
|
64
|
+
return Object.fromEntries(this.reach);
|
|
65
|
+
}
|
|
66
|
+
async hint(pk: string, url: string): Promise<void> {
|
|
67
|
+
this.reach.set(pk, url);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The allowance. Every ask carries, in its signed body, the time it may still
|
|
3
|
+
// spend, in milliseconds. A being who says nothing gets her ward's default and
|
|
4
|
+
// never thinks about it; one who wants to say so passes a third argument, and
|
|
5
|
+
// is held to the ward's ceiling. The door reads it before anything is done
|
|
6
|
+
// under it and refuses a budget already gone, as one silence like every other
|
|
7
|
+
// refusal there. The sender bounds the whole of her ask to the same number,
|
|
8
|
+
// and a wait that ran out is silence, never unreached.
|
|
9
|
+
//
|
|
10
|
+
// Each ask is bounded on its own. The time an arriving call has left does not
|
|
11
|
+
// bound the asks a being makes while answering it: attributing her onward ask
|
|
12
|
+
// to the arrival that caused it needs a context the ward cannot get without
|
|
13
|
+
// naming a runtime, and the ward names none. Every wait still ends.
|
|
14
|
+
|
|
15
|
+
// What a being may ask for is Wanted, and it belongs to the stance, where she
|
|
16
|
+
// reads it. This file holds only what the ward does with it.
|
|
17
|
+
import type { Wanted } from '../being/types.ts';
|
|
18
|
+
|
|
19
|
+
// A budget, as it crosses a door. Time in milliseconds.
|
|
20
|
+
export type Allowance = { time: number };
|
|
21
|
+
|
|
22
|
+
// How wide the default is, and how wide she may ask for, are the ward's own —
|
|
23
|
+
// wider is more patient, and no peer can tell the difference except by being
|
|
24
|
+
// refused. A being who never learns the third parameter exists writes correct
|
|
25
|
+
// code forever under these. They are two numbers because the third argument
|
|
26
|
+
// goes both ways: less for the ask she wants back quickly, more for the one
|
|
27
|
+
// she knows is slow.
|
|
28
|
+
export const DEFAULT: Allowance = { time: 30_000 };
|
|
29
|
+
export const CEILING: Allowance = { time: 300_000 };
|
|
30
|
+
|
|
31
|
+
// What she asked for, held to what her ward allows. A being cannot mint budget
|
|
32
|
+
// nobody granted her: asking for a minute where the ward gives thirty seconds
|
|
33
|
+
// is thirty seconds, silently, because the ceiling is not hers to know.
|
|
34
|
+
// Asking for nothing at all is the default, which is the whole point.
|
|
35
|
+
export function allow(wanted: Wanted | undefined, ceiling: Allowance = CEILING, base: Allowance = DEFAULT): Allowance {
|
|
36
|
+
const whole = (n: unknown, fallback: number) => (typeof n === 'number' && Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback);
|
|
37
|
+
return { time: Math.min(whole(wanted?.time, base.time), ceiling.time) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Whether a budget has anything left to spend. A door reads this on arrival.
|
|
41
|
+
export const spent = (a: Allowance): boolean => !(a.time > 0);
|
|
42
|
+
|
|
43
|
+
// What a wait that ran out comes back as. Its own value, held by nobody
|
|
44
|
+
// outside the ward, so no answer from any door can be mistaken for it.
|
|
45
|
+
export const LATE = Symbol('late');
|
|
46
|
+
|
|
47
|
+
// A wait with an end. This is the one thing the ward times, and it covers the
|
|
48
|
+
// whole of an ask, not only the wire: a relation that comes back round is
|
|
49
|
+
// stopped at its own lane, before a single byte is sealed, and a bound that
|
|
50
|
+
// only watched the wire would never see it. A timer is the only way to stop
|
|
51
|
+
// waiting and every language that runs Quo has one; no runtime is named here.
|
|
52
|
+
//
|
|
53
|
+
// `rang` is called the moment the bell rings, before anyone waiting on the
|
|
54
|
+
// wait is told: whoever holds work that has not started yet reads it and does
|
|
55
|
+
// not start it. What comes back late is not read, and what has not left yet
|
|
56
|
+
// does not leave.
|
|
57
|
+
export async function within<T>(ms: number, work: Promise<T>, rang?: () => void): Promise<T | typeof LATE> {
|
|
58
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
59
|
+
const bell = new Promise<typeof LATE>((ring) => {
|
|
60
|
+
timer = setTimeout(() => {
|
|
61
|
+
rang?.();
|
|
62
|
+
ring(LATE);
|
|
63
|
+
}, ms);
|
|
64
|
+
});
|
|
65
|
+
try {
|
|
66
|
+
return await Promise.race([work, bell]);
|
|
67
|
+
} finally {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
void work.then(
|
|
70
|
+
() => {},
|
|
71
|
+
() => {},
|
|
72
|
+
); // whatever it was, it is nobody's answer now
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Four algorithms, named once and never negotiated: Ed25519 signs, X25519
|
|
3
|
+
// agrees, SHA-256 hashes, AES-256-GCM encrypts with the key derived through
|
|
4
|
+
// HKDF-SHA-256 under a fixed label. All four live in `crypto.subtle`, so this
|
|
5
|
+
// takes no package and runs the same in a browser tab as on a server -- on
|
|
6
|
+
// any terrain that carries them. SHA-256, AES-GCM and HKDF are everywhere;
|
|
7
|
+
// the two curves are recent, and a terrain without them is a terrain no ward
|
|
8
|
+
// runs on. `test/floor.test.ts` names the floor and probes for it. Subtle is
|
|
9
|
+
// asynchronous, so everything here is.
|
|
10
|
+
//
|
|
11
|
+
// Ported from an earlier kit's arithmetic. Same bytes, same vectors.
|
|
12
|
+
// `crypto.subtle` is read at every use and never captured at load. A browser
|
|
13
|
+
// on a plain http:// origin has `crypto` without `subtle`, and a terrain may
|
|
14
|
+
// install one after this module is first imported; a reference taken here
|
|
15
|
+
// would turn either into an unreadable failure deep inside a key import.
|
|
16
|
+
const subtle = (): SubtleCrypto => {
|
|
17
|
+
const s = globalThis.crypto?.subtle;
|
|
18
|
+
if (!s) throw new Error('this terrain has no crypto.subtle: a ward needs a secure context');
|
|
19
|
+
return s;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const KEY = 32;
|
|
23
|
+
export const SIGNATURE = 64;
|
|
24
|
+
export const NONCE = 12;
|
|
25
|
+
export const TAG = 16;
|
|
26
|
+
const SEAL_INFO = new TextEncoder().encode('quo-seal');
|
|
27
|
+
const SEAL_SALT = new Uint8Array(0);
|
|
28
|
+
|
|
29
|
+
// A 32-byte secret plus a fixed prefix is the whole PKCS#8 wrapping for both curves.
|
|
30
|
+
const ED_SECRET = unhex('302e020100300506032b657004220420');
|
|
31
|
+
const X_SECRET = unhex('302e020100300506032b656e04220420');
|
|
32
|
+
const ED = { name: 'Ed25519' };
|
|
33
|
+
const X = { name: 'X25519' };
|
|
34
|
+
|
|
35
|
+
const HEX = Array.from({ length: 256 }, (_, at) => at.toString(16).padStart(2, '0'));
|
|
36
|
+
export function hex(bytes: Uint8Array | ArrayBuffer): string {
|
|
37
|
+
let out = '';
|
|
38
|
+
for (const byte of new Uint8Array(bytes)) out += HEX[byte];
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
export function unhex(text: string): Uint8Array {
|
|
42
|
+
const out = new Uint8Array(text.length / 2);
|
|
43
|
+
for (let at = 0; at < out.length; at += 1) out[at] = parseInt(text.slice(at * 2, at * 2 + 2), 16);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
export function concat(parts: Uint8Array[]): Uint8Array {
|
|
47
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
48
|
+
let at = 0;
|
|
49
|
+
for (const part of parts) {
|
|
50
|
+
out.set(part, at);
|
|
51
|
+
at += part.length;
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
export function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
|
|
56
|
+
if (a.length !== b.length) return false;
|
|
57
|
+
let diff = 0;
|
|
58
|
+
for (let at = 0; at < a.length; at += 1) diff |= a[at] ^ b[at];
|
|
59
|
+
return diff === 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The eight small-order points. A public key among them verifies nothing.
|
|
63
|
+
const SMALL_ORDER = [
|
|
64
|
+
'0100000000000000000000000000000000000000000000000000000000000000',
|
|
65
|
+
'ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
|
|
66
|
+
'0000000000000000000000000000000000000000000000000000000000000000',
|
|
67
|
+
'0000000000000000000000000000000000000000000000000000000000000080',
|
|
68
|
+
'26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05',
|
|
69
|
+
'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a',
|
|
70
|
+
'26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85',
|
|
71
|
+
'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa',
|
|
72
|
+
].map(unhex);
|
|
73
|
+
export const smallOrder = (pk: Uint8Array): boolean => SMALL_ORDER.some((p) => sameBytes(p, pk));
|
|
74
|
+
|
|
75
|
+
const key32 = (value: Uint8Array, what: string): Uint8Array => {
|
|
76
|
+
if (!(value instanceof Uint8Array) || value.length !== KEY) throw new Error(`${what} is not a 32-byte key`);
|
|
77
|
+
return value;
|
|
78
|
+
};
|
|
79
|
+
const pkcs8 = (prefix: Uint8Array, value: Uint8Array, what: string) => concat([prefix, key32(value, what)]);
|
|
80
|
+
const secretKey = (alg: { name: string }, prefix: Uint8Array, value: Uint8Array, what: string, uses: KeyUsage[]) =>
|
|
81
|
+
subtle().importKey('pkcs8', pkcs8(prefix, value, what) as BufferSource, alg, true, uses);
|
|
82
|
+
const publicKey = (alg: { name: string }, value: Uint8Array, what: string, uses: KeyUsage[]) =>
|
|
83
|
+
subtle().importKey('raw', key32(value, what) as BufferSource, alg, true, uses);
|
|
84
|
+
|
|
85
|
+
// Subtle exports the public half of a private key only through a JWK, where `x` is the 32 raw bytes in base64url.
|
|
86
|
+
async function rawPublic(secret: CryptoKey): Promise<Uint8Array> {
|
|
87
|
+
const jwk = await subtle().exportKey('jwk', secret);
|
|
88
|
+
const binary = atob(jwk.x!.replaceAll('-', '+').replaceAll('_', '/'));
|
|
89
|
+
const out = new Uint8Array(binary.length);
|
|
90
|
+
for (let at = 0; at < binary.length; at += 1) out[at] = binary.charCodeAt(at);
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function sha256(...parts: Uint8Array[]): Promise<Uint8Array> {
|
|
95
|
+
return new Uint8Array(await subtle().digest('SHA-256', concat(parts) as BufferSource));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type Pair = { secret: Uint8Array; pk: Uint8Array };
|
|
99
|
+
export async function signingPair(seed: Uint8Array): Promise<Pair> {
|
|
100
|
+
const secret = await secretKey(ED, ED_SECRET, seed, 'seed', ['sign']);
|
|
101
|
+
return { secret: Uint8Array.from(seed), pk: await rawPublic(secret) };
|
|
102
|
+
}
|
|
103
|
+
export async function sealingPair(seed: Uint8Array): Promise<Pair> {
|
|
104
|
+
const secret = await secretKey(X, X_SECRET, seed, 'seed', ['deriveBits']);
|
|
105
|
+
return { secret: Uint8Array.from(seed), pk: await rawPublic(secret) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function sign(message: Uint8Array, secret: Uint8Array): Promise<Uint8Array> {
|
|
109
|
+
const key = await secretKey(ED, ED_SECRET, secret, 'secret', ['sign']);
|
|
110
|
+
return new Uint8Array(await subtle().sign(ED, key, message as BufferSource));
|
|
111
|
+
}
|
|
112
|
+
export async function verify(message: Uint8Array, signature: Uint8Array, pk: Uint8Array): Promise<boolean> {
|
|
113
|
+
if (!(signature instanceof Uint8Array) || signature.length !== SIGNATURE) return false;
|
|
114
|
+
if (!(pk instanceof Uint8Array) || pk.length !== KEY || smallOrder(pk)) return false;
|
|
115
|
+
try {
|
|
116
|
+
return await subtle().verify(ED, await publicKey(ED, pk, 'pk', ['verify']), signature as BufferSource, message as BufferSource);
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function agree(secret: Uint8Array, peerPk: Uint8Array): Promise<Uint8Array> {
|
|
123
|
+
const key = await secretKey(X, X_SECRET, secret, 'secret', ['deriveBits']);
|
|
124
|
+
const peer = await publicKey(X, peerPk, 'padlock', []);
|
|
125
|
+
const shared = new Uint8Array(await subtle().deriveBits({ name: X.name, public: peer }, key, KEY * 8));
|
|
126
|
+
if (shared.every((b) => b === 0)) throw new Error('dead agreement'); // a padlock that was not a real key
|
|
127
|
+
return shared;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// One HKDF-SHA-256 yields the AES key and the nonce together. The nonce needs
|
|
131
|
+
// no randomness of its own: the key it pairs with is fresh on every message.
|
|
132
|
+
async function cipherKey(shared: Uint8Array, use: KeyUsage) {
|
|
133
|
+
const material = await subtle().importKey('raw', shared as BufferSource, 'HKDF', false, ['deriveBits']);
|
|
134
|
+
const out = new Uint8Array(await subtle().deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: SEAL_SALT, info: SEAL_INFO }, material, (KEY + NONCE) * 8));
|
|
135
|
+
return { key: await subtle().importKey('raw', out.subarray(0, KEY) as BufferSource, 'AES-GCM', false, [use]), nonce: out.subarray(KEY) };
|
|
136
|
+
}
|
|
137
|
+
// The additional authenticated data is the ephemeral public key: the one thing outside the seal, bound to it.
|
|
138
|
+
export async function encrypt(shared: Uint8Array, plaintext: Uint8Array, aad: Uint8Array): Promise<Uint8Array> {
|
|
139
|
+
const { key, nonce } = await cipherKey(shared, 'encrypt');
|
|
140
|
+
return new Uint8Array(await subtle().encrypt({ name: 'AES-GCM', iv: nonce, additionalData: key32(aad, 'aad') as BufferSource, tagLength: TAG * 8 }, key, plaintext as BufferSource));
|
|
141
|
+
}
|
|
142
|
+
export async function decrypt(shared: Uint8Array, ciphertext: Uint8Array, aad: Uint8Array): Promise<Uint8Array> {
|
|
143
|
+
if (ciphertext.length < TAG) throw new Error('short input');
|
|
144
|
+
const { key, nonce } = await cipherKey(shared, 'decrypt');
|
|
145
|
+
return new Uint8Array(await subtle().decrypt({ name: 'AES-GCM', iv: nonce, additionalData: key32(aad, 'aad') as BufferSource, tagLength: TAG * 8 }, key, ciphertext as BufferSource));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// A box: an ephemeral X25519 pk outside, one ciphertext sealed to the
|
|
149
|
+
// padlock. The answer to a box is a box sealed to that ephemeral pk, so the
|
|
150
|
+
// sender keeps the ephemeral secret until the answer comes.
|
|
151
|
+
export async function box(inside: Uint8Array, padlock: Uint8Array, seed: Uint8Array): Promise<{ bytes: Uint8Array; ephemeral: Pair }> {
|
|
152
|
+
const ephemeral = await sealingPair(seed);
|
|
153
|
+
const shared = await agree(ephemeral.secret, padlock);
|
|
154
|
+
return { bytes: concat([ephemeral.pk, await encrypt(shared, inside, ephemeral.pk)]), ephemeral };
|
|
155
|
+
}
|
|
156
|
+
export async function unbox(bytes: Uint8Array, padlockSecret: Uint8Array): Promise<Uint8Array> {
|
|
157
|
+
if (bytes.length <= KEY) throw new Error('short input');
|
|
158
|
+
const ephemeralPk = bytes.subarray(0, KEY);
|
|
159
|
+
const shared = await agree(padlockSecret, ephemeralPk);
|
|
160
|
+
return decrypt(shared, bytes.subarray(KEY), ephemeralPk);
|
|
161
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Cells hold values, and nothing else. The partition promises a harbor it may
|
|
3
|
+
// persist a being's cells as it likes: as this process's objects, as a row, as
|
|
4
|
+
// a line of JSON on a disk. That promise is only worth what the cells honour.
|
|
5
|
+
//
|
|
6
|
+
// Nothing checked it. A being could write a Map, a Date, an undefined, a NaN.
|
|
7
|
+
// In a memory harbor the object survives, because there is nowhere for it to
|
|
8
|
+
// go and come back from. Through any harbor that keeps values it silently
|
|
9
|
+
// becomes {}, or a string, or absent, or null — and she is never told which
|
|
10
|
+
// harbor she is standing in. That is the memory harbor lying to her, which is
|
|
11
|
+
// the one thing it must never do.
|
|
12
|
+
//
|
|
13
|
+
// So the cells refuse the write. Loud, where she wrote it, in her own frame:
|
|
14
|
+
// a being holds three answers and a throw is not one of them, so this throw
|
|
15
|
+
// lands inside her method and the door turns it into the silence it already
|
|
16
|
+
// turns every throw into. She has not answered, and nothing was written down
|
|
17
|
+
// that a harbor would have to lie about later.
|
|
18
|
+
import type { Cells, Json } from '../being/types.ts';
|
|
19
|
+
|
|
20
|
+
// I-JSON, all the way down. A number that JSON cannot write is not a number
|
|
21
|
+
// a harbor can keep, and a key on the prototype is not a key she wrote.
|
|
22
|
+
function fault(v: unknown, path: string, seen: Set<object>): string | null {
|
|
23
|
+
if (v === null || typeof v === 'boolean' || typeof v === 'string') return null;
|
|
24
|
+
if (typeof v === 'number') return Number.isFinite(v) ? null : `${path} is ${String(v)}, which no harbor can write down`;
|
|
25
|
+
if (typeof v !== 'object') return `${path} is a ${typeof v}, which is not a value`;
|
|
26
|
+
if (seen.has(v)) return `${path} refers back to itself`;
|
|
27
|
+
seen.add(v);
|
|
28
|
+
try {
|
|
29
|
+
if (Array.isArray(v)) {
|
|
30
|
+
for (let i = 0; i < v.length; i += 1) {
|
|
31
|
+
const f = fault(v[i], `${path}[${i}]`, seen);
|
|
32
|
+
if (f) return f;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const proto = Object.getPrototypeOf(v);
|
|
37
|
+
if (proto !== Object.prototype && proto !== null) return `${path} is a ${(v).constructor?.name ?? 'object'}, which is not a value`;
|
|
38
|
+
for (const k of Object.keys(v)) {
|
|
39
|
+
const f = fault((v as Record<string, unknown>)[k], `${path}.${k}`, seen);
|
|
40
|
+
if (f) return f;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
} finally {
|
|
44
|
+
seen.delete(v);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const cellFault = (v: unknown, path: string): string | null => fault(v, path, new Set());
|
|
49
|
+
|
|
50
|
+
// The guard is one proxy at the root and one for every container read through
|
|
51
|
+
// it, so a write nested three deep is refused the same way a write at the top
|
|
52
|
+
// is. Wrappers are remembered, so reading the same array twice is the same
|
|
53
|
+
// object twice and a being may still compare what she holds.
|
|
54
|
+
const wrapped = new WeakMap<object, object>();
|
|
55
|
+
const guards = new WeakSet();
|
|
56
|
+
|
|
57
|
+
function guard<T extends object>(target: T, path: string): T {
|
|
58
|
+
if (guards.has(target)) return target;
|
|
59
|
+
const had = wrapped.get(target);
|
|
60
|
+
if (had) return had as T;
|
|
61
|
+
const p = new Proxy(target, {
|
|
62
|
+
get(t, k, r) {
|
|
63
|
+
const v = Reflect.get(t, k, r);
|
|
64
|
+
// A container reached through her cells is part of her cells.
|
|
65
|
+
return v !== null && typeof v === 'object' && !ArrayBuffer.isView(v) ? guard(v as object, `${path}.${String(k)}`) : v;
|
|
66
|
+
},
|
|
67
|
+
set(t, k, v, r) {
|
|
68
|
+
if (typeof k === 'string') {
|
|
69
|
+
const f = cellFault(v, `${path}.${k}`);
|
|
70
|
+
if (f) throw new TypeError(`cells hold values: ${f}`);
|
|
71
|
+
}
|
|
72
|
+
return Reflect.set(t, k, v, r);
|
|
73
|
+
},
|
|
74
|
+
defineProperty(t, k, d) {
|
|
75
|
+
if (typeof k === 'string' && 'value' in d) {
|
|
76
|
+
const f = cellFault(d.value, `${path}.${k}`);
|
|
77
|
+
if (f) throw new TypeError(`cells hold values: ${f}`);
|
|
78
|
+
}
|
|
79
|
+
return Reflect.defineProperty(t, k, d);
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
wrapped.set(target, p);
|
|
83
|
+
guards.add(p);
|
|
84
|
+
return p;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Her cells, guarded. Called once per being at boot; the guarded object is
|
|
88
|
+
// what goes into the partition and what the stance hands her, so there is no
|
|
89
|
+
// second door onto the same cells.
|
|
90
|
+
export const guardCells = (cells: Cells): Cells => guard(cells as unknown as Record<string, Json>, 'cells') as unknown as Cells;
|
package/src/ward/door.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Judgment at the door. Sealed bytes in, sealed bytes out. Every failure is
|
|
3
|
+
// one silence, sealed to whoever asked so that a stranger learns nothing,
|
|
4
|
+
// not even which case they hit. What passes is named and dispatched to her
|
|
5
|
+
// answer, and her digest for that asker rides back with the object.
|
|
6
|
+
import { isSilence, isUnreached } from '../being/silence.ts';
|
|
7
|
+
import { digest } from '../being/digest.ts';
|
|
8
|
+
import type { Asker, BeingLike, JsonObject } from '../being/types.ts';
|
|
9
|
+
import { at } from './partition.ts';
|
|
10
|
+
import type { Heirs } from './heirs.ts';
|
|
11
|
+
import { openAsk, sealReply, verifyAsk, type ReplyPayload, type WardKey } from './seal.ts';
|
|
12
|
+
import { spent } from './allowance.ts';
|
|
13
|
+
import { KEY, sealingPair } from './arithmetic.ts';
|
|
14
|
+
|
|
15
|
+
export type Door = { key: string; being: BeingLike; cells: { occupants: Record<string, unknown> } };
|
|
16
|
+
const SILENCE: ReplyPayload = { silence: true };
|
|
17
|
+
|
|
18
|
+
// One arrival at one being, already named. Catches every throw.
|
|
19
|
+
export async function arrive(door: Door, asker: Asker, method: string | undefined, args: JsonObject): Promise<ReplyPayload> {
|
|
20
|
+
let out: Awaited<ReturnType<BeingLike['answer']>>;
|
|
21
|
+
try {
|
|
22
|
+
out = await door.being.answer(asker, method, args);
|
|
23
|
+
} catch {
|
|
24
|
+
return SILENCE; // she threw where she was asked. there is no answer.
|
|
25
|
+
}
|
|
26
|
+
// Nothing at all is not an answer either: a method that forgot to return
|
|
27
|
+
// has said nothing, and nothing is silence, never a value that JSON drops
|
|
28
|
+
// on the way out and the far side reads back as a fourth word.
|
|
29
|
+
if (out === undefined || isSilence(out) || isUnreached(out)) return SILENCE;
|
|
30
|
+
if (method === undefined) return { object: out, seen: null };
|
|
31
|
+
// The digest rides along, it is not the answer. She has already answered:
|
|
32
|
+
// a describe that will not run costs the digest, and nothing else.
|
|
33
|
+
return { object: out, seen: await seen(door, asker) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function seen(door: Door, asker: Asker): Promise<string | null> {
|
|
37
|
+
try {
|
|
38
|
+
const bp = await door.being.answer(asker);
|
|
39
|
+
return isSilence(bp) || isUnreached(bp) ? null : await digest(bp);
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function makeDoor(key: WardKey, heirs: Heirs, doors: Map<string, Door>, publicKey: () => string | null, random: (n: number) => Uint8Array) {
|
|
46
|
+
const judge = async (bytes: Uint8Array): Promise<{ reply: ReplyPayload; ephemeralPk: Uint8Array } | null> => {
|
|
47
|
+
const a = await openAsk(bytes, key.padlock);
|
|
48
|
+
if (!a) return null; // it did not open. there is nobody to answer.
|
|
49
|
+
const { to, payload, ephemeralPk } = a;
|
|
50
|
+
// The allowance, read before anything is done under it. A budget already
|
|
51
|
+
// gone is one silence like every other refusal here: a stranger learns
|
|
52
|
+
// nothing, not even that what she ran out of was time. Nothing is spent
|
|
53
|
+
// and nothing rotates, because nothing was heard.
|
|
54
|
+
if (spent({ time: payload.time })) return { reply: SILENCE, ephemeralPk };
|
|
55
|
+
// An ask with no doors left is one silence like a budget already gone.
|
|
56
|
+
// Nothing sets hops and nothing decrements it yet; this door refuses zero
|
|
57
|
+
// so that a relay chain invented later meets doors that already stop it.
|
|
58
|
+
if (payload.hops === 0) return { reply: SILENCE, ephemeralPk };
|
|
59
|
+
const args = payload.args ?? {};
|
|
60
|
+
if (to === null) {
|
|
61
|
+
const pk = publicKey();
|
|
62
|
+
const pub = pk !== null ? doors.get(pk) : undefined;
|
|
63
|
+
if (!pub || !(await verifyAsk(a, payload.by))) return { reply: SILENCE, ephemeralPk };
|
|
64
|
+
return { reply: await arrive(pub, {}, payload.method, args), ephemeralPk };
|
|
65
|
+
}
|
|
66
|
+
const h = heirs.admits(to, payload.by);
|
|
67
|
+
if (!h) return { reply: SILENCE, ephemeralPk };
|
|
68
|
+
const door = doors.get(h.being);
|
|
69
|
+
if (!door || !at(door.cells.occupants, h.id)) return { reply: SILENCE, ephemeralPk }; // she removed it, or she is gone
|
|
70
|
+
if (!(await verifyAsk(a, payload.by))) return { reply: SILENCE, ephemeralPk };
|
|
71
|
+
// Once only, and only now: the number is spent after the signature, so a
|
|
72
|
+
// stranger cannot burn a number she could not sign for, and together with
|
|
73
|
+
// the keys, so the same bytes twice rotate nothing and refusal writes nothing.
|
|
74
|
+
if (!heirs.honour(h, payload.by, payload.next, payload.seq)) return { reply: SILENCE, ephemeralPk };
|
|
75
|
+
return { reply: await arrive(door, { id: h.id }, payload.method, args), ephemeralPk };
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// The door itself. Always answers bytes. When the ask did not open, the
|
|
79
|
+
// reply is sealed to the ephemeral pk on its lid, which is all a stranger
|
|
80
|
+
// holds, and says silence.
|
|
81
|
+
//
|
|
82
|
+
// A lid that is not a key -- a small-order point, of which the two curves
|
|
83
|
+
// have several each -- makes a dead agreement, and the seal refuses it. The
|
|
84
|
+
// door never throws: that reply is noise, sealed to a key nobody holds.
|
|
85
|
+
return async function door(bytes: Uint8Array): Promise<Uint8Array> {
|
|
86
|
+
const out = await judge(bytes);
|
|
87
|
+
const reply = out?.reply ?? SILENCE;
|
|
88
|
+
try {
|
|
89
|
+
return await sealReply(reply, out?.ephemeralPk ?? lid(bytes, random), key.sign, random(32));
|
|
90
|
+
} catch {
|
|
91
|
+
return sealReply(reply, (await sealingPair(random(32))).pk, key.sign, random(32));
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The ephemeral pk on an ask that would not open, if the bytes are long
|
|
97
|
+
// enough to carry one. Otherwise a fresh key nobody holds: the reply is
|
|
98
|
+
// bytes, and it is noise.
|
|
99
|
+
function lid(bytes: Uint8Array, random: (n: number) => Uint8Array): Uint8Array {
|
|
100
|
+
if (bytes instanceof Uint8Array && bytes.length >= KEY) return bytes.subarray(0, KEY);
|
|
101
|
+
return random(KEY);
|
|
102
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The ground. The one object a harbor passes a ward at birth. Five things,
|
|
3
|
+
// never a sixth. Everything a runtime differs on arrives here, which is
|
|
4
|
+
// why the ward itself knows no runtime.
|
|
5
|
+
import type { Stance, BeingLike } from '../being/types.ts';
|
|
6
|
+
|
|
7
|
+
export type Ground = {
|
|
8
|
+
seed: string | Uint8Array; // the ward derives its pk from it and nothing else
|
|
9
|
+
memory: Record<string, unknown>; // the partition. the ward's files. opaque to the harbor
|
|
10
|
+
instantiate(className: string, stance: Stance): BeingLike | null; // the code half
|
|
11
|
+
// Sealed bytes to a ward pk. What comes back, or undefined.
|
|
12
|
+
//
|
|
13
|
+
// undefined is a promise, not a shrug: no door was reached, and nothing was
|
|
14
|
+
// delivered. The ward hands it to a being as unreached, which is the one
|
|
15
|
+
// answer that says asking again is safe, so a harbor may only return it
|
|
16
|
+
// when it knows the bytes never arrived — no reach for that pk, a socket
|
|
17
|
+
// that would not open, a link that is down.
|
|
18
|
+
//
|
|
19
|
+
// A harbor that sent the bytes and then gave up waiting knows no such
|
|
20
|
+
// thing: the far door may have heard and be working still. It must not
|
|
21
|
+
// answer at all in that case. The ward bounds every ask itself, and an
|
|
22
|
+
// answer that never comes is silence, which promises nothing. So a harbor
|
|
23
|
+
// may hold a shorter patience than the ward's for its own reasons — a
|
|
24
|
+
// socket it wants back, a queue it will not grow — and the two bounds never
|
|
25
|
+
// need to read each other: whichever ends first ends the ask, and each says
|
|
26
|
+
// only what it can honestly say.
|
|
27
|
+
carry(pk: string, bytes: Uint8Array): Promise<Uint8Array | undefined>;
|
|
28
|
+
random(n: number): Uint8Array; // entropy. every key a ward mints is drawn from it
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// What a ward hands back. Two pointers.
|
|
32
|
+
export type WardPointers = {
|
|
33
|
+
door(bytes: Uint8Array): Promise<Uint8Array>;
|
|
34
|
+
ask(method?: string, args?: Record<string, unknown>): Promise<unknown>;
|
|
35
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Heirs. The door's view of every occupant of every being in the ward, by
|
|
3
|
+
// the heir pk the invitation carried. One rule: the key I hold for you may
|
|
4
|
+
// speak, and so may the key it announced last time. A knock is the first
|
|
5
|
+
// use of the heir, which the inviter announced on your behalf, and the heir
|
|
6
|
+
// dies as it speaks.
|
|
7
|
+
import type { Heir, Partition } from './partition.ts';
|
|
8
|
+
|
|
9
|
+
// How wide the span is, is the ward's own — wider is more forgiving of a
|
|
10
|
+
// rough road, and no peer can tell the difference except by being refused.
|
|
11
|
+
const SPAN = 64;
|
|
12
|
+
|
|
13
|
+
export class Heirs {
|
|
14
|
+
#p: Partition;
|
|
15
|
+
constructor(p: Partition) {
|
|
16
|
+
this.#p = p;
|
|
17
|
+
}
|
|
18
|
+
open(heir: string, being: string, id: string): void {
|
|
19
|
+
this.#p.heirs[heir] = { being, id, current: heir, announced: null, fresh: true, mark: 0, spent: [] };
|
|
20
|
+
}
|
|
21
|
+
close(heir: string): void {
|
|
22
|
+
delete this.#p.heirs[heir];
|
|
23
|
+
}
|
|
24
|
+
get(heir: string): Heir | undefined {
|
|
25
|
+
return this.#p.heirs[heir];
|
|
26
|
+
}
|
|
27
|
+
// May `by` speak for this heir? Returns the record if so, null if not.
|
|
28
|
+
// Does not write: the caller verifies the signature first, then settles.
|
|
29
|
+
admits(heir: string, by: string): Heir | null {
|
|
30
|
+
const h = this.#p.heirs[heir];
|
|
31
|
+
if (!h) return null;
|
|
32
|
+
if (h.fresh) return by === h.current ? h : null;
|
|
33
|
+
return by === h.current || by === h.announced ? h : null;
|
|
34
|
+
}
|
|
35
|
+
// Once only. A number above the mark is honoured and moves it; a number
|
|
36
|
+
// inside the span is honoured once and never again; a number at or below
|
|
37
|
+
// the span is refused, because a door that remembered every number ever
|
|
38
|
+
// seen would be a door with unbounded memory. Counting starts where
|
|
39
|
+
// strangers must agree: the first legal number is one.
|
|
40
|
+
//
|
|
41
|
+
// The number rides inside the signed body, so bytes caught on the road
|
|
42
|
+
// carry the number they were sent under and are refused as themselves. A
|
|
43
|
+
// caller who means to ask again asks again, under the next number, and is
|
|
44
|
+
// heard: retry and fire-and-forget stay hers to build. Only the accident
|
|
45
|
+
// and the interception are refused.
|
|
46
|
+
//
|
|
47
|
+
// Writes, so the caller settles it only once the signature has checked out.
|
|
48
|
+
// The same bytes twice are one honoured call and one silence.
|
|
49
|
+
spend(h: Heir, seq: number): boolean {
|
|
50
|
+
if (!Number.isSafeInteger(seq) || seq < 1) return false;
|
|
51
|
+
if (seq > h.mark) {
|
|
52
|
+
if (h.mark > 0) h.spent.push(h.mark);
|
|
53
|
+
h.mark = seq;
|
|
54
|
+
h.spent = h.spent.filter((past) => past > seq - SPAN);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
if (seq === h.mark || seq <= h.mark - SPAN || h.spent.includes(seq)) return false;
|
|
58
|
+
h.spent.push(seq);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The signature checked out. Spend the number and settle the keys, or do
|
|
63
|
+
// neither: a call that binds nothing must not burn a number on its way to
|
|
64
|
+
// being refused, or a stranger who cannot be heard would still leave a mark
|
|
65
|
+
// behind her. Every write below this line is one that is going to hold.
|
|
66
|
+
honour(h: Heir, by: string, next: string | null, seq: number): boolean {
|
|
67
|
+
if (h.fresh && next === null) return false; // a knock without a key of her own binds nothing
|
|
68
|
+
if (!this.spend(h, seq)) return false;
|
|
69
|
+
return this.settle(h, by, next);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The signature checked out. Settle the keys: a fresh heir rotates at once
|
|
73
|
+
// to what it announced and must announce something; a current key replaces
|
|
74
|
+
// its announcement; an announced key becomes current.
|
|
75
|
+
settle(h: Heir, by: string, next: string | null): boolean {
|
|
76
|
+
if (h.fresh) {
|
|
77
|
+
if (next === null) return false; // a knock without a key of her own binds nothing
|
|
78
|
+
h.current = next;
|
|
79
|
+
h.announced = null;
|
|
80
|
+
h.fresh = false;
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
if (by === h.announced) h.current = by;
|
|
84
|
+
h.announced = next;
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// @quo-systems/quo/ward — one function. Every ward is the same ward.
|
|
3
|
+
export { Ward } from './ward.ts';
|
|
4
|
+
export type { Ground, WardPointers } from './ground.ts';
|
|
5
|
+
export type { Partition, Heir, Bind, StandingKeys } from './partition.ts';
|
|
6
|
+
export type { AskPayload, ReplyPayload } from './seal.ts';
|
|
7
|
+
// The seal and the arithmetic, for a kit in another language to check its
|
|
8
|
+
// bytes against, and for tests that speak to a door directly.
|
|
9
|
+
export * as seal from './seal.ts';
|
|
10
|
+
export * as arithmetic from './arithmetic.ts';
|
|
11
|
+
// The allowance every ask carries, for a kit in another language and for
|
|
12
|
+
// tests that speak to a door directly.
|
|
13
|
+
export { allow, spent, DEFAULT, CEILING } from './allowance.ts';
|
|
14
|
+
export type { Allowance } from './allowance.ts';
|